Skip to content

Commit b01a518

Browse files
committed
Add Count Query Builder and Statement
1 parent be80673 commit b01a518

File tree

5 files changed

+318
-0
lines changed

5 files changed

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

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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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 readonly string $countArgument;
15+
16+
public function __construct(
17+
public readonly TableDefinition $table,
18+
public ?string $column = null,
19+
public ImmutableArray $where = new ImmutableArray(),
20+
) {
21+
$this->countArgument = $this->column === null
22+
? '*'
23+
: "`{$this->column}`";
24+
}
25+
26+
public function compile(DatabaseDialect $dialect): string
27+
{
28+
$query = arr([
29+
sprintf('SELECT COUNT(%s)', $this->countArgument),
30+
sprintf('FROM `%s`', $this->table->name),
31+
]);
32+
33+
if ($this->where->isNotEmpty()) {
34+
$query[] = 'WHERE ' . $this->where
35+
->map(fn (WhereStatement $where) => $where->compile($dialect))
36+
->implode(PHP_EOL);
37+
}
38+
39+
return $query->implode(PHP_EOL);
40+
}
41+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace Tempest\Database\Tests\QueryStatements;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Tempest\Database\Builder\FieldDefinition;
7+
use Tempest\Database\Builder\TableDefinition;
8+
use Tempest\Database\Config\DatabaseDialect;
9+
use Tempest\Database\QueryStatements\CountStatement;
10+
use Tempest\Database\QueryStatements\GroupByStatement;
11+
use Tempest\Database\QueryStatements\HavingStatement;
12+
use Tempest\Database\QueryStatements\JoinStatement;
13+
use Tempest\Database\QueryStatements\OrderByStatement;
14+
use Tempest\Database\QueryStatements\SelectStatement;
15+
use Tempest\Database\QueryStatements\WhereStatement;
16+
17+
use function Tempest\Support\arr;
18+
19+
final class CountStatementTest extends TestCase
20+
{
21+
public function test_count_statement(): void
22+
{
23+
$tableDefinition = new TableDefinition('foo', 'bar');
24+
25+
$statement = new CountStatement(
26+
table: $tableDefinition,
27+
column: null,
28+
);
29+
30+
$expected = <<<SQL
31+
SELECT COUNT(*)
32+
FROM `foo`
33+
SQL;
34+
35+
$this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL));
36+
$this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL));
37+
$this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE));
38+
}
39+
40+
public function test_count_statement_with_specified_column(): void
41+
{
42+
$tableDefinition = new TableDefinition('foo', 'bar');
43+
44+
$statement = new CountStatement(
45+
table: $tableDefinition,
46+
column: 'foobar',
47+
);
48+
49+
$expected = <<<SQL
50+
SELECT COUNT(`foobar`)
51+
FROM `foo`
52+
SQL;
53+
54+
$this->assertSame($expected, $statement->compile(DatabaseDialect::MYSQL));
55+
$this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL));
56+
$this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE));
57+
}
58+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Tempest\Integration\Database\Builder;
6+
7+
use Tempest\Database\Builder\QueryBuilders\CountQueryBuilder;
8+
use Tests\Tempest\Fixtures\Modules\Books\Models\Author;
9+
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
10+
11+
use function Tempest\Database\query;
12+
13+
/**
14+
* @internal
15+
*/
16+
final class CountQueryBuilderTest extends FrameworkIntegrationTestCase
17+
{
18+
public function test_count_query(): void
19+
{
20+
$query = query('chapters')
21+
->count()
22+
->where('`title` = ?', 'Timeline Taxi')
23+
->andWhere('`index` <> ?', '1')
24+
->orWhere('`createdAt` > ?', '2025-01-01')
25+
->build();
26+
27+
$expected = <<<SQL
28+
SELECT COUNT(*)
29+
FROM `chapters`
30+
WHERE `title` = ?
31+
AND `index` <> ?
32+
OR `createdAt` > ?
33+
SQL;
34+
35+
$sql = $query->getSql();
36+
$bindings = $query->bindings;
37+
38+
$this->assertSame($expected, $sql);
39+
$this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings);
40+
}
41+
42+
public function test_count_query_with_specified_field(): void
43+
{
44+
$query = query('chapters')->count('title')->build();
45+
46+
$sql = $query->getSql();
47+
48+
$expected = <<<SQL
49+
SELECT COUNT(`title`)
50+
FROM `chapters`
51+
SQL;
52+
53+
$this->assertSame($expected, $sql);
54+
}
55+
56+
public function test_count_from_model(): void
57+
{
58+
$query = query(Author::class)->count()->build();
59+
60+
$sql = $query->getSql();
61+
62+
$expected = <<<SQL
63+
SELECT COUNT(*)
64+
FROM `authors`
65+
SQL;
66+
67+
$this->assertSame($expected, $sql);
68+
}
69+
70+
public function test_count_query_with_conditions(): void
71+
{
72+
$query = query('chapters')
73+
->count()
74+
->when(
75+
true,
76+
fn (CountQueryBuilder $query) => $query
77+
->where('`title` = ?', 'Timeline Taxi')
78+
->andWhere('`index` <> ?', '1')
79+
->orWhere('`createdAt` > ?', '2025-01-01'),
80+
)
81+
->when(
82+
false,
83+
fn (CountQueryBuilder $query) => $query
84+
->where('`title` = ?', 'Timeline Uber')
85+
->andWhere('`index` <> ?', '2')
86+
->orWhere('`createdAt` > ?', '2025-01-02'),
87+
)
88+
->build();
89+
90+
$expected = <<<SQL
91+
SELECT COUNT(*)
92+
FROM `chapters`
93+
WHERE `title` = ?
94+
AND `index` <> ?
95+
OR `createdAt` > ?
96+
SQL;
97+
98+
$sql = $query->getSql();
99+
$bindings = $query->bindings;
100+
101+
$this->assertSame($expected, $sql);
102+
$this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings);
103+
}
104+
}

0 commit comments

Comments
 (0)