From 11292b044100dea3f0c82ea06bbf007e45aaeac7 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Tue, 15 Jul 2025 18:05:06 +0100 Subject: [PATCH 1/4] fix: ELSEIF handling in SQL formatter to resolve unclosed parentheses warning This resolves the "WARNING: unclosed parentheses or section" error that occurred when parsing SQL statements containing ELSEIF keywords in IF/ELSEIF/ELSE/END IF blocks. --- src/SqlFormatter.php | 2 +- src/Tokenizer.php | 1 + tests/SqlFormatterTest.php | 1 + tests/clihighlight.txt | 12 ++++++++++++ tests/compress.txt | 2 ++ tests/format-highlight.html | 12 ++++++++++++ tests/format.txt | 12 ++++++++++++ tests/highlight.html | 2 ++ tests/sql.sql | 2 ++ 9 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/SqlFormatter.php b/src/SqlFormatter.php index aee3f23..7b626f4 100644 --- a/src/SqlFormatter.php +++ b/src/SqlFormatter.php @@ -301,7 +301,7 @@ public function format(string $string, string $indentString = ' '): string $newline = true; $increaseBlockIndent = true; } - } elseif (in_array($tokenValueUpper, ['WHEN', 'THEN', 'ELSE', 'END'], true)) { + } elseif (in_array($tokenValueUpper, ['WHEN', 'THEN', 'ELSE', 'ELSEIF', 'END'], true)) { if ($tokenValueUpper !== 'THEN') { $decreaseIndentationLevelFx(); diff --git a/src/Tokenizer.php b/src/Tokenizer.php index f8f35a6..68b1f3e 100644 --- a/src/Tokenizer.php +++ b/src/Tokenizer.php @@ -104,6 +104,7 @@ final class Tokenizer 'DUPLICATE', 'DYNAMIC', 'ELSE', + 'ELSEIF', 'ENCLOSED', 'END', 'ENGINE', diff --git a/tests/SqlFormatterTest.php b/tests/SqlFormatterTest.php index 6993bbc..64c3d32 100644 --- a/tests/SqlFormatterTest.php +++ b/tests/SqlFormatterTest.php @@ -175,4 +175,5 @@ public static function highlightData(): Generator { return self::fileDataProvider('highlight.html'); } + } diff --git a/tests/clihighlight.txt b/tests/clihighlight.txt index 01fcff0..71386f8 100644 --- a/tests/clihighlight.txt +++ b/tests/clihighlight.txt @@ -1205,3 +1205,15 @@ MY_NON_TOP_LEVEL_KEYWORD_FX_3(); CREATE TABLE t ( c VARCHAR(20) ) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB +--- +BEGIN + IF first_name != '' AND last_name != '' THEN + RETURN CONCAT(first_name, ' ', last_name); + ELSEIF first_name != '' THEN + RETURN first_name; + ELSEIF last_name != '' THEN + RETURN last_name; + ELSE + RETURN id; + END IF; +END; diff --git a/tests/compress.txt b/tests/compress.txt index 58a0391..5194d92 100644 --- a/tests/compress.txt +++ b/tests/compress.txt @@ -111,3 +111,5 @@ SELECT a FROM test STRAIGHT_JOIN test2 ON test.id = test2.id SELECT t.id, t.start, t.end, t.end AS e2, t.limit, t.begin, t.case, t.when, t.then, t.else FROM t WHERE t.start = t.end --- CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB +--- +BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; diff --git a/tests/format-highlight.html b/tests/format-highlight.html index 5286c90..ac1af22 100644 --- a/tests/format-highlight.html +++ b/tests/format-highlight.html @@ -1205,3 +1205,15 @@
CREATE TABLE t (
   c VARCHAR(20)
 ) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB
+--- +
BEGIN
+  IF first_name != '' AND last_name != '' THEN
+    RETURN CONCAT(first_name, ' ', last_name);
+  ELSEIF first_name != '' THEN
+    RETURN first_name;
+  ELSEIF last_name != '' THEN
+    RETURN last_name;
+  ELSE
+    RETURN id;
+  END IF;
+END;
diff --git a/tests/format.txt b/tests/format.txt index 9216132..c87440c 100644 --- a/tests/format.txt +++ b/tests/format.txt @@ -1203,3 +1203,15 @@ WHERE CREATE TABLE t ( c VARCHAR(20) ) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB +--- +BEGIN + IF first_name != '' AND last_name != '' THEN + RETURN CONCAT(first_name, ' ', last_name); + ELSEIF first_name != '' THEN + RETURN first_name; + ELSEIF last_name != '' THEN + RETURN last_name; + ELSE + RETURN id; + END IF; +END; diff --git a/tests/highlight.html b/tests/highlight.html index fa79610..d4fb631 100644 --- a/tests/highlight.html +++ b/tests/highlight.html @@ -425,3 +425,5 @@ WHERE t.start = t.end ---
CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB
+--- +
BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END;
diff --git a/tests/sql.sql b/tests/sql.sql index d595f76..f303388 100644 --- a/tests/sql.sql +++ b/tests/sql.sql @@ -425,3 +425,5 @@ FROM t WHERE t.start = t.end --- CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB +--- +BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; From d2702cbb299921cb18c8e358795307718000cbc4 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Tue, 15 Jul 2025 18:20:15 +0100 Subject: [PATCH 2/4] fix: AND/OR newline behavior within IF conditions - Add context tracking to detect when inside IF conditions - Skip newlines for AND/OR tokens within IF conditions while preserving newline behavior for AND/OR in WHERE clauses and other contexts - This improves readability of formatted SQL with complex IF statements --- src/SqlFormatter.php | 26 ++++++++++++++++++++++---- tests/SqlFormatterTest.php | 1 - 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/SqlFormatter.php b/src/SqlFormatter.php index 7b626f4..217ecbb 100644 --- a/src/SqlFormatter.php +++ b/src/SqlFormatter.php @@ -66,6 +66,7 @@ public function format(string $string, string $indentString = ' '): string $inlineCount = 0; $inlineIndented = false; $clauseLimit = false; + $inIfCondition = false; $appendNewLineIfNotAddedFx = static function () use (&$addedNewline, &$return, $tab, &$indentLevel): void { // Add a newline if not already added @@ -301,8 +302,8 @@ public function format(string $string, string $indentString = ' '): string $newline = true; $increaseBlockIndent = true; } - } elseif (in_array($tokenValueUpper, ['WHEN', 'THEN', 'ELSE', 'ELSEIF', 'END'], true)) { - if ($tokenValueUpper !== 'THEN') { + } elseif (in_array($tokenValueUpper, ['IF', 'WHEN', 'THEN', 'ELSE', 'ELSEIF', 'END'], true)) { + if ($tokenValueUpper !== 'THEN' && $tokenValueUpper !== 'IF') { $decreaseIndentationLevelFx(); if ($prevNotWhitespaceToken !== null && strtoupper($prevNotWhitespaceToken->value()) !== 'CASE') { @@ -314,6 +315,20 @@ public function format(string $string, string $indentString = ' '): string $newline = true; $increaseBlockIndent = true; } + + // Track IF condition context only for IF/ELSEIF that are part of conditional blocks + // (not for "IF()" function calls) + if ($tokenValueUpper === 'IF' || $tokenValueUpper === 'ELSEIF') { + // Check if this IF is part of a conditional block by looking at the next token + $nextToken = $cursor->subCursor()->next(Token::TOKEN_TYPE_WHITESPACE); + if ( + $nextToken !== null && $nextToken->value() !== '(' + ) { + $inIfCondition = true; + } + } elseif ($tokenValueUpper === 'THEN' || $tokenValueUpper === 'ELSE' || $tokenValueUpper === 'END') { + $inIfCondition = false; + } } elseif ( $clauseLimit && $token->value() !== ',' && @@ -332,9 +347,12 @@ public function format(string $string, string $indentString = ' '): string $newline = true; } } elseif ($token->isOfType(Token::TOKEN_TYPE_RESERVED_NEWLINE)) { - // Newline reserved words start a new line + // Newline reserved words start a new line, except for AND/OR within IF conditions - $appendNewLineIfNotAddedFx(); + // Skip newlines for AND/OR when inside IF condition + if (! $inIfCondition || ! in_array($tokenValueUpper, ['AND', 'OR'], true)) { + $appendNewLineIfNotAddedFx(); + } if ($token->hasExtraWhitespace()) { $highlighted = preg_replace('/\s+/', ' ', $highlighted); diff --git a/tests/SqlFormatterTest.php b/tests/SqlFormatterTest.php index 64c3d32..6993bbc 100644 --- a/tests/SqlFormatterTest.php +++ b/tests/SqlFormatterTest.php @@ -175,5 +175,4 @@ public static function highlightData(): Generator { return self::fileDataProvider('highlight.html'); } - } From bafed54e48e9ad7cc5f8fbeafa5b5643fb14036b Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Tue, 22 Jul 2025 20:29:04 +0100 Subject: [PATCH 3/4] feat: add support for ELSIF keyword alongside ELSEIF --- src/SqlFormatter.php | 4 ++-- src/Tokenizer.php | 1 + tests/clihighlight.txt | 2 +- tests/compress.txt | 2 +- tests/format-highlight.html | 2 +- tests/format.txt | 2 +- tests/highlight.html | 2 +- tests/sql.sql | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/SqlFormatter.php b/src/SqlFormatter.php index 217ecbb..b60faa0 100644 --- a/src/SqlFormatter.php +++ b/src/SqlFormatter.php @@ -302,7 +302,7 @@ public function format(string $string, string $indentString = ' '): string $newline = true; $increaseBlockIndent = true; } - } elseif (in_array($tokenValueUpper, ['IF', 'WHEN', 'THEN', 'ELSE', 'ELSEIF', 'END'], true)) { + } elseif (in_array($tokenValueUpper, ['IF', 'WHEN', 'THEN', 'ELSE', 'ELSEIF', 'ELSIF', 'END'], true)) { if ($tokenValueUpper !== 'THEN' && $tokenValueUpper !== 'IF') { $decreaseIndentationLevelFx(); @@ -318,7 +318,7 @@ public function format(string $string, string $indentString = ' '): string // Track IF condition context only for IF/ELSEIF that are part of conditional blocks // (not for "IF()" function calls) - if ($tokenValueUpper === 'IF' || $tokenValueUpper === 'ELSEIF') { + if (in_array($tokenValueUpper, ['IF', 'ELSEIF', 'ELSIF'], true)) { // Check if this IF is part of a conditional block by looking at the next token $nextToken = $cursor->subCursor()->next(Token::TOKEN_TYPE_WHITESPACE); if ( diff --git a/src/Tokenizer.php b/src/Tokenizer.php index 68b1f3e..1ba23ff 100644 --- a/src/Tokenizer.php +++ b/src/Tokenizer.php @@ -105,6 +105,7 @@ final class Tokenizer 'DYNAMIC', 'ELSE', 'ELSEIF', + 'ELSIF', 'ENCLOSED', 'END', 'ENGINE', diff --git a/tests/clihighlight.txt b/tests/clihighlight.txt index 71386f8..81470ea 100644 --- a/tests/clihighlight.txt +++ b/tests/clihighlight.txt @@ -1211,7 +1211,7 @@ MY_NON_TOP_LEVEL_KEYWORD_FX_3(); RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; - ELSEIF last_name != '' THEN + ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; diff --git a/tests/compress.txt b/tests/compress.txt index 5194d92..4c3899a 100644 --- a/tests/compress.txt +++ b/tests/compress.txt @@ -112,4 +112,4 @@ SELECT t.id, t.start, t.end, t.end AS e2, t.limit, t.begin, t.case, t.when, t.th --- CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB --- -BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; +BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; diff --git a/tests/format-highlight.html b/tests/format-highlight.html index ac1af22..35c1cb1 100644 --- a/tests/format-highlight.html +++ b/tests/format-highlight.html @@ -1211,7 +1211,7 @@ RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; - ELSEIF last_name != '' THEN + ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; diff --git a/tests/format.txt b/tests/format.txt index c87440c..7b29fc5 100644 --- a/tests/format.txt +++ b/tests/format.txt @@ -1209,7 +1209,7 @@ BEGIN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; - ELSEIF last_name != '' THEN + ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; diff --git a/tests/highlight.html b/tests/highlight.html index d4fb631..1b1f1de 100644 --- a/tests/highlight.html +++ b/tests/highlight.html @@ -426,4 +426,4 @@ ---
CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB
--- -
BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END;
+
BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END;
diff --git a/tests/sql.sql b/tests/sql.sql index f303388..3009f27 100644 --- a/tests/sql.sql +++ b/tests/sql.sql @@ -426,4 +426,4 @@ WHERE t.start = t.end --- CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB --- -BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSEIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; +BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; From a9ec3ec8dec6e99e30a7c39d997d56433c5e2af9 Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Thu, 4 Sep 2025 22:05:03 +0100 Subject: [PATCH 4/4] feat: extend AND/OR formatting optimization to CASE WHEN conditions - Apply same-line AND/OR formatting to CASE WHEN conditions - Maintain newline behavior for WHERE/HAVING/JOIN clauses --- src/SqlFormatter.php | 16 +++++++++------- tests/clihighlight.txt | 11 +++++++++++ tests/compress.txt | 2 ++ tests/format-highlight.html | 11 +++++++++++ tests/format.txt | 11 +++++++++++ tests/highlight.html | 2 ++ tests/sql.sql | 2 ++ 7 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/SqlFormatter.php b/src/SqlFormatter.php index b60faa0..d4e0875 100644 --- a/src/SqlFormatter.php +++ b/src/SqlFormatter.php @@ -66,7 +66,7 @@ public function format(string $string, string $indentString = ' '): string $inlineCount = 0; $inlineIndented = false; $clauseLimit = false; - $inIfCondition = false; + $inBooleanExpression = false; $appendNewLineIfNotAddedFx = static function () use (&$addedNewline, &$return, $tab, &$indentLevel): void { // Add a newline if not already added @@ -316,7 +316,7 @@ public function format(string $string, string $indentString = ' '): string $increaseBlockIndent = true; } - // Track IF condition context only for IF/ELSEIF that are part of conditional blocks + // Track boolean expression context for IF/ELSEIF/CASE WHEN conditions // (not for "IF()" function calls) if (in_array($tokenValueUpper, ['IF', 'ELSEIF', 'ELSIF'], true)) { // Check if this IF is part of a conditional block by looking at the next token @@ -324,10 +324,12 @@ public function format(string $string, string $indentString = ' '): string if ( $nextToken !== null && $nextToken->value() !== '(' ) { - $inIfCondition = true; + $inBooleanExpression = true; } + } elseif ($tokenValueUpper === 'WHEN') { + $inBooleanExpression = true; } elseif ($tokenValueUpper === 'THEN' || $tokenValueUpper === 'ELSE' || $tokenValueUpper === 'END') { - $inIfCondition = false; + $inBooleanExpression = false; } } elseif ( $clauseLimit && @@ -347,10 +349,10 @@ public function format(string $string, string $indentString = ' '): string $newline = true; } } elseif ($token->isOfType(Token::TOKEN_TYPE_RESERVED_NEWLINE)) { - // Newline reserved words start a new line, except for AND/OR within IF conditions + // Newline reserved words start a new line, except for AND/OR within boolean expressions - // Skip newlines for AND/OR when inside IF condition - if (! $inIfCondition || ! in_array($tokenValueUpper, ['AND', 'OR'], true)) { + // Skip newlines for AND/OR when inside boolean expressions (IF conditions, CASE WHEN) + if (! $inBooleanExpression || ! in_array($tokenValueUpper, ['AND', 'OR'], true)) { $appendNewLineIfNotAddedFx(); } diff --git a/tests/clihighlight.txt b/tests/clihighlight.txt index 81470ea..0cf5d3b 100644 --- a/tests/clihighlight.txt +++ b/tests/clihighlight.txt @@ -1217,3 +1217,14 @@ MY_NON_TOP_LEVEL_KEYWORD_FX_3(); RETURN id; END IF; END; +--- +SELECT + CASE WHEN status = 'active' AND priority = 'high' THEN + 'urgent' + WHEN status = 'active' OR status = 'pending' THEN + 'normal' + ELSE + 'low' + END AS task_priority +FROM + tasks; diff --git a/tests/compress.txt b/tests/compress.txt index 4c3899a..1eb4641 100644 --- a/tests/compress.txt +++ b/tests/compress.txt @@ -113,3 +113,5 @@ SELECT t.id, t.start, t.end, t.end AS e2, t.limit, t.begin, t.case, t.when, t.th CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB --- BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; +--- +SELECT CASE WHEN status = 'active' AND priority = 'high' THEN 'urgent' WHEN status = 'active' OR status = 'pending' THEN 'normal' ELSE 'low' END AS task_priority FROM tasks; \ No newline at end of file diff --git a/tests/format-highlight.html b/tests/format-highlight.html index 35c1cb1..e1a3059 100644 --- a/tests/format-highlight.html +++ b/tests/format-highlight.html @@ -1217,3 +1217,14 @@ RETURN id; END IF; END; +--- +
SELECT
+  CASE WHEN status = 'active' AND priority = 'high' THEN
+    'urgent'
+  WHEN status = 'active' OR status = 'pending' THEN
+    'normal'
+  ELSE
+    'low'
+  END AS task_priority
+FROM
+  tasks;
diff --git a/tests/format.txt b/tests/format.txt index 7b29fc5..7495bb3 100644 --- a/tests/format.txt +++ b/tests/format.txt @@ -1215,3 +1215,14 @@ BEGIN RETURN id; END IF; END; +--- +SELECT + CASE WHEN status = 'active' AND priority = 'high' THEN + 'urgent' + WHEN status = 'active' OR status = 'pending' THEN + 'normal' + ELSE + 'low' + END AS task_priority +FROM + tasks; diff --git a/tests/highlight.html b/tests/highlight.html index 1b1f1de..249d5d9 100644 --- a/tests/highlight.html +++ b/tests/highlight.html @@ -427,3 +427,5 @@
CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB
---
BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END;
+--- +
SELECT CASE WHEN status = 'active' AND priority = 'high' THEN 'urgent' WHEN status = 'active' OR status = 'pending' THEN 'normal' ELSE 'low' END AS task_priority FROM tasks;
diff --git a/tests/sql.sql b/tests/sql.sql index 3009f27..31672e2 100644 --- a/tests/sql.sql +++ b/tests/sql.sql @@ -427,3 +427,5 @@ WHERE t.start = t.end CREATE TABLE t (c VARCHAR(20)) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB --- BEGIN IF first_name != '' AND last_name != '' THEN RETURN CONCAT(first_name, ' ', last_name); ELSEIF first_name != '' THEN RETURN first_name; ELSIF last_name != '' THEN RETURN last_name; ELSE RETURN id; END IF; END; +--- +SELECT CASE WHEN status = 'active' AND priority = 'high' THEN 'urgent' WHEN status = 'active' OR status = 'pending' THEN 'normal' ELSE 'low' END AS task_priority FROM tasks;