Skip to content

Commit 676d2e5

Browse files
authored
Merge pull request #2648 from flow-php/pgsql-query-traversing-bug
fix(flow-php/postgresql): traverse every AST node through protobuf descriptors
2 parents 49daf58 + f5f7d79 commit 676d2e5

84 files changed

Lines changed: 2517 additions & 1035 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

documentation/components/libs/postgresql.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ foreach (sql_query_functions($query)->all() as $func) {
8787
}
8888
```
8989

90+
Extractors report every node of their kind anywhere in the statement — DDL targets (`CREATE TABLE x AS …`,
91+
`CREATE VIEW v …`, `SELECT … INTO t`), `FOR UPDATE OF t`, `excluded.*`, window `ORDER BY` — so filter the result
92+
when you need only some of them.
93+
9094
### Parsing Utilities
9195

9296
```php
@@ -621,17 +625,24 @@ interface NodeModifier
621625

622626
The `ModificationContext` provides:
623627

624-
- `$context->depth` - current traversal depth
625-
- `$context->ancestors` - array of parent nodes
626-
- `$context->getParent()` - immediate parent node
628+
- `$context->depth()` - traversal depth: 1 for a top-level statement, +1 per message edge
629+
- `$context->ancestors()` - parent messages from the root statement down, without `Node` wrappers
630+
- `$context->parent()` - immediate parent message
627631
- `$context->isTopLevel()` - whether this is the top-level statement
628632

629633
Return values:
630634

631635
- `null` - continue traversal
632-
- `Traverser::DONT_TRAVERSE_CHILDREN` - skip children
633-
- `Traverser::STOP_TRAVERSAL` - stop entire traversal
634-
- `object` - replace current node with returned object
636+
- `NodeModifier::DONT_TRAVERSE_CHILDREN` - skip children
637+
- `NodeModifier::STOP_TRAVERSAL` - stop entire traversal
638+
- `NodeModifier::REMOVE_NODE` - remove the node from its list (throws for a single-node slot)
639+
- `object` - replace the current node
640+
641+
Replacement rules:
642+
643+
- a slot holding a `Node` takes a `Node`; any other slot takes the same class; anything else throws
644+
- a replacement is not traversed (unlike PHP-Parser) - run a second traversal if needed
645+
- to clear a single slot, mutate the parent
635646

636647
### Using Modifiers Directly
637648

@@ -668,6 +679,22 @@ echo $query->deparse();
668679
// SELECT * FROM users WHERE created_at > $1 OR (created_at = $1 AND id > $2) ORDER BY created_at, id LIMIT 10
669680
```
670681

682+
`ExplainModifier` wraps every statement before any of them is visited, so run it in its own `traverse()` after the
683+
other modifiers:
684+
685+
```php
686+
<?php
687+
688+
use Flow\PostgreSql\AST\Transformers\{ExplainConfig, ExplainModifier, PaginationConfig, PaginationModifier};
689+
690+
use function Flow\PostgreSql\DSL\sql_parse;
691+
692+
$query = sql_parse('SELECT * FROM users ORDER BY id');
693+
$query->traverse(new PaginationModifier(new PaginationConfig(limit: 10)));
694+
$query->traverse(new ExplainModifier(ExplainConfig::forEstimate()));
695+
echo $query->deparse(); // EXPLAIN (COSTS 1, FORMAT "json") SELECT * FROM users ORDER BY id LIMIT 10
696+
```
697+
671698
Pass `param()` instead of a number to leave the value to the query's parameters - every page then sends the same SQL:
672699

673700
```php

documentation/components/libs/postgresql/client-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ echo "Planning time: {$plan->planningTime()}ms\n";
2929
echo "Total cost: {$plan->rootNode()->cost()->totalCost()}\n";
3030
```
3131

32+
With `analyze` (the default), the statement runs inside a transaction - a savepoint when one is already open - that
33+
is always rolled back, so `INSERT`, `UPDATE`, `DELETE`, `MERGE` and `CREATE TABLE … AS` leave no rows behind (sequences
34+
still advance). SELECT, INSERT, UPDATE, DELETE, MERGE, CREATE TABLE AS, EXECUTE and DECLARE CURSOR can be explained;
35+
any other statement, including an `EXPLAIN …` query, throws `InvalidStatementException`.
36+
3237
You can also pass raw SQL strings with parameters:
3338

3439
```php

documentation/components/libs/postgresql/client-telemetry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ All query methods are traced with individual spans:
111111
- `fetchScalar()`, `fetchScalarInt()`, `fetchScalarString()`, etc.
112112
- `fetchInto()`, `fetchOneInto()`, `fetchAllInto()` - Object mapping queries
113113
- `execute()` - INSERT, UPDATE, DELETE operations
114-
- `explain()` - Query plan analysis
114+
- `explain()` - Query plan analysis; the BEGIN/SAVEPOINT/ROLLBACK it runs internally for ANALYZE get no span
115115

116116
Span names follow the pattern: `{OPERATION} {table}` (e.g., `SELECT users`)
117117

documentation/components/libs/postgresql/select-query-builder.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ echo $query->toSql();
177177
<?php
178178

179179
use function Flow\PostgreSql\DSL\{
180-
select, star, table, col, asc, desc, order_by
180+
select, star, table, col, asc, desc, order_by, collate
181181
};
182182

183183
use Flow\PostgreSql\QueryBuilder\Clause\{SortDirection, NullsPosition};
@@ -211,6 +211,14 @@ $query = select(star())
211211

212212
echo $query->toSql();
213213
// SELECT * FROM products ORDER BY price ASC NULLS FIRST, name DESC NULLS LAST
214+
215+
// COLLATE - the collation is written as in SQL: 'C', '"de_DE"', 'pg_catalog."C"'
216+
$query = select(star())
217+
->from(table('users'))
218+
->orderBy(asc(collate(col('name'), 'C')));
219+
220+
echo $query->toSql();
221+
// SELECT * FROM users ORDER BY name COLLATE "C" ASC
214222
```
215223

216224
## LIMIT and OFFSET

documentation/upgrading.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,77 @@ final class MyExtractor implements Extractor
278278
|--------------------------------------------------|------------------------------------------------------------------------------------------------------|
279279
| `new Report(?Schema $schema, Statistics $stats)` | `new Report(?Schema $schema, Statistics $stats, ?array $sources)` - `null` unless analyzed with them |
280280

281+
### 27) `flow-php/postgresql` - `Traverser` visits every node
282+
283+
| Before | After |
284+
|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------|
285+
| `sql_query_tables('CREATE TABLE x AS SELECT * FROM t')` - `[]` | `[t, x]` |
286+
| `sql_query_tables('SELECT * FROM t JOIN u ON true FOR UPDATE OF t')` - `[t, u]` | `[t, u, t]` - every reference, filter duplicates yourself |
287+
| `sql_query_tables('CREATE VIEW v AS SELECT a FROM src')` - `[]` | `[v, src]` |
288+
| `sql_query_tables('SELECT a INTO new_t FROM src')` - `[src]` | `[new_t, src]` |
289+
| `sql_query_columns('… ON CONFLICT (name) DO UPDATE SET name = excluded.name')` - `[]` | `[excluded.name]` |
290+
| `OrderBy` of `SELECT a, row_number() OVER (ORDER BY b) FROM t ORDER BY a` - 1 clause | 2 clauses - window `ORDER BY` included |
291+
| `sql_query_tables('SELECT (SELECT x FROM a) FROM b')` - `[b, a]` | `[a, b]` - descriptor (PostgreSQL walker) order |
292+
| `sql_query_depth()`: `EXPLAIN SELECT 1` 0, `CREATE VIEW v AS SELECT 1` 0, window subquery 1 | 1, 1, 2 |
293+
| `sql_to_keyset_query()` cursor on `… WHERE $1 IN (SELECT …)` - `$1`, clashing with the user's | `$2` |
294+
| `TypeCastStripper` left casts under a `SubLink` test, a window and `COLLATE` | stripped |
295+
296+
### 28) `flow-php/postgresql` - traversal contract: messages, depth, replacement, `REMOVE_NODE`
297+
298+
| Before | After |
299+
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------|
300+
| `ModificationContext::ancestors()` / `parent()` - `Node` wrappers | the real messages (`SelectStmt`, `RangeSubselect`, …), no `Node` wrappers |
301+
| depth of a CTE body - 3 | 4 - `WithClause`, `WindowDef`, `IntoClause`, `OnConflictClause` are levels too |
302+
| a replacement returned below the top-level statement - ignored | written into its slot |
303+
| a replacement of the wrong class - ignored | `ParserException` |
304+
| `NodeVisitor::REMOVE_NODE` - declared, never honoured | removed; `NodeModifier::REMOVE_NODE` removes a node from a list, throws on a single slot |
305+
306+
### 29) `flow-php/postgresql` - `EXPLAIN` wraps every explainable statement, ANALYZE is rolled back
307+
308+
| Before | After |
309+
|-------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|
310+
| `ExplainModifier::nodeClasses()` - `[SelectStmt::class]` | `[ParseResult::class]` |
311+
| INSERT/UPDATE/DELETE/MERGE/CTAS/EXECUTE/DECLARE - returned unwrapped | wrapped in `EXPLAIN` |
312+
| `sql_to_explain('CREATE TABLE x (a int)')`, `sql_to_explain('EXPLAIN SELECT 1')` - returned unwrapped | `InvalidStatementException` |
313+
| `$client->explain('INSERT …')` - the `INSERT` ran and committed | EXPLAIN; with ANALYZE inside a transaction (savepoint when one is open) that is always rolled back |
314+
| `traverse(new PaginationModifier(…), new ExplainModifier(…))` - both applied | the pagination is dropped (or the traversal throws) - call `traverse()` again with `ExplainModifier` alone, last |
315+
316+
### 30) `flow-php/postgresql` - keyset pagination wraps `UNION` / `INTERSECT` / `EXCEPT`
317+
318+
| Before | After |
319+
|-----------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
320+
| `sql_to_keyset_query('SELECT id FROM t UNION SELECT id FROM u ORDER BY id', …)` - cursor dropped, page 2 fails with `08P01` | `SELECT * FROM (…) _keyset_subq WHERE id > $1 ORDER BY id LIMIT …` |
321+
| qualified keyset column (`t.id`) on a set operation - `42P01` at run time | `PaginationException` |
322+
323+
### 31) `flow-php/postgresql` - schema keeps the declared expression text, compares normalised keys
324+
325+
| Before | After |
326+
|------------------------------------------------------------------|------------------------------------------------------------------------------------|
327+
| `Column::$generationExpression` - normalised (`lower(i)`) | declared/catalog text (`lower(i::text)`); compare with `generationExpressionKey()` |
328+
| `CheckConstraint::$expression` - normalised | declared/catalog text; `expressionKey()` |
329+
| `Index::$predicate` - normalised | declared/catalog text; `predicateKey()` |
330+
| `Trigger::$whenCondition` - compared verbatim | declared/catalog text; compared by `whenConditionKey()` |
331+
| `ColumnDefault` EXPRESSION `literal` - normalised | declared/catalog text; `equals()` normalises |
332+
| `CheckDefinitionParser::parse()` - normalised expression | the catalog text, still validated |
333+
| `ExpressionParser::normalizeNode(Node)` | removed - `deparseNode(Node)` keeps the text, `normalize(string)` strips casts |
334+
| DDL emitted the normalised text - `lower(i)` failed with `42883` | DDL emits the declared expression |
335+
336+
### 32) `flow-php/postgresql` - schema DDL emits index `WHERE` and trigger `WHEN`
337+
338+
| Before | After |
339+
|----------------------------------------------------------------|-------------------------------------------------------------------------------|
340+
| `CREATE UNIQUE INDEX t_email_live ON s.t (email)` | `CREATE UNIQUE INDEX t_email_live ON s.t (email) WHERE deleted_at IS NULL` |
341+
| `CREATE TRIGGER t_trg … FOR EACH ROW EXECUTE FUNCTION f()` | `CREATE TRIGGER t_trg … FOR EACH ROW WHEN (new.i > 0) EXECUTE FUNCTION s.f()` |
342+
| an unqualified trigger function resolved through `search_path` | resolved to the table's schema |
343+
| introspected `Trigger::$functionName` - `name` | `schema.name`; new `Trigger::withFunctionSchema()` |
344+
| a declared `'s.f'` always drifted against the catalog | no drift |
345+
346+
### 33) `flow-php/postgresql` - a failed `SAVEPOINT` leaves the outer transaction open
347+
348+
| Before | After |
349+
|---------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
350+
| nesting level reset to 0, the caller's `rollBack()` throws, the connection stays in `25P02` | nesting level kept; the caller's `rollBack()` ends the outer transaction |
351+
281352
---
282353

283354
## Upgrading from 0.43.x to 0.44.x

src/adapter/etl-adapter-postgresql/tests/Flow/ETL/Adapter/PostgreSql/Tests/Integration/PostgreSqlKeySetExtractorIntegrationTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use function Flow\ETL\DSL\from_array;
1818
use function Flow\ETL\DSL\int_schema;
1919
use function Flow\ETL\DSL\schema;
20+
use function Flow\PostgreSql\DSL\asc;
2021
use function Flow\PostgreSql\DSL\col;
2122
use function Flow\PostgreSql\DSL\column;
2223
use function Flow\PostgreSql\DSL\column_type_integer;
@@ -134,6 +135,49 @@ public function test_extracts_all_rows_with_keyset_pagination(): void
134135
static::assertSame(range(1, 25), array_column($rows, 'id'));
135136
}
136137

138+
public function test_extracts_all_rows_of_a_union(): void
139+
{
140+
$odd = $this->tableName . '_odd';
141+
$even = $this->tableName . '_even';
142+
143+
$this->client->execute(create()->table($odd)->column(column('id', column_type_integer())->primaryKey()));
144+
$this->client->execute(create()->table($even)->column(column('id', column_type_integer())->primaryKey()));
145+
$this->client->execute(
146+
insert()
147+
->into($odd)
148+
->columns('id')
149+
->values(literal(1))
150+
->values(literal(3))
151+
->values(literal(5))
152+
->values(literal(7))
153+
->values(literal(9)),
154+
);
155+
$this->client->execute(
156+
insert()
157+
->into($even)
158+
->columns('id')
159+
->values(literal(2))
160+
->values(literal(4))
161+
->values(literal(6))
162+
->values(literal(8))
163+
->values(literal(10)),
164+
);
165+
166+
$rows = df()
167+
->read(from_pgsql_key_set(
168+
$this->client,
169+
select(col('id'))
170+
->from(table($odd))
171+
->union(select(col('id'))->from(table($even)))
172+
->orderBy(asc(col('id'))),
173+
pgsql_pagination_key_set(pgsql_pagination_key_asc('id')),
174+
)->withBatchSize(3))
175+
->fetch()
176+
->toArray();
177+
178+
static::assertSame(range(1, 10), array_column($rows, 'id'));
179+
}
180+
137181
public function test_extracts_limited_rows_with_maximum(): void
138182
{
139183
$rows = df()

src/lib/postgresql/src/Flow/PostgreSql/AST/ModificationContext.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Flow\PostgreSql\AST;
66

77
use Flow\PostgreSql\Protobuf\AST\ParseResult;
8+
use Google\Protobuf\Internal\Message;
89

910
use function count;
1011

@@ -17,8 +18,8 @@
1718
final readonly class ModificationContext
1819
{
1920
/**
20-
* @param array<object> $ancestors Stack of parent nodes (from root to immediate parent)
21-
* @param int $depth Current depth in the AST (1-based, root statements are at depth 1)
21+
* @param list<Message> $ancestors Messages from the root statement down to the parent, excluding Node wrappers
22+
* @param int $depth Current depth in the AST: root statements are at 1, +1 per message edge
2223
* @param ParseResult $parseResult The full parsed AST for context-aware operations
2324
*/
2425
public function __construct(
@@ -28,7 +29,7 @@ public function __construct(
2829
) {}
2930

3031
/**
31-
* @return array<object>
32+
* @return list<Message>
3233
*/
3334
public function ancestors(): array
3435
{
@@ -45,7 +46,7 @@ public function isTopLevel(): bool
4546
return $this->depth === 1;
4647
}
4748

48-
public function parent(): ?object
49+
public function parent(): ?Message
4950
{
5051
return $this->ancestors[count($this->ancestors) - 1] ?? null;
5152
}

src/lib/postgresql/src/Flow/PostgreSql/AST/NodeModifier.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ interface NodeModifier
2424
*/
2525
public const STOP_TRAVERSAL = 2;
2626

27+
/**
28+
* Remove the current node from its list slot.
29+
*/
30+
public const REMOVE_NODE = 3;
31+
2732
/**
2833
* Returns the fully qualified class names of the node types this modifier handles.
2934
*
@@ -41,14 +46,15 @@ public static function nodeClasses(): array;
4146
* - Mutate the node in-place and return null to continue traversal
4247
* - Return DONT_TRAVERSE_CHILDREN to skip child nodes
4348
* - Return STOP_TRAVERSAL to stop the entire traversal
44-
* - Return a new node to replace the current node (used for wrapping operations)
49+
* - Return REMOVE_NODE to remove the node from a list slot; on a single-node slot the traverser throws
50+
* - Return a new node to replace the current node; it is written into the slot and not descended into
4551
*
4652
* @param object $node The node instance to modify (one of the types listed in nodeClasses())
4753
* @param ModificationContext $context Context providing parent information
4854
*
4955
* @return null|int|object
5056
* - null: Continue traversal (node unchanged or modified in-place)
51-
* - int (DONT_TRAVERSE_CHILDREN, STOP_TRAVERSAL): Control flow
57+
* - int (DONT_TRAVERSE_CHILDREN, STOP_TRAVERSAL, REMOVE_NODE): Control flow
5258
* - object: Replace current node with returned node
5359
*/
5460
public function modify(object $node, ModificationContext $context): int|object|null;

src/lib/postgresql/src/Flow/PostgreSql/AST/NodeVisitor.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,6 @@ interface NodeVisitor
1919
*/
2020
public const DONT_TRAVERSE_CHILDREN = 1;
2121

22-
/**
23-
* Remove the node from its parent array.
24-
*/
25-
public const REMOVE_NODE = 3;
26-
2722
/**
2823
* Stop the entire traversal.
2924
*/
@@ -58,7 +53,6 @@ public function enter(object $node): ?int;
5853
*
5954
* @return null|int Return value determines traversal behavior:
6055
* - null: Continue traversal
61-
* - REMOVE_NODE: Remove node from parent
6256
* - STOP_TRAVERSAL: Stop entire traversal
6357
*/
6458
public function leave(object $node): ?int;

0 commit comments

Comments
 (0)