From d43b18241d826a2b017897fff051a4b77d29c1c0 Mon Sep 17 00:00:00 2001 From: Daniel Beardsley Date: Mon, 27 Jul 2026 11:36:44 -0700 Subject: [PATCH 1/5] feat: add union() and unionAll() to QueryGenerator Allow combining SELECT statements with UNION / UNION ALL. Operands may be another QueryGenerator (built and validated immediately, keywords intact) or a raw SQL string with params. Implemented as real methods rather than via __call because the nested generator path strips clause keywords (skipClauses), which would mangle union operands. The UNION keyword is baked into each stored piece so UNION and UNION ALL can be mixed in call order. The clause is rendered between HAVING and ORDER BY so trailing ORDER BY/LIMIT apply to the whole union and params merge in placeholder order. Co-Authored-By: Claude Fable 5 --- QueryGenerator.php | 37 +++++++++++- QueryGeneratorTest.php | 125 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/QueryGenerator.php b/QueryGenerator.php index 220c43b..f1f3078 100644 --- a/QueryGenerator.php +++ b/QueryGenerator.php @@ -11,7 +11,7 @@ * * Available clauses / methods are: * select, insert, replace, update, delete, from, join, set, columns, values, - * where, group, having, order, limit, offset, duplicate, modify + * where, group, having, union, order, limit, offset, duplicate, modify * * After all clauses have been built, call the 'build' member function to * compose the entire query. This returns an array containing the query and @@ -136,6 +136,12 @@ class QueryGenerator { 'suffix' => ')', 'requiresArgument' => true, ], + 'union' => [ + 'clause' => '', + 'prefix' => '', + 'glue' => "\n", + 'suffix' => '', + ], 'order' => [ 'clause' => 'ORDER BY ', 'prefix' => '', @@ -195,7 +201,7 @@ class QueryGenerator { * @var non-empty-array> */ private static array $possibleClauses = [ - 'select' => ['from', 'join', 'where', 'group', 'having', 'order', 'limit', 'offset', 'forupdate'], + 'select' => ['from', 'join', 'where', 'group', 'having', 'union', 'order', 'limit', 'offset', 'forupdate'], 'insert' => ['set', 'columns', 'values', 'duplicate', 'as'], 'replace' => ['set', 'columns', 'values'], 'update' => ['set', 'where', 'order', 'limit'], @@ -299,6 +305,33 @@ public function __call(string $method, array $args) { return $this; } + /** + * Append a UNION operand rendered as a complete SELECT statement. + * QueryGenerator operands are built (and validated, unless the operand + * has called skipValidation) immediately. + */ + public function union(self|string $query, mixed $params = []): self { + return $this->addUnion('UNION', $query, $params); + } + + public function unionAll(self|string $query, mixed $params = []): self { + return $this->addUnion('UNION ALL', $query, $params); + } + + private function addUnion(string $keyword, self|string $query, mixed $params): self { + if ($query instanceof self) { + [$query, $params] = $query->build(); + } + + if (!is_array($params)) { + $params = [$params]; + } + + $this->clauses['union'][] = "$keyword\n$query"; + $this->params['union'] = array_merge($this->params['union'], $params); + return $this; + } + /** * Combine the clauses and parameters in this QueryGenerator to compose a * complete query and paramter list. diff --git a/QueryGeneratorTest.php b/QueryGeneratorTest.php index b19850c..f62b9b2 100644 --- a/QueryGeneratorTest.php +++ b/QueryGeneratorTest.php @@ -241,6 +241,131 @@ public function testOnDuplicate(): void { $this->assertQuery($qGen, $expectedQuery, []); } + public function testUnion(): void { + $other = new QueryGenerator(); + $other->select('field'); + $other->from('table2'); + + $qGen = new QueryGenerator(); + $qGen->select('field'); + $qGen->from('table'); + $qGen->union($other); + + $expectedQuery = <<assertQuery($qGen, $expectedQuery, []); + } + + public function testUnionAll(): void { + $other = new QueryGenerator(); + $other->select('field'); + $other->from('table2'); + + $qGen = new QueryGenerator(); + $qGen->select('field'); + $qGen->from('table'); + $qGen = $qGen->unionAll($other); + + $expectedQuery = <<assertQuery($qGen, $expectedQuery, []); + } + + public function testMixedUnions(): void { + $qGen = new QueryGenerator(); + $qGen->select('a'); + $qGen->from('b'); + $qGen->union('SELECT a FROM c'); + $qGen->unionAll('SELECT a FROM d'); + + $expectedQuery = <<assertQuery($qGen, $expectedQuery, []); + } + + public function testUnionStringOperand(): void { + $qGen = new QueryGenerator(); + $qGen->select('id'); + $qGen->from('live'); + $qGen->union('SELECT id FROM archive WHERE x = ?', 5); + + $expectedQuery = <<assertQuery($qGen, $expectedQuery, [5]); + } + + public function testUnionParamOrdering(): void { + $other = new QueryGenerator(); + $other->select('a'); + $other->from('c'); + $other->where('y = ?', 2); + + $qGen = new QueryGenerator(); + $qGen->select('a'); + $qGen->from('b'); + $qGen->where('x = ?', 1); + $qGen->union($other); + $qGen->order('a'); + $qGen->limit(10); + $qGen->offset('?', 5); + + $expectedQuery = <<assertQuery($qGen, $expectedQuery, [1, 2, 5]); + } + + public function testUnionOperandValidation(): void { + $incomplete = new QueryGenerator(); + $incomplete->select('a'); + + $qGen = new QueryGenerator(); + $qGen->select('a'); + $qGen->from('b'); + + $union = function() use ($qGen, $incomplete): QueryGenerator { + return $qGen->union($incomplete); + }; + + $this->assertTrue((bool)$this->didThrowException($union)); + $incomplete->skipValidation(); + $this->assertFalse($this->didThrowException($union)); + } + public function assertQuery(QueryGenerator $qGen, string $expectedQuery, array $expectedParams): void { [$actualQuery, $actualParams] = $qGen->build(); $this->assertEquals($expectedQuery, $actualQuery); From 3dcd6b5f50bc908a61167aacd6a96f5d44784496 Mon Sep 17 00:00:00 2001 From: Daniel Beardsley Date: Mon, 27 Jul 2026 11:36:58 -0700 Subject: [PATCH 2/5] docs: document union clause in README Co-Authored-By: Claude Fable 5 --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 15569e9..6aae066 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ WHERE field1 > ? AND field3 < ? QueryGenerator has support for SELECT, INSERT, REPLACE, UPDATE, and DELETE queries. Each of those query types supports a selection of different clauses: - * select: from, join, where, group, having, order, limit, offset, forupdate + * select: from, join, where, group, having, union, order, limit, offset, forupdate * insert: set, columns, values, duplicate * replace: set, columns, values * update: set, where, order, limit @@ -92,3 +92,21 @@ queries. Each of those query types supports a selection of different clauses: Simply call the member function of the clause you want to add to, passing strings of SQL and (optionally) parameters for use in prepared statements. + +SELECT queries can be combined with `union()` and `unionAll()`. Operands +must be complete SELECT statements, either another QueryGenerator or a raw +SQL string: + +```php +$archived = new QueryGenerator(); +$archived->select('id')->from('archived_orders')->where('total > ?', 100); + +$qGen = new QueryGenerator(); +$qGen->select('id')->from('orders')->where('total > ?', 100); +$qGen->union($archived); +$qGen->unionAll('SELECT id FROM pending_orders'); +$qGen->order('id'); +``` + +A trailing `order()` or `limit()` applies to the whole union result. To +order or limit an individual operand, pass it as a parenthesized string. From b32755fd93df143c5901d6b72325cc8d225f8f65 Mon Sep 17 00:00:00 2001 From: Daniel Beardsley Date: Mon, 27 Jul 2026 14:00:54 -0700 Subject: [PATCH 3/5] fix: throw when params accompany a QueryGenerator union operand A QueryGenerator operand carries its own params, so any passed alongside were silently dropped. Make the mistake loud instead. Co-Authored-By: Claude Fable 5 --- QueryGenerator.php | 6 ++++++ QueryGeneratorTest.php | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/QueryGenerator.php b/QueryGenerator.php index f1f3078..94a3d86 100644 --- a/QueryGenerator.php +++ b/QueryGenerator.php @@ -320,6 +320,12 @@ public function unionAll(self|string $query, mixed $params = []): self { private function addUnion(string $keyword, self|string $query, mixed $params): self { if ($query instanceof self) { + if ($params !== []) { + throw new Exception( + "Params can't be passed alongside a QueryGenerator operand; " . + "add them to the operand instead." + ); + } [$query, $params] = $query->build(); } diff --git a/QueryGeneratorTest.php b/QueryGeneratorTest.php index f62b9b2..813a04b 100644 --- a/QueryGeneratorTest.php +++ b/QueryGeneratorTest.php @@ -349,6 +349,19 @@ public function testUnionParamOrdering(): void { $this->assertQuery($qGen, $expectedQuery, [1, 2, 5]); } + public function testUnionGeneratorOperandWithParamsThrows(): void { + $other = new QueryGenerator(); + $other->select('a'); + $other->from('c'); + + $qGen = new QueryGenerator(); + $qGen->select('a'); + $qGen->from('b'); + + $this->expectException(Exception::class); + $qGen->union($other, [1]); + } + public function testUnionOperandValidation(): void { $incomplete = new QueryGenerator(); $incomplete->select('a'); From fe447cf0a153dc11628fd35115fbc9d36dd86731 Mon Sep 17 00:00:00 2001 From: Daniel Beardsley Date: Mon, 27 Jul 2026 14:01:20 -0700 Subject: [PATCH 4/5] fix: declare build() params as a list The return annotation claimed array, suggesting named params, but nothing in PopSQL uses them and params placeholders are positional. Use array_values to make the list shape concrete. Co-Authored-By: Claude Fable 5 --- QueryGenerator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QueryGenerator.php b/QueryGenerator.php index 94a3d86..9504a64 100644 --- a/QueryGenerator.php +++ b/QueryGenerator.php @@ -352,7 +352,7 @@ private function addUnion(string $keyword, self|string $query, mixed $params): s * * Returns an array containing the query and paramter list, respectively. * - * @return array{0: string, 1: array} + * @return array{0: string, 1: list} */ public function build(bool $skipClauses = false): array { if ($this->validateQuery) { @@ -377,7 +377,7 @@ public function build(bool $skipClauses = false): array { $clauses[] = $this->constructClause($method, $skipClauses); $params = array_merge($params, $this->params[$method]); } - return [implode("\n", $clauses), $params]; + return [implode("\n", $clauses), array_values($params)]; } /** From 7f39b6ae83a6465a0dd356f089dda8eb92723bc1 Mon Sep 17 00:00:00 2001 From: Daniel Beardsley Date: Mon, 27 Jul 2026 14:01:21 -0700 Subject: [PATCH 5/5] docs: note union operands are rendered eagerly Co-Authored-By: Claude Fable 5 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6aae066..024c44e 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,6 @@ $qGen->order('id'); A trailing `order()` or `limit()` applies to the whole union result. To order or limit an individual operand, pass it as a parenthesized string. + +QueryGenerator operands are rendered when `union()` or `unionAll()` is +called; mutating the operand afterward has no effect on the outer query.