fix(editor): show insert value hints for all statements in a window

This commit is contained in:
zipg authored and t8y2 committed 2026-09-22 22:33:07 +08:00
1 parent 20e0c6a95b
commit 60b5480374
4 files changed
+116 -3

No files matched your search

+41 -3
View File
@@ -686,14 +686,52 @@ export function parseInsertValuesClausesInRanges(sql: string, ranges: readonly T
return clauses;
}
/**
* Split one statement group into its individual INSERT statements.
*
* Semicolons are optional in several dialects (T-SQL in particular), so a single top-level group
* can hold more than one INSERT statement -- `BEGIN TRANSACTION ... INSERT ... INSERT ... COMMIT`
* typed in one query window is the everyday case. `parseInsertClause` only ever reads the first
* INSERT of the token list it is handed, so passing it the whole group left every later INSERT
* without its VALUES/SELECT-to-column mapping (#9966). Splitting at each INSERT keyword keeps the
* per-statement parse working for semicolon-less scripts; scripts that do terminate their
* statements with `;` still yield one group (and therefore one segment) per statement.
*
* An `insert` word is only a statement start when the next token is `into`: MySQL/MariaDB also
* expose the INSERT(str, pos, len, newstr) string function, and splitting at that call would
* truncate its host statement mid-expression (parseInsertClause requires INTO anyway).
*/
function splitInsertStatements(tokens: readonly SqlSemanticToken[]): Array<{ span: SqlSemanticSpan; tokens: SqlSemanticToken[] }> {
const starts: number[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const item = tokens[index];
const next = tokens[index + 1];
if (item?.kind === "word" && item.normalized === "insert" && next?.kind === "word" && next.normalized === "into") {
starts.push(index);
}
}
const statements: Array<{ span: SqlSemanticSpan; tokens: SqlSemanticToken[] }> = [];
for (let position = 0; position < starts.length; position += 1) {
const from = starts[position]!;
const to = position + 1 < starts.length ? starts[position + 1]! : tokens.length;
const statementTokens = tokens.slice(from, to);
const first = statementTokens[0]!;
const last = statementTokens[statementTokens.length - 1]!;
statements.push({ span: { start: first.span.start, end: last.span.end }, tokens: statementTokens });
}
return statements;
}
/** Parse all INSERT ... VALUES/SELECT clauses in `sql` (multi-statement aware). Prefer ranged parsing for editors. */
export function parseInsertValuesClauses(sql: string, dialectId = "mysql"): InsertValuesClause[] {
if (!sql.trim()) return [];
const allTokens = tokenizeSqlSemantic(sql, dialectId);
const clauses: InsertValuesClause[] = [];
for (const { span, tokens } of statementTokenGroups(sql, allTokens)) {
const clause = parseInsertClause(tokens, span);
if (clause) clauses.push(clause);
for (const { tokens } of statementTokenGroups(sql, allTokens)) {
for (const statement of splitInsertStatements(tokens)) {
const clause = parseInsertClause(statement.tokens, statement.span);
if (clause) clauses.push(clause);
}
}
return clauses;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+75
View File
@@ -583,6 +583,81 @@ test("documents a known limitation of the pure-string fallback (no live EditorSt
assert.notEqual(window.from, 0, "known-imperfect (pure-string fallback only): a correct implementation would return 0 here (the whole CREATE FUNCTION is one statement)");
});
test("parses every INSERT inside a semicolon-less statement group", () => {
// T-SQL scripts routinely omit statement terminators; the reported case is one query window
// holding BEGIN TRANSACTION + two INSERT ... SELECT statements (#9966).
const sql = ["BEGIN TRANSACTION", "INSERT INTO u_msfx_a (id, billno) SELECT src_id, src_billno FROM staging_a", "INSERT INTO u_msfx_b (id, billno) SELECT src_id, src_billno FROM staging_b", "COMMIT TRANSACTION"].join("\n");
const clauses = parseInsertValuesClauses(sql, "sqlserver");
assert.deepEqual(
clauses.map((clause) => clause.table),
["u_msfx_a", "u_msfx_b"],
);
const hints = buildInsertValueHints(clauses);
assert.deepEqual(
hints.map((hint) => hint.column),
["id", "billno", "id", "billno"],
);
assert.deepEqual(
hints.map((hint) => hint.from),
[sql.indexOf("src_id"), sql.indexOf("src_billno"), sql.lastIndexOf("src_id"), sql.lastIndexOf("src_billno")],
);
});
test("parses every INSERT ... VALUES inside a semicolon-less statement group", () => {
const sql = "INSERT INTO t1 (id, name) VALUES (1, 'a')\nINSERT INTO t2 (id, name) VALUES (2, 'b')";
const clauses = parseInsertValuesClauses(sql, "sqlserver");
assert.deepEqual(
clauses.map((clause) => clause.table),
["t1", "t2"],
);
assert.deepEqual(
buildInsertValueHints(clauses).map((hint) => hint.column),
["id", "name", "id", "name"],
);
});
test("does not split a statement at the INSERT() string function", () => {
// MySQL/MariaDB expose INSERT(str, pos, len, newstr); an `insert` word without a following
// INTO is a function call, not a statement start.
const sql = "INSERT INTO t1 (id, name) SELECT id, INSERT('ab', 1, 2, 'xy') FROM src\nINSERT INTO t2 (id, name) VALUES (1, INSERT('ab', 1, 2, 'xy'))";
const clauses = parseInsertValuesClauses(sql, "mysql");
assert.deepEqual(
clauses.map((clause) => clause.table),
["t1", "t2"],
);
});
test("parses each batch of a GO-separated SQL Server script", () => {
const sql = ["INSERT INTO t1 (id, name) SELECT src_id, src_name FROM staging1", "GO", "INSERT INTO t2 (id, name) SELECT src_id, src_name FROM staging2", "GO"].join("\n");
const clauses = parseInsertValuesClauses(sql, "sqlserver");
assert.deepEqual(
clauses.map((clause) => clause.table),
["t1", "t2"],
);
assert.deepEqual(
buildInsertValueHints(clauses).map((hint) => hint.column),
["id", "name", "id", "name"],
);
});
test("still parses INSERT statements separated by semicolons", () => {
const sql = "INSERT INTO t1 (id, name) SELECT src_id, src_name FROM staging1; INSERT INTO t2 (id, name) SELECT src_id, src_name FROM staging2;";
const clauses = parseInsertValuesClauses(sql, "sqlserver");
assert.deepEqual(
clauses.map((clause) => clause.table),
["t1", "t2"],
);
assert.deepEqual(
buildInsertValueHints(clauses).map((hint) => hint.column),
["id", "name", "id", "name"],
);
});
test("does not read a MERGE INSERT branch as an INSERT statement", () => {
const sql = "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name)";
assert.deepEqual(parseInsertValuesClauses(sql, "sqlserver"), []);
});
test("ignores statements that are not INSERT VALUES", () => {
const sql = "SELECT 1; UPDATE users SET name = 'a' WHERE id = 1;";
assert.deepEqual(parseInsertValueHints(sql), []);