Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 238 additions & 82 deletions documentation/components/libs/pg-query.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/cli/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"flow-php/etl-adapter-chartjs": "self.version",
"flow-php/openapi-specification-bridge": "self.version",
"flow-php/parquet-viewer": "self.version",
"flow-php/pg-query": "1.x-dev"
"flow-php/pg-query": "self.version"
},
"config": {
"optimize-autoloader": true,
Expand Down
1 change: 1 addition & 0 deletions src/core/etl/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"flow-php/types": "self.version",
"flow-php/array-dot": "self.version",
"flow-php/filesystem": "self.version",
"flow-php/pg-query": "self.version",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
"webmozart/glob": "^3.0 || ^4.0",
"symfony/string": "^6.4 || ^7.3 || ^8.0"
Expand Down
47 changes: 47 additions & 0 deletions src/lib/pg-query/src/Flow/PgQuery/AST/ModificationContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Flow\PgQuery\AST;

/**
* Provides context information during AST modification.
*
* This class gives modifiers access to the traversal state, including
* parent nodes and depth in the tree, enabling context-aware modifications.
*/
final readonly class ModificationContext
{
/**
* @param array<object> $ancestors Stack of parent nodes (from root to immediate parent)
* @param int $depth Current depth in the AST (1-based, root statements are at depth 1)
*/
public function __construct(
private array $ancestors,
private int $depth,
) {
}

/**
* @return array<object>
*/
public function ancestors() : array
{
return $this->ancestors;
}

public function depth() : int
{
return $this->depth;
}

public function isTopLevel() : bool
{
return $this->depth === 1;
}

public function parent() : ?object
{
return $this->ancestors[\count($this->ancestors) - 1] ?? null;
}
}
50 changes: 50 additions & 0 deletions src/lib/pg-query/src/Flow/PgQuery/AST/NodeModifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Flow\PgQuery\AST;

/**
* Interface for AST node modifiers.
*
* Modifiers can mutate nodes in-place during traversal.
* Unlike visitors (which are read-only collectors), modifiers are expected to change the AST.
*/
interface NodeModifier
{
/**
* Don't traverse children of the current node.
*/
public const DONT_TRAVERSE_CHILDREN = 1;

/**
* Stop the entire traversal.
*/
public const STOP_TRAVERSAL = 2;

/**
* Returns the fully qualified class name of the node type this modifier handles.
*
* @return class-string The node class this modifier is registered for
*/
public static function nodeClass() : string;

/**
* Called to modify a node of the registered type.
*
* The modifier can:
* - Mutate the node in-place and return null to continue traversal
* - Return DONT_TRAVERSE_CHILDREN to skip child nodes
* - Return STOP_TRAVERSAL to stop the entire traversal
* - Return a new node to replace the current node (used for wrapping operations)
*
* @param object $node The node instance to modify (type depends on nodeClass())
* @param ModificationContext $context Context providing parent information
*
* @return null|int|object
* - null: Continue traversal (node unchanged or modified in-place)
* - int (DONT_TRAVERSE_CHILDREN, STOP_TRAVERSAL): Control flow
* - object: Replace current node with returned node
*/
public function modify(object $node, ModificationContext $context) : int|object|null;
}
117 changes: 117 additions & 0 deletions src/lib/pg-query/src/Flow/PgQuery/AST/Transformers/CountModifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Flow\PgQuery\AST\Transformers;

use Flow\PgQuery\AST\{ModificationContext, NodeModifier};
use Flow\PgQuery\Protobuf\AST\{
Alias,
ColumnRef,
FuncCall,
Node,
PBString,
RangeSubselect,
ResTarget,
SelectStmt
};
use Flow\PgQuery\Protobuf\AST\A_Star;

/**
* Transforms SELECT queries into COUNT queries for pagination.
*
* Wraps the original query in: SELECT COUNT(*) FROM (...) AS _count_subq
* - Removes ORDER BY (not needed for counting)
* - Removes LIMIT/OFFSET (we want total count)
*/
final readonly class CountModifier implements NodeModifier
{
public static function nodeClass() : string
{
return SelectStmt::class;
}

/** @phpstan-ignore return.unusedType (interface requires full signature) */
public function modify(object $node, ModificationContext $context) : int|object|null
{
/** @var SelectStmt $node */
if (!$context->isTopLevel()) {
return null;
}

$this->removeOrderBy($node);
$this->removeLimitOffset($node);

return $this->wrapWithCount($node);
}

private function createCountFunctionCall() : Node
{
$aStar = new A_Star();
$aStarNode = new Node();
$aStarNode->setAStar($aStar);

$columnRef = new ColumnRef();
$columnRef->setFields([$aStarNode]);

$columnRefNode = new Node();
$columnRefNode->setColumnRef($columnRef);

$funcName = new PBString();
$funcName->setSval('count');
$funcNameNode = new Node();
$funcNameNode->setString($funcName);

$funcCall = new FuncCall();
$funcCall->setFuncname([$funcNameNode]);
$funcCall->setArgs([$columnRefNode]);
$funcCall->setAggStar(true);

$funcCallNode = new Node();
$funcCallNode->setFuncCall($funcCall);

return $funcCallNode;
}

private function removeLimitOffset(SelectStmt $stmt) : void
{
$stmt->clearLimitCount();
$stmt->clearLimitOffset();
}

private function removeOrderBy(SelectStmt $stmt) : void
{
$stmt->setSortClause([]);
}

private function wrapWithCount(SelectStmt $stmt) : Node
{
$innerNode = new Node();
$innerNode->setSelectStmt($stmt);

$alias = new Alias();
$alias->setAliasname('_count_subq');

$rangeSubselect = new RangeSubselect();
$rangeSubselect->setSubquery($innerNode);
$rangeSubselect->setAlias($alias);

$rangeSubselectNode = new Node();
$rangeSubselectNode->setRangeSubselect($rangeSubselect);

$resTarget = new ResTarget();
$resTarget->setVal($this->createCountFunctionCall());

$resTargetNode = new Node();
$resTargetNode->setResTarget($resTarget);

$outerSelect = new SelectStmt();
$outerSelect->setTargetList([$resTargetNode]);
$outerSelect->setFromClause([$rangeSubselectNode]);

$resultNode = new Node();
$resultNode->setSelectStmt($outerSelect);

return $resultNode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Flow\PgQuery\AST\Transformers;

/**
* Defines a column for keyset pagination.
*/
final readonly class KeysetColumn
{
public function __construct(
public string $column,
public SortOrder $order = SortOrder::ASC,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Flow\PgQuery\AST\Transformers;

/**
* Configuration for keyset (cursor-based) pagination.
*
* @psalm-type CursorValue = string|int|float|bool|null
*/
final readonly class KeysetPaginationConfig
{
/**
* @param int $limit Maximum number of rows to return
* @param list<KeysetColumn> $columns Columns to use for keyset comparison (must match ORDER BY)
* @param null|list<CursorValue> $cursor Values from the last row of previous page (null for first page)
*/
public function __construct(
public int $limit,
public array $columns,
public ?array $cursor = null,
) {
}
}
Loading