diff --git a/QueryGenerator.php b/QueryGenerator.php index 220c43b..9504a64 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,39 @@ 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) { + if ($params !== []) { + throw new Exception( + "Params can't be passed alongside a QueryGenerator operand; " . + "add them to the operand instead." + ); + } + [$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. @@ -313,7 +352,7 @@ public function __call(string $method, array $args) { * * 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) { @@ -338,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)]; } /** diff --git a/QueryGeneratorTest.php b/QueryGeneratorTest.php index b19850c..813a04b 100644 --- a/QueryGeneratorTest.php +++ b/QueryGeneratorTest.php @@ -241,6 +241,144 @@ 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 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'); + + $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); diff --git a/README.md b/README.md index 15569e9..024c44e 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,24 @@ 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. + +QueryGenerator operands are rendered when `union()` or `unionAll()` is +called; mutating the operand afterward has no effect on the outer query.