Skip to content

Commit e47b004

Browse files
RihanArfanatinux
andauthored
fix: split sql trigger statements as a single query (#451)
Co-authored-by: Sébastien Chopin <[email protected]>
1 parent dcef1c8 commit e47b004

File tree

2 files changed

+115
-32
lines changed

2 files changed

+115
-32
lines changed

src/runtime/database/server/utils/migrations/helpers.ts

+75-14
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,28 @@ export async function copyDatabaseQueriesToHubDir(hub: HubConfig) {
7878
// #endregion
7979

8080
// #region Utils
81+
/**
82+
* Split a string containing SQL queries into an array of individual queries after removing comments
83+
*/
8184
export function splitSqlQueries(sqlFileContent: string): string[] {
82-
const queries = []
83-
// Track whether we're inside a string literal
85+
const queries: string[] = []
8486
let inString = false
8587
let stringFence = ''
8688
let result = ''
8789

88-
// Process the content character by character
90+
let currentGeneralWord = ''
91+
let previousGeneralWord = ''
92+
let inTrigger = false
93+
94+
let currentTriggerWord = ''
95+
let triggerBlockNestingLevel = 0
96+
8997
for (let i = 0; i < sqlFileContent.length; i += 1) {
9098
const char = sqlFileContent[i]
9199
const nextChar = sqlFileContent[i + 1]
92100

93101
// Handle string literals
94-
if ((char === '\'' || char === '"') && sqlFileContent[i - 1] !== '\\') {
102+
if ((char === '\'' || char === '"') && (i === 0 || sqlFileContent[i - 1] !== '\\')) {
95103
if (!inString) {
96104
inString = true
97105
stringFence = char
@@ -102,15 +110,15 @@ export function splitSqlQueries(sqlFileContent: string): string[] {
102110

103111
// Only remove comments when not inside a string
104112
if (!inString) {
105-
// `--` comments
113+
// Handle -- comments
106114
if (char === '-' && nextChar === '-') {
107115
while (i < sqlFileContent.length && sqlFileContent[i] !== '\n') {
108116
i += 1
109117
}
110118
continue
111119
}
112120

113-
// `/* */` comments
121+
// Handle /* */ comments
114122
if (char === '/' && nextChar === '*') {
115123
i += 2
116124
while (i < sqlFileContent.length && !(sqlFileContent[i] === '*' && sqlFileContent[i + 1] === '/')) {
@@ -120,29 +128,82 @@ export function splitSqlQueries(sqlFileContent: string): string[] {
120128
continue
121129
}
122130

131+
// Track general keywords for CREATE TRIGGER detection
132+
if (/\w/.test(char)) {
133+
currentGeneralWord += char.toLowerCase()
134+
} else {
135+
// Check if previous word was 'create' and current is 'trigger'
136+
if (previousGeneralWord === 'create' && currentGeneralWord === 'trigger') {
137+
inTrigger = true
138+
}
139+
previousGeneralWord = currentGeneralWord
140+
currentGeneralWord = ''
141+
}
142+
143+
// If in trigger, track BEGIN/END
144+
if (inTrigger) {
145+
if (/\w/.test(char)) {
146+
currentTriggerWord += char.toLowerCase()
147+
} else {
148+
if (currentTriggerWord === 'begin') {
149+
triggerBlockNestingLevel++
150+
} else if (currentTriggerWord === 'end') {
151+
triggerBlockNestingLevel = Math.max(triggerBlockNestingLevel - 1, 0)
152+
}
153+
currentTriggerWord = ''
154+
}
155+
}
156+
157+
// Handle semicolon
123158
if (char === ';' && sqlFileContent[i - 1] !== '\\') {
124-
if (result.trim() !== '') {
159+
if (inTrigger) {
160+
if (triggerBlockNestingLevel === 0) {
161+
// End of trigger, split here
162+
result += char
163+
const trimmedResult = result.trim()
164+
if (trimmedResult !== '') {
165+
queries.push(trimmedResult)
166+
}
167+
result = ''
168+
inTrigger = false
169+
triggerBlockNestingLevel = 0
170+
continue
171+
} else {
172+
// Inside trigger, do not split
173+
result += char
174+
}
175+
} else {
176+
// Not in trigger, split as usual
125177
result += char
126-
queries.push(result.trim())
178+
const trimmedResult = result.trim()
179+
if (trimmedResult !== '') {
180+
queries.push(trimmedResult)
181+
}
127182
result = ''
183+
continue
128184
}
129-
continue
130185
}
131186
}
132187

133188
result += char
134189
}
135-
if (result.trim() !== '') {
136-
queries.push(result.trim())
190+
191+
// Add any remaining content as a query
192+
const finalTrimmed = result.trim()
193+
if (finalTrimmed !== '') {
194+
queries.push(finalTrimmed)
137195
}
138196

139-
// Process each query
197+
// Process each query to ensure it ends with a single semicolon and filter out empty/semicolon-only
140198
return queries
141199
.map((query) => {
142-
if (!query.endsWith(';')) {
143-
query += ';'
200+
// Handle semicolons in trigger bodies
201+
if (query.includes('TRIGGER') && query.includes('BEGIN')) {
202+
// First, handle the statements inside the trigger
203+
query = query.replace(/;+(?=\s+(?:END|\S|$))/g, ';')
144204
}
145205
return query.replace(/;+$/, ';')
146206
})
207+
.filter(query => query !== ';' && query.trim() !== '')
147208
}
148209
// #endregion

test/migration.helpers.test.ts

+40-18
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe('splitSqlQueries', () => {
7676
it('Should respect -- within a string', () => {
7777
const sqlFileContent = `
7878
CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255));
79-
INSERT INTO users (id, name) VALUES (1, 'John -- This is a comment');
79+
INSERT INTO users (id, name) VALUES (1, 'John -- This is a comment');
8080
`
8181
const queries = splitSqlQueries(sqlFileContent)
8282
expect(queries).toHaveLength(2)
@@ -86,7 +86,7 @@ describe('splitSqlQueries', () => {
8686
it('Should respect /* */ within a string', () => {
8787
const sqlFileContent = `
8888
CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(255));
89-
INSERT INTO users (id, name) VALUES (1, 'John /* This is a comment */');
89+
INSERT INTO users (id, name) VALUES (1, 'John /* This is a comment */');
9090
`
9191
const queries = splitSqlQueries(sqlFileContent)
9292
expect(queries).toHaveLength(2)
@@ -102,16 +102,16 @@ describe('splitSqlQueries', () => {
102102
103103
-- 2. Empty Results
104104
SELECT * FROM users WHERE id = -1;
105-
SELECT orders.id, users.name
106-
FROM orders
107-
LEFT JOIN users ON orders.user_id = users.id
105+
SELECT orders.id, users.name
106+
FROM orders
107+
LEFT JOIN users ON orders.user_id = users.id
108108
WHERE users.id IS NULL;
109109
110110
-- 3. Duplicate Handling
111111
INSERT INTO products (id, name) VALUES (1, 'Widget'), (1, 'Widget');
112-
SELECT name, COUNT(*) AS cnt
113-
FROM products
114-
GROUP BY name
112+
SELECT name, COUNT(*) AS cnt
113+
FROM products
114+
GROUP BY name
115115
HAVING cnt > 1;
116116
117117
-- 4. Aggregation Edge Cases
@@ -152,8 +152,8 @@ describe('splitSqlQueries', () => {
152152
SELECT * FROM events WHERE event_date BETWEEN '2024-01-01' AND '2024-12-31';
153153
154154
-- 13. Self-JOIN
155-
SELECT a.id AS parent_id, b.id AS child_id
156-
FROM users a
155+
SELECT a.id AS parent_id, b.id AS child_id
156+
FROM users a
157157
JOIN users b ON a.id = b.parent_id;
158158
159159
-- 14. Triggers or Constraints
@@ -168,14 +168,14 @@ describe('splitSqlQueries', () => {
168168
'SELECT * FROM users WHERE email IS NULL;',
169169
'SELECT * FROM users WHERE email = \'\';',
170170
'SELECT * FROM users WHERE id = -1;',
171-
'SELECT orders.id, users.name \n'
172-
+ ' FROM orders \n'
173-
+ ' LEFT JOIN users ON orders.user_id = users.id \n'
171+
'SELECT orders.id, users.name\n'
172+
+ ' FROM orders\n'
173+
+ ' LEFT JOIN users ON orders.user_id = users.id\n'
174174
+ ' WHERE users.id IS NULL;',
175175
'INSERT INTO products (id, name) VALUES (1, \'Widget\'), (1, \'Widget\');',
176-
'SELECT name, COUNT(*) AS cnt \n'
177-
+ ' FROM products \n'
178-
+ ' GROUP BY name \n'
176+
'SELECT name, COUNT(*) AS cnt\n'
177+
+ ' FROM products\n'
178+
+ ' GROUP BY name\n'
179179
+ ' HAVING cnt > 1;',
180180
'SELECT AVG(price), SUM(price) FROM orders WHERE 1 = 0;',
181181
'SELECT user_id, COUNT(*) FROM orders WHERE user_id = 1 GROUP BY user_id;',
@@ -196,11 +196,33 @@ describe('splitSqlQueries', () => {
196196
'SELECT * FROM orders FORCE INDEX (order_date_index) WHERE order_date = \'2024-01-01\';',
197197
'INSERT INTO events (id, event_date) VALUES (1, \'2024-02-29\'), (2, \'0000-00-00\'), (3, \'9999-12-31\');',
198198
'SELECT * FROM events WHERE event_date BETWEEN \'2024-01-01\' AND \'2024-12-31\';',
199-
'SELECT a.id AS parent_id, b.id AS child_id \n'
200-
+ ' FROM users a \n'
199+
'SELECT a.id AS parent_id, b.id AS child_id\n'
200+
+ ' FROM users a\n'
201201
+ ' JOIN users b ON a.id = b.parent_id;',
202202
'INSERT INTO users (id, name, email) VALUES (NULL, \'Test\', \'[email protected]\');',
203203
'INSERT INTO orders (id, status) VALUES (NULL, NULL);'
204204
])
205205
})
206+
207+
it('Should keep trigger sql queries as one query', () => {
208+
const sqlFileContent = `
209+
-- Create a table. And an external content fts5 table to index it.
210+
CREATE TABLE t1(a INTEGER PRIMARY KEY, b, c);
211+
CREATE VIRTUAL TABLE fts_idx USING fts5(b, c, content='t1', content_rowid='a');
212+
213+
-- Triggers to keep the FTS index up to date.
214+
CREATE TRIGGER t1_ai AFTER INSERT ON t1 BEGIN
215+
INSERT INTO fts_idx(rowid, b, c) VALUES (new.a, new.b, new.c);
216+
END;
217+
CREATE TRIGGER t1_ad AFTER DELETE ON t1 BEGIN
218+
INSERT INTO fts_idx(fts_idx, rowid, b, c) VALUES('delete', old.a, old.b, old.c);
219+
END;
220+
CREATE TRIGGER t1_au AFTER UPDATE ON t1 BEGIN
221+
INSERT INTO fts_idx(fts_idx, rowid, b, c) VALUES('delete', old.a, old.b, old.c);
222+
INSERT INTO fts_idx(rowid, b, c) VALUES (new.a, new.b, new.c);
223+
END;
224+
`
225+
const queries = splitSqlQueries(sqlFileContent)
226+
expect(queries).toHaveLength(5)
227+
})
206228
})

0 commit comments

Comments
 (0)