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
47 changes: 43 additions & 4 deletions QueryGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -136,6 +136,12 @@ class QueryGenerator {
'suffix' => ')',
'requiresArgument' => true,
],
'union' => [
'clause' => '',
'prefix' => '',
'glue' => "\n",
'suffix' => '',
],
'order' => [
'clause' => 'ORDER BY ',
'prefix' => '',
Expand Down Expand Up @@ -195,7 +201,7 @@ class QueryGenerator {
* @var non-empty-array<string, list<string>>
*/
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'],
Expand Down Expand Up @@ -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 {
Comment thread
sctice-ifixit marked this conversation as resolved.
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.
Expand All @@ -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<string, mixed>}
* @return array{0: string, 1: list<mixed>}
*/
public function build(bool $skipClauses = false): array {
if ($this->validateQuery) {
Expand All @@ -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)];
}

/**
Expand Down
138 changes: 138 additions & 0 deletions QueryGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <<<EOT
SELECT field
FROM table
UNION
SELECT field
FROM table2
EOT;

$this->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 = <<<EOT
SELECT field
FROM table
UNION ALL
SELECT field
FROM table2
EOT;

$this->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 = <<<EOT
SELECT a
FROM b
UNION
SELECT a FROM c
UNION ALL
SELECT a FROM d
EOT;

$this->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 = <<<EOT
SELECT id
FROM live
UNION
SELECT id FROM archive WHERE x = ?
EOT;

$this->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 = <<<EOT
SELECT a
FROM b
WHERE (x = ?)
UNION
SELECT a
FROM c
WHERE (y = ?)
ORDER BY a
LIMIT 10
OFFSET ?
EOT;

$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');

$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);
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,32 @@ 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
* delete: from, where, order, limit

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
Comment thread
sctice-ifixit marked this conversation as resolved.
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.
Loading