feat(editor): show target columns for INSERT SELECT

Closes #6696
This commit is contained in:
t8y2 committed 2026-08-19 13:10:22 +00:00
1 parent 2b11af94ba
commit 74be035352
2 files changed
+140 -54

No files matched your search

+85 -8
View File
@@ -14,7 +14,7 @@ export interface InsertValuesClause {
database?: string; database?: string;
/** Explicit column list, or null when `INSERT INTO t VALUES` has no column list. */ /** Explicit column list, or null when `INSERT INTO t VALUES` has no column list. */
columns: string[] | null; columns: string[] | null;
/** For each VALUES row, start offsets of top-level value expressions. */ /** For each VALUES row or SELECT projection list, start offsets of top-level expressions. */
rows: number[][]; rows: number[][];
span: SqlSemanticSpan; span: SqlSemanticSpan;
} }
@@ -164,6 +164,82 @@ function parseValuesRows(tokens: readonly SqlSemanticToken[], valuesIndex: numbe
return rows; return rows;
} }
function findWordIndexAtDepth(tokens: readonly SqlSemanticToken[], word: string, from: number, depth: number): number {
const needle = word.toLowerCase();
for (let index = from; index < tokens.length; index += 1) {
const item = tokens[index];
if (item?.depth === depth && item.kind === "word" && item.normalized === needle) return index;
}
return -1;
}
function findClosingParenIndex(tokens: readonly SqlSemanticToken[], openIndex: number): number {
const open = tokens[openIndex];
if (!open || open.text !== "(") return -1;
for (let index = openIndex + 1; index < tokens.length; index += 1) {
const item = tokens[index];
if (item?.text === ")" && item.depth === open.depth) return index;
}
return -1;
}
const SELECT_CLAUSE_BOUNDARIES = new Set(["from", "into", "where", "group", "having", "order", "offset", "fetch", "for", "option", "union", "except", "intersect", "limit", "qualify", "window"]);
function isSelectClauseBoundary(item: SqlSemanticToken | undefined, depth: number): boolean {
if (!item || item.depth !== depth || item.kind !== "word") return false;
return SELECT_CLAUSE_BOUNDARIES.has(item.normalized);
}
function isWildcardProjection(tokens: readonly SqlSemanticToken[], from: number, to: number, depth: number): boolean {
const topLevel = tokens.slice(from, to).filter((item) => item.depth === depth);
if (topLevel[topLevel.length - 1]?.text !== "*") return false;
if (topLevel.length === 1) return true;
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[] {
const select = tokens[selectIndex];
if (!select) return [];
const depth = select.depth;
let index = selectIndex + 1;
if (tokens[index]?.depth === depth && tokens[index]?.kind === "word" && (tokens[index]?.normalized === "all" || tokens[index]?.normalized === "distinct")) {
index += 1;
}
const maybeTop = tokens[index];
const topValue = tokens[index + 1];
const hasTopModifier = maybeTop?.depth === depth && maybeTop.kind === "word" && maybeTop.normalized === "top" && topValue?.depth === depth && (topValue.text === "(" || topValue.kind === "number" || topValue.kind === "parameter" || (topValue.kind === "word" && topValue.text.startsWith("@")));
if (hasTopModifier) {
index += 1;
if (tokens[index]?.text === "(") {
const closeIndex = findClosingParenIndex(tokens, index);
if (closeIndex < 0) return [];
index = closeIndex + 1;
} else {
index += 1;
}
if (tokens[index]?.depth === depth && tokens[index]?.normalized === "percent") index += 1;
if (tokens[index]?.depth === depth && tokens[index]?.normalized === "with" && tokens[index + 1]?.depth === depth && tokens[index + 1]?.normalized === "ties") index += 2;
}
const starts: number[] = [];
let expressionStart = index;
for (; index < tokens.length; index += 1) {
const item = tokens[index];
if (!item) break;
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);
expressionStart = index + 1;
}
if (index <= expressionStart || isWildcardProjection(tokens, expressionStart, index, depth)) return [];
starts.push(tokens[expressionStart]!.span.start);
return starts;
}
function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): InsertValuesClause | null { function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanticSpan): InsertValuesClause | null {
const insertIndex = findWordIndex(tokens, "insert"); const insertIndex = findWordIndex(tokens, "insert");
if (insertIndex < 0) return null; if (insertIndex < 0) return null;
@@ -200,13 +276,14 @@ function parseInsertClause(tokens: readonly SqlSemanticToken[], span: SqlSemanti
index = columnList.nextIndex; index = columnList.nextIndex;
} }
const valuesIndex = findWordIndex(tokens, "values", index); const sourceDepth = tokens[insertIndex]?.depth ?? 0;
const selectIndex = findWordIndex(tokens, "select", index); const valuesIndex = findWordIndexAtDepth(tokens, "values", index, sourceDepth);
if (valuesIndex < 0) return null; const selectIndex = findWordIndexAtDepth(tokens, "select", index, sourceDepth);
if (selectIndex >= 0 && selectIndex < valuesIndex) return null; if (valuesIndex < 0 && selectIndex < 0) return null;
const rows = parseValuesRows(tokens, valuesIndex); const rows = selectIndex >= 0 && (valuesIndex < 0 || selectIndex < valuesIndex) ? [selectProjectionStarts(tokens, selectIndex)] : parseValuesRows(tokens, valuesIndex);
if (rows.length === 0) return null; if (rows.length === 0) return null;
if (rows[0]?.length === 0) return null;
return { return {
table: tableInfo.name, table: tableInfo.name,
@@ -488,7 +565,7 @@ function mergeTextRanges(ranges: readonly TextRange[]): TextRange[] {
return merged; return merged;
} }
/** Parse INSERT ... VALUES clauses only inside the given document ranges (expanded to statement windows). */ /** Parse INSERT ... VALUES/SELECT clauses only inside the given document ranges (expanded to statement windows). */
export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly TextRange[]): InsertValuesClause[] { export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly TextRange[]): InsertValuesClause[] {
if (!sql.trim() || ranges.length === 0) return []; if (!sql.trim() || ranges.length === 0) return [];
const windows = mergeTextRanges(ranges.map((range) => expandToSqlStatementWindow(sql, range.from, range.to))); const windows = mergeTextRanges(ranges.map((range) => expandToSqlStatementWindow(sql, range.from, range.to)));
@@ -503,7 +580,7 @@ export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly T
return clauses; return clauses;
} }
/** Parse all INSERT ... VALUES clauses in `sql` (multi-statement aware). Prefer ranged parsing for editors. */ /** Parse all INSERT ... VALUES/SELECT clauses in `sql` (multi-statement aware). Prefer ranged parsing for editors. */
export function parseInsertValuesClauses(sql: string, dialectId = "mysql"): InsertValuesClause[] { export function parseInsertValuesClauses(sql: string, dialectId = "mysql"): InsertValuesClause[] {
if (!sql.trim()) return []; if (!sql.trim()) return [];
const allTokens = tokenizeSqlSemantic(sql, dialectId); const allTokens = tokenizeSqlSemantic(sql, dialectId);
+55 -46
View File
@@ -14,10 +14,7 @@ function assertSublinearScaling(measureAt: (scale: number) => number, options: {
const { smallScale, bigScale, maxRatio, maxMs, label } = options; const { smallScale, bigScale, maxRatio, maxMs, label } = options;
const smallMs = measureAt(smallScale); const smallMs = measureAt(smallScale);
const bigMs = measureAt(bigScale); const bigMs = measureAt(bigScale);
assert.ok( 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`);
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("maps explicit column list to single-row VALUES", () => { test("maps explicit column list to single-row VALUES", () => {
@@ -88,11 +85,7 @@ test("resolves columns from table metadata when column list is omitted", () => {
test("skips SQL Server identity columns when mapping multi-row VALUES without a column list", () => { test("skips SQL Server identity columns when mapping multi-row VALUES without a column list", () => {
const sql = "INSERT INTO dbo.users VALUES (N'A', 1), (N'B', 2)"; const sql = "INSERT INTO dbo.users VALUES (N'A', 1), (N'B', 2)";
const columns = insertValueHintColumnNames("sqlserver", [ const columns = insertValueHintColumnNames("sqlserver", [{ name: "id", is_identity: true }, { name: "name" }, { name: "status" }]);
{ name: "id", is_identity: true },
{ name: "name" },
{ name: "status" },
]);
const hints = parseInsertValueHints(sql, { const hints = parseInsertValueHints(sql, {
resolveTableColumns: () => columns, resolveTableColumns: () => columns,
}); });
@@ -116,30 +109,62 @@ test("skips SQL Server computed and temporal generated columns in positional hin
}); });
test("skips visible SQL Server generated columns in positional hints", () => { test("skips visible SQL Server generated columns in positional hints", () => {
assert.deepEqual( assert.deepEqual(insertValueHintColumnNames("sqlserver", [{ name: "name" }, { name: "valid_from", generated_always_type: 1 }, { name: "valid_to", generated_always_type: 2 }]), ["name"]);
insertValueHintColumnNames("sqlserver", [
{ name: "name" },
{ name: "valid_from", generated_always_type: 1 },
{ name: "valid_to", generated_always_type: 2 },
]),
["name"],
);
}); });
test("keeps identity columns in positional hints for databases other than SQL Server", () => { test("keeps identity columns in positional hints for databases other than SQL Server", () => {
assert.deepEqual(insertValueHintColumnNames("postgres", [{ name: "id", is_identity: true }, { name: "name" }]), ["id", "name"]);
});
test("maps INSERT ... SELECT projections to explicit target columns", () => {
const sql = "INSERT INTO dbo.users (id, name) SELECT source_id, source_name FROM staging";
const hints = parseInsertValueHints(sql);
assert.deepEqual( assert.deepEqual(
insertValueHintColumnNames("postgres", [ hints.map((hint) => ({ column: hint.column, text: sql.slice(hint.from).split(/[ ,]/u, 1)[0] })),
{ name: "id", is_identity: true }, [
{ name: "name" }, { column: "id", text: "source_id" },
]), { column: "name", text: "source_name" },
["id", "name"], ],
); );
}); });
test("returns no hints for INSERT ... SELECT", () => { test("skips SELECT modifiers and does not split nested projection expressions", () => {
const sql = "INSERT INTO users (id, name) SELECT id, name FROM staging"; const sql = "INSERT INTO dbo.users (name, row_count) SELECT DISTINCT TOP (10) COALESCE(first_name, last_name), COUNT(*) FROM staging";
assert.deepEqual(parseInsertValueHints(sql), []); const hints = parseInsertValueHints(sql);
assert.deepEqual(parseInsertValuesClauses(sql), []); assert.deepEqual(
hints.map((hint) => hint.column),
["name", "row_count"],
);
assert.ok(sql.slice(hints[0]!.from).startsWith("COALESCE(first_name, last_name)"));
assert.ok(sql.slice(hints[1]!.from).startsWith("COUNT(*)"));
});
test("resolves filtered SQL Server target columns for INSERT ... SELECT without a column list", () => {
const sql = "INSERT INTO dbo.users SELECT source_name, source_status FROM staging";
const columns = insertValueHintColumnNames("sqlserver", [{ name: "id", is_identity: true }, { name: "name" }, { name: "doubled", is_computed: true }, { name: "status" }, { name: "valid_from", generated_always_type: 1 }]);
const hints = parseInsertValueHints(sql, { resolveTableColumns: () => columns });
assert.deepEqual(
hints.map((hint) => hint.column),
["name", "status"],
);
});
test("returns no positional hints for wildcard INSERT ... SELECT projections", () => {
for (const sql of ["INSERT INTO users (id, name) SELECT * FROM staging", "INSERT INTO users (id, name) SELECT source.* FROM staging source"]) {
assert.deepEqual(parseInsertValueHints(sql), []);
assert.deepEqual(parseInsertValuesClauses(sql), []);
}
});
test("caps INSERT ... SELECT hints to the smaller target or projection count", () => {
assert.deepEqual(
parseInsertValueHints("INSERT INTO t (a, b) SELECT x, y, z FROM source").map((hint) => hint.column),
["a", "b"],
);
assert.deepEqual(
parseInsertValueHints("INSERT INTO t (a, b, c) SELECT x, y FROM source").map((hint) => hint.column),
["a", "b"],
);
}); });
test("caps hints when value count exceeds column count", () => { test("caps hints when value count exceeds column count", () => {
@@ -252,11 +277,7 @@ test("expandToSqlStatementWindow proves clean state when the cursor is more than
const sql = `CREATE FUNCTION f() RETURNS void AS $$ ${body} $$ LANGUAGE sql;`; const sql = `CREATE FUNCTION f() RETURNS void AS $$ ${body} $$ LANGUAGE sql;`;
const cursor = sql.indexOf(body) + 40_000; const cursor = sql.indexOf(body) + 40_000;
const window = expandToSqlStatementWindow(sql, cursor, cursor); const window = expandToSqlStatementWindow(sql, cursor, cursor);
assert.equal( assert.equal(window.from, 0, "semicolons inside a dollar-quoted function body are not statement boundaries, even past the lookback window");
window.from,
0,
"semicolons inside a dollar-quoted function body are not statement boundaries, even past the lookback window",
);
}); });
test("expandToSqlStatementWindow proves clean state when the cursor is more than 32KiB into deeply nested parens", () => { test("expandToSqlStatementWindow proves clean state when the cursor is more than 32KiB into deeply nested parens", () => {
@@ -266,11 +287,7 @@ test("expandToSqlStatementWindow proves clean state when the cursor is more than
const sql = `SELECT ${opens}${junk}${closes};`; const sql = `SELECT ${opens}${junk}${closes};`;
const cursor = sql.indexOf(junk) + junk.length - 100; const cursor = sql.indexOf(junk) + junk.length - 100;
const window = expandToSqlStatementWindow(sql, cursor, cursor); const window = expandToSqlStatementWindow(sql, cursor, cursor);
assert.equal( assert.equal(window.from, 0, "a ';' more than 32KiB past unclosed '(' characters is still nested, not a top-level statement boundary");
window.from,
0,
"a ';' more than 32KiB past unclosed '(' characters is still nested, not a top-level statement boundary",
);
}); });
test("expandToSqlStatementWindow stays bounded (fast path) for many small statements even far into the document", () => { test("expandToSqlStatementWindow stays bounded (fast path) for many small statements even far into the document", () => {
@@ -338,22 +355,14 @@ test("expandToSqlStatementWindow does not treat a backslash-escaped quote as clo
const sql = "SELECT 'it\\'s a test; end' FROM t;"; const sql = "SELECT 'it\\'s a test; end' FROM t;";
const cursor = sql.indexOf("FROM") + 2; const cursor = sql.indexOf("FROM") + 2;
const window = expandToSqlStatementWindow(sql, cursor, cursor); const window = expandToSqlStatementWindow(sql, cursor, cursor);
assert.equal( assert.equal(sql.slice(window.from, window.to), "SELECT 'it\\'s a test; end' FROM t", "the ';' inside the backslash-escaped string must not be mistaken for the statement boundary");
sql.slice(window.from, window.to),
"SELECT 'it\\'s a test; end' FROM t",
"the ';' inside the backslash-escaped string must not be mistaken for the statement boundary",
);
}); });
test("expandToSqlStatementWindow terminates a line comment at a bare '\\r' (no trailing '\\n')", () => { test("expandToSqlStatementWindow terminates a line comment at a bare '\\r' (no trailing '\\n')", () => {
const sql = "SELECT 1; -- comment\rSELECT 2;"; const sql = "SELECT 1; -- comment\rSELECT 2;";
const cursor = sql.indexOf("SELECT 2") + 4; const cursor = sql.indexOf("SELECT 2") + 4;
const window = expandToSqlStatementWindow(sql, cursor, cursor); const window = expandToSqlStatementWindow(sql, cursor, cursor);
assert.equal( assert.equal(sql.slice(window.from, window.to), "-- comment\rSELECT 2", "the comment must end at '\\r' so the trailing ';' is recognized as the real statement boundary");
sql.slice(window.from, window.to),
"-- comment\rSELECT 2",
"the comment must end at '\\r' so the trailing ';' is recognized as the real statement boundary",
);
}); });
test("expandToSqlStatementWindow finds the real end of a single statement larger than one lookahead window, instead of truncating", () => { test("expandToSqlStatementWindow finds the real end of a single statement larger than one lookahead window, instead of truncating", () => {