From 8bc0545a26035655eba66a566b6d23392dffab62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Fri, 29 May 2026 13:51:09 +0200 Subject: [PATCH 01/10] Add preserveLeadingComments option to MySQL parser By default comments preceding a query are stripped. The new opt-in constructor flag `preserveLeadingComments` keeps them as a prefix of the yielded query string instead, which is useful when comments carry meaningful annotations. The leading "skip" zone of the query regex is split into a part that strips pure leading whitespace and a captured `leadingComments` group that collects --, # and /* */ comments (with their original formatting) directly preceding a query. A comment between two queries is treated as preceding the following one; comments not followed by any query are dropped. Default behavior is unchanged. Co-Authored-By: Claude Code --- readme.md | 16 +++ src/MySqlMultiQueryParser.php | 29 ++++-- tests/cases/MySqlMultiQueryParserTest.phpt | 108 +++++++++++++++++++++ 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index e4e3c9e..b0d874e 100644 --- a/readme.md +++ b/readme.md @@ -60,6 +60,22 @@ foreach ($parser->parseFileStream($stream) as $query) { Available parsers: `MySqlMultiQueryParser`, `PostgreSqlMultiQueryParser`, `SqlServerMultiQueryParser`, `SqliteMultiQueryParser`. +**Preserve leading comments (MySQL):** + +By default, comments preceding a query are stripped. Pass `preserveLeadingComments: true` to keep them as a prefix of the yielded query instead -- useful when comments carry meaningful annotations: + +```php +$parser = new MySqlMultiQueryParser(preserveLeadingComments: true); + +$sql = "-- create the users table\nCREATE TABLE users (id INT);"; + +foreach ($parser->parseString($sql) as $query) { + echo $query; // "-- create the users table\nCREATE TABLE users (id INT)" +} +``` + +All comment styles (`--`, `#`, `/* */`) that directly precede a query are preserved with their original formatting; only pure leading whitespace is stripped. A comment that sits between two queries is treated as preceding the following one. Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped. + ### License MIT. See full [license](license.md). diff --git a/src/MySqlMultiQueryParser.php b/src/MySqlMultiQueryParser.php index 5fc66a9..45dd8a4 100644 --- a/src/MySqlMultiQueryParser.php +++ b/src/MySqlMultiQueryParser.php @@ -8,6 +8,17 @@ class MySqlMultiQueryParser extends BaseMultiQueryParser { + /** + * @param bool $preserveLeadingComments When true, comments (`--`, `#`, `/* *​/`) that precede a query + * are kept as a prefix of the yielded query string instead of + * being stripped. Only pure leading whitespace is stripped. + */ + public function __construct( + private bool $preserveLeadingComments = false, + ) { + } + + public function parseStringStream(Iterator $stream): Iterator { $patternIterator = new PatternIterator($stream, $this->getQueryPattern(';')); @@ -17,7 +28,8 @@ public function parseStringStream(Iterator $stream): Iterator $patternIterator->setPattern($this->getQueryPattern($match['delimiter'])); } elseif (isset($match['query']) && $match['query'] !== '') { - yield $match['query']; + $leadingComments = $this->preserveLeadingComments ? (string) $match['leadingComments'] : ''; + yield $leadingComments . $match['query']; } } } @@ -30,12 +42,15 @@ private function getQueryPattern(string $delimiter): string return /** @lang PhpRegExp */ " ~ - (?: - \\s - | /\\* (*PRUNE) (?: [^*]++ | \\*(?!/) )*+ \\*/ - | --[^\\n]*+(?:\\n|\\z) - | \\#[^\\n]*+(?:\\n|\\z) - )*+ + \\s*+ + (? + (?: + \\s + | /\\* (*PRUNE) (?: [^*]++ | \\*(?!/) )*+ \\*/ + | --[^\\n]*+(?:\\n|\\z) + | \\#[^\\n]*+(?:\\n|\\z) + )*+ + ) (?: (?i: diff --git a/tests/cases/MySqlMultiQueryParserTest.phpt b/tests/cases/MySqlMultiQueryParserTest.phpt index 3ffd3c7..3a132ac 100644 --- a/tests/cases/MySqlMultiQueryParserTest.phpt +++ b/tests/cases/MySqlMultiQueryParserTest.phpt @@ -45,6 +45,114 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase } + /** + * @dataProvider providePreserveLeadingCommentsData + * @param list $expectedQueries + */ + public function testPreserveLeadingComments(string $content, array $expectedQueries): void + { + $parser = new MySqlMultiQueryParser(preserveLeadingComments: true); + $queries = iterator_to_array($parser->parseString($content)); + Assert::same($expectedQueries, $queries); + } + + + /** + * The restructured leading-comment pattern must keep streaming chunk-safe. + * Every two-chunk split of the input must reproduce the whole-string result. + */ + public function testPreserveLeadingCommentsChunkBoundary(): void + { + $parser = new MySqlMultiQueryParser(preserveLeadingComments: true); + $content = implode("\n", [ + '-- header comment', + '-- second line', + 'SELECT 1;', + '', + '# hash note', + 'SELECT 2;', + '/* block ; with semi */', + 'SELECT 3;', + 'SELECT 4; -- trailing', + '-- leading before 5', + 'SELECT 5;', + ]); + + $expected = iterator_to_array($parser->parseString($content)); + $len = strlen($content); + + for ($i = 0; $i <= $len; $i++) { + $chunks = [substr($content, 0, $i), substr($content, $i)]; + $queries = iterator_to_array($parser->parseStringStream(new \ArrayIterator($chunks))); + Assert::same($expected, $queries, "Failed with chunk boundary at offset $i"); + } + } + + + /** + * @return list}> + */ + protected function providePreserveLeadingCommentsData(): array + { + return [ + // Single -- comment kept as a prefix of the following query + [ + "-- add users table\nCREATE TABLE users (id INT);", + ["-- add users table\nCREATE TABLE users (id INT)"], + ], + // Multiple consecutive -- comment lines + [ + "-- line 1\n-- line 2\nSELECT 1;", + ["-- line 1\n-- line 2\nSELECT 1"], + ], + // Each query keeps only its own leading comment + [ + "-- first\nSELECT 1;\n-- second\nSELECT 2;", + ["-- first\nSELECT 1", "-- second\nSELECT 2"], + ], + // A comment between two queries attaches to the following query + [ + "SELECT 1; -- between\nSELECT 2;", + ["SELECT 1", "-- between\nSELECT 2"], + ], + // # hash comments are preserved too + [ + "# hash note\nSELECT 1;", + ["# hash note\nSELECT 1"], + ], + // /* */ block comments are preserved too + [ + "/* block */ SELECT 1;", + ["/* block */ SELECT 1"], + ], + // Mixed comment types are preserved with their original formatting + [ + "-- a\n# b\n/* c */\nSELECT 1;", + ["-- a\n# b\n/* c */\nSELECT 1"], + ], + // Pure leading whitespace / blank lines before the comment are stripped + [ + "\n\n-- spaced\n\nSELECT 1;", + ["-- spaced\n\nSELECT 1"], + ], + // Comment-only input yields nothing (no query to attach to) + ["-- only a comment", []], + ["-- line 1\n-- line 2\n", []], + ["/* only a block */", []], + // A trailing comment after the last query (no following query) is dropped + [ + "SELECT 1;\n-- trailing", + ["SELECT 1"], + ], + // Pure whitespace produces no leading prefix + [ + "\n\nSELECT 1;\n\n", + ["SELECT 1"], + ], + ]; + } + + /** * @return list}> */ From fc9f1b61dd86899afd79aa38b9d6642e8b49a3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Fri, 29 May 2026 14:00:35 +0200 Subject: [PATCH 02/10] Extend preserveLeadingComments to all parsers Move the `preserveLeadingComments` constructor flag and the comment prepending logic into BaseMultiQueryParser, and apply the same regex restructure (strip pure leading whitespace, then capture preceding comments into a `leadingComments` group) to the PostgreSQL, SQL Server and SQLite parsers. The shared, dialect-agnostic behavior is now tested once in MultiQueryParserTestCase (so every parser runs it), including a two-chunk-boundary streaming test; MySQL keeps its # hash-comment specific cases. Default behavior is unchanged for all parsers. Co-Authored-By: Claude Code --- readme.md | 6 +- src/BaseMultiQueryParser.php | 28 +++++ src/MySqlMultiQueryParser.php | 14 +-- src/PostgreSqlMultiQueryParser.php | 15 ++- src/SqlServerMultiQueryParser.php | 15 ++- src/SqliteMultiQueryParser.php | 5 +- tests/cases/MySqlMultiQueryParserTest.phpt | 98 +++------------- .../cases/PostgreSqlMultiQueryParserTest.phpt | 4 +- .../cases/SqlServerMultiQueryParserTest.phpt | 4 +- tests/cases/SqliteMultiQueryParserTest.phpt | 4 +- tests/inc/MultiQueryParserTestCase.php | 107 +++++++++++++++++- 11 files changed, 181 insertions(+), 119 deletions(-) diff --git a/readme.md b/readme.md index b0d874e..993797b 100644 --- a/readme.md +++ b/readme.md @@ -60,9 +60,9 @@ foreach ($parser->parseFileStream($stream) as $query) { Available parsers: `MySqlMultiQueryParser`, `PostgreSqlMultiQueryParser`, `SqlServerMultiQueryParser`, `SqliteMultiQueryParser`. -**Preserve leading comments (MySQL):** +**Preserve leading comments:** -By default, comments preceding a query are stripped. Pass `preserveLeadingComments: true` to keep them as a prefix of the yielded query instead -- useful when comments carry meaningful annotations: +By default, comments preceding a query are stripped. Pass `preserveLeadingComments: true` to any parser to keep them as a prefix of the yielded query instead -- useful when comments carry meaningful annotations: ```php $parser = new MySqlMultiQueryParser(preserveLeadingComments: true); @@ -74,7 +74,7 @@ foreach ($parser->parseString($sql) as $query) { } ``` -All comment styles (`--`, `#`, `/* */`) that directly precede a query are preserved with their original formatting; only pure leading whitespace is stripped. A comment that sits between two queries is treated as preceding the following one. Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped. +All comment styles supported by the given dialect (`--`, `/* */`, and `#` for MySQL) that directly precede a query are preserved with their original formatting; only pure leading whitespace is stripped. A comment that sits between two queries is treated as preceding the following one. Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped. ### License diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index f5314ac..4277b38 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -12,6 +12,17 @@ abstract class BaseMultiQueryParser implements IMultiQueryParser { + /** + * @param bool $preserveLeadingComments When true, comments preceding a query are kept as a prefix of + * the yielded query string instead of being stripped. Only pure + * leading whitespace is stripped. + */ + public function __construct( + protected bool $preserveLeadingComments = false, + ) { + } + + /** * @param positive-int $chunkSize * @return Iterator @@ -55,6 +66,23 @@ public function parseString(string $s): Iterator abstract public function parseStringStream(Iterator $stream): Iterator; + /** + * Builds the yielded query string, prepending captured leading comments when enabled. + * + * @param array $match + */ + protected function buildQuery(array $match): string + { + $query = (string) $match['query']; + + if (!$this->preserveLeadingComments) { + return $query; + } + + return (string) ($match['leadingComments'] ?? '') . $query; + } + + /** * @param resource $fileStream * @param positive-int $chunkSize diff --git a/src/MySqlMultiQueryParser.php b/src/MySqlMultiQueryParser.php index 45dd8a4..07fd765 100644 --- a/src/MySqlMultiQueryParser.php +++ b/src/MySqlMultiQueryParser.php @@ -8,17 +8,6 @@ class MySqlMultiQueryParser extends BaseMultiQueryParser { - /** - * @param bool $preserveLeadingComments When true, comments (`--`, `#`, `/* *​/`) that precede a query - * are kept as a prefix of the yielded query string instead of - * being stripped. Only pure leading whitespace is stripped. - */ - public function __construct( - private bool $preserveLeadingComments = false, - ) { - } - - public function parseStringStream(Iterator $stream): Iterator { $patternIterator = new PatternIterator($stream, $this->getQueryPattern(';')); @@ -28,8 +17,7 @@ public function parseStringStream(Iterator $stream): Iterator $patternIterator->setPattern($this->getQueryPattern($match['delimiter'])); } elseif (isset($match['query']) && $match['query'] !== '') { - $leadingComments = $this->preserveLeadingComments ? (string) $match['leadingComments'] : ''; - yield $leadingComments . $match['query']; + yield $this->buildQuery($match); } } } diff --git a/src/PostgreSqlMultiQueryParser.php b/src/PostgreSqlMultiQueryParser.php index 4fd33c3..b3e6c74 100644 --- a/src/PostgreSqlMultiQueryParser.php +++ b/src/PostgreSqlMultiQueryParser.php @@ -13,7 +13,7 @@ public function parseStringStream(Iterator $stream): Iterator foreach ($patternIterator as $match) { if (isset($match['query']) && $match['query'] !== '') { - yield $match['query']; + yield $this->buildQuery($match); } } } @@ -29,11 +29,14 @@ private function getQueryPattern(): string (? /\\* (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ ) ) - (?: - \\s - | /\\* (*PRUNE) (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ - | -- [^\\n]*+ - )*+ + \\s*+ + (? + (?: + \\s + | /\\* (*PRUNE) (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ + | -- [^\\n]*+ + )*+ + ) (?: (?: diff --git a/src/SqlServerMultiQueryParser.php b/src/SqlServerMultiQueryParser.php index 2624d9f..14f2473 100644 --- a/src/SqlServerMultiQueryParser.php +++ b/src/SqlServerMultiQueryParser.php @@ -13,7 +13,7 @@ public function parseStringStream(Iterator $stream): Iterator foreach ($patternIterator as $match) { if (isset($match['query']) && $match['query'] !== '') { - yield $match['query']; + yield $this->buildQuery($match); } } } @@ -45,11 +45,14 @@ private function getQueryPattern(): string (? /\\* (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ ) ) - (?: - \\s - | /\\* (*PRUNE) (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ - | -- [^\\n]*+ - )*+ + \\s*+ + (? + (?: + \\s + | /\\* (*PRUNE) (?: [^/*]++ | /(?!\\*) | \\*(?!/) | (?&nestedBc) )*+ \\*/ + | -- [^\\n]*+ + )*+ + ) (?: (?: diff --git a/src/SqliteMultiQueryParser.php b/src/SqliteMultiQueryParser.php index f8bdf82..86f8ca8 100644 --- a/src/SqliteMultiQueryParser.php +++ b/src/SqliteMultiQueryParser.php @@ -13,7 +13,7 @@ public function parseStringStream(Iterator $stream): Iterator foreach ($patternIterator as $match) { if (isset($match['query']) && $match['query'] !== '') { - yield $match['query']; + yield $this->buildQuery($match); } } } @@ -55,7 +55,8 @@ private function getQueryPattern(): string ) ) - (?&skip) + \s*+ + (? (?&skip) ) (?: (?: diff --git a/tests/cases/MySqlMultiQueryParserTest.phpt b/tests/cases/MySqlMultiQueryParserTest.phpt index 3a132ac..6a48c5d 100644 --- a/tests/cases/MySqlMultiQueryParserTest.phpt +++ b/tests/cases/MySqlMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class MySqlMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(): IMultiQueryParser + protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser { - return new MySqlMultiQueryParser(); + return new MySqlMultiQueryParser($preserveLeadingComments); } @@ -46,109 +46,43 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase /** - * @dataProvider providePreserveLeadingCommentsData + * MySQL-specific leading-comment cases: # hash comments. The generic line- and + * block-comment cases are covered by the shared test in MultiQueryParserTestCase. + * + * @dataProvider providePreserveLeadingCommentsHashData * @param list $expectedQueries */ - public function testPreserveLeadingComments(string $content, array $expectedQueries): void + public function testPreserveLeadingCommentsHash(string $content, array $expectedQueries): void { - $parser = new MySqlMultiQueryParser(preserveLeadingComments: true); + $parser = $this->createParser(preserveLeadingComments: true); $queries = iterator_to_array($parser->parseString($content)); Assert::same($expectedQueries, $queries); } - /** - * The restructured leading-comment pattern must keep streaming chunk-safe. - * Every two-chunk split of the input must reproduce the whole-string result. - */ - public function testPreserveLeadingCommentsChunkBoundary(): void - { - $parser = new MySqlMultiQueryParser(preserveLeadingComments: true); - $content = implode("\n", [ - '-- header comment', - '-- second line', - 'SELECT 1;', - '', - '# hash note', - 'SELECT 2;', - '/* block ; with semi */', - 'SELECT 3;', - 'SELECT 4; -- trailing', - '-- leading before 5', - 'SELECT 5;', - ]); - - $expected = iterator_to_array($parser->parseString($content)); - $len = strlen($content); - - for ($i = 0; $i <= $len; $i++) { - $chunks = [substr($content, 0, $i), substr($content, $i)]; - $queries = iterator_to_array($parser->parseStringStream(new \ArrayIterator($chunks))); - Assert::same($expected, $queries, "Failed with chunk boundary at offset $i"); - } - } - - /** * @return list}> */ - protected function providePreserveLeadingCommentsData(): array + protected function providePreserveLeadingCommentsHashData(): array { return [ - // Single -- comment kept as a prefix of the following query - [ - "-- add users table\nCREATE TABLE users (id INT);", - ["-- add users table\nCREATE TABLE users (id INT)"], - ], - // Multiple consecutive -- comment lines - [ - "-- line 1\n-- line 2\nSELECT 1;", - ["-- line 1\n-- line 2\nSELECT 1"], - ], - // Each query keeps only its own leading comment - [ - "-- first\nSELECT 1;\n-- second\nSELECT 2;", - ["-- first\nSELECT 1", "-- second\nSELECT 2"], - ], - // A comment between two queries attaches to the following query - [ - "SELECT 1; -- between\nSELECT 2;", - ["SELECT 1", "-- between\nSELECT 2"], - ], - // # hash comments are preserved too + // # hash comments are preserved as a prefix [ "# hash note\nSELECT 1;", ["# hash note\nSELECT 1"], ], - // /* */ block comments are preserved too - [ - "/* block */ SELECT 1;", - ["/* block */ SELECT 1"], - ], - // Mixed comment types are preserved with their original formatting + // All three comment styles mixed, with original formatting preserved [ "-- a\n# b\n/* c */\nSELECT 1;", ["-- a\n# b\n/* c */\nSELECT 1"], ], - // Pure leading whitespace / blank lines before the comment are stripped - [ - "\n\n-- spaced\n\nSELECT 1;", - ["-- spaced\n\nSELECT 1"], - ], - // Comment-only input yields nothing (no query to attach to) - ["-- only a comment", []], - ["-- line 1\n-- line 2\n", []], - ["/* only a block */", []], - // A trailing comment after the last query (no following query) is dropped - [ - "SELECT 1;\n-- trailing", - ["SELECT 1"], - ], - // Pure whitespace produces no leading prefix + // A hash comment between two queries attaches to the following query [ - "\n\nSELECT 1;\n\n", - ["SELECT 1"], + "SELECT 1; # between\nSELECT 2;", + ["SELECT 1", "# between\nSELECT 2"], ], + // Hash-comment-only input yields nothing + ["# only a comment", []], ]; } diff --git a/tests/cases/PostgreSqlMultiQueryParserTest.phpt b/tests/cases/PostgreSqlMultiQueryParserTest.phpt index 39f8763..f7a0d74 100644 --- a/tests/cases/PostgreSqlMultiQueryParserTest.phpt +++ b/tests/cases/PostgreSqlMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class PostgreSqlMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(): IMultiQueryParser + protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser { - return new PostgreSqlMultiQueryParser(); + return new PostgreSqlMultiQueryParser($preserveLeadingComments); } diff --git a/tests/cases/SqlServerMultiQueryParserTest.phpt b/tests/cases/SqlServerMultiQueryParserTest.phpt index 293bde9..d785216 100644 --- a/tests/cases/SqlServerMultiQueryParserTest.phpt +++ b/tests/cases/SqlServerMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class SqlServerMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(): IMultiQueryParser + protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser { - return new SqlServerMultiQueryParser(); + return new SqlServerMultiQueryParser($preserveLeadingComments); } diff --git a/tests/cases/SqliteMultiQueryParserTest.phpt b/tests/cases/SqliteMultiQueryParserTest.phpt index 0591875..95cc510 100644 --- a/tests/cases/SqliteMultiQueryParserTest.phpt +++ b/tests/cases/SqliteMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class SqliteMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(): IMultiQueryParser + protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser { - return new SqliteMultiQueryParser(); + return new SqliteMultiQueryParser($preserveLeadingComments); } diff --git a/tests/inc/MultiQueryParserTestCase.php b/tests/inc/MultiQueryParserTestCase.php index afc67b3..8fa39ce 100644 --- a/tests/inc/MultiQueryParserTestCase.php +++ b/tests/inc/MultiQueryParserTestCase.php @@ -10,7 +10,7 @@ abstract class MultiQueryParserTestCase extends TestCase { - abstract protected function createParser(): IMultiQueryParser; + abstract protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser; /** @@ -80,6 +80,111 @@ public function testChunkBoundary(array $chunks, array $expectedQueries): void } + /** + * Dialect-agnostic leading-comment cases (line and block comments), shared by every + * parser. Dialect-specific comment styles are tested in the subclasses. + * + * @dataProvider provideCommonPreserveLeadingCommentsData + * @param list $expectedQueries + */ + public function testPreserveLeadingComments(string $content, array $expectedQueries): void + { + $parser = $this->createParser(preserveLeadingComments: true); + $queries = iterator_to_array($parser->parseString($content)); + Assert::same($expectedQueries, $queries); + } + + + /** + * The restructured leading-comment pattern must keep streaming chunk-safe: + * every two-chunk split of the input must reproduce the whole-string result. + */ + public function testPreserveLeadingCommentsChunkBoundary(): void + { + $parser = $this->createParser(preserveLeadingComments: true); + $content = implode("\n", [ + '-- header comment', + '-- second line', + 'SELECT 1;', + '', + 'SELECT 2;', + '/* block ; with semi */', + 'SELECT 3;', + 'SELECT 4; -- trailing', + '-- leading before 5', + 'SELECT 5;', + ]); + + $expected = iterator_to_array($parser->parseString($content)); + $len = strlen($content); + + for ($i = 0; $i <= $len; $i++) { + $chunks = [substr($content, 0, $i), substr($content, $i)]; + $queries = iterator_to_array($parser->parseStringStream(new \ArrayIterator($chunks))); + Assert::same($expected, $queries, "Failed with chunk boundary at offset $i"); + } + } + + + /** + * @return list}> + */ + protected function provideCommonPreserveLeadingCommentsData(): array + { + return [ + // A single -- comment kept as a prefix of the following query + [ + "-- create the users table\nCREATE TABLE users (id INT);", + ["-- create the users table\nCREATE TABLE users (id INT)"], + ], + // Multiple consecutive -- comment lines + [ + "-- line 1\n-- line 2\nSELECT 1;", + ["-- line 1\n-- line 2\nSELECT 1"], + ], + // Each query keeps only its own leading comment + [ + "-- first\nSELECT 1;\n-- second\nSELECT 2;", + ["-- first\nSELECT 1", "-- second\nSELECT 2"], + ], + // A comment between two queries attaches to the following query + [ + "SELECT 1; -- between\nSELECT 2;", + ["SELECT 1", "-- between\nSELECT 2"], + ], + // /* */ block comments are preserved too + [ + "/* block */ SELECT 1;", + ["/* block */ SELECT 1"], + ], + // Mixed comment types preserve their original formatting + [ + "-- a\n/* b */\nSELECT 1;", + ["-- a\n/* b */\nSELECT 1"], + ], + // Pure leading whitespace / blank lines before the comment are stripped + [ + "\n\n-- spaced\n\nSELECT 1;", + ["-- spaced\n\nSELECT 1"], + ], + // Comment-only input yields nothing (no query to attach to) + ["-- only a comment", []], + ["-- line 1\n-- line 2\n", []], + ["/* only a block */", []], + // A trailing comment after the last query (no following query) is dropped + [ + "SELECT 1;\n-- trailing", + ["SELECT 1"], + ], + // Pure whitespace produces no leading prefix + [ + "\n\nSELECT 1;\n\n", + ["SELECT 1"], + ], + ]; + } + + public function testFile(): void { $parser = $this->createParser(); From 7c99c33207ed81c7a7c660b2a6dfb3b8202da26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Fri, 29 May 2026 14:09:21 +0200 Subject: [PATCH 03/10] Narrow preserveLeadingComments property to private It is read only inside BaseMultiQueryParser::buildQuery(); no subclass accesses it, so `protected` advertised an extension point that does not exist. Tighten the boundary to `private`. Co-Authored-By: Claude Code --- src/BaseMultiQueryParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index 4277b38..9107682 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -18,7 +18,7 @@ abstract class BaseMultiQueryParser implements IMultiQueryParser * leading whitespace is stripped. */ public function __construct( - protected bool $preserveLeadingComments = false, + private bool $preserveLeadingComments = false, ) { } From c86fd8e53ea340fbf65bd33dc6e152f4ea6bab34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Fri, 29 May 2026 14:14:33 +0200 Subject: [PATCH 04/10] Tighten match type to drop (string) casts PatternIterator declared its yielded match as array, but preg_match results (no PREG_OFFSET_CAPTURE / PREG_UNMATCHED_AS_NULL) are always string-valued. Declaring array reflects that guarantee and lets buildQuery() return/concatenate the match values directly, removing the (string) casts. Co-Authored-By: Claude Code --- src/BaseMultiQueryParser.php | 8 +++----- src/PatternIterator.php | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index 9107682..8dcb879 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -69,17 +69,15 @@ abstract public function parseStringStream(Iterator $stream): Iterator; /** * Builds the yielded query string, prepending captured leading comments when enabled. * - * @param array $match + * @param array $match */ protected function buildQuery(array $match): string { - $query = (string) $match['query']; - if (!$this->preserveLeadingComments) { - return $query; + return $match['query']; } - return (string) ($match['leadingComments'] ?? '') . $query; + return ($match['leadingComments'] ?? '') . $match['query']; } diff --git a/src/PatternIterator.php b/src/PatternIterator.php index 788239c..374b892 100644 --- a/src/PatternIterator.php +++ b/src/PatternIterator.php @@ -30,7 +30,7 @@ * the regex engine commits to the construct — if the closing delimiter is missing (because * it is in a later chunk), the overall match fails, causing the iterator to load more data. * - * @implements IteratorAggregate> + * @implements IteratorAggregate> */ class PatternIterator implements IteratorAggregate { From 5ca6d3eb9a1189cd2cf0238bf006d6f51a1d8d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Fri, 29 May 2026 14:16:06 +0200 Subject: [PATCH 05/10] Drop redundant preserveLeadingComments docblock The behavior is documented in the readme; the typed, self-describing parameter does not need a duplicate prose comment. Co-Authored-By: Claude Code --- src/BaseMultiQueryParser.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index 8dcb879..c2986b7 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -12,11 +12,6 @@ abstract class BaseMultiQueryParser implements IMultiQueryParser { - /** - * @param bool $preserveLeadingComments When true, comments preceding a query are kept as a prefix of - * the yielded query string instead of being stripped. Only pure - * leading whitespace is stripped. - */ public function __construct( private bool $preserveLeadingComments = false, ) { From 0a03d29c1c7e7a7a3db93b1f7935382fa6f05c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 1 Jun 2026 22:22:30 +0200 Subject: [PATCH 06/10] Replace preserveLeadingComments flag with CommentStrategy Parsers now tokenize input into a neutral Query|Comment fragment stream; a pluggable CommentStrategy collapses it into the final query strings. The default DropComments reproduces the historical comment-stripping output, while KeepLeadingComments prepends a query's leading comments. This keeps the opinionated comment-handling policy out of the parser core and leaves the public return type as Iterator. Co-Authored-By: Claude Code --- readme.md | 40 ++++++++- src/BaseMultiQueryParser.php | 39 ++++++--- src/CommentStrategy.php | 22 +++++ src/Fragment/Comment.php | 15 ++++ src/Fragment/Fragment.php | 11 +++ src/Fragment/Query.php | 15 ++++ src/MySqlMultiQueryParser.php | 7 +- src/PostgreSqlMultiQueryParser.php | 6 +- src/SqlServerMultiQueryParser.php | 6 +- src/SqliteMultiQueryParser.php | 6 +- src/Strategy/DropComments.php | 20 +++++ src/Strategy/KeepLeadingComments.php | 33 ++++++++ tests/cases/CommentStrategyTest.phpt | 84 +++++++++++++++++++ tests/cases/MySqlMultiQueryParserTest.phpt | 36 +++++++- .../cases/PostgreSqlMultiQueryParserTest.phpt | 4 +- .../cases/SqlServerMultiQueryParserTest.phpt | 4 +- tests/cases/SqliteMultiQueryParserTest.phpt | 4 +- tests/inc/MultiQueryParserTestCase.php | 63 +++++++++++++- 18 files changed, 373 insertions(+), 42 deletions(-) create mode 100644 src/CommentStrategy.php create mode 100644 src/Fragment/Comment.php create mode 100644 src/Fragment/Fragment.php create mode 100644 src/Fragment/Query.php create mode 100644 src/Strategy/DropComments.php create mode 100644 src/Strategy/KeepLeadingComments.php create mode 100644 tests/cases/CommentStrategyTest.phpt diff --git a/readme.md b/readme.md index 993797b..2326644 100644 --- a/readme.md +++ b/readme.md @@ -60,12 +60,17 @@ foreach ($parser->parseFileStream($stream) as $query) { Available parsers: `MySqlMultiQueryParser`, `PostgreSqlMultiQueryParser`, `SqlServerMultiQueryParser`, `SqliteMultiQueryParser`. -**Preserve leading comments:** +**Keep leading comments:** -By default, comments preceding a query are stripped. Pass `preserveLeadingComments: true` to any parser to keep them as a prefix of the yielded query instead -- useful when comments carry meaningful annotations: +By default, comments are stripped and only query strings are yielded. To control what happens to +comments, pass a `CommentStrategy` to the parser constructor. The bundled `KeepLeadingComments` +strategy keeps the comments preceding a query as a prefix of that query -- useful when comments +carry meaningful annotations, e.g. so they remain visible in observability tools: ```php -$parser = new MySqlMultiQueryParser(preserveLeadingComments: true); +use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; + +$parser = new MySqlMultiQueryParser(new KeepLeadingComments()); $sql = "-- create the users table\nCREATE TABLE users (id INT);"; @@ -76,6 +81,35 @@ foreach ($parser->parseString($sql) as $query) { All comment styles supported by the given dialect (`--`, `/* */`, and `#` for MySQL) that directly precede a query are preserved with their original formatting; only pure leading whitespace is stripped. A comment that sits between two queries is treated as preceding the following one. Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped. +**Custom comment handling:** + +Internally a parser tokenizes the input into a stream of `Query` and `Comment` fragments; the +`CommentStrategy` collapses that stream into the final query strings. The default `DropComments` +strategy discards every comment. To implement a different policy (for example, requiring a blank +line between a comment and its query, or appending trailing comments), implement `CommentStrategy` +yourself: + +```php +use Iterator; +use Nextras\MultiQueryParser\CommentStrategy; +use Nextras\MultiQueryParser\Fragment\Query; + +final class MyCommentStrategy implements CommentStrategy +{ + public function apply(Iterator $fragments): Iterator + { + foreach ($fragments as $fragment) { + if ($fragment instanceof Query) { + yield $fragment->sql; + } + // decide what to do with Comment fragments + } + } +} + +$parser = new MySqlMultiQueryParser(new MyCommentStrategy()); +``` + ### License MIT. See full [license](license.md). diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index c2986b7..b1ba3f4 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -5,6 +5,10 @@ use ArrayIterator; use Iterator; use Nextras\MultiQueryParser\Exception\RuntimeException; +use Nextras\MultiQueryParser\Fragment\Comment; +use Nextras\MultiQueryParser\Fragment\Fragment; +use Nextras\MultiQueryParser\Fragment\Query; +use Nextras\MultiQueryParser\Strategy\DropComments; use function feof; use function fopen; use function fread; @@ -12,9 +16,12 @@ abstract class BaseMultiQueryParser implements IMultiQueryParser { - public function __construct( - private bool $preserveLeadingComments = false, - ) { + private CommentStrategy $commentStrategy; + + + public function __construct(?CommentStrategy $commentStrategy = null) + { + $this->commentStrategy = $commentStrategy ?? new DropComments(); } @@ -58,21 +65,31 @@ public function parseString(string $s): Iterator * @param Iterator $stream * @return Iterator */ - abstract public function parseStringStream(Iterator $stream): Iterator; + final public function parseStringStream(Iterator $stream): Iterator + { + return $this->commentStrategy->apply($this->parseStringStreamToFragments($stream)); + } /** - * Builds the yielded query string, prepending captured leading comments when enabled. - * - * @param array $match + * @param Iterator $stream + * @return Iterator */ - protected function buildQuery(array $match): string + abstract protected function parseStringStreamToFragments(Iterator $stream): Iterator; + + + /** + * @return Iterator + */ + protected function buildFragments(?string $leadingComments, ?string $query): Iterator { - if (!$this->preserveLeadingComments) { - return $match['query']; + if ($leadingComments !== null && $leadingComments !== '') { + yield new Comment($leadingComments); } - return ($match['leadingComments'] ?? '') . $match['query']; + if ($query !== null && $query !== '') { + yield new Query($query); + } } diff --git a/src/CommentStrategy.php b/src/CommentStrategy.php new file mode 100644 index 0000000..dc77c73 --- /dev/null +++ b/src/CommentStrategy.php @@ -0,0 +1,22 @@ + $fragments + * @return Iterator + */ + public function apply(Iterator $fragments): Iterator; +} diff --git a/src/Fragment/Comment.php b/src/Fragment/Comment.php new file mode 100644 index 0000000..a9d395a --- /dev/null +++ b/src/Fragment/Comment.php @@ -0,0 +1,15 @@ +getQueryPattern(';')); foreach ($patternIterator as $match) { + yield from $this->buildFragments($match['leadingComments'] ?? null, $match['query'] ?? null); + if (isset($match['delimiter']) && $match['delimiter'] !== '') { $patternIterator->setPattern($this->getQueryPattern($match['delimiter'])); - - } elseif (isset($match['query']) && $match['query'] !== '') { - yield $this->buildQuery($match); } } } diff --git a/src/PostgreSqlMultiQueryParser.php b/src/PostgreSqlMultiQueryParser.php index b3e6c74..5edaae4 100644 --- a/src/PostgreSqlMultiQueryParser.php +++ b/src/PostgreSqlMultiQueryParser.php @@ -7,14 +7,12 @@ class PostgreSqlMultiQueryParser extends BaseMultiQueryParser { - public function parseStringStream(Iterator $stream): Iterator + protected function parseStringStreamToFragments(Iterator $stream): Iterator { $patternIterator = new PatternIterator($stream, $this->getQueryPattern()); foreach ($patternIterator as $match) { - if (isset($match['query']) && $match['query'] !== '') { - yield $this->buildQuery($match); - } + yield from $this->buildFragments($match['leadingComments'] ?? null, $match['query'] ?? null); } } diff --git a/src/SqlServerMultiQueryParser.php b/src/SqlServerMultiQueryParser.php index 14f2473..09e5b6d 100644 --- a/src/SqlServerMultiQueryParser.php +++ b/src/SqlServerMultiQueryParser.php @@ -7,14 +7,12 @@ class SqlServerMultiQueryParser extends BaseMultiQueryParser { - public function parseStringStream(Iterator $stream): Iterator + protected function parseStringStreamToFragments(Iterator $stream): Iterator { $patternIterator = new PatternIterator($stream, $this->getQueryPattern()); foreach ($patternIterator as $match) { - if (isset($match['query']) && $match['query'] !== '') { - yield $this->buildQuery($match); - } + yield from $this->buildFragments($match['leadingComments'] ?? null, $match['query'] ?? null); } } diff --git a/src/SqliteMultiQueryParser.php b/src/SqliteMultiQueryParser.php index 86f8ca8..e248dae 100644 --- a/src/SqliteMultiQueryParser.php +++ b/src/SqliteMultiQueryParser.php @@ -7,14 +7,12 @@ class SqliteMultiQueryParser extends BaseMultiQueryParser { - public function parseStringStream(Iterator $stream): Iterator + protected function parseStringStreamToFragments(Iterator $stream): Iterator { $patternIterator = new PatternIterator($stream, $this->getQueryPattern()); foreach ($patternIterator as $match) { - if (isset($match['query']) && $match['query'] !== '') { - yield $this->buildQuery($match); - } + yield from $this->buildFragments($match['leadingComments'] ?? null, $match['query'] ?? null); } } diff --git a/src/Strategy/DropComments.php b/src/Strategy/DropComments.php new file mode 100644 index 0000000..e95d541 --- /dev/null +++ b/src/Strategy/DropComments.php @@ -0,0 +1,20 @@ +sql; + } + } + } +} diff --git a/src/Strategy/KeepLeadingComments.php b/src/Strategy/KeepLeadingComments.php new file mode 100644 index 0000000..1893236 --- /dev/null +++ b/src/Strategy/KeepLeadingComments.php @@ -0,0 +1,33 @@ +text; + + } elseif ($fragment instanceof Query) { + yield $leadingComments . $fragment->sql; + $leadingComments = ''; + } + } + } +} diff --git a/tests/cases/CommentStrategyTest.phpt b/tests/cases/CommentStrategyTest.phpt new file mode 100644 index 0000000..c2d1f96 --- /dev/null +++ b/tests/cases/CommentStrategyTest.phpt @@ -0,0 +1,84 @@ +apply(new DropComments(), [ + new Comment('-- a'), + new Query('SELECT 1'), + new Comment('-- b'), + new Query('SELECT 2'), + new Comment('-- trailing'), + ]); + + Assert::same(['SELECT 1', 'SELECT 2'], $result); + } + + + public function testKeepLeadingCommentsPrependsComments(): void + { + $result = $this->apply(new KeepLeadingComments(), [ + new Comment("-- a\n"), + new Query('SELECT 1'), + new Comment("-- b\n"), + new Query('SELECT 2'), + ]); + + Assert::same(["-- a\nSELECT 1", "-- b\nSELECT 2"], $result); + } + + + public function testKeepLeadingCommentsWithoutComments(): void + { + $result = $this->apply(new KeepLeadingComments(), [ + new Query('SELECT 1'), + new Query('SELECT 2'), + ]); + + Assert::same(['SELECT 1', 'SELECT 2'], $result); + } + + + public function testKeepLeadingCommentsDropsTrailingComment(): void + { + $result = $this->apply(new KeepLeadingComments(), [ + new Query('SELECT 1'), + new Comment('-- trailing'), + ]); + + Assert::same(['SELECT 1'], $result); + } + + + /** + * @param list $fragments + * @return list + */ + private function apply(CommentStrategy $strategy, array $fragments): array + { + return iterator_to_array($strategy->apply(new ArrayIterator($fragments)), false); + } +} + + +(new CommentStrategyTest())->run(); diff --git a/tests/cases/MySqlMultiQueryParserTest.phpt b/tests/cases/MySqlMultiQueryParserTest.phpt index 6a48c5d..d37ca19 100644 --- a/tests/cases/MySqlMultiQueryParserTest.phpt +++ b/tests/cases/MySqlMultiQueryParserTest.phpt @@ -6,6 +6,9 @@ namespace Nextras\MultiQueryParser; +use Nextras\MultiQueryParser\Fragment\Comment; +use Nextras\MultiQueryParser\Fragment\Query; +use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; use Tester\Assert; @@ -15,9 +18,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class MySqlMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser + protected function createParser(?CommentStrategy $commentStrategy = null): IMultiQueryParser { - return new MySqlMultiQueryParser($preserveLeadingComments); + return new MySqlMultiQueryParser($commentStrategy); } @@ -54,7 +57,7 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase */ public function testPreserveLeadingCommentsHash(string $content, array $expectedQueries): void { - $parser = $this->createParser(preserveLeadingComments: true); + $parser = $this->createParser(new KeepLeadingComments()); $queries = iterator_to_array($parser->parseString($content)); Assert::same($expectedQueries, $queries); } @@ -87,6 +90,33 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase } + /** + * A comment preceding a DELIMITER directive must still be emitted as a Comment fragment, so + * that a strategy can attach it to the following query instead of the parser dropping it. + */ + public function testCommentBeforeDelimiterIsEmittedAsFragment(): void + { + $content = "-- before delimiter\nDELIMITER //\nSELECT 1//"; + + $fragments = $this->collectFragments($content); + Assert::count(2, $fragments); + + $comment = $fragments[0]; + Assert::type(Comment::class, $comment); + assert($comment instanceof Comment); + Assert::same("-- before delimiter\n", $comment->text); + + $query = $fragments[1]; + Assert::type(Query::class, $query); + assert($query instanceof Query); + Assert::same('SELECT 1', $query->sql); + + // under KeepLeadingComments the comment attaches to the following query + $queries = iterator_to_array($this->createParser(new KeepLeadingComments())->parseString($content)); + Assert::same(["-- before delimiter\nSELECT 1"], $queries); + } + + /** * @return list}> */ diff --git a/tests/cases/PostgreSqlMultiQueryParserTest.phpt b/tests/cases/PostgreSqlMultiQueryParserTest.phpt index f7a0d74..47b1be2 100644 --- a/tests/cases/PostgreSqlMultiQueryParserTest.phpt +++ b/tests/cases/PostgreSqlMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class PostgreSqlMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser + protected function createParser(?CommentStrategy $commentStrategy = null): IMultiQueryParser { - return new PostgreSqlMultiQueryParser($preserveLeadingComments); + return new PostgreSqlMultiQueryParser($commentStrategy); } diff --git a/tests/cases/SqlServerMultiQueryParserTest.phpt b/tests/cases/SqlServerMultiQueryParserTest.phpt index d785216..42cc779 100644 --- a/tests/cases/SqlServerMultiQueryParserTest.phpt +++ b/tests/cases/SqlServerMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class SqlServerMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser + protected function createParser(?CommentStrategy $commentStrategy = null): IMultiQueryParser { - return new SqlServerMultiQueryParser($preserveLeadingComments); + return new SqlServerMultiQueryParser($commentStrategy); } diff --git a/tests/cases/SqliteMultiQueryParserTest.phpt b/tests/cases/SqliteMultiQueryParserTest.phpt index 95cc510..0302d88 100644 --- a/tests/cases/SqliteMultiQueryParserTest.phpt +++ b/tests/cases/SqliteMultiQueryParserTest.phpt @@ -15,9 +15,9 @@ require_once __DIR__ . '/../inc/MultiQueryParserTestCase.php'; class SqliteMultiQueryParserTest extends MultiQueryParserTestCase { - protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser + protected function createParser(?CommentStrategy $commentStrategy = null): IMultiQueryParser { - return new SqliteMultiQueryParser($preserveLeadingComments); + return new SqliteMultiQueryParser($commentStrategy); } diff --git a/tests/inc/MultiQueryParserTestCase.php b/tests/inc/MultiQueryParserTestCase.php index 8fa39ce..e262d25 100644 --- a/tests/inc/MultiQueryParserTestCase.php +++ b/tests/inc/MultiQueryParserTestCase.php @@ -2,15 +2,20 @@ namespace Nextras\MultiQueryParser; +use Iterator; use LogicException; use Nextras\MultiQueryParser\Exception\RuntimeException; +use Nextras\MultiQueryParser\Fragment\Comment; +use Nextras\MultiQueryParser\Fragment\Fragment; +use Nextras\MultiQueryParser\Fragment\Query; +use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; use Tester\Assert; use Tester\TestCase; abstract class MultiQueryParserTestCase extends TestCase { - abstract protected function createParser(bool $preserveLeadingComments = false): IMultiQueryParser; + abstract protected function createParser(?CommentStrategy $commentStrategy = null): IMultiQueryParser; /** @@ -89,7 +94,7 @@ public function testChunkBoundary(array $chunks, array $expectedQueries): void */ public function testPreserveLeadingComments(string $content, array $expectedQueries): void { - $parser = $this->createParser(preserveLeadingComments: true); + $parser = $this->createParser(new KeepLeadingComments()); $queries = iterator_to_array($parser->parseString($content)); Assert::same($expectedQueries, $queries); } @@ -101,7 +106,7 @@ public function testPreserveLeadingComments(string $content, array $expectedQuer */ public function testPreserveLeadingCommentsChunkBoundary(): void { - $parser = $this->createParser(preserveLeadingComments: true); + $parser = $this->createParser(new KeepLeadingComments()); $content = implode("\n", [ '-- header comment', '-- second line', @@ -126,6 +131,58 @@ public function testPreserveLeadingCommentsChunkBoundary(): void } + /** + * A comment trailing the last query (with no query following it) must still be emitted as a + * Comment fragment by the parser, so that a custom CommentStrategy can act on it. The bundled + * strategies happen to drop it, which is why this has to be asserted at the fragment level + * rather than via the yielded query strings. + */ + public function testTrailingCommentIsEmittedAsFragment(): void + { + $fragments = $this->collectFragments("SELECT 1;\n-- trailing"); + Assert::count(2, $fragments); + + $query = $fragments[0]; + Assert::type(Query::class, $query); + assert($query instanceof Query); + Assert::same('SELECT 1', $query->sql); + + $comment = $fragments[1]; + Assert::type(Comment::class, $comment); + assert($comment instanceof Comment); + Assert::same('-- trailing', $comment->text); + } + + + /** + * Parses the content and returns the raw Fragment stream the parser emits (before any + * CommentStrategy collapses it), by plugging in a fragment-collecting strategy. + * + * @return list + */ + protected function collectFragments(string $content): array + { + $strategy = new class implements CommentStrategy { + /** @var list */ + public array $fragments = []; + + + public function apply(Iterator $fragments): Iterator + { + foreach ($fragments as $fragment) { + $this->fragments[] = $fragment; + } + + yield from []; + } + }; + + iterator_to_array($this->createParser($strategy)->parseString($content)); + + return $strategy->fragments; + } + + /** * @return list}> */ From 619ac646d625567d1b27c7abc5450175df253308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 1 Jun 2026 22:25:11 +0200 Subject: [PATCH 07/10] Rename KeepLeadingComments strategy to PrependLeadingComments The name now states the mechanism (the comment is prepended to the following query, producing one combined string) and encodes the prepend-vs-append distinction that motivates the feature. Co-Authored-By: Claude Code --- readme.md | 6 +++--- ...dingComments.php => PrependLeadingComments.php} | 2 +- tests/cases/CommentStrategyTest.phpt | 14 +++++++------- tests/cases/MySqlMultiQueryParserTest.phpt | 8 ++++---- tests/inc/MultiQueryParserTestCase.php | 6 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) rename src/Strategy/{KeepLeadingComments.php => PrependLeadingComments.php} (92%) diff --git a/readme.md b/readme.md index 2326644..fde9998 100644 --- a/readme.md +++ b/readme.md @@ -63,14 +63,14 @@ Available parsers: `MySqlMultiQueryParser`, `PostgreSqlMultiQueryParser`, `SqlSe **Keep leading comments:** By default, comments are stripped and only query strings are yielded. To control what happens to -comments, pass a `CommentStrategy` to the parser constructor. The bundled `KeepLeadingComments` +comments, pass a `CommentStrategy` to the parser constructor. The bundled `PrependLeadingComments` strategy keeps the comments preceding a query as a prefix of that query -- useful when comments carry meaningful annotations, e.g. so they remain visible in observability tools: ```php -use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; +use Nextras\MultiQueryParser\Strategy\PrependLeadingComments; -$parser = new MySqlMultiQueryParser(new KeepLeadingComments()); +$parser = new MySqlMultiQueryParser(new PrependLeadingComments()); $sql = "-- create the users table\nCREATE TABLE users (id INT);"; diff --git a/src/Strategy/KeepLeadingComments.php b/src/Strategy/PrependLeadingComments.php similarity index 92% rename from src/Strategy/KeepLeadingComments.php rename to src/Strategy/PrependLeadingComments.php index 1893236..0d23cca 100644 --- a/src/Strategy/KeepLeadingComments.php +++ b/src/Strategy/PrependLeadingComments.php @@ -14,7 +14,7 @@ * Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped, * since there is no query to attach them to. */ -final class KeepLeadingComments implements CommentStrategy +final class PrependLeadingComments implements CommentStrategy { public function apply(Iterator $fragments): Iterator { diff --git a/tests/cases/CommentStrategyTest.phpt b/tests/cases/CommentStrategyTest.phpt index c2d1f96..5274147 100644 --- a/tests/cases/CommentStrategyTest.phpt +++ b/tests/cases/CommentStrategyTest.phpt @@ -11,7 +11,7 @@ use Nextras\MultiQueryParser\Fragment\Comment; use Nextras\MultiQueryParser\Fragment\Fragment; use Nextras\MultiQueryParser\Fragment\Query; use Nextras\MultiQueryParser\Strategy\DropComments; -use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; +use Nextras\MultiQueryParser\Strategy\PrependLeadingComments; use Tester\Assert; use Tester\TestCase; @@ -35,9 +35,9 @@ class CommentStrategyTest extends TestCase } - public function testKeepLeadingCommentsPrependsComments(): void + public function testPrependLeadingComments(): void { - $result = $this->apply(new KeepLeadingComments(), [ + $result = $this->apply(new PrependLeadingComments(), [ new Comment("-- a\n"), new Query('SELECT 1'), new Comment("-- b\n"), @@ -48,9 +48,9 @@ class CommentStrategyTest extends TestCase } - public function testKeepLeadingCommentsWithoutComments(): void + public function testPrependLeadingCommentsWithoutComments(): void { - $result = $this->apply(new KeepLeadingComments(), [ + $result = $this->apply(new PrependLeadingComments(), [ new Query('SELECT 1'), new Query('SELECT 2'), ]); @@ -59,9 +59,9 @@ class CommentStrategyTest extends TestCase } - public function testKeepLeadingCommentsDropsTrailingComment(): void + public function testPrependLeadingCommentsDropsTrailingComment(): void { - $result = $this->apply(new KeepLeadingComments(), [ + $result = $this->apply(new PrependLeadingComments(), [ new Query('SELECT 1'), new Comment('-- trailing'), ]); diff --git a/tests/cases/MySqlMultiQueryParserTest.phpt b/tests/cases/MySqlMultiQueryParserTest.phpt index d37ca19..03760a0 100644 --- a/tests/cases/MySqlMultiQueryParserTest.phpt +++ b/tests/cases/MySqlMultiQueryParserTest.phpt @@ -8,7 +8,7 @@ namespace Nextras\MultiQueryParser; use Nextras\MultiQueryParser\Fragment\Comment; use Nextras\MultiQueryParser\Fragment\Query; -use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; +use Nextras\MultiQueryParser\Strategy\PrependLeadingComments; use Tester\Assert; @@ -57,7 +57,7 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase */ public function testPreserveLeadingCommentsHash(string $content, array $expectedQueries): void { - $parser = $this->createParser(new KeepLeadingComments()); + $parser = $this->createParser(new PrependLeadingComments()); $queries = iterator_to_array($parser->parseString($content)); Assert::same($expectedQueries, $queries); } @@ -111,8 +111,8 @@ class MySqlMultiQueryParserTest extends MultiQueryParserTestCase assert($query instanceof Query); Assert::same('SELECT 1', $query->sql); - // under KeepLeadingComments the comment attaches to the following query - $queries = iterator_to_array($this->createParser(new KeepLeadingComments())->parseString($content)); + // under PrependLeadingComments the comment attaches to the following query + $queries = iterator_to_array($this->createParser(new PrependLeadingComments())->parseString($content)); Assert::same(["-- before delimiter\nSELECT 1"], $queries); } diff --git a/tests/inc/MultiQueryParserTestCase.php b/tests/inc/MultiQueryParserTestCase.php index e262d25..8f27e7c 100644 --- a/tests/inc/MultiQueryParserTestCase.php +++ b/tests/inc/MultiQueryParserTestCase.php @@ -8,7 +8,7 @@ use Nextras\MultiQueryParser\Fragment\Comment; use Nextras\MultiQueryParser\Fragment\Fragment; use Nextras\MultiQueryParser\Fragment\Query; -use Nextras\MultiQueryParser\Strategy\KeepLeadingComments; +use Nextras\MultiQueryParser\Strategy\PrependLeadingComments; use Tester\Assert; use Tester\TestCase; @@ -94,7 +94,7 @@ public function testChunkBoundary(array $chunks, array $expectedQueries): void */ public function testPreserveLeadingComments(string $content, array $expectedQueries): void { - $parser = $this->createParser(new KeepLeadingComments()); + $parser = $this->createParser(new PrependLeadingComments()); $queries = iterator_to_array($parser->parseString($content)); Assert::same($expectedQueries, $queries); } @@ -106,7 +106,7 @@ public function testPreserveLeadingComments(string $content, array $expectedQuer */ public function testPreserveLeadingCommentsChunkBoundary(): void { - $parser = $this->createParser(new KeepLeadingComments()); + $parser = $this->createParser(new PrependLeadingComments()); $content = implode("\n", [ '-- header comment', '-- second line', From 6bc3e48177ee6acc85dbda1a3f08222afa5f0329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 1 Jun 2026 22:27:03 +0200 Subject: [PATCH 08/10] Drop final from parseStringStream for consistency Co-Authored-By: Claude Code --- src/BaseMultiQueryParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BaseMultiQueryParser.php b/src/BaseMultiQueryParser.php index b1ba3f4..3f5735b 100644 --- a/src/BaseMultiQueryParser.php +++ b/src/BaseMultiQueryParser.php @@ -65,7 +65,7 @@ public function parseString(string $s): Iterator * @param Iterator $stream * @return Iterator */ - final public function parseStringStream(Iterator $stream): Iterator + public function parseStringStream(Iterator $stream): Iterator { return $this->commentStrategy->apply($this->parseStringStreamToFragments($stream)); } From 4ceedc136ef530c44037440830fc61d91e37a689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 1 Jun 2026 22:29:41 +0200 Subject: [PATCH 09/10] Simplify CommentStrategy iterator generics to single type param Co-Authored-By: Claude Code --- src/CommentStrategy.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CommentStrategy.php b/src/CommentStrategy.php index dc77c73..92dbb7a 100644 --- a/src/CommentStrategy.php +++ b/src/CommentStrategy.php @@ -15,8 +15,8 @@ interface CommentStrategy { /** - * @param Iterator $fragments - * @return Iterator + * @param Iterator $fragments + * @return Iterator */ public function apply(Iterator $fragments): Iterator; } From a3c163f860dd32a58a1558701122872c22e643a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Tvrd=C3=ADk?= Date: Mon, 1 Jun 2026 22:32:25 +0200 Subject: [PATCH 10/10] Drop niche custom comment handling section from readme Co-Authored-By: Claude Code --- readme.md | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/readme.md b/readme.md index fde9998..0a69a3c 100644 --- a/readme.md +++ b/readme.md @@ -81,35 +81,6 @@ foreach ($parser->parseString($sql) as $query) { All comment styles supported by the given dialect (`--`, `/* */`, and `#` for MySQL) that directly precede a query are preserved with their original formatting; only pure leading whitespace is stripped. A comment that sits between two queries is treated as preceding the following one. Comments not followed by any query (e.g. a trailing comment at the end of input) are dropped. -**Custom comment handling:** - -Internally a parser tokenizes the input into a stream of `Query` and `Comment` fragments; the -`CommentStrategy` collapses that stream into the final query strings. The default `DropComments` -strategy discards every comment. To implement a different policy (for example, requiring a blank -line between a comment and its query, or appending trailing comments), implement `CommentStrategy` -yourself: - -```php -use Iterator; -use Nextras\MultiQueryParser\CommentStrategy; -use Nextras\MultiQueryParser\Fragment\Query; - -final class MyCommentStrategy implements CommentStrategy -{ - public function apply(Iterator $fragments): Iterator - { - foreach ($fragments as $fragment) { - if ($fragment instanceof Query) { - yield $fragment->sql; - } - // decide what to do with Comment fragments - } - } -} - -$parser = new MySqlMultiQueryParser(new MyCommentStrategy()); -``` - ### License MIT. See full [license](license.md).