fix(editor): skip INSERT hints duplicating projection aliases

This commit is contained in:
zipg authored and GitHub committed 2026-09-20 00:51:06 +08:00
1 parent 86115971fa
commit 3cb1faf1c6
6 files changed
+113 -16

No files matched your search

@@ -1,6 +1,7 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { buildInsertValueHintDecorations, insertValueHintsRefreshDispatchSpec, isInsertValueHintDecorationAt } from "@/lib/editor/codemirrorInsertValueHints";
import { parseInsertValueHints } from "@/lib/sql/insertValueHints";
test("insertValueHintsRefreshDispatchSpec disables scrollIntoView", () => {
assert.equal(insertValueHintsRefreshDispatchSpec.scrollIntoView, false);
@@ -18,3 +19,10 @@ test("isInsertValueHintDecorationAt detects widget anchors", () => {
assert.equal(isInsertValueHintDecorationAt(decorations, valueTwo), true);
assert.equal(isInsertValueHintDecorationAt(decorations, sql.indexOf("INSERT")), false);
});
test("a projection that already aliases its target column renders no hint widget", () => {
const sql = ["INSERT INTO t (operation_apply_id, pacu_status)", "SELECT", " noprid.ruid as operation_apply_id,", " noprid.flag", "FROM noprid"].join("\n");
const decorations = buildInsertValueHintDecorations(parseInsertValueHints(sql));
assert.equal(isInsertValueHintDecorationAt(decorations, sql.indexOf("noprid.ruid")), false);
assert.equal(isInsertValueHintDecorationAt(decorations, sql.indexOf("noprid.flag")), true);
});
@@ -99,7 +99,7 @@ function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesCl
return {
...clause,
span: { start: clause.span.start + offset, end: clause.span.end + offset },
rows: clause.rows.map((row) => row.map((from) => from + offset)),
rows: clause.rows.map((row) => row.map((source) => ({ ...source, from: source.from + offset }))),
};
}
+58 -15
View File
@@ -14,11 +14,25 @@ export interface InsertValuesClause {
database?: string;
/** Explicit column list, or null when `INSERT INTO t VALUES` has no column list. */
columns: string[] | null;
/** For each VALUES row or SELECT projection list, start offsets of top-level expressions. */
rows: number[][];
/** For each VALUES row or SELECT projection list, the top-level source expressions. */
rows: InsertValueSource[][];
span: SqlSemanticSpan;
}
/** One source expression of an INSERT, positioned for its inlay hint. */
export interface InsertValueSource {
/** Document offset where the inlay widget is inserted (before the value expression). */
from: number;
/**
* Explicit alias of a SELECT projection item (`expr AS alias`, `expr alias`), unquoted.
*
* The positional hint maps a source expression to its target column; when the projection
* already names that same column itself, the hint would only duplicate the alias the user
* typed, so `buildInsertValueHints` drops it.
*/
alias?: string;
}
export interface ParseInsertValueHintsOptions {
/** Resolve table columns when the INSERT has no explicit column list. */
resolveTableColumns?: (table: string, schema?: string, database?: string) => string[] | undefined;
@@ -115,11 +129,11 @@ function parseColumnList(tokens: readonly SqlSemanticToken[], openIndex: number)
return { columns, nextIndex: index };
}
function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number): { starts: number[]; nextIndex: number } | null {
function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number): { starts: InsertValueSource[]; nextIndex: number } | null {
const open = tokens[openIndex];
if (!open || open.text !== "(") return null;
const contentDepth = open.depth + 1;
const starts: number[] = [];
const starts: InsertValueSource[] = [];
let expectValue = true;
let index = openIndex + 1;
@@ -130,7 +144,7 @@ function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number
return { starts, nextIndex: index + 1 };
}
if (expectValue && item.depth === contentDepth) {
starts.push(item.span.start);
starts.push({ from: item.span.start });
expectValue = false;
}
if (item.text === "," && item.depth === contentDepth) {
@@ -141,8 +155,8 @@ function valueStartsInRow(tokens: readonly SqlSemanticToken[], openIndex: number
return { starts, nextIndex: index };
}
function parseValuesRows(tokens: readonly SqlSemanticToken[], valuesIndex: number): number[][] {
const rows: number[][] = [];
function parseValuesRows(tokens: readonly SqlSemanticToken[], valuesIndex: number): InsertValueSource[][] {
const rows: InsertValueSource[][] = [];
let index = valuesIndex + 1;
while (index < tokens.length) {
const item = tokens[index];
@@ -205,7 +219,7 @@ function isWildcardProjection(tokens: readonly SqlSemanticToken[], from: number,
return topLevel.length % 2 === 1 && topLevel.every((item, index) => (index % 2 === 0 ? (index === topLevel.length - 1 ? item.text === "*" : tokenIsIdentifier(item)) : item.text === "."));
}
function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex: number): number[] {
function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex: number): InsertValueSource[] {
const select = tokens[selectIndex];
if (!select) return [];
const depth = select.depth;
@@ -239,7 +253,7 @@ function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex
if (tokens[index]?.depth === depth && tokens[index]?.normalized === "with" && tokens[index + 1]?.depth === depth && tokens[index + 1]?.normalized === "ties") index += 2;
}
const starts: number[] = [];
const starts: InsertValueSource[] = [];
let expressionStart = index;
for (; index < tokens.length; index += 1) {
const item = tokens[index];
@@ -247,15 +261,37 @@ function selectProjectionStarts(tokens: readonly SqlSemanticToken[], selectIndex
if (isSelectClauseBoundary(item, depth)) break;
if (item.text !== "," || item.depth !== depth) continue;
if (index <= expressionStart || isWildcardProjection(tokens, expressionStart, index, depth)) return [];
starts.push(tokens[expressionStart]!.span.start);
starts.push(selectProjectionSource(tokens, expressionStart, index, depth));
expressionStart = index + 1;
}
if (index <= expressionStart || isWildcardProjection(tokens, expressionStart, index, depth)) return [];
starts.push(tokens[expressionStart]!.span.start);
starts.push(selectProjectionSource(tokens, expressionStart, index, depth));
return starts;
}
/**
* Projection source expression start, plus the alias that ends the projection when it has one.
* Only the *trailing name* of a projection is an alias, so the token in front of it decides:
* `AS` or another identifier (`x total`, `f(x) total`) introduces an alias, while a dotted name
* (`t.col`), an operator (`x::text`) or an open expression does not.
*/
function selectProjectionSource(tokens: readonly SqlSemanticToken[], from: number, to: number, depth: number): InsertValueSource {
const source: InsertValueSource = { from: tokens[from]!.span.start };
const alias = selectProjectionAlias(tokens, to, depth);
if (alias !== undefined) source.alias = alias;
return source;
}
function selectProjectionAlias(tokens: readonly SqlSemanticToken[], to: number, depth: number): string | undefined {
const last = tokens[to - 1];
if (!tokenIsIdentifier(last) || last.depth !== depth) return undefined;
const previous = tokens[to - 2];
if (!previous) return undefined;
const introducesAlias = previous.kind === "word" || previous.kind === "quoted_identifier" || previous.text === ")";
return introducesAlias ? unquoteSqlSemanticIdentifier(last) : undefined;
}
function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): InsertValuesClause | null {
const insertIndex = findWordIndex(tokens, "insert");
if (insertIndex < 0) return null;
@@ -316,7 +352,7 @@ function shiftClause(clause: InsertValuesClause, offset: number): InsertValuesCl
return {
...clause,
span: { start: clause.span.start + offset, end: clause.span.end + offset },
rows: clause.rows.map((row) => row.map((from) => from + offset)),
rows: clause.rows.map((row) => row.map((source) => ({ ...source, from: source.from + offset }))),
};
}
@@ -671,16 +707,23 @@ export function buildInsertValueHints(clauses: readonly InsertValuesClause[], op
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 source = row[index];
const column = columns[index];
if (from === undefined || !column) continue;
hints.push({ from, column });
if (source === undefined || !column) continue;
// A projection that already ends with the target column name states the mapping by
// itself; repeating it as an inlay pill only duplicates the alias the user typed.
if (source.alias !== undefined && sameSqlIdentifier(source.alias, column)) continue;
hints.push({ from: source.from, column });
}
}
}
return hints;
}
function sameSqlIdentifier(left: string, right: string): boolean {
return left.toLowerCase() === right.toLowerCase();
}
/** Parse SQL and return insert-value inlay hints. */
export function parseInsertValueHints(sql: string, options: ParseInsertValueHintsOptions = {}): InsertValueHint[] {
return buildInsertValueHints(parseInsertValuesClauses(sql), options);