Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/olive-donuts-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"wrangler": patch
---

Recognise compound statement markers that are not padded with whitespace

`wrangler d1 execute --file` and `wrangler d1 migrations apply` split a SQL file into statements before sending them to D1. The splitter only recognised `BEGIN`, `CASE` and `END` when they were surrounded by whitespace, so SQL that SQLite accepts, such as a trigger body ending in `INSERT ...;END;` or a trigger declared with `WHEN (1=1)BEGIN`, was split incorrectly: statements after the trigger were swallowed into it and silently sent as a single statement.

Markers are now matched when delimited by punctuation as well, while identifiers that merely end in a keyword, such as a `weekend` table, are still left alone.
66 changes: 66 additions & 0 deletions packages/wrangler/src/__tests__/d1/splitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,72 @@ describe("splitSqlQuery()", () => {
`);
});

it("should end a compound statement when END follows a semicolon", ({
expect,
}) => {
expect(
splitSqlQuery(`
CREATE TRIGGER audit_trigger AFTER INSERT ON items
BEGIN
INSERT INTO audit (item_id) VALUES (new.id);END;
INSERT INTO items (id) VALUES (1);
SELECT * FROM items;`)
).toEqual([
`CREATE TRIGGER audit_trigger AFTER INSERT ON items
BEGIN
INSERT INTO audit (item_id) VALUES (new.id);END`,
"INSERT INTO items (id) VALUES (1)",
"SELECT * FROM items",
]);
});

it("should start a compound statement when BEGIN follows a parenthesis", ({
expect,
}) => {
expect(
splitSqlQuery(`
CREATE TRIGGER audit_trigger AFTER INSERT ON items FOR EACH ROW WHEN (1=1)BEGIN
INSERT INTO audit (item_id) VALUES (new.id);
END;
SELECT * FROM items;`)
).toEqual([
`CREATE TRIGGER audit_trigger AFTER INSERT ON items FOR EACH ROW WHEN (1=1)BEGIN
INSERT INTO audit (item_id) VALUES (new.id);
END`,
"SELECT * FROM items",
]);
});

it("should end a CASE expression closed by a parenthesis or comma", ({
expect,
}) => {
expect(
splitSqlQuery(`
SELECT SUM(CASE WHEN a THEN 1 ELSE 0 END) FROM t;
SELECT CASE WHEN a THEN 1 ELSE 0 END, b FROM t;
SELECT * FROM t;`)
).toEqual([
"SELECT SUM(CASE WHEN a THEN 1 ELSE 0 END) FROM t",
"SELECT CASE WHEN a THEN 1 ELSE 0 END, b FROM t",
"SELECT * FROM t",
]);
});

it("should not treat an identifier ending in END as a compound statement end", ({
expect,
}) => {
expect(
splitSqlQuery(`
CREATE TABLE weekend (id INTEGER PRIMARY KEY);
INSERT INTO weekend (id) VALUES (1);
SELECT * FROM weekend;`)
).toEqual([
"CREATE TABLE weekend (id INTEGER PRIMARY KEY)",
"INSERT INTO weekend (id) VALUES (1)",
"SELECT * FROM weekend",
]);
});

it("should handle compound statements for BEGINs", ({ expect }) => {
expect(
splitSqlQuery(`
Expand Down
13 changes: 11 additions & 2 deletions packages/wrangler/src/d1/splitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,25 @@ function isDollarQuoteIdentifier(str: string) {
);
}

/**
* Compound statement markers only need to be delimited from surrounding
* identifiers, not padded with whitespace: SQLite accepts `WHEN (1=1)BEGIN` and
* `INSERT ...;END;`. The lookbehind keeps identifiers that merely end in the
* keyword, such as a `weekend` column, from matching.
*/
const COMPOUND_STATEMENT_START = /(?<![A-Za-z0-9_$])(BEGIN|CASE)\s$/i;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A SQL file that opens with a transaction keyword is now sent to D1 as a single unsplit blob

A file whose very first word opens a transaction is now treated as the start of a trigger body (COMPOUND_STATEMENT_START at packages/wrangler/src/d1/splitter.ts:256), so every statement in the file is merged into one and sent to D1 unsplit.

Impact: Running such a SQL file with wrangler d1 execute --file or d1 migrations apply fails or behaves incorrectly instead of executing each statement.

Why removing the leading \s changes first-statement behaviour

The old regex /\s(BEGIN|CASE)\s$/i required whitespace before the keyword, so a BEGIN at the very beginning of the accumulated buffer (i.e. at the start of the file, before any ; has reset str) never started a compound statement. The new lookbehind also passes on an empty prefix, so begin transaction; as the first line pushes a frame onto compoundStatementStack and no END ever pops it — all subsequent semicolons are swallowed (packages/wrangler/src/d1/splitter.ts:183-190).

trimSqlQuery only strips the exact uppercase literal BEGIN TRANSACTION; (packages/wrangler/src/d1/trimmer.ts:17-19), so lowercase begin transaction;, BEGIN TRANSACTION ;, or BEGIN IMMEDIATE; are not removed.

Verified: with the old regexes, begin transaction;\nINSERT INTO a VALUES (1);\nINSERT INTO b VALUES (2);\ncommit; splits into four statements; with the new one it returns the whole file as a single statement.

Prompt for agents
COMPOUND_STATEMENT_START in packages/wrangler/src/d1/splitter.ts previously required whitespace before BEGIN/CASE, which incidentally prevented a BEGIN at the very start of the input (before any semicolon resets the accumulator) from opening a compound statement. With the negative lookbehind, an empty prefix now matches, so a file that begins with a transaction opener that trimSqlQuery did not strip (it only removes the exact literal 'BEGIN TRANSACTION;', see packages/wrangler/src/d1/trimmer.ts) — e.g. lowercase 'begin transaction;' or 'BEGIN IMMEDIATE;' — pushes a compound frame that is never popped, so the whole file collapses into one statement. Consider either making transaction openers recognisable (e.g. broaden trimSqlQuery/mayContainTransaction to be case-insensitive and tolerate the DEFERRED/IMMEDIATE/EXCLUSIVE forms) or making the splitter not treat a BEGIN that starts a transaction as a compound-statement start. Add a regression test covering a file starting with a lowercase transaction opener.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this one holds. splitSqlQuery() calls trimSqlQuery() first, which strips a leading BEGIN TRANSACTION; and trailing COMMIT; by design, since D1 already wraps the file in a transaction (src/d1/trimmer.ts). So for

BEGIN TRANSACTION;
INSERT INTO t VALUES (1);
COMMIT;

the splitter never sees the transaction keywords, and the output is the single remaining INSERT — which is the intended behaviour, not an unsplit blob. I checked the output on this branch and on main and it is byte-for-byte identical: ["\\nINSERT INTO t VALUES (1);\\n"]. The existing should trim a regular old sqlite dump test covers this path and still passes.

const COMPOUND_STATEMENT_END = /(?<![A-Za-z0-9_$])END[^A-Za-z0-9_$]$/i;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Trigger bodies are split apart when they mention a bracket-quoted or accented name ending in "end"

A trigger body is treated as finished (COMPOUND_STATEMENT_END at packages/wrangler/src/d1/splitter.ts:257) as soon as it contains a name such as [end] or néend, so the trigger is chopped into several broken statements and sent to D1 that way.

Impact: SQL files whose triggers reference such column/table names are executed as invalid fragments instead of one trigger, causing errors or a partially applied migration.

Why the new delimiter class matches inside identifiers

The new end marker regex is /(?<![A-Za-z0-9_$])END[^A-Za-z0-9_$]$/i. The character class is ASCII-only and does not account for identifier quoting:

  • néend — the character before end is é, which is not in [A-Za-z0-9_$], so the lookbehind passes and the following ;/space satisfies the trailing class. Note isDollarQuoteIdentifier (packages/wrangler/src/d1/splitter.ts:239-248) deliberately treats accented letters as identifier characters, so the two helpers disagree.
  • [end][ and ] are both outside the class, so the marker matches. Bracket quoting is recognised elsewhere in this module (packages/wrangler/src/d1/splitter.ts:114-118), but splitSqlIntoStatements (packages/wrangler/src/d1/splitter.ts:146-194) does not consume [...] in bulk the way it does ', " and `.

Verified against both regex versions: with the old \sEND[;\s]$ the trigger below stays intact; with the new regex it splits into CREATE TRIGGER ... néend, UPDATE z SET c = 2, END, SELECT 1:

CREATE TRIGGER t AFTER INSERT ON x
BEGIN
  UPDATE y SET a = 1 WHERE b = néend;
  UPDATE z SET c = 2;
END;
SELECT 1;
Prompt for agents
The new compound-statement marker regexes in packages/wrangler/src/d1/splitter.ts use an ASCII-only identifier class ([A-Za-z0-9_$]) for the negative lookbehind and the trailing delimiter. Two false positives result: (1) identifiers containing non-ASCII letters immediately before the keyword, e.g. `néend`, match because `é` is not in the class — note isDollarQuoteIdentifier in the same file intentionally treats diacritical letters as identifier characters, so the two helpers are inconsistent; (2) bracket-quoted identifiers such as `[end]` match because `[` and `]` are outside the class, and unlike '"` quoting, splitSqlIntoStatements does not consume `[...]` in bulk (normalizeSqlLineEndings in the same file does treat `[` as a quote opener). Consider using Unicode-aware classes (\p{L}\p{N}_$ with the u flag) for both regexes, and handling `[` ... `]` in the switch in splitSqlIntoStatements the same way as the other quote characters so bracket-quoted identifiers are skipped.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one reproduces, but it predates this PR — the behaviour is identical before and after my change. For

CREATE TRIGGER t AFTER INSERT ON items
BEGIN
 UPDATE x SET [end] = 1;
 UPDATE y SET z = 2;
END;
SELECT 1;

both main and this branch return the same four fragments (...SET [end] = 1, UPDATE y SET z = 2, END, SELECT 1).

The root cause is separate from the delimiter matching: splitSqlIntoStatements() treats ', " and backticks as quotes but not SQLite's bracket-quoted identifiers, so [end] is scanned as bare SQL. Interestingly normalizeSqlLineEndings() in the same file does handle [], so the two functions disagree. Happy to open a separate issue for that (and to take it in a follow-up PR) — it needs its own fix in the scanner rather than in these regexes, so I'd rather not widen this PR to cover it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correcting my reply above — I got this wrong, and both halves of this finding were valid regressions introduced by this branch.

The four-fragment split I attributed to main was actually measured on this branch. On main, the whitespace-delimited \sEND[;\s]$ never fires inside [end] (no whitespace on either side of the keyword), so that example splits correctly into two statements — re-verified end-to-end on released wrangler 4.123.0, where the trigger executes fine via d1 execute --local --file. The auto-triage bot on #15228 flagged exactly this discrepancy, and it was right.

Fixed in two commits:

  • de80155splitSqlIntoStatements() now consumes [] like the other quote styles, so bracket-quoted identifiers are opaque to both the ; splitting and the marker regexes. This also fixes the genuinely pre-existing half of [wrangler] D1 SQL splitter does not treat bracket-quoted identifiers as quoted #15228: a ; inside a bracket-quoted identifier (CREATE TABLE metrics ([value;unit] TEXT);) mis-split on main too.
  • d543f3a — the marker regexes now use Unicode identifier classes (\p{L}\p{N}_$ with the u flag), so néend no longer matches, consistent with how isDollarQuoteIdentifier() treats letters with diacritics.

Regression tests added for the trigger-[end], [value;unit] and néend cases — all three fail against the previous state of this branch and pass now.


/**
* Returns true if the `str` ends with a compound statement `BEGIN` or `CASE` marker.
*/
function isCompoundStatementStart(str: string) {
return /\s(BEGIN|CASE)\s$/i.test(str);
return COMPOUND_STATEMENT_START.test(str);
}

/**
* Returns true if the `str` ends with a compound statement `END` marker.
*/
function isCompoundStatementEnd(str: string) {
return /\sEND[;\s]$/i.test(str);
return COMPOUND_STATEMENT_END.test(str);
}
Loading