Skip to content

Commit 22dbe07

Browse files
authored
feat(database): add Count query builder and statement (#1174)
1 parent be80673 commit 22dbe07

File tree

6 files changed

+414
-0
lines changed

6 files changed

+414
-0
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Database\Builder\QueryBuilders;
6+
7+
use Tempest\Database\Builder\ModelDefinition;
8+
use Tempest\Database\Builder\TableDefinition;
9+
use Tempest\Database\Exceptions\CannotCountDistinctWithoutSpecifyingAColumn;
10+
use Tempest\Database\Query;
11+
use Tempest\Database\QueryStatements\CountStatement;
12+
use Tempest\Database\QueryStatements\WhereStatement;
13+
use Tempest\Support\Conditions\HasConditions;
14+
15+
/**
16+
* @template TModelClass of object
17+
*/
18+
final class CountQueryBuilder
19+
{
20+
use HasConditions;
21+
22+
private ?ModelDefinition $modelDefinition;
23+
24+
private CountStatement $count;
25+
26+
private array $bindings = [];
27+
28+
public function __construct(string|object $model, ?string $column = null)
29+
{
30+
$this->modelDefinition = ModelDefinition::tryFrom($model);
31+
32+
$this->count = new CountStatement(
33+
table: $this->resolveTable($model),
34+
column: $column,
35+
);
36+
}
37+
38+
public function execute(mixed ...$bindings): int
39+
{
40+
return $this->build()->fetchFirst(...$bindings)[$this->count->getKey()];
41+
}
42+
43+
/** @return self<TModelClass> */
44+
public function distinct(): self
45+
{
46+
if ($this->count->column === null || $this->count->column === '*') {
47+
throw new CannotCountDistinctWithoutSpecifyingAColumn();
48+
}
49+
50+
$this->count->distinct = true;
51+
52+
return $this;
53+
}
54+
55+
/** @return self<TModelClass> */
56+
public function where(string $where, mixed ...$bindings): self
57+
{
58+
$this->count->where[] = new WhereStatement($where);
59+
60+
$this->bind(...$bindings);
61+
62+
return $this;
63+
}
64+
65+
public function andWhere(string $where, mixed ...$bindings): self
66+
{
67+
return $this->where("AND {$where}", ...$bindings);
68+
}
69+
70+
public function orWhere(string $where, mixed ...$bindings): self
71+
{
72+
return $this->where("OR {$where}", ...$bindings);
73+
}
74+
75+
/** @return self<TModelClass> */
76+
public function whereField(string $field, mixed $value): self
77+
{
78+
$field = $this->modelDefinition->getFieldDefinition($field);
79+
80+
return $this->where("{$field} = :{$field->name}", ...[$field->name => $value]);
81+
}
82+
83+
/** @return self<TModelClass> */
84+
public function bind(mixed ...$bindings): self
85+
{
86+
$this->bindings = [...$this->bindings, ...$bindings];
87+
88+
return $this;
89+
}
90+
91+
public function toSql(): string
92+
{
93+
return $this->build()->getSql();
94+
}
95+
96+
public function build(array $bindings = []): Query
97+
{
98+
return new Query($this->count, [...$this->bindings, ...$bindings]);
99+
}
100+
101+
private function resolveTable(string|object $model): TableDefinition
102+
{
103+
if ($this->modelDefinition === null) {
104+
return new TableDefinition($model);
105+
}
106+
107+
return $this->modelDefinition->getTableDefinition();
108+
}
109+
}

src/Tempest/Database/src/Builder/QueryBuilders/QueryBuilder.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,12 @@ public function delete(): DeleteQueryBuilder
4747
{
4848
return new DeleteQueryBuilder($this->model);
4949
}
50+
51+
public function count(?string $column = null): CountQueryBuilder
52+
{
53+
return new CountQueryBuilder(
54+
model: $this->model,
55+
column: $column,
56+
);
57+
}
5058
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace Tempest\Database\Exceptions;
4+
5+
use LogicException;
6+
7+
final class CannotCountDistinctWithoutSpecifyingAColumn extends LogicException
8+
{
9+
public function __construct()
10+
{
11+
parent::__construct('Cannot count distinct without specifying a column');
12+
}
13+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Tempest\Database\QueryStatements;
4+
5+
use Tempest\Database\Builder\TableDefinition;
6+
use Tempest\Database\Config\DatabaseDialect;
7+
use Tempest\Database\QueryStatement;
8+
use Tempest\Support\Arr\ImmutableArray;
9+
10+
use function Tempest\Support\arr;
11+
12+
final class CountStatement implements QueryStatement
13+
{
14+
public bool $distinct = false;
15+
16+
public function __construct(
17+
public readonly TableDefinition $table,
18+
public ?string $column = null,
19+
public ImmutableArray $where = new ImmutableArray(),
20+
) {}
21+
22+
public function compile(DatabaseDialect $dialect): string
23+
{
24+
$query = arr([
25+
sprintf(
26+
'SELECT COUNT(%s)',
27+
$this->getCountArgument(),
28+
),
29+
sprintf('FROM `%s`', $this->table->name),
30+
]);
31+
32+
if ($this->where->isNotEmpty()) {
33+
$query[] = 'WHERE ' . $this->where
34+
->map(fn (WhereStatement $where) => $where->compile($dialect))
35+
->implode(PHP_EOL);
36+
}
37+
38+
return $query->implode(PHP_EOL);
39+
}
40+
41+
public function getCountArgument(): string
42+
{
43+
return $this->column === null || $this->column === '*'
44+
? '*'
45+
: sprintf(
46+
'%s`%s`',
47+
$this->distinct ? 'DISTINCT ' : '',
48+
$this->column,
49+
);
50+
}
51+
52+
public function getKey(): string
53+
{
54+
return "COUNT({$this->getCountArgument()})";
55+
}
56+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
3+
namespace Tempest\Database\Tests\QueryStatements;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Tempest\Database\Builder\TableDefinition;
7+
use Tempest\Database\Config\DatabaseDialect;
8+
use Tempest\Database\QueryStatements\CountStatement;
9+
10+
final class CountStatementTest extends TestCase
11+
{
12+
public function test_count_statement(): void
13+
{
14+
$tableDefinition = new TableDefinition('foo', 'bar');
15+
16+
$statement = new CountStatement(
17+
table: $tableDefinition,
18+
column: null,
19+
);
20+
21+
$expected = <<<SQL
22+
SELECT COUNT(*)
23+
FROM `foo`
24+
SQL;
25+
26+
$this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL));
27+
$this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL));
28+
$this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE));
29+
}
30+
31+
public function test_count_statement_with_specified_column(): void
32+
{
33+
$tableDefinition = new TableDefinition('foo', 'bar');
34+
35+
$statement = new CountStatement(
36+
table: $tableDefinition,
37+
column: 'foobar',
38+
);
39+
40+
$expected = <<<SQL
41+
SELECT COUNT(`foobar`)
42+
FROM `foo`
43+
SQL;
44+
45+
$this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL));
46+
$this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL));
47+
$this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE));
48+
}
49+
50+
public function test_count_statement_with_distinct_specified_column(): void
51+
{
52+
$tableDefinition = new TableDefinition('foo', 'bar');
53+
54+
$statement = new CountStatement(
55+
table: $tableDefinition,
56+
column: 'foobar',
57+
);
58+
59+
$statement->distinct = true;
60+
61+
$expected = <<<SQL
62+
SELECT COUNT(DISTINCT `foobar`)
63+
FROM `foo`
64+
SQL;
65+
66+
$this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL));
67+
$this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL));
68+
$this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE));
69+
}
70+
}

0 commit comments

Comments
 (0)