diff --git a/src/Tempest/Database/src/Builder/QueryBuilders/CountQueryBuilder.php b/src/Tempest/Database/src/Builder/QueryBuilders/CountQueryBuilder.php new file mode 100644 index 000000000..f70e1ec0a --- /dev/null +++ b/src/Tempest/Database/src/Builder/QueryBuilders/CountQueryBuilder.php @@ -0,0 +1,109 @@ +modelDefinition = ModelDefinition::tryFrom($model); + + $this->count = new CountStatement( + table: $this->resolveTable($model), + column: $column, + ); + } + + public function execute(mixed ...$bindings): int + { + return $this->build()->fetchFirst(...$bindings)[$this->count->getKey()]; + } + + /** @return self */ + public function distinct(): self + { + if ($this->count->column === null || $this->count->column === '*') { + throw new CannotCountDistinctWithoutSpecifyingAColumn(); + } + + $this->count->distinct = true; + + return $this; + } + + /** @return self */ + public function where(string $where, mixed ...$bindings): self + { + $this->count->where[] = new WhereStatement($where); + + $this->bind(...$bindings); + + return $this; + } + + public function andWhere(string $where, mixed ...$bindings): self + { + return $this->where("AND {$where}", ...$bindings); + } + + public function orWhere(string $where, mixed ...$bindings): self + { + return $this->where("OR {$where}", ...$bindings); + } + + /** @return self */ + public function whereField(string $field, mixed $value): self + { + $field = $this->modelDefinition->getFieldDefinition($field); + + return $this->where("{$field} = :{$field->name}", ...[$field->name => $value]); + } + + /** @return self */ + public function bind(mixed ...$bindings): self + { + $this->bindings = [...$this->bindings, ...$bindings]; + + return $this; + } + + public function toSql(): string + { + return $this->build()->getSql(); + } + + public function build(array $bindings = []): Query + { + return new Query($this->count, [...$this->bindings, ...$bindings]); + } + + private function resolveTable(string|object $model): TableDefinition + { + if ($this->modelDefinition === null) { + return new TableDefinition($model); + } + + return $this->modelDefinition->getTableDefinition(); + } +} diff --git a/src/Tempest/Database/src/Builder/QueryBuilders/QueryBuilder.php b/src/Tempest/Database/src/Builder/QueryBuilders/QueryBuilder.php index e9fc0622e..750888951 100644 --- a/src/Tempest/Database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/src/Tempest/Database/src/Builder/QueryBuilders/QueryBuilder.php @@ -47,4 +47,12 @@ public function delete(): DeleteQueryBuilder { return new DeleteQueryBuilder($this->model); } + + public function count(?string $column = null): CountQueryBuilder + { + return new CountQueryBuilder( + model: $this->model, + column: $column, + ); + } } diff --git a/src/Tempest/Database/src/Exceptions/CannotCountDistinctWithoutSpecifyingAColumn.php b/src/Tempest/Database/src/Exceptions/CannotCountDistinctWithoutSpecifyingAColumn.php new file mode 100644 index 000000000..ea2e9594e --- /dev/null +++ b/src/Tempest/Database/src/Exceptions/CannotCountDistinctWithoutSpecifyingAColumn.php @@ -0,0 +1,13 @@ +getCountArgument(), + ), + sprintf('FROM `%s`', $this->table->name), + ]); + + if ($this->where->isNotEmpty()) { + $query[] = 'WHERE ' . $this->where + ->map(fn (WhereStatement $where) => $where->compile($dialect)) + ->implode(PHP_EOL); + } + + return $query->implode(PHP_EOL); + } + + public function getCountArgument(): string + { + return $this->column === null || $this->column === '*' + ? '*' + : sprintf( + '%s`%s`', + $this->distinct ? 'DISTINCT ' : '', + $this->column, + ); + } + + public function getKey(): string + { + return "COUNT({$this->getCountArgument()})"; + } +} diff --git a/src/Tempest/Database/tests/QueryStatements/CountStatementTest.php b/src/Tempest/Database/tests/QueryStatements/CountStatementTest.php new file mode 100644 index 000000000..2e6682afa --- /dev/null +++ b/src/Tempest/Database/tests/QueryStatements/CountStatementTest.php @@ -0,0 +1,70 @@ +assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); + } + + public function test_count_statement_with_specified_column(): void + { + $tableDefinition = new TableDefinition('foo', 'bar'); + + $statement = new CountStatement( + table: $tableDefinition, + column: 'foobar', + ); + + $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); + } + + public function test_count_statement_with_distinct_specified_column(): void + { + $tableDefinition = new TableDefinition('foo', 'bar'); + + $statement = new CountStatement( + table: $tableDefinition, + column: 'foobar', + ); + + $statement->distinct = true; + + $expected = <<assertSame($expected, $statement->compile(DatabaseDialect::MYSQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::POSTGRESQL)); + $this->assertSame($expected, $statement->compile(DatabaseDialect::SQLITE)); + } +} diff --git a/tests/Integration/Database/Builder/CountQueryBuilderTest.php b/tests/Integration/Database/Builder/CountQueryBuilderTest.php new file mode 100644 index 000000000..19847807d --- /dev/null +++ b/tests/Integration/Database/Builder/CountQueryBuilderTest.php @@ -0,0 +1,158 @@ +count() + ->where('`title` = ?', 'Timeline Taxi') + ->andWhere('`index` <> ?', '1') + ->orWhere('`createdAt` > ?', '2025-01-01') + ->build(); + + $expected = << ? + OR `createdAt` > ? + SQL; + + $sql = $query->getSql(); + $bindings = $query->bindings; + + $this->assertSame($expected, $sql); + $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); + } + + public function test_count_query_with_specified_asterisk(): void + { + $query = query('chapters') + ->count('*') + ->build(); + + $sql = $query->getSql(); + + $expected = <<assertSame($expected, $sql); + } + + public function test_count_query_with_specified_field(): void + { + $query = query('chapters')->count('title')->build(); + + $sql = $query->getSql(); + + $expected = <<assertSame($expected, $sql); + } + + public function test_count_query_without_specifying_column_cannot_be_distinct(): void + { + $this->expectException(CannotCountDistinctWithoutSpecifyingAColumn::class); + + query('chapters') + ->count() + ->distinct() + ->build(); + } + + public function test_count_query_with_specified_asterisk_cannot_be_distinct(): void + { + $this->expectException(CannotCountDistinctWithoutSpecifyingAColumn::class); + + query('chapters') + ->count('*') + ->distinct() + ->build(); + } + + public function test_count_query_with_distinct_specified_field(): void + { + $query = query('chapters') + ->count('title') + ->distinct() + ->build(); + + $sql = $query->getSql(); + + $expected = <<assertSame($expected, $sql); + } + + public function test_count_from_model(): void + { + $query = query(Author::class)->count()->build(); + + $sql = $query->getSql(); + + $expected = <<assertSame($expected, $sql); + } + + public function test_count_query_with_conditions(): void + { + $query = query('chapters') + ->count() + ->when( + true, + fn (CountQueryBuilder $query) => $query + ->where('`title` = ?', 'Timeline Taxi') + ->andWhere('`index` <> ?', '1') + ->orWhere('`createdAt` > ?', '2025-01-01'), + ) + ->when( + false, + fn (CountQueryBuilder $query) => $query + ->where('`title` = ?', 'Timeline Uber') + ->andWhere('`index` <> ?', '2') + ->orWhere('`createdAt` > ?', '2025-01-02'), + ) + ->build(); + + $expected = << ? + OR `createdAt` > ? + SQL; + + $sql = $query->getSql(); + $bindings = $query->bindings; + + $this->assertSame($expected, $sql); + $this->assertSame(['Timeline Taxi', '1', '2025-01-01'], $bindings); + } +}