From 31d90434fd6b16056a4ef0cf0b70e62b3fbac9ca Mon Sep 17 00:00:00 2001 From: jevb Date: Wed, 1 Apr 2026 15:35:50 +0200 Subject: [PATCH] fix: handle BEGIN...END blocks in SQL migration splitter The splitStatements function naively split on every semicolon, breaking CREATE TRIGGER definitions that contain semicolons inside their BEGIN...END bodies. Now tracks depth so trigger bodies are kept intact. Fixes TestWebhook_ParticipantLeft_NoDoubleBroadcast_AfterFreshCleanup and TestWebhook_ParticipantLeft_OldToken_DoesNotTeardownReplacement. --- Server/db/migrate.go | 78 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/Server/db/migrate.go b/Server/db/migrate.go index baf6b56e..dc1e80af 100644 --- a/Server/db/migrate.go +++ b/Server/db/migrate.go @@ -227,18 +227,84 @@ func applyMigration(database *DB, name, rawSQL string) error { return nil } -// splitStatements splits raw SQL into individual statements on semicolons. +// splitStatements splits raw SQL into individual statements on semicolons, +// correctly handling BEGIN...END blocks used by CREATE TRIGGER definitions. // Empty/comment-only fragments are discarded. func splitStatements(raw string) []string { - parts := strings.Split(raw, ";") - out := make([]string, 0, len(parts)) - for _, p := range parts { - s := strings.TrimSpace(p) - if s == "" || isCommentOnly(s) { + out := make([]string, 0) + var buf strings.Builder + depth := 0 + + for _, line := range strings.Split(raw, "\n") { + trimmed := strings.TrimSpace(line) + + // Track BEGIN...END depth for trigger bodies. + upperTrimmed := strings.ToUpper(trimmed) + if depth > 0 && (upperTrimmed == "END;" || upperTrimmed == "END") { + depth-- + buf.WriteString(line) + buf.WriteString("\n") + if depth == 0 { + // END; closes the trigger — flush the entire block as one statement. + s := strings.TrimSpace(buf.String()) + // Strip trailing semicolons so the executor doesn't choke. + s = strings.TrimRight(s, ";") + s = strings.TrimSpace(s) + if s != "" && !isCommentOnly(s) { + out = append(out, s) + } + buf.Reset() + } continue } + + // Detect BEGIN that opens a trigger body. The keyword appears at + // the end of a CREATE TRIGGER line (e.g. "... BEGIN") or on its + // own line inside a trigger definition. + if strings.HasSuffix(upperTrimmed, " BEGIN") || upperTrimmed == "BEGIN" { + depth++ + buf.WriteString(line) + buf.WriteString("\n") + continue + } + + if depth > 0 { + // Inside a BEGIN...END block — accumulate without splitting. + buf.WriteString(line) + buf.WriteString("\n") + continue + } + + // Outside any block — split on semicolons within this line. + buf.WriteString(line) + buf.WriteString("\n") + + // Check whether the accumulated buffer contains a semicolon to split on. + // We split the full buffer content, not just the current line, because a + // statement may span multiple lines before its terminating semicolon. + content := buf.String() + if strings.Contains(content, ";") { + parts := strings.Split(content, ";") + // All parts except the last are complete statements. + for _, p := range parts[:len(parts)-1] { + s := strings.TrimSpace(p) + if s == "" || isCommentOnly(s) { + continue + } + out = append(out, s) + } + // The last part is the remainder after the final semicolon. + buf.Reset() + buf.WriteString(parts[len(parts)-1]) + } + } + + // Flush any remaining content (statement without trailing semicolon). + s := strings.TrimSpace(buf.String()) + if s != "" && !isCommentOnly(s) { out = append(out, s) } + return out }