feat(editor): show column hints for INSERT values
This commit is contained in:
parent
ff570d5268
commit
86dad5db9f
|
|
@ -84,7 +84,7 @@ import { eventToShortcut } from "@/lib/editor/keyboardShortcuts";
|
|||
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame";
|
||||
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
|
|
@ -255,6 +255,7 @@ const editExecuteMode = ref(settingsStore.editorSettings.executeMode);
|
|||
const editShowExecutionTargetPicker = ref(settingsStore.editorSettings.showExecutionTargetPicker);
|
||||
const editShowStatementRunButtons = ref(settingsStore.editorSettings.showStatementRunButtons);
|
||||
const editShowCurrentStatementFrame = ref(settingsStore.editorSettings.showCurrentStatementFrame);
|
||||
const editShowInsertValueHints = ref(settingsStore.editorSettings.showInsertValueHints);
|
||||
const editAutoAliasTables = ref(settingsStore.editorSettings.autoAliasTables);
|
||||
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
|
||||
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
|
||||
|
|
@ -388,6 +389,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
|
|||
showExecutionTargetPicker: editShowExecutionTargetPicker.value,
|
||||
showStatementRunButtons: editShowStatementRunButtons.value,
|
||||
showCurrentStatementFrame: editShowCurrentStatementFrame.value,
|
||||
showInsertValueHints: editShowInsertValueHints.value,
|
||||
autoAliasTables: editAutoAliasTables.value,
|
||||
wordWrap: editWordWrap.value,
|
||||
vimModeEnabled: editVimModeEnabled.value,
|
||||
|
|
@ -660,6 +662,7 @@ function syncEditorSettingsDraftFromStore() {
|
|||
editShowExecutionTargetPicker.value = settingsStore.editorSettings.showExecutionTargetPicker;
|
||||
editShowStatementRunButtons.value = settingsStore.editorSettings.showStatementRunButtons;
|
||||
editShowCurrentStatementFrame.value = settingsStore.editorSettings.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = settingsStore.editorSettings.showInsertValueHints;
|
||||
editAutoAliasTables.value = settingsStore.editorSettings.autoAliasTables;
|
||||
editWordWrap.value = settingsStore.editorSettings.wordWrap;
|
||||
editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled;
|
||||
|
|
@ -845,6 +848,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
|
|||
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
||||
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
|
||||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
|
|
@ -919,6 +923,7 @@ function resetAllDefaults() {
|
|||
editShowExecutionTargetPicker.value = DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker;
|
||||
editShowStatementRunButtons.value = DEFAULT_EDITOR_SETTINGS.showStatementRunButtons;
|
||||
editShowCurrentStatementFrame.value = DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame;
|
||||
editShowInsertValueHints.value = DEFAULT_EDITOR_SETTINGS.showInsertValueHints;
|
||||
editAutoAliasTables.value = DEFAULT_EDITOR_SETTINGS.autoAliasTables;
|
||||
editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap;
|
||||
editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled;
|
||||
|
|
@ -2398,7 +2403,7 @@ function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof impo
|
|||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const lineRangeTo = Math.min(line.to, frameTo);
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumns(view.state.doc.sliceString(line.from, lineRangeTo)));
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo));
|
||||
}
|
||||
|
||||
const deco: any[] = [];
|
||||
|
|
@ -2723,6 +2728,14 @@ onUnmounted(cleanupPreviewEditor);
|
|||
<Switch id="editor-show-current-statement-frame" v-model="editShowCurrentStatementFrame" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-show-insert-value-hints">{{ t("settings.showInsertValueHints") }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t("settings.showInsertValueHintsDescription") }}</p>
|
||||
</div>
|
||||
<Switch id="editor-show-insert-value-hints" v-model="editShowInsertValueHints" class="mt-0.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<div class="space-y-1">
|
||||
<Label for="editor-word-wrap">{{ t("settings.wordWrap") }}</Label>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ 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 { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
|
||||
import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret";
|
||||
|
|
@ -56,6 +57,7 @@ import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestu
|
|||
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
|
||||
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
|
|
@ -296,6 +298,7 @@ const queryEditorAppearanceSettings = computed(() => {
|
|||
vimModeEnabled: settings.vimModeEnabled,
|
||||
autoCloseBrackets: settings.autoCloseBrackets,
|
||||
showCurrentStatementFrame: settings.showCurrentStatementFrame,
|
||||
showInsertValueHints: settings.showInsertValueHints,
|
||||
shortcuts: settings.shortcuts,
|
||||
showStatementRunButtons: settings.showStatementRunButtons,
|
||||
};
|
||||
|
|
@ -1098,6 +1101,51 @@ function completionCacheKey(table: { name: string; schema?: string | null }) {
|
|||
return schema ? `${schema}.${table.name}` : table.name;
|
||||
}
|
||||
|
||||
const pendingInsertValueHintColumnLoads = new Set<string>();
|
||||
|
||||
function insertHintCacheKey(table: { name: string; schema?: string | null; database?: string | null }) {
|
||||
if (table.database) {
|
||||
return table.schema ? `${table.database}.${table.schema}.${table.name}` : `${table.database}.${table.name}`;
|
||||
}
|
||||
return completionCacheKey(table);
|
||||
}
|
||||
|
||||
function insertHintMetadataTarget(table: { name: string; schema?: string | null; database?: string | null }): { database: string; schema?: string } | null {
|
||||
if (props.database == null) return null;
|
||||
if (table.database) {
|
||||
return { database: table.database, schema: table.schema ?? undefined };
|
||||
}
|
||||
return completionMetadataTarget(table);
|
||||
}
|
||||
|
||||
function getInsertValueHintTableColumns(table: string, schema?: string, database?: string): string[] | undefined {
|
||||
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
||||
const cached = cachedColumnsByTable.get(cacheKey);
|
||||
if (!cached) return undefined;
|
||||
return cached.map((column) => column.name);
|
||||
}
|
||||
|
||||
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
|
||||
if (!props.connectionId || props.database == null) return;
|
||||
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch") return;
|
||||
const cacheKey = insertHintCacheKey({ name: table, schema, database });
|
||||
if (cachedColumnsByTable.has(cacheKey) || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
|
||||
const target = insertHintMetadataTarget({ name: table, schema, database });
|
||||
if (!target) return;
|
||||
pendingInsertValueHintColumnLoads.add(cacheKey);
|
||||
void connectionStore
|
||||
.listCompletionColumns(props.connectionId, target.database, table, target.schema)
|
||||
.then((columns) => {
|
||||
cachedColumnsByTable.set(cacheKey, columns);
|
||||
loadedColumnsByTable.add(cacheKey.toLowerCase());
|
||||
if (view.value) requestInsertValueHintsRefresh(view.value);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pendingInsertValueHintColumnLoads.delete(cacheKey);
|
||||
});
|
||||
}
|
||||
|
||||
function supportsDatabaseQualifierCompletion(): boolean {
|
||||
return !!props.databaseType && !isSchemaAware(props.databaseType) && !isSingleDatabase(props.databaseType);
|
||||
}
|
||||
|
|
@ -2698,11 +2746,22 @@ onMounted(async () => {
|
|||
const startLine = view.state.doc.lineAt(range.from);
|
||||
const frameTo = currentStatementFrameTo(view, range);
|
||||
const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1));
|
||||
let insertValueHints: Array<{ from: number; column: string }> = [];
|
||||
try {
|
||||
if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch") {
|
||||
insertValueHints = parseInsertValueHints(view.state.doc.sliceString(range.from, range.to), { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({
|
||||
...hint,
|
||||
from: hint.from + range.from,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
insertValueHints = [];
|
||||
}
|
||||
let maxWidth = 1;
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const lineRangeTo = Math.min(line.to, frameTo);
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumns(view.state.doc.sliceString(line.from, lineRangeTo)));
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo, insertValueHints));
|
||||
}
|
||||
|
||||
const deco: any[] = [];
|
||||
|
|
@ -2807,6 +2866,11 @@ onMounted(async () => {
|
|||
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||
buildSqlSignatureExtension(),
|
||||
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
||||
createInsertValueHintsExtension({
|
||||
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch",
|
||||
getTableColumns: getInsertValueHintTableColumns,
|
||||
requestTableColumns: requestInsertValueHintTableColumns,
|
||||
}),
|
||||
previewRangeComp.of(buildPreviewRangeExtension()),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
|
|
@ -3211,6 +3275,13 @@ watch(
|
|||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => settingsStore.editorSettings.showInsertValueHints,
|
||||
() => {
|
||||
if (view.value) requestInsertValueHintsRefresh(view.value);
|
||||
},
|
||||
);
|
||||
|
||||
function pauseQueryEditorBackgroundWork() {
|
||||
flushEditorViewport();
|
||||
flushEditorSelection();
|
||||
|
|
|
|||
|
|
@ -3336,6 +3336,8 @@ export default {
|
|||
showStatementRunButtonsDescription: "Show per-statement run buttons in the SQL editor gutter. Keyboard shortcuts and context menu execution still work when disabled.",
|
||||
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.",
|
||||
showInsertValueHints: "Show INSERT value column hints",
|
||||
showInsertValueHintsDescription: "When enabled, the SQL editor shows column-name hints next to each value in INSERT ... VALUES clauses.",
|
||||
previewStatementRunButton: "Preview statement run button",
|
||||
wordWrap: "Word wrap",
|
||||
wordWrapDescription: "Wrap long SQL lines within the editor width",
|
||||
|
|
|
|||
|
|
@ -3172,6 +3172,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "Muestra botones para ejecutar cada sentencia en el margen del editor SQL. Los atajos de teclado y el menú contextual seguirán funcionando al desactivarlo.",
|
||||
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.",
|
||||
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.",
|
||||
previewStatementRunButton: "Botón de ejecución de sentencia en vista previa",
|
||||
wordWrap: "Ajuste de línea",
|
||||
wordWrapDescription: "Ajustar las líneas largas de SQL al ancho del editor",
|
||||
|
|
|
|||
|
|
@ -3170,6 +3170,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "Mostra nel margine dell'editor SQL i pulsanti per eseguire ogni istruzione. Scorciatoie da tastiera e menu contestuale continuano a funzionare quando disattivati.",
|
||||
showCurrentStatementFrame: "Mostra cornice istruzione corrente",
|
||||
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",
|
||||
showInsertValueHintsDescription: "Se attivo, l'editor SQL mostra il nome della colonna accanto a ogni valore nelle clausole INSERT ... VALUES.",
|
||||
previewStatementRunButton: "Pulsante di esecuzione istruzione in anteprima",
|
||||
wordWrap: "A capo automatico",
|
||||
wordWrapDescription: "Incolonna le righe SQL lunghe entro la larghezza dell'editor",
|
||||
|
|
|
|||
|
|
@ -3160,6 +3160,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "SQLエディタのガターに文ごとの実行ボタンを表示します。無効にしてもキーボードショートカットとコンテキストメニューからの実行は引き続き使えます。",
|
||||
showCurrentStatementFrame: "現在の文の枠線を表示",
|
||||
showCurrentStatementFrameDescription: "有効にすると、SQLエディタで現在実行可能な文を枠線で示します。無効にすると枠線を表示しません。",
|
||||
showInsertValueHints: "INSERT 値の列名ヒントを表示",
|
||||
showInsertValueHintsDescription: "有効にすると、INSERT ... VALUES 句の各値の横に対応する列名ヒントを表示します。",
|
||||
previewStatementRunButton: "文の実行ボタンのプレビュー",
|
||||
wordWrap: "折り返し",
|
||||
wordWrapDescription: "長いSQL行をエディタ幅内で折り返します",
|
||||
|
|
|
|||
|
|
@ -3172,6 +3172,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "Mostra botões para executar cada instrução na margem do editor SQL. Atalhos de teclado e execução pelo menu de contexto continuam funcionando quando desativado.",
|
||||
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.",
|
||||
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.",
|
||||
previewStatementRunButton: "Botão de execução de instrução na prévia",
|
||||
wordWrap: "Quebra de linha",
|
||||
wordWrapDescription: "Quebrar linhas SQL longas dentro da largura do editor",
|
||||
|
|
|
|||
|
|
@ -3335,6 +3335,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "在 SQL 编辑器左侧显示按语句执行的快捷按钮。关闭后仍可通过快捷键和右键菜单执行。",
|
||||
showCurrentStatementFrame: "显示当前语句外框线",
|
||||
showCurrentStatementFrameDescription: "开启后,在 SQL 编辑器中用外框线标出当前可执行语句;关闭后不显示外框线。",
|
||||
showInsertValueHints: "显示 INSERT 值列名提示",
|
||||
showInsertValueHintsDescription: "开启后,在 INSERT ... VALUES 子句的每个值旁显示对应列名提示。",
|
||||
previewStatementRunButton: "预览语句执行按钮",
|
||||
wordWrap: "自动换行",
|
||||
wordWrapDescription: "长 SQL 在编辑器宽度内自动折行显示",
|
||||
|
|
|
|||
|
|
@ -3021,6 +3021,8 @@ export default withEnglishFallback({
|
|||
showStatementRunButtonsDescription: "在 SQL 編輯器左側顯示按語句執行的快捷按鈕。關閉後仍可透過快捷鍵和右鍵選單執行。",
|
||||
showCurrentStatementFrame: "顯示目前語句外框線",
|
||||
showCurrentStatementFrameDescription: "啟用後,在 SQL 編輯器中用外框線標出目前可執行語句;關閉後不顯示外框線。",
|
||||
showInsertValueHints: "顯示 INSERT 值欄位提示",
|
||||
showInsertValueHintsDescription: "啟用後,在 INSERT ... VALUES 子句的每個值旁顯示對應欄位名稱提示。",
|
||||
previewStatementRunButton: "預覽語句執行按鈕",
|
||||
wordWrap: "自動換行",
|
||||
wordWrapDescription: "長 SQL 在編輯器寬度內自動折行顯示",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { currentStatementFrameRangeTo, isWideSqlChar, visualSqlColumns } from "@/lib/sql/currentStatementFrame";
|
||||
import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
|
||||
describe("currentStatementFrameRangeTo", () => {
|
||||
|
|
@ -25,3 +25,32 @@ describe("visualSqlColumns", () => {
|
|||
expect(isWideSqlChar("A")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visualSqlColumnsWithInlineHints", () => {
|
||||
it("adds estimated columns for insert-value hints on the line", () => {
|
||||
const text = "VALUES (12, 'a')";
|
||||
const lineFrom = 0;
|
||||
const lineTo = text.length;
|
||||
const withoutHints = visualSqlColumns(text);
|
||||
const withHints = visualSqlColumnsWithInlineHints(text, lineFrom, lineTo, [
|
||||
{ from: 8, column: "id" },
|
||||
{ from: 12, column: "name" },
|
||||
]);
|
||||
expect(withHints).toBe(withoutHints + estimateInlineHintVisualColumns("id") + estimateInlineHintVisualColumns("name"));
|
||||
});
|
||||
|
||||
it("ignores hints that belong to other lines", () => {
|
||||
const text = "VALUES (1)";
|
||||
expect(visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 100, column: "id" }])).toBe(visualSqlColumns(text));
|
||||
});
|
||||
|
||||
it("dedupes hints that share the same document offset", () => {
|
||||
const text = "VALUES (1)";
|
||||
const once = visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 8, column: "id" }]);
|
||||
const twice = visualSqlColumnsWithInlineHints(text, 0, text.length, [
|
||||
{ from: 8, column: "id" },
|
||||
{ from: 8, column: "id" },
|
||||
]);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { StateEffect, type Extension } from "@codemirror/state";
|
||||
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";
|
||||
|
||||
export const refreshInsertValueHintsEffect = StateEffect.define<null>();
|
||||
|
||||
/** Debounce hint reparse while the user is typing / holding a key. */
|
||||
const INSERT_HINT_REPARSE_DELAY_MS = 80;
|
||||
|
||||
export interface InsertValueHintsExtensionOptions {
|
||||
isEnabled?: () => boolean;
|
||||
/** Sync cache lookup for table columns when INSERT has no explicit column list. */
|
||||
getTableColumns?: (table: string, schema?: string, database?: string) => string[] | undefined;
|
||||
/** Async loader invoked when sync cache misses; should call refresh after load. */
|
||||
requestTableColumns?: (table: string, schema?: string, database?: string) => void;
|
||||
}
|
||||
|
||||
class InsertValueHintWidget extends WidgetType {
|
||||
constructor(readonly column: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
eq(other: InsertValueHintWidget) {
|
||||
return other.column === this.column;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-insert-value-hint";
|
||||
span.textContent = this.column;
|
||||
span.setAttribute("aria-hidden", "true");
|
||||
return span;
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet {
|
||||
if (hints.length === 0) return Decoration.none;
|
||||
const deduped: InsertValueHint[] = [];
|
||||
const seenFrom = new Set<number>();
|
||||
for (const hint of [...hints].sort((a, b) => a.from - b.from || a.column.localeCompare(b.column))) {
|
||||
if (seenFrom.has(hint.from)) continue;
|
||||
seenFrom.add(hint.from);
|
||||
deduped.push(hint);
|
||||
}
|
||||
const ranges = deduped.map((hint) =>
|
||||
Decoration.widget({
|
||||
widget: new InsertValueHintWidget(hint.column),
|
||||
side: -1,
|
||||
}).range(hint.from),
|
||||
);
|
||||
return Decoration.set(ranges);
|
||||
}
|
||||
|
||||
function interestRanges(view: EditorView): TextRange[] {
|
||||
const ranges: TextRange[] = view.visibleRanges.map((range) => ({ from: range.from, to: range.to }));
|
||||
const cursor = view.state.selection.main.head;
|
||||
ranges.push({ from: cursor, to: cursor });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesClause {
|
||||
if (offset === 0) return clause;
|
||||
return {
|
||||
...clause,
|
||||
span: { start: clause.span.start + offset, end: clause.span.end + offset },
|
||||
rows: clause.rows.map((row) => row.map((from) => from + offset)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse INSERT hints using only local slices around interest ranges — no full-document tokenize. */
|
||||
function parseClausesNearView(view: EditorView): InsertValuesClause[] {
|
||||
const doc = view.state.doc;
|
||||
const clauses: InsertValuesClause[] = [];
|
||||
const seenStmtStarts = new Set<number>();
|
||||
|
||||
for (const range of interestRanges(view)) {
|
||||
// Pull a bounded neighborhood from the doc instead of materializing the whole script.
|
||||
const pad = 32 * 1024;
|
||||
const sliceFrom = Math.max(0, range.from - pad);
|
||||
const sliceTo = Math.min(doc.length, range.to + pad);
|
||||
if (sliceTo <= sliceFrom) continue;
|
||||
const slice = doc.sliceString(sliceFrom, sliceTo);
|
||||
const window = expandToSqlStatementWindow(slice, range.from - sliceFrom, range.to - sliceFrom);
|
||||
if (window.to <= window.from) continue;
|
||||
const absStart = sliceFrom + window.from;
|
||||
if (seenStmtStarts.has(absStart)) continue;
|
||||
seenStmtStarts.add(absStart);
|
||||
const stmt = slice.slice(window.from, window.to);
|
||||
// Cheap reject: skip tokenize when the window clearly has no INSERT.
|
||||
if (!/\binsert\b/i.test(stmt)) continue;
|
||||
for (const clause of parseInsertValuesClauses(stmt)) {
|
||||
clauses.push(shiftClause(clause, absStart));
|
||||
}
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
function buildHints(view: EditorView, options: InsertValueHintsExtensionOptions): InsertValueHint[] {
|
||||
const clauses = parseClausesNearView(view);
|
||||
for (const clause of clauses) {
|
||||
if (clause.columns !== null) continue;
|
||||
const cached = options.getTableColumns?.(clause.table, clause.schema, clause.database);
|
||||
if (!cached) options.requestTableColumns?.(clause.table, clause.schema, clause.database);
|
||||
}
|
||||
return buildInsertValueHints(clauses, {
|
||||
resolveTableColumns: (table, schema, database) => options.getTableColumns?.(table, schema, database),
|
||||
});
|
||||
}
|
||||
|
||||
const insertValueHintsTheme = EditorView.baseTheme({
|
||||
".cm-insert-value-hint": {
|
||||
display: "inline-block",
|
||||
marginRight: "0.35em",
|
||||
padding: "0 0.3em",
|
||||
borderRadius: "3px",
|
||||
fontSize: "0.85em",
|
||||
lineHeight: "1.2",
|
||||
verticalAlign: "baseline",
|
||||
color: "var(--cm-insert-value-hint-color, rgba(120, 120, 120, 0.95))",
|
||||
backgroundColor: "var(--cm-insert-value-hint-bg, rgba(120, 120, 120, 0.18))",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
fontStyle: "normal",
|
||||
fontWeight: "500",
|
||||
},
|
||||
"&dark .cm-insert-value-hint": {
|
||||
color: "var(--cm-insert-value-hint-color, rgba(180, 180, 180, 0.9))",
|
||||
backgroundColor: "var(--cm-insert-value-hint-bg, rgba(180, 180, 180, 0.16))",
|
||||
},
|
||||
});
|
||||
|
||||
export function createInsertValueHintsExtension(options: InsertValueHintsExtensionOptions = {}): Extension {
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
private lastEnabled = true;
|
||||
private reparseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.compute(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
const refreshed = update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshInsertValueHintsEffect)));
|
||||
const enabled = options.isEnabled?.() ?? true;
|
||||
|
||||
if (!enabled) {
|
||||
this.clearTimer();
|
||||
this.lastEnabled = false;
|
||||
this.decorations = Decoration.none;
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep widget positions valid while a debounced reparse is pending.
|
||||
if (update.docChanged) {
|
||||
this.decorations = this.decorations.map(update.changes);
|
||||
}
|
||||
|
||||
if (refreshed || enabled !== this.lastEnabled) {
|
||||
this.clearTimer();
|
||||
this.lastEnabled = enabled;
|
||||
this.decorations = this.compute(update.view);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastEnabled = enabled;
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
// Do not reparse on the hot path — key-repeat would block the UI.
|
||||
this.scheduleReparse(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.clearTimer();
|
||||
}
|
||||
|
||||
private scheduleReparse(view: EditorView) {
|
||||
this.clearTimer();
|
||||
this.reparseTimer = setTimeout(() => {
|
||||
this.reparseTimer = null;
|
||||
// Trigger a lightweight transaction so update() runs compute once typing pauses.
|
||||
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) });
|
||||
}, INSERT_HINT_REPARSE_DELAY_MS);
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
if (this.reparseTimer !== null) {
|
||||
clearTimeout(this.reparseTimer);
|
||||
this.reparseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private compute(view: EditorView): DecorationSet {
|
||||
if (!(options.isEnabled?.() ?? true)) return Decoration.none;
|
||||
return decorationsForHints(buildHints(view, options));
|
||||
}
|
||||
},
|
||||
{ decorations: (value) => value.decorations },
|
||||
);
|
||||
|
||||
return [insertValueHintsTheme, plugin];
|
||||
}
|
||||
|
||||
export function requestInsertValueHintsRefresh(view: EditorView) {
|
||||
view.dispatch({ effects: refreshInsertValueHintsEffect.of(null) });
|
||||
}
|
||||
|
||||
export { insertValueHintsNeedTableColumns };
|
||||
|
|
@ -12,6 +12,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
|||
"showExecutionTargetPicker",
|
||||
"showStatementRunButtons",
|
||||
"showCurrentStatementFrame",
|
||||
"showInsertValueHints",
|
||||
"autoAliasTables",
|
||||
"wordWrap",
|
||||
"vimModeEnabled",
|
||||
|
|
|
|||
|
|
@ -21,3 +21,39 @@ export function visualSqlColumns(text: string): number {
|
|||
export function isWideSqlChar(ch: string): boolean {
|
||||
return /[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3040-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/u.test(ch);
|
||||
}
|
||||
|
||||
/** Approximate visual columns for a `.cm-insert-value-hint` widget (0.85em text + padding/margin). */
|
||||
export function estimateInlineHintVisualColumns(label: string): number {
|
||||
// Slightly generous vs CSS (0.85em font + 0.3em*2 padding + 0.35em margin) so the
|
||||
// statement frame does not clip past inlay hints.
|
||||
return Math.max(2, Math.ceil(label.length * 0.9 + 2.2));
|
||||
}
|
||||
|
||||
export interface InlineHintForFrameWidth {
|
||||
from: number;
|
||||
column: string;
|
||||
}
|
||||
|
||||
/** Document text columns plus inline widget hints that sit on `[lineFrom, lineTo)`. */
|
||||
export function visualSqlColumnsWithInlineHints(text: string, lineFrom: number, lineTo: number, hints: readonly InlineHintForFrameWidth[] = []): number {
|
||||
let columns = visualSqlColumns(text);
|
||||
const seen = new Set<number>();
|
||||
for (const hint of hints) {
|
||||
if (hint.from < lineFrom || hint.from >= lineTo) continue;
|
||||
if (seen.has(hint.from)) continue;
|
||||
seen.add(hint.from);
|
||||
columns += estimateInlineHintVisualColumns(hint.column);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** Measure rendered line width in CSS pixels, including inline widgets already in the DOM. */
|
||||
export function measureSqlLineWidthPx(view: { coordsAtPos: (pos: number, side?: -1 | 1) => { left: number; right: number; top: number; bottom: number } | null }, from: number, to: number): number | null {
|
||||
if (to < from) return null;
|
||||
const start = view.coordsAtPos(from, 1);
|
||||
const end = view.coordsAtPos(to, -1);
|
||||
if (!start || !end) return null;
|
||||
// Ignore cross-line measurements (wrapping / mismatched sides).
|
||||
if (Math.abs(start.top - end.top) > 2) return null;
|
||||
return Math.max(0, end.right - start.left);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,499 @@
|
|||
import { findActiveSqlStatementSpan, tokenizeSqlSemantic, tokenIsIdentifier, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
|
||||
import type { SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types";
|
||||
|
||||
export interface InsertValueHint {
|
||||
/** Document offset where the inlay widget is inserted (before the value expression). */
|
||||
from: number;
|
||||
column: string;
|
||||
}
|
||||
|
||||
export interface InsertValuesClause {
|
||||
table: string;
|
||||
schema?: string;
|
||||
/** Catalog/database qualifier for three-part names like OtherDb.dbo.Users. */
|
||||
database?: string;
|
||||
/** Explicit column list, or null when `INSERT INTO t VALUES` has no column list. */
|
||||
columns: string[] | null;
|
||||
/** For each VALUES row, start offsets of top-level value expressions. */
|
||||
rows: number[][];
|
||||
span: SqlSemanticSpan;
|
||||
}
|
||||
|
||||
export interface ParseInsertValueHintsOptions {
|
||||
/** Resolve table columns when the INSERT has no explicit column list. */
|
||||
resolveTableColumns?: (table: string, schema?: string, database?: string) => string[] | undefined;
|
||||
}
|
||||
|
||||
export interface TextRange {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function significantTokens(tokens: readonly SqlSemanticToken[]): SqlSemanticToken[] {
|
||||
return tokens.filter((item) => item.kind !== "comment");
|
||||
}
|
||||
|
||||
function statementSpans(sql: string, tokens: readonly SqlSemanticToken[]): SqlSemanticSpan[] {
|
||||
const spans: SqlSemanticSpan[] = [];
|
||||
let start = 0;
|
||||
for (const item of tokens) {
|
||||
if (item.kind !== "punctuation" || item.text !== ";" || item.depth !== 0) continue;
|
||||
const span = trimSpan(sql, start, item.span.start);
|
||||
if (span.end > span.start) spans.push(span);
|
||||
start = item.span.end;
|
||||
}
|
||||
const last = trimSpan(sql, start, sql.length);
|
||||
if (last.end > last.start) spans.push(last);
|
||||
return spans;
|
||||
}
|
||||
|
||||
function trimSpan(sql: string, start: number, end: number): SqlSemanticSpan {
|
||||
let from = start;
|
||||
let to = end;
|
||||
while (from < to && /\s/.test(sql[from] ?? "")) from += 1;
|
||||
while (to > from && /\s/.test(sql[to - 1] ?? "")) to -= 1;
|
||||
return { start: from, end: to };
|
||||
}
|
||||
|
||||
function tokensInSpan(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): SqlSemanticToken[] {
|
||||
return tokens.filter((item) => item.span.end > span.start && item.span.start < span.end);
|
||||
}
|
||||
|
||||
function findWordIndex(tokens: readonly SqlSemanticToken[], word: string, from = 0): number {
|
||||
const needle = word.toLowerCase();
|
||||
for (let index = from; index < tokens.length; index += 1) {
|
||||
const item = tokens[index];
|
||||
if (item?.kind === "word" && item.normalized === needle) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function readQualifiedName(tokens: readonly SqlSemanticToken[], startIndex: number): { name: string; schema?: string; database?: string; nextIndex: number } | null {
|
||||
const first = tokens[startIndex];
|
||||
if (!tokenIsIdentifier(first)) return null;
|
||||
const parts = [unquoteSqlSemanticIdentifier(first)];
|
||||
let index = startIndex + 1;
|
||||
while (tokens[index]?.text === "." && tokenIsIdentifier(tokens[index + 1])) {
|
||||
parts.push(unquoteSqlSemanticIdentifier(tokens[index + 1]!));
|
||||
index += 2;
|
||||
}
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
database: parts[parts.length - 3],
|
||||
schema: parts[parts.length - 2],
|
||||
name: parts[parts.length - 1]!,
|
||||
nextIndex: index,
|
||||
};
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { schema: parts[0], name: parts[1]!, nextIndex: index };
|
||||
}
|
||||
return { name: parts[0]!, nextIndex: index };
|
||||
}
|
||||
|
||||
function parseColumnList(tokens: readonly SqlSemanticToken[], openIndex: number): { columns: string[]; nextIndex: number } | null {
|
||||
const open = tokens[openIndex];
|
||||
if (!open || open.text !== "(") return null;
|
||||
const columns: string[] = [];
|
||||
let index = openIndex + 1;
|
||||
while (index < tokens.length) {
|
||||
const item = tokens[index];
|
||||
if (!item) break;
|
||||
if (item.text === ")" && item.depth === open.depth) {
|
||||
return { columns, nextIndex: index + 1 };
|
||||
}
|
||||
if (tokenIsIdentifier(item) && item.depth === open.depth + 1) {
|
||||
columns.push(unquoteSqlSemanticIdentifier(item));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { columns, nextIndex: index };
|
||||
}
|
||||
|
||||
function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number): { starts: number[]; nextIndex: number } | null {
|
||||
const open = tokens[openIndex];
|
||||
if (!open || open.text !== "(") return null;
|
||||
const contentDepth = open.depth + 1;
|
||||
const starts: number[] = [];
|
||||
let expectValue = true;
|
||||
let index = openIndex + 1;
|
||||
|
||||
while (index < tokens.length) {
|
||||
const item = tokens[index];
|
||||
if (!item) break;
|
||||
if (item.text === ")" && item.depth === open.depth) {
|
||||
return { starts, nextIndex: index + 1 };
|
||||
}
|
||||
if (expectValue && item.depth === contentDepth) {
|
||||
starts.push(item.span.start);
|
||||
expectValue = false;
|
||||
}
|
||||
if (item.text === "," && item.depth === contentDepth) {
|
||||
expectValue = true;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return { starts, nextIndex: index };
|
||||
}
|
||||
|
||||
function parseValuesRows(tokens: readonly SqlSemanticToken[], valuesIndex: number): number[][] {
|
||||
const rows: number[][] = [];
|
||||
let index = valuesIndex + 1;
|
||||
while (index < tokens.length) {
|
||||
const item = tokens[index];
|
||||
if (!item) break;
|
||||
if (item.kind === "word" && (item.normalized === "returning" || item.normalized === "on" || item.normalized === "select")) break;
|
||||
if (item.text === "(") {
|
||||
const row = valueStartsInRow(tokens, index);
|
||||
if (!row) break;
|
||||
if (row.starts.length > 0) rows.push(row.starts);
|
||||
index = row.nextIndex;
|
||||
continue;
|
||||
}
|
||||
if (item.text === ",") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): InsertValuesClause | null {
|
||||
const insertIndex = findWordIndex(tokens, "insert");
|
||||
if (insertIndex < 0) return null;
|
||||
const intoIndex = findWordIndex(tokens, "into", insertIndex + 1);
|
||||
if (intoIndex < 0) return null;
|
||||
|
||||
const tableInfo = readQualifiedName(tokens, intoIndex + 1);
|
||||
if (!tableInfo) return null;
|
||||
|
||||
let index = tableInfo.nextIndex;
|
||||
let columns: string[] | null = null;
|
||||
|
||||
// SQL Server table hints appear between the target table and INSERT column list.
|
||||
if (tokens[index]?.normalized === "with" && tokens[index + 1]?.text === "(") {
|
||||
const hintList = parseColumnList(tokens, index + 1);
|
||||
if (!hintList) return null;
|
||||
index = hintList.nextIndex;
|
||||
}
|
||||
|
||||
// Optional alias between table and column list / VALUES / SELECT
|
||||
if (tokenIsIdentifier(tokens[index]) && tokens[index]?.normalized !== "values" && tokens[index]?.normalized !== "select" && tokens[index]?.normalized !== "default") {
|
||||
const maybeAs = tokens[index];
|
||||
if (maybeAs?.normalized === "as" && tokenIsIdentifier(tokens[index + 1])) {
|
||||
index += 2;
|
||||
} else if (tokens[index]?.text !== "(") {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens[index]?.text === "(") {
|
||||
const columnList = parseColumnList(tokens, index);
|
||||
if (!columnList) return null;
|
||||
columns = columnList.columns;
|
||||
index = columnList.nextIndex;
|
||||
}
|
||||
|
||||
const valuesIndex = findWordIndex(tokens, "values", index);
|
||||
const selectIndex = findWordIndex(tokens, "select", index);
|
||||
if (valuesIndex < 0) return null;
|
||||
if (selectIndex >= 0 && selectIndex < valuesIndex) return null;
|
||||
|
||||
const rows = parseValuesRows(tokens, valuesIndex);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return {
|
||||
table: tableInfo.name,
|
||||
schema: tableInfo.schema,
|
||||
database: tableInfo.database,
|
||||
columns,
|
||||
rows,
|
||||
span,
|
||||
};
|
||||
}
|
||||
|
||||
function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesClause {
|
||||
if (offset === 0) return clause;
|
||||
return {
|
||||
...clause,
|
||||
span: { start: clause.span.start + offset, end: clause.span.end + offset },
|
||||
rows: clause.rows.map((row) => row.map((from) => from + offset)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand [from, to) to the nearest top-level statement window (quote/comment aware).
|
||||
* Scans at most LOOKBACK bytes before `from` and LOOKAHEAD after `to` so large scripts
|
||||
* do not pay O(document) on every keystroke.
|
||||
*/
|
||||
const STATEMENT_LOOKBACK = 32 * 1024;
|
||||
const STATEMENT_LOOKAHEAD = 32 * 1024;
|
||||
|
||||
export function expandToSqlStatementWindow(sql: string, from: number, to: number): TextRange {
|
||||
const safeFrom = Math.max(0, Math.min(from, sql.length));
|
||||
const safeTo = Math.max(safeFrom, Math.min(to, sql.length));
|
||||
const scanFrom = Math.max(0, safeFrom - STATEMENT_LOOKBACK);
|
||||
const scanTo = Math.min(sql.length, safeTo + STATEMENT_LOOKAHEAD);
|
||||
const slice = scanFrom === 0 && scanTo === sql.length ? sql : sql.slice(scanFrom, scanTo);
|
||||
const localFrom = safeFrom - scanFrom;
|
||||
const localTo = safeTo - scanFrom;
|
||||
const start = findStatementStart(slice, localFrom) + scanFrom;
|
||||
const end = findStatementEnd(slice, Math.max(localFrom, localTo)) + scanFrom;
|
||||
const trimmed = trimSpan(sql, start, Math.min(end, scanTo));
|
||||
return { from: trimmed.start, to: trimmed.end };
|
||||
}
|
||||
|
||||
function findStatementStart(sql: string, pos: number): number {
|
||||
let index = 0;
|
||||
let start = 0;
|
||||
let depth = 0;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (index < pos) {
|
||||
const ch = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
|
||||
if (inLineComment) {
|
||||
if (ch === "\n") inLineComment = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
if (next === quote) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
quote = null;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "-" && next === "-") {
|
||||
inLineComment = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
quote = ch;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
quote = "]";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") {
|
||||
depth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ")") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" && depth === 0) {
|
||||
start = index + 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
function findStatementEnd(sql: string, pos: number): number {
|
||||
let index = pos;
|
||||
let depth = 0;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (index < sql.length) {
|
||||
const ch = sql[index] ?? "";
|
||||
const next = sql[index + 1] ?? "";
|
||||
|
||||
if (inLineComment) {
|
||||
if (ch === "\n") inLineComment = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === "*" && next === "/") {
|
||||
inBlockComment = false;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if ((quote === "]" && ch === "]") || (quote !== "]" && ch === quote)) {
|
||||
if (next === (quote === "]" ? "]" : quote)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
quote = null;
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "-" && next === "-") {
|
||||
inLineComment = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "#") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "/" && next === "*") {
|
||||
inBlockComment = true;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
quote = ch;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
quote = "]";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "(") {
|
||||
depth += 1;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ")") {
|
||||
depth = Math.max(0, depth - 1);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" && depth === 0) {
|
||||
return index;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return sql.length;
|
||||
}
|
||||
|
||||
function mergeTextRanges(ranges: readonly TextRange[]): TextRange[] {
|
||||
if (ranges.length === 0) return [];
|
||||
const sorted = [...ranges].sort((a, b) => a.from - b.from || a.to - b.to);
|
||||
const merged: TextRange[] = [{ ...sorted[0]! }];
|
||||
for (let index = 1; index < sorted.length; index += 1) {
|
||||
const current = sorted[index]!;
|
||||
const last = merged[merged.length - 1]!;
|
||||
if (current.from <= last.to) {
|
||||
last.to = Math.max(last.to, current.to);
|
||||
} else {
|
||||
merged.push({ ...current });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse INSERT ... VALUES clauses only inside the given document ranges (expanded to statement windows). */
|
||||
export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly TextRange[]): InsertValuesClause[] {
|
||||
if (!sql.trim() || ranges.length === 0) return [];
|
||||
const windows = mergeTextRanges(ranges.map((range) => expandToSqlStatementWindow(sql, range.from, range.to)));
|
||||
const clauses: InsertValuesClause[] = [];
|
||||
for (const window of windows) {
|
||||
if (window.to <= window.from) continue;
|
||||
const slice = sql.slice(window.from, window.to);
|
||||
for (const clause of parseInsertValuesClauses(slice)) {
|
||||
clauses.push(shiftClause(clause, window.from));
|
||||
}
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
/** Parse all INSERT ... VALUES clauses in `sql` (multi-statement aware). Prefer ranged parsing for editors. */
|
||||
export function parseInsertValuesClauses(sql: string): InsertValuesClause[] {
|
||||
if (!sql.trim()) return [];
|
||||
const allTokens = tokenizeSqlSemantic(sql);
|
||||
const spans = statementSpans(sql, allTokens);
|
||||
const clauses: InsertValuesClause[] = [];
|
||||
for (const span of spans) {
|
||||
const tokens = significantTokens(tokensInSpan(allTokens, span));
|
||||
const clause = parseInsertClause(tokens, span);
|
||||
if (clause) clauses.push(clause);
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
/** Build inlay hint positions from parsed clauses and optional table-column resolver. */
|
||||
export function buildInsertValueHints(clauses: readonly InsertValuesClause[], options: ParseInsertValueHintsOptions = {}): InsertValueHint[] {
|
||||
const hints: InsertValueHint[] = [];
|
||||
for (const clause of clauses) {
|
||||
const columns = clause.columns ?? options.resolveTableColumns?.(clause.table, clause.schema, clause.database);
|
||||
if (!columns || columns.length === 0) continue;
|
||||
for (const row of clause.rows) {
|
||||
const count = Math.min(row.length, columns.length);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const from = row[index];
|
||||
const column = columns[index];
|
||||
if (from === undefined || !column) continue;
|
||||
hints.push({ from, column });
|
||||
}
|
||||
}
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
/** Parse SQL and return insert-value inlay hints. */
|
||||
export function parseInsertValueHints(sql: string, options: ParseInsertValueHintsOptions = {}): InsertValueHint[] {
|
||||
return buildInsertValueHints(parseInsertValuesClauses(sql), options);
|
||||
}
|
||||
|
||||
/** Parse only the statements covering `ranges` and return insert-value inlay hints. */
|
||||
export function parseInsertValueHintsInRanges(sql: string, ranges: readonly TextRange[], options: ParseInsertValueHintsOptions = {}): InsertValueHint[] {
|
||||
return buildInsertValueHints(parseInsertValuesClausesInRanges(sql, ranges), options);
|
||||
}
|
||||
|
||||
/** True when the document still needs table metadata for at least one INSERT without a column list. */
|
||||
export function insertValueHintsNeedTableColumns(sql: string): InsertValuesClause[] {
|
||||
return parseInsertValuesClauses(sql).filter((clause) => clause.columns === null);
|
||||
}
|
||||
|
||||
/** Convenience: hints for the statement containing `cursor` only. */
|
||||
export function parseInsertValueHintsAtCursor(sql: string, cursor: number, options: ParseInsertValueHintsOptions = {}): InsertValueHint[] {
|
||||
const tokens = tokenizeSqlSemantic(sql);
|
||||
const span = findActiveSqlStatementSpan(sql, tokens, cursor);
|
||||
const statementTokens = significantTokens(tokensInSpan(tokens, span));
|
||||
const clause = parseInsertClause(statementTokens, span);
|
||||
if (!clause) return [];
|
||||
return buildInsertValueHints([clause], options);
|
||||
}
|
||||
|
|
@ -72,6 +72,16 @@ export function tokenizeSqlSemantic(input: string): SqlSemanticToken[] {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (ch === "$") {
|
||||
const marker = /^\$[A-Za-z_0-9]*\$/.exec(input.slice(start))?.[0];
|
||||
if (marker) {
|
||||
const closing = input.indexOf(marker, start + marker.length);
|
||||
index = closing < 0 ? input.length : closing + marker.length;
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, marker));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
index = readQuoted(input, start, '"', '"');
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"'));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ describe("normalizeEditorSettings", () => {
|
|||
expect(normalizeEditorSettings({ showCurrentStatementFrame: false }).showCurrentStatementFrame).toBe(false);
|
||||
});
|
||||
|
||||
it("shows INSERT value column hints by default", () => {
|
||||
expect(normalizeEditorSettings({}).showInsertValueHints).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves disabled INSERT value column hints", () => {
|
||||
expect(normalizeEditorSettings({ showInsertValueHints: false }).showInsertValueHints).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps SQL semantic diagnostics in auto mode and disabled by default", () => {
|
||||
const settings = normalizeEditorSettings({});
|
||||
expect(settings.sqlSemanticDiagnosticsMode).toBe("auto");
|
||||
|
|
|
|||
|
|
@ -375,6 +375,7 @@ export interface EditorSettings {
|
|||
showExecutionTargetPicker: boolean;
|
||||
showStatementRunButtons: boolean;
|
||||
showCurrentStatementFrame: boolean;
|
||||
showInsertValueHints: boolean;
|
||||
autoAliasTables: boolean;
|
||||
wordWrap: boolean;
|
||||
vimModeEnabled: boolean;
|
||||
|
|
@ -509,6 +510,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
showExecutionTargetPicker: false,
|
||||
showStatementRunButtons: true,
|
||||
showCurrentStatementFrame: true,
|
||||
showInsertValueHints: true,
|
||||
autoAliasTables: true,
|
||||
wordWrap: false,
|
||||
vimModeEnabled: false,
|
||||
|
|
@ -743,6 +745,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
showExecutionTargetPicker: settings.showExecutionTargetPicker ?? DEFAULT_EDITOR_SETTINGS.showExecutionTargetPicker,
|
||||
showStatementRunButtons: typeof settings.showStatementRunButtons === "boolean" ? settings.showStatementRunButtons : DEFAULT_EDITOR_SETTINGS.showStatementRunButtons,
|
||||
showCurrentStatementFrame: typeof settings.showCurrentStatementFrame === "boolean" ? settings.showCurrentStatementFrame : DEFAULT_EDITOR_SETTINGS.showCurrentStatementFrame,
|
||||
showInsertValueHints: typeof settings.showInsertValueHints === "boolean" ? settings.showInsertValueHints : DEFAULT_EDITOR_SETTINGS.showInsertValueHints,
|
||||
autoAliasTables: settings.autoAliasTables ?? DEFAULT_EDITOR_SETTINGS.autoAliasTables,
|
||||
wordWrap: settings.wordWrap ?? DEFAULT_EDITOR_SETTINGS.wordWrap,
|
||||
vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled,
|
||||
|
|
@ -985,6 +988,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.showExecutionTargetPicker !== undefined) editorSettings.value.showExecutionTargetPicker = partial.showExecutionTargetPicker;
|
||||
if (partial.showStatementRunButtons !== undefined) editorSettings.value.showStatementRunButtons = partial.showStatementRunButtons === true;
|
||||
if (partial.showCurrentStatementFrame !== undefined) editorSettings.value.showCurrentStatementFrame = partial.showCurrentStatementFrame === true;
|
||||
if (partial.showInsertValueHints !== undefined) editorSettings.value.showInsertValueHints = partial.showInsertValueHints === true;
|
||||
if (partial.autoAliasTables !== undefined) editorSettings.value.autoAliasTables = partial.autoAliasTables;
|
||||
if (partial.wordWrap !== undefined) editorSettings.value.wordWrap = partial.wordWrap;
|
||||
if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { buildInsertValueHints, expandToSqlStatementWindow, parseInsertValueHints, parseInsertValueHintsInRanges, parseInsertValuesClauses } from "../../apps/desktop/src/lib/sql/insertValueHints.ts";
|
||||
|
||||
test("maps explicit column list to single-row VALUES", () => {
|
||||
const sql = "INSERT INTO auth_user (id, password, last_login) VALUES (5, 'hash', NULL)";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from, hint.from + 1) })),
|
||||
[
|
||||
{ column: "id", text: "5" },
|
||||
{ column: "password", text: "'" },
|
||||
{ column: "last_login", text: "N" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("supports multi-row VALUES", () => {
|
||||
const sql = "INSERT INTO users (id, name) VALUES (1, 'a'), (2, 'b')";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["id", "name", "id", "name"],
|
||||
);
|
||||
assert.equal(sql.slice(hints[0]!.from, hints[0]!.from + 1), "1");
|
||||
assert.equal(sql.slice(hints[2]!.from, hints[2]!.from + 1), "2");
|
||||
});
|
||||
|
||||
test("does not split nested parentheses inside a value", () => {
|
||||
const sql = "INSERT INTO t (a, b) VALUES (COALESCE(x, y), NOW())";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["a", "b"],
|
||||
);
|
||||
assert.ok(sql.slice(hints[0]!.from).startsWith("COALESCE(x, y)"));
|
||||
assert.ok(sql.slice(hints[1]!.from).startsWith("NOW()"));
|
||||
});
|
||||
|
||||
test("does not split PostgreSQL dollar-quoted values", () => {
|
||||
const sql = "INSERT INTO t (body, count) VALUES ($tag$hello,(world),again$tag$, 2)";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["body", "count"],
|
||||
);
|
||||
assert.ok(sql.slice(hints[0]!.from).startsWith("$tag$hello,(world),again$tag$"));
|
||||
assert.equal(sql.slice(hints[1]!.from, hints[1]!.from + 1), "2");
|
||||
});
|
||||
|
||||
test("skips SQL Server table hints before the INSERT column list", () => {
|
||||
const sql = "INSERT INTO dbo.Users WITH (TABLOCK) (id, name) VALUES (1, 'alice')";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["id", "name"],
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves columns from table metadata when column list is omitted", () => {
|
||||
const sql = "INSERT INTO users VALUES (1, 'alice')";
|
||||
const hints = parseInsertValueHints(sql, {
|
||||
resolveTableColumns: (table) => (table === "users" ? ["id", "name"] : undefined),
|
||||
});
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["id", "name"],
|
||||
);
|
||||
});
|
||||
|
||||
test("returns no hints for INSERT ... SELECT", () => {
|
||||
const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging";
|
||||
assert.deepEqual(parseInsertValueHints(sql), []);
|
||||
assert.deepEqual(parseInsertValuesClauses(sql), []);
|
||||
});
|
||||
|
||||
test("caps hints when value count exceeds column count", () => {
|
||||
const sql = "INSERT INTO t (a, b) VALUES (1, 2, 3)";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["a", "b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("caps hints when column count exceeds value count", () => {
|
||||
const sql = "INSERT INTO t (a, b, c) VALUES (1, 2)";
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["a", "b"],
|
||||
);
|
||||
});
|
||||
|
||||
test("handles quoted identifiers in column list", () => {
|
||||
const sql = 'INSERT INTO "User" ("Id", "Name") VALUES (1, \'x\')';
|
||||
const hints = parseInsertValueHints(sql);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["Id", "Name"],
|
||||
);
|
||||
});
|
||||
|
||||
test("parses schema-qualified table without column list", () => {
|
||||
const clauses = parseInsertValuesClauses("INSERT INTO dbo.Users VALUES (1)");
|
||||
assert.equal(clauses.length, 1);
|
||||
assert.equal(clauses[0]?.table, "Users");
|
||||
assert.equal(clauses[0]?.schema, "dbo");
|
||||
assert.equal(clauses[0]?.database, undefined);
|
||||
assert.equal(clauses[0]?.columns, null);
|
||||
});
|
||||
|
||||
test("preserves three-part database.schema.table qualifiers", () => {
|
||||
const clauses = parseInsertValuesClauses("INSERT INTO OtherDb.dbo.Users VALUES (1, 'a')");
|
||||
assert.equal(clauses.length, 1);
|
||||
assert.equal(clauses[0]?.database, "OtherDb");
|
||||
assert.equal(clauses[0]?.schema, "dbo");
|
||||
assert.equal(clauses[0]?.table, "Users");
|
||||
});
|
||||
|
||||
test("preserves quoted three-part database.schema.table qualifiers", () => {
|
||||
const clauses = parseInsertValuesClauses('INSERT INTO "OtherDb"."dbo"."Users" VALUES (1)');
|
||||
assert.equal(clauses[0]?.database, "OtherDb");
|
||||
assert.equal(clauses[0]?.schema, "dbo");
|
||||
assert.equal(clauses[0]?.table, "Users");
|
||||
});
|
||||
|
||||
test("routes three-part names through resolveTableColumns database argument", () => {
|
||||
const sql = "INSERT INTO OtherDb.dbo.Users VALUES (1, 'a')";
|
||||
const calls: Array<{ table: string; schema?: string; database?: string }> = [];
|
||||
const hints = parseInsertValueHints(sql, {
|
||||
resolveTableColumns: (table, schema, database) => {
|
||||
calls.push({ table, schema, database });
|
||||
if (database === "OtherDb" && schema === "dbo" && table === "Users") return ["id", "name"];
|
||||
return ["wrong_id"];
|
||||
},
|
||||
});
|
||||
assert.deepEqual(calls, [{ table: "Users", schema: "dbo", database: "OtherDb" }]);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["id", "name"],
|
||||
);
|
||||
});
|
||||
|
||||
test("parses only statement windows covering provided ranges", () => {
|
||||
const prefix = `${"SELECT 1;\n".repeat(200)}`;
|
||||
const insert = "INSERT INTO t (id, name) VALUES (1, 'x');";
|
||||
const suffix = `\n${"SELECT 2;\n".repeat(200)}`;
|
||||
const sql = `${prefix}${insert}${suffix}`;
|
||||
const insertFrom = prefix.length;
|
||||
const hints = parseInsertValueHintsInRanges(sql, [{ from: insertFrom, to: insertFrom + 10 }]);
|
||||
assert.deepEqual(
|
||||
hints.map((hint) => hint.column),
|
||||
["id", "name"],
|
||||
);
|
||||
assert.equal(sql.slice(hints[0]!.from, hints[0]!.from + 1), "1");
|
||||
});
|
||||
|
||||
test("expandToSqlStatementWindow stops at neighboring statements", () => {
|
||||
const sql = "SELECT 1; INSERT INTO t (a) VALUES (1); SELECT 2;";
|
||||
const insertAt = sql.indexOf("INSERT");
|
||||
const window = expandToSqlStatementWindow(sql, insertAt, insertAt + 6);
|
||||
assert.equal(sql.slice(window.from, window.to), "INSERT INTO t (a) VALUES (1)");
|
||||
});
|
||||
|
||||
test("ignores statements that are not INSERT VALUES", () => {
|
||||
const sql = "SELECT 1; UPDATE users SET name = 'a' WHERE id = 1;";
|
||||
assert.deepEqual(parseInsertValueHints(sql), []);
|
||||
});
|
||||
|
||||
test("buildInsertValueHints skips unresolved tables without metadata", () => {
|
||||
const clauses = parseInsertValuesClauses("INSERT INTO mystery VALUES (1, 2)");
|
||||
assert.deepEqual(buildInsertValueHints(clauses), []);
|
||||
});
|
||||
Loading…
Reference in New Issue