diff --git a/apps/desktop/src/lib/sql/insertValueHints.ts b/apps/desktop/src/lib/sql/insertValueHints.ts index 0549de7a75..4762556993 100644 --- a/apps/desktop/src/lib/sql/insertValueHints.ts +++ b/apps/desktop/src/lib/sql/insertValueHints.ts @@ -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; } diff --git a/docs/screenshots/issue-9966-after.png b/docs/screenshots/issue-9966-after.png new file mode 100644 index 0000000000..7deadade7a Binary files /dev/null and b/docs/screenshots/issue-9966-after.png differ diff --git a/docs/screenshots/issue-9966-before.png b/docs/screenshots/issue-9966-before.png new file mode 100644 index 0000000000..e70251e22f Binary files /dev/null and b/docs/screenshots/issue-9966-before.png differ diff --git a/packages/app-tests/insertValueHints.test.ts b/packages/app-tests/insertValueHints.test.ts index 0bbe5b6f24..25c99395ac 100644 --- a/packages/app-tests/insertValueHints.test.ts +++ b/packages/app-tests/insertValueHints.test.ts @@ -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), []);