From 986482a951dcbe7150701a487cd5dfc9ec5dfe74 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 20:48:57 +0000 Subject: [PATCH 1/9] feat(database): add AggregateFunction enum --- packages/database/src/AggregateFunction.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 packages/database/src/AggregateFunction.php diff --git a/packages/database/src/AggregateFunction.php b/packages/database/src/AggregateFunction.php new file mode 100644 index 000000000..546dc9827 --- /dev/null +++ b/packages/database/src/AggregateFunction.php @@ -0,0 +1,13 @@ + Date: Fri, 27 Mar 2026 20:52:59 +0000 Subject: [PATCH 2/9] feat(database): add sum/avg/max/min aggregate methods to SelectQueryBuilder --- .../QueryBuilders/SelectQueryBuilder.php | 61 ++++++++++ .../Builder/AggregateQueryBuilderTest.php | 115 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 tests/Integration/Database/Builder/AggregateQueryBuilderTest.php diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index bc7115014..0615f8abb 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -5,6 +5,7 @@ namespace Tempest\Database\Builder\QueryBuilders; use Closure; +use Tempest\Database\AggregateFunction; use Tempest\Database\Builder\ModelInspector; use Tempest\Database\Database; use Tempest\Database\DatabaseContext; @@ -396,6 +397,66 @@ public function build(mixed ...$bindings): Query return new Query($select, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); } + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public function sum(string $column): int|float + { + return $this->aggregate(AggregateFunction::SUM, $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public function avg(string $column): float + { + return (float) $this->aggregate(AggregateFunction::AVG, $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public function max(string $column): mixed + { + return $this->aggregate(AggregateFunction::MAX, $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public function min(string $column): mixed + { + return $this->aggregate(AggregateFunction::MIN, $column); + } + + private function aggregate(AggregateFunction $function, string $column): mixed + { + $key = strtolower($function->value); + + $field = new FieldStatement( + field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), + ); + + $result = SelectQueryBuilder::fromQueryBuilder( + $this, + fields: new ImmutableArray([$field]), + )->build()->fetchFirst()[$key]; + + if ($result === null) { + return match ($function) { + AggregateFunction::AVG => 0.0, + AggregateFunction::SUM => 0, + default => null, + }; + } + + return match ($function) { + AggregateFunction::AVG => (float) $result, + AggregateFunction::SUM => str_contains((string) $result, '.') ? (float) $result : (int) $result, + default => $result, + }; + } + private function clone(): self { return clone $this; diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php new file mode 100644 index 000000000..2a4eb8dce --- /dev/null +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -0,0 +1,115 @@ +buildAggregate( + builder: query('books')->select(), + function: AggregateFunction::SUM, + column: 'price', + ); + + $expected = 'SELECT SUM(`price`) AS `sum` FROM `books`'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + public function test_avg_compiles_correct_sql(): void + { + $query = $this->buildAggregate( + builder: query('books')->select(), + function: AggregateFunction::AVG, + column: 'price', + ); + + $expected = 'SELECT AVG(`price`) AS `avg` FROM `books`'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + public function test_max_compiles_correct_sql(): void + { + $query = $this->buildAggregate( + builder: query('books')->select(), + function: AggregateFunction::MAX, + column: 'price', + ); + + $expected = 'SELECT MAX(`price`) AS `max` FROM `books`'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + public function test_min_compiles_correct_sql(): void + { + $query = $this->buildAggregate( + builder: query('books')->select(), + function: AggregateFunction::MIN, + column: 'price', + ); + + $expected = 'SELECT MIN(`price`) AS `min` FROM `books`'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + public function test_sum_with_where_compiles_correct_sql(): void + { + $query = $this->buildAggregate( + builder: query('books')->select()->where('author_id', 1), + function: AggregateFunction::SUM, + column: 'price', + ); + + $expected = 'SELECT SUM(`price`) AS `sum` FROM `books` WHERE books.author_id = ?'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + public function test_sum_from_model(): void + { + $query = $this->buildAggregate( + builder: query(Author::class)->select(), + function: AggregateFunction::SUM, + column: 'id', + ); + + $expected = 'SELECT SUM(`id`) AS `sum` FROM `authors`'; + + $this->assertSameWithoutBackticks($expected, $query->compile()); + } + + private function buildAggregate( + SelectQueryBuilder $builder, + AggregateFunction $function, + string $column, + ): \Tempest\Database\Query { + $key = strtolower($function->value); + + $field = new FieldStatement( + field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), + ); + + return SelectQueryBuilder::fromQueryBuilder( + $builder, + fields: new ImmutableArray([$field]), + )->build(); + } +} From 5f0e1628aee6402422d4458627c0d176dd872eda Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 20:57:08 +0000 Subject: [PATCH 3/9] feat(database): add sum/avg/max/min shortcuts to QueryBuilder and IsDatabaseModel --- .../Builder/QueryBuilders/QueryBuilder.php | 52 +++++++++++++++++++ .../QueryBuilders/SelectQueryBuilder.php | 2 +- packages/database/src/IsDatabaseModel.php | 32 ++++++++++++ .../Builder/AggregateQueryBuilderTest.php | 19 ++++--- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index 8f99aa2bd..cad4c2c41 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -127,6 +127,58 @@ public function count(?string $column = null): CountQueryBuilder )->onDatabase($this->onDatabase); } + /** + * Executes an aggregate query and returns the sum of the given column. + * + * **Example** + * ```php + * query(User::class)->sum('price'); + * ``` + */ + public function sum(string $column): int|float + { + return $this->select()->onDatabase($this->onDatabase)->sum($column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + * + * **Example** + * ```php + * query(User::class)->avg('price'); + * ``` + */ + public function avg(string $column): float + { + return $this->select()->onDatabase($this->onDatabase)->avg($column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + * + * **Example** + * ```php + * query(User::class)->max('price'); + * ``` + */ + public function max(string $column): mixed + { + return $this->select()->onDatabase($this->onDatabase)->max($column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + * + * **Example** + * ```php + * query(User::class)->min('price'); + * ``` + */ + public function min(string $column): mixed + { + return $this->select()->onDatabase($this->onDatabase)->min($column); + } + /** * Creates a new instance of this model without persisting it to the database. * diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 0615f8abb..75ba7abd3 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -440,7 +440,7 @@ private function aggregate(AggregateFunction $function, string $column): mixed $result = SelectQueryBuilder::fromQueryBuilder( $this, fields: new ImmutableArray([$field]), - )->build()->fetchFirst()[$key]; + )->build()->fetchFirst()[$key] ?? null; if ($result === null) { return match ($function) { diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 71def875e..cf21ac5ab 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -84,6 +84,38 @@ public static function count(): CountQueryBuilder return self::queryBuilder()->count(); } + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public static function sum(string $column): int|float + { + return self::queryBuilder()->sum($column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public static function avg(string $column): float + { + return self::queryBuilder()->avg($column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public static function max(string $column): mixed + { + return self::queryBuilder()->max($column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public static function min(string $column): mixed + { + return self::queryBuilder()->min($column); + } + /** * Creates a new instance of this model without persisting it to the database. */ diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index 2a4eb8dce..f03f6375e 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Database\Builder; +use PHPUnit\Framework\Attributes\Test; use Tempest\Database\AggregateFunction; use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; use Tempest\Database\QueryStatements\FieldStatement; @@ -18,7 +19,8 @@ */ final class AggregateQueryBuilderTest extends FrameworkIntegrationTestCase { - public function test_sum_compiles_correct_sql(): void + #[Test] + public function sum_compiles_correct_sql(): void { $query = $this->buildAggregate( builder: query('books')->select(), @@ -31,7 +33,8 @@ function: AggregateFunction::SUM, $this->assertSameWithoutBackticks($expected, $query->compile()); } - public function test_avg_compiles_correct_sql(): void + #[Test] + public function avg_compiles_correct_sql(): void { $query = $this->buildAggregate( builder: query('books')->select(), @@ -44,7 +47,8 @@ function: AggregateFunction::AVG, $this->assertSameWithoutBackticks($expected, $query->compile()); } - public function test_max_compiles_correct_sql(): void + #[Test] + public function max_compiles_correct_sql(): void { $query = $this->buildAggregate( builder: query('books')->select(), @@ -57,7 +61,8 @@ function: AggregateFunction::MAX, $this->assertSameWithoutBackticks($expected, $query->compile()); } - public function test_min_compiles_correct_sql(): void + #[Test] + public function min_compiles_correct_sql(): void { $query = $this->buildAggregate( builder: query('books')->select(), @@ -70,7 +75,8 @@ function: AggregateFunction::MIN, $this->assertSameWithoutBackticks($expected, $query->compile()); } - public function test_sum_with_where_compiles_correct_sql(): void + #[Test] + public function sum_with_where_compiles_correct_sql(): void { $query = $this->buildAggregate( builder: query('books')->select()->where('author_id', 1), @@ -83,7 +89,8 @@ function: AggregateFunction::SUM, $this->assertSameWithoutBackticks($expected, $query->compile()); } - public function test_sum_from_model(): void + #[Test] + public function sum_from_model(): void { $query = $this->buildAggregate( builder: query(Author::class)->select(), From 84c9e4a6a308a35bc10c95c8097c96a2a5185322 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 20:59:03 +0000 Subject: [PATCH 4/9] test(database): add integration tests for aggregate functions --- .../Builder/AggregateQueryBuilderTest.php | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index f03f6375e..5d1f98c96 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -103,6 +103,166 @@ function: AggregateFunction::SUM, $this->assertSameWithoutBackticks($expected, $query->compile()); } + #[Test] + public function sum_returns_correct_value(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $sum = query(Author::class)->sum('id'); + + $this->assertSame(6, $sum); + } + + #[Test] + public function avg_returns_correct_value(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $avg = query(Author::class)->avg('id'); + + $this->assertSame(2.0, $avg); + } + + #[Test] + public function max_returns_correct_value(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $max = query(Author::class)->max('id'); + + $this->assertSame(3, $max); + } + + #[Test] + public function min_returns_correct_value(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $min = query(Author::class)->min('id'); + + $this->assertSame(1, $min); + } + + #[Test] + public function sum_with_where_condition(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $sum = query(Author::class)->find(name: 'Author A')->sum('id'); + + $this->assertSame(1, $sum); + } + + #[Test] + public function max_on_string_column(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $max = query(Author::class)->max('name'); + + $this->assertSame('Zeta', $max); + } + + #[Test] + public function min_on_string_column(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $min = query(Author::class)->min('name'); + + $this->assertSame('Alpha', $min); + } + + #[Test] + public function sum_static_shortcut_on_model(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $sum = Author::sum('id'); + + $this->assertSame(3, $sum); + } + + #[Test] + public function avg_static_shortcut_on_model(): void + { + $this->database->migrate( + \Tempest\Database\Migrations\CreateMigrationsTable::class, + \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, + \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $avg = Author::avg('id'); + + $this->assertSame(1.5, $avg); + } + private function buildAggregate( SelectQueryBuilder $builder, AggregateFunction $function, From f4629dea9774bae8a6e4d95f33448430df7f712b Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 22:15:48 +0000 Subject: [PATCH 5/9] refactor(database): use named args, arr() helper, and clean up aggregate code --- .../Builder/QueryBuilders/QueryBuilder.php | 112 +++++++++ .../QueryBuilders/SelectQueryBuilder.php | 137 ++++++++++ packages/database/src/IsDatabaseModel.php | 72 ++++++ .../Builder/AggregateQueryBuilderTest.php | 235 ++++++++++++++++++ 4 files changed, 556 insertions(+) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index cad4c2c41..ffd09e197 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -128,6 +128,117 @@ public function count(?string $column = null): CountQueryBuilder } /** +<<<<<<< ours +<<<<<<< ours + * Executes an aggregate query and returns the sum of the given column. + * + * **Example** + * ```php + * query(User::class)->sum('price'); + * ``` + */ + public function sum(string $column): int|float + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->sum(column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + * + * **Example** + * ```php + * query(User::class)->avg('price'); + * ``` + */ + public function avg(string $column): float + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->avg(column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + * + * **Example** + * ```php + * query(User::class)->max('price'); + * ``` + */ + public function max(string $column): mixed + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->max(column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + * + * **Example** + * ```php + * query(User::class)->min('price'); + * ``` + */ + public function min(string $column): mixed + { + return $this->select()->onDatabase(databaseTag: $this->onDatabase)->min(column: $column); + } + + /** +||||||| ancestor + * Executes an aggregate query and returns the sum of the given column. + * + * **Example** + * ```php + * query(User::class)->sum('price'); + * ``` + */ + public function sum(string $column): int|float + { + return $this->select()->onDatabase($this->onDatabase)->sum($column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + * + * **Example** + * ```php + * query(User::class)->avg('price'); + * ``` + */ + public function avg(string $column): float + { + return $this->select()->onDatabase($this->onDatabase)->avg($column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + * + * **Example** + * ```php + * query(User::class)->max('price'); + * ``` + */ + public function max(string $column): mixed + { + return $this->select()->onDatabase($this->onDatabase)->max($column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + * + * **Example** + * ```php + * query(User::class)->min('price'); + * ``` + */ + public function min(string $column): mixed + { + return $this->select()->onDatabase($this->onDatabase)->min($column); + } + + /** +======= +>>>>>>> theirs +||||||| ancestor +======= * Executes an aggregate query and returns the sum of the given column. * * **Example** @@ -180,6 +291,7 @@ public function min(string $column): mixed } /** +>>>>>>> theirs * Creates a new instance of this model without persisting it to the database. * * **Example** diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 75ba7abd3..8d5283213 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -33,6 +33,13 @@ use function Tempest\Container\get; use function Tempest\Database\inspect; use function Tempest\Mapper\map; +<<<<<<< ours +use function Tempest\Support\arr; +use function Tempest\Support\str; +||||||| ancestor +use function Tempest\Support\arr; +======= +>>>>>>> theirs /** * @template TModel @@ -397,6 +404,135 @@ public function build(mixed ...$bindings): Query return new Query($select, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); } +<<<<<<< ours +<<<<<<< ours + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public function sum(string $column): int|float + { + return $this->aggregate(function: AggregateFunction::SUM, column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public function avg(string $column): float + { + return (float) $this->aggregate(function: AggregateFunction::AVG, column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public function max(string $column): mixed + { + return $this->aggregate(function: AggregateFunction::MAX, column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public function min(string $column): mixed + { + return $this->aggregate(function: AggregateFunction::MIN, column: $column); + } + + private function aggregate(AggregateFunction $function, string $column): mixed + { + $key = str(string: $function->value)->lower()->toString(); + + $field = new FieldStatement( + field: "{$function->value}(`{$column}`) AS `{$key}`", + ); + + $result = + SelectQueryBuilder::fromQueryBuilder( + source: $this, + fields: arr([$field]), + ) + ->build() + ->fetchFirst()[$key] ?? null; + + if ($result === null) { + return match ($function) { + AggregateFunction::AVG => 0.0, + AggregateFunction::SUM => 0, + default => null, + }; + } + + return match ($function) { + AggregateFunction::SUM => $result + 0, // Adding 0 triggers PHP type juggling: "3" → int, "3.5" → float + default => $result, + }; + } + +||||||| ancestor + /** + * Executes an aggregate query and returns the sum of the given column. + */ + public function sum(string $column): int|float + { + return $this->aggregate(AggregateFunction::SUM, $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public function avg(string $column): float + { + return (float) $this->aggregate(AggregateFunction::AVG, $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public function max(string $column): mixed + { + return $this->aggregate(AggregateFunction::MAX, $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public function min(string $column): mixed + { + return $this->aggregate(AggregateFunction::MIN, $column); + } + + private function aggregate(AggregateFunction $function, string $column): mixed + { + $key = strtolower($function->value); + + $field = new FieldStatement( + field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), + ); + + $result = SelectQueryBuilder::fromQueryBuilder( + $this, + fields: new ImmutableArray([$field]), + )->build()->fetchFirst()[$key] ?? null; + + if ($result === null) { + return match ($function) { + AggregateFunction::AVG => 0.0, + AggregateFunction::SUM => 0, + default => null, + }; + } + + return match ($function) { + AggregateFunction::AVG => (float) $result, + AggregateFunction::SUM => str_contains((string) $result, '.') ? (float) $result : (int) $result, + default => $result, + }; + } + +======= +>>>>>>> theirs +||||||| ancestor +======= /** * Executes an aggregate query and returns the sum of the given column. */ @@ -457,6 +593,7 @@ private function aggregate(AggregateFunction $function, string $column): mixed }; } +>>>>>>> theirs private function clone(): self { return clone $this; diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index cf21ac5ab..e28e823ef 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -85,6 +85,77 @@ public static function count(): CountQueryBuilder } /** +<<<<<<< ours +<<<<<<< ours + * Executes an aggregate query and returns the sum of the given column. + */ + public static function sum(string $column): int|float + { + return self::queryBuilder()->sum(column: $column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public static function avg(string $column): float + { + return self::queryBuilder()->avg(column: $column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public static function max(string $column): mixed + { + return self::queryBuilder()->max(column: $column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public static function min(string $column): mixed + { + return self::queryBuilder()->min(column: $column); + } + + /** +||||||| ancestor + * Executes an aggregate query and returns the sum of the given column. + */ + public static function sum(string $column): int|float + { + return self::queryBuilder()->sum($column); + } + + /** + * Executes an aggregate query and returns the average of the given column. + */ + public static function avg(string $column): float + { + return self::queryBuilder()->avg($column); + } + + /** + * Executes an aggregate query and returns the maximum value of the given column. + */ + public static function max(string $column): mixed + { + return self::queryBuilder()->max($column); + } + + /** + * Executes an aggregate query and returns the minimum value of the given column. + */ + public static function min(string $column): mixed + { + return self::queryBuilder()->min($column); + } + + /** +======= +>>>>>>> theirs +||||||| ancestor +======= * Executes an aggregate query and returns the sum of the given column. */ public static function sum(string $column): int|float @@ -117,6 +188,7 @@ public static function min(string $column): mixed } /** +>>>>>>> theirs * Creates a new instance of this model without persisting it to the database. */ public static function new(mixed ...$params): self diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index 5d1f98c96..04d7f87b4 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -1,3 +1,237 @@ +<<<<<<< ours +select(), + fields: arr(input: [new FieldStatement(field: 'SUM(`price`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`price`) AS `sum` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function avg_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'AVG(`price`) AS `avg`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT AVG(`price`) AS `avg` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function max_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'MAX(`price`) AS `max`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT MAX(`price`) AS `max` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function min_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select(), + fields: arr(input: [new FieldStatement(field: 'MIN(`price`) AS `min`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT MIN(`price`) AS `min` FROM `books`', actual: $query->compile()); + } + + #[Test] + public function sum_with_where_compiles_correct_sql(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: 'books')->select()->where('author_id', 1), + fields: arr(input: [new FieldStatement(field: 'SUM(`price`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`price`) AS `sum` FROM `books` WHERE books.author_id = ?', actual: $query->compile()); + } + + #[Test] + public function sum_from_model(): void + { + $query = SelectQueryBuilder::fromQueryBuilder( + source: query(model: Author::class)->select(), + fields: arr(input: [new FieldStatement(field: 'SUM(`id`) AS `sum`')]), + )->build(); + + $this->assertSameWithoutBackticks(expected: 'SELECT SUM(`id`) AS `sum` FROM `authors`', actual: $query->compile()); + } + + #[Test] + public function sum_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(6, query(model: Author::class)->sum(column: 'id')); + } + + #[Test] + public function avg_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(2.0, query(model: Author::class)->avg(column: 'id')); + } + + #[Test] + public function max_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(3, query(model: Author::class)->max(column: 'id')); + } + + #[Test] + public function min_returns_correct_value(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(1, query(model: Author::class)->min(column: 'id')); + } + + #[Test] + public function sum_with_where_condition(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + Author::create(name: 'Author C'); + + $this->assertSame(1, query(model: Author::class)->find(name: 'Author A')->sum(column: 'id')); + } + + #[Test] + public function max_on_string_column(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $this->assertSame('Zeta', query(model: Author::class)->max(column: 'name')); + } + + #[Test] + public function min_on_string_column(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Alpha'); + Author::create(name: 'Zeta'); + Author::create(name: 'Beta'); + + $this->assertSame('Alpha', query(model: Author::class)->min(column: 'name')); + } + + #[Test] + public function sum_static_shortcut_on_model(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $this->assertSame(3, Author::sum(column: 'id')); + } + + #[Test] + public function avg_static_shortcut_on_model(): void + { + $this->database->migrate( + CreateMigrationsTable::class, + CreatePublishersTable::class, + CreateAuthorTable::class, + ); + + Author::create(name: 'Author A'); + Author::create(name: 'Author B'); + + $this->assertSame(1.5, Author::avg(column: 'id')); + } +} +||||||| +======= build(); } } +>>>>>>> theirs From 3f9ff45881e125792023fe968c5b36870f240ce6 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Fri, 27 Mar 2026 22:23:46 +0000 Subject: [PATCH 6/9] fix(database): resolve merge conflict markers in aggregate files --- .../Builder/QueryBuilders/QueryBuilder.php | 112 ------- .../QueryBuilders/SelectQueryBuilder.php | 133 -------- packages/database/src/IsDatabaseModel.php | 72 ----- .../Builder/AggregateQueryBuilderTest.php | 286 ------------------ 4 files changed, 603 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index ffd09e197..c3d4e46bf 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -128,8 +128,6 @@ public function count(?string $column = null): CountQueryBuilder } /** -<<<<<<< ours -<<<<<<< ours * Executes an aggregate query and returns the sum of the given column. * * **Example** @@ -182,116 +180,6 @@ public function min(string $column): mixed } /** -||||||| ancestor - * Executes an aggregate query and returns the sum of the given column. - * - * **Example** - * ```php - * query(User::class)->sum('price'); - * ``` - */ - public function sum(string $column): int|float - { - return $this->select()->onDatabase($this->onDatabase)->sum($column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - * - * **Example** - * ```php - * query(User::class)->avg('price'); - * ``` - */ - public function avg(string $column): float - { - return $this->select()->onDatabase($this->onDatabase)->avg($column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - * - * **Example** - * ```php - * query(User::class)->max('price'); - * ``` - */ - public function max(string $column): mixed - { - return $this->select()->onDatabase($this->onDatabase)->max($column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - * - * **Example** - * ```php - * query(User::class)->min('price'); - * ``` - */ - public function min(string $column): mixed - { - return $this->select()->onDatabase($this->onDatabase)->min($column); - } - - /** -======= ->>>>>>> theirs -||||||| ancestor -======= - * Executes an aggregate query and returns the sum of the given column. - * - * **Example** - * ```php - * query(User::class)->sum('price'); - * ``` - */ - public function sum(string $column): int|float - { - return $this->select()->onDatabase($this->onDatabase)->sum($column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - * - * **Example** - * ```php - * query(User::class)->avg('price'); - * ``` - */ - public function avg(string $column): float - { - return $this->select()->onDatabase($this->onDatabase)->avg($column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - * - * **Example** - * ```php - * query(User::class)->max('price'); - * ``` - */ - public function max(string $column): mixed - { - return $this->select()->onDatabase($this->onDatabase)->max($column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - * - * **Example** - * ```php - * query(User::class)->min('price'); - * ``` - */ - public function min(string $column): mixed - { - return $this->select()->onDatabase($this->onDatabase)->min($column); - } - - /** ->>>>>>> theirs * Creates a new instance of this model without persisting it to the database. * * **Example** diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 8d5283213..fb2cc5a4d 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -33,13 +33,8 @@ use function Tempest\Container\get; use function Tempest\Database\inspect; use function Tempest\Mapper\map; -<<<<<<< ours use function Tempest\Support\arr; use function Tempest\Support\str; -||||||| ancestor -use function Tempest\Support\arr; -======= ->>>>>>> theirs /** * @template TModel @@ -404,8 +399,6 @@ public function build(mixed ...$bindings): Query return new Query($select, [...$this->bindings, ...$bindings])->onDatabase($this->onDatabase); } -<<<<<<< ours -<<<<<<< ours /** * Executes an aggregate query and returns the sum of the given column. */ @@ -468,132 +461,6 @@ private function aggregate(AggregateFunction $function, string $column): mixed }; } -||||||| ancestor - /** - * Executes an aggregate query and returns the sum of the given column. - */ - public function sum(string $column): int|float - { - return $this->aggregate(AggregateFunction::SUM, $column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - */ - public function avg(string $column): float - { - return (float) $this->aggregate(AggregateFunction::AVG, $column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - */ - public function max(string $column): mixed - { - return $this->aggregate(AggregateFunction::MAX, $column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - */ - public function min(string $column): mixed - { - return $this->aggregate(AggregateFunction::MIN, $column); - } - - private function aggregate(AggregateFunction $function, string $column): mixed - { - $key = strtolower($function->value); - - $field = new FieldStatement( - field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), - ); - - $result = SelectQueryBuilder::fromQueryBuilder( - $this, - fields: new ImmutableArray([$field]), - )->build()->fetchFirst()[$key] ?? null; - - if ($result === null) { - return match ($function) { - AggregateFunction::AVG => 0.0, - AggregateFunction::SUM => 0, - default => null, - }; - } - - return match ($function) { - AggregateFunction::AVG => (float) $result, - AggregateFunction::SUM => str_contains((string) $result, '.') ? (float) $result : (int) $result, - default => $result, - }; - } - -======= ->>>>>>> theirs -||||||| ancestor -======= - /** - * Executes an aggregate query and returns the sum of the given column. - */ - public function sum(string $column): int|float - { - return $this->aggregate(AggregateFunction::SUM, $column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - */ - public function avg(string $column): float - { - return (float) $this->aggregate(AggregateFunction::AVG, $column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - */ - public function max(string $column): mixed - { - return $this->aggregate(AggregateFunction::MAX, $column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - */ - public function min(string $column): mixed - { - return $this->aggregate(AggregateFunction::MIN, $column); - } - - private function aggregate(AggregateFunction $function, string $column): mixed - { - $key = strtolower($function->value); - - $field = new FieldStatement( - field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), - ); - - $result = SelectQueryBuilder::fromQueryBuilder( - $this, - fields: new ImmutableArray([$field]), - )->build()->fetchFirst()[$key] ?? null; - - if ($result === null) { - return match ($function) { - AggregateFunction::AVG => 0.0, - AggregateFunction::SUM => 0, - default => null, - }; - } - - return match ($function) { - AggregateFunction::AVG => (float) $result, - AggregateFunction::SUM => str_contains((string) $result, '.') ? (float) $result : (int) $result, - default => $result, - }; - } - ->>>>>>> theirs private function clone(): self { return clone $this; diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index e28e823ef..873439ff5 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -85,8 +85,6 @@ public static function count(): CountQueryBuilder } /** -<<<<<<< ours -<<<<<<< ours * Executes an aggregate query and returns the sum of the given column. */ public static function sum(string $column): int|float @@ -119,76 +117,6 @@ public static function min(string $column): mixed } /** -||||||| ancestor - * Executes an aggregate query and returns the sum of the given column. - */ - public static function sum(string $column): int|float - { - return self::queryBuilder()->sum($column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - */ - public static function avg(string $column): float - { - return self::queryBuilder()->avg($column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - */ - public static function max(string $column): mixed - { - return self::queryBuilder()->max($column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - */ - public static function min(string $column): mixed - { - return self::queryBuilder()->min($column); - } - - /** -======= ->>>>>>> theirs -||||||| ancestor -======= - * Executes an aggregate query and returns the sum of the given column. - */ - public static function sum(string $column): int|float - { - return self::queryBuilder()->sum($column); - } - - /** - * Executes an aggregate query and returns the average of the given column. - */ - public static function avg(string $column): float - { - return self::queryBuilder()->avg($column); - } - - /** - * Executes an aggregate query and returns the maximum value of the given column. - */ - public static function max(string $column): mixed - { - return self::queryBuilder()->max($column); - } - - /** - * Executes an aggregate query and returns the minimum value of the given column. - */ - public static function min(string $column): mixed - { - return self::queryBuilder()->min($column); - } - - /** ->>>>>>> theirs * Creates a new instance of this model without persisting it to the database. */ public static function new(mixed ...$params): self diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index 04d7f87b4..006618102 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -1,4 +1,3 @@ -<<<<<<< ours assertSame(1.5, Author::avg(column: 'id')); } } -||||||| -======= -buildAggregate( - builder: query('books')->select(), - function: AggregateFunction::SUM, - column: 'price', - ); - - $expected = 'SELECT SUM(`price`) AS `sum` FROM `books`'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function avg_compiles_correct_sql(): void - { - $query = $this->buildAggregate( - builder: query('books')->select(), - function: AggregateFunction::AVG, - column: 'price', - ); - - $expected = 'SELECT AVG(`price`) AS `avg` FROM `books`'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function max_compiles_correct_sql(): void - { - $query = $this->buildAggregate( - builder: query('books')->select(), - function: AggregateFunction::MAX, - column: 'price', - ); - - $expected = 'SELECT MAX(`price`) AS `max` FROM `books`'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function min_compiles_correct_sql(): void - { - $query = $this->buildAggregate( - builder: query('books')->select(), - function: AggregateFunction::MIN, - column: 'price', - ); - - $expected = 'SELECT MIN(`price`) AS `min` FROM `books`'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function sum_with_where_compiles_correct_sql(): void - { - $query = $this->buildAggregate( - builder: query('books')->select()->where('author_id', 1), - function: AggregateFunction::SUM, - column: 'price', - ); - - $expected = 'SELECT SUM(`price`) AS `sum` FROM `books` WHERE books.author_id = ?'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function sum_from_model(): void - { - $query = $this->buildAggregate( - builder: query(Author::class)->select(), - function: AggregateFunction::SUM, - column: 'id', - ); - - $expected = 'SELECT SUM(`id`) AS `sum` FROM `authors`'; - - $this->assertSameWithoutBackticks($expected, $query->compile()); - } - - #[Test] - public function sum_returns_correct_value(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - Author::create(name: 'Author C'); - - $sum = query(Author::class)->sum('id'); - - $this->assertSame(6, $sum); - } - - #[Test] - public function avg_returns_correct_value(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - Author::create(name: 'Author C'); - - $avg = query(Author::class)->avg('id'); - - $this->assertSame(2.0, $avg); - } - - #[Test] - public function max_returns_correct_value(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - Author::create(name: 'Author C'); - - $max = query(Author::class)->max('id'); - - $this->assertSame(3, $max); - } - - #[Test] - public function min_returns_correct_value(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - Author::create(name: 'Author C'); - - $min = query(Author::class)->min('id'); - - $this->assertSame(1, $min); - } - - #[Test] - public function sum_with_where_condition(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - Author::create(name: 'Author C'); - - $sum = query(Author::class)->find(name: 'Author A')->sum('id'); - - $this->assertSame(1, $sum); - } - - #[Test] - public function max_on_string_column(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Alpha'); - Author::create(name: 'Zeta'); - Author::create(name: 'Beta'); - - $max = query(Author::class)->max('name'); - - $this->assertSame('Zeta', $max); - } - - #[Test] - public function min_on_string_column(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Alpha'); - Author::create(name: 'Zeta'); - Author::create(name: 'Beta'); - - $min = query(Author::class)->min('name'); - - $this->assertSame('Alpha', $min); - } - - #[Test] - public function sum_static_shortcut_on_model(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - - $sum = Author::sum('id'); - - $this->assertSame(3, $sum); - } - - #[Test] - public function avg_static_shortcut_on_model(): void - { - $this->database->migrate( - \Tempest\Database\Migrations\CreateMigrationsTable::class, - \Tests\Tempest\Fixtures\Migrations\CreatePublishersTable::class, - \Tests\Tempest\Fixtures\Migrations\CreateAuthorTable::class, - ); - - Author::create(name: 'Author A'); - Author::create(name: 'Author B'); - - $avg = Author::avg('id'); - - $this->assertSame(1.5, $avg); - } - - private function buildAggregate( - SelectQueryBuilder $builder, - AggregateFunction $function, - string $column, - ): \Tempest\Database\Query { - $key = strtolower($function->value); - - $field = new FieldStatement( - field: sprintf('%s(`%s`) AS `%s`', $function->value, $column, $key), - ); - - return SelectQueryBuilder::fromQueryBuilder( - $builder, - fields: new ImmutableArray([$field]), - )->build(); - } -} ->>>>>>> theirs From c8a9730f7bdec2e580230928385892ca69cd3dbf Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 28 Mar 2026 11:06:58 +0000 Subject: [PATCH 7/9] refactor(database): change avg return type to int|float, use filter_var for type coercion --- .../src/Builder/QueryBuilders/QueryBuilder.php | 2 +- .../Builder/QueryBuilders/SelectQueryBuilder.php | 13 +++++++------ packages/database/src/IsDatabaseModel.php | 2 +- .../Database/Builder/AggregateQueryBuilderTest.php | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index c3d4e46bf..f357ce6ff 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -148,7 +148,7 @@ public function sum(string $column): int|float * query(User::class)->avg('price'); * ``` */ - public function avg(string $column): float + public function avg(string $column): int|float { return $this->select()->onDatabase(databaseTag: $this->onDatabase)->avg(column: $column); } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index fb2cc5a4d..27149ea6a 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -410,9 +410,9 @@ public function sum(string $column): int|float /** * Executes an aggregate query and returns the average of the given column. */ - public function avg(string $column): float + public function avg(string $column): int|float { - return (float) $this->aggregate(function: AggregateFunction::AVG, column: $column); + return $this->aggregate(function: AggregateFunction::AVG, column: $column); } /** @@ -442,21 +442,22 @@ private function aggregate(AggregateFunction $function, string $column): mixed $result = SelectQueryBuilder::fromQueryBuilder( source: $this, - fields: arr([$field]), + fields: arr(input: [$field]), ) ->build() ->fetchFirst()[$key] ?? null; if ($result === null) { return match ($function) { - AggregateFunction::AVG => 0.0, - AggregateFunction::SUM => 0, + AggregateFunction::AVG, AggregateFunction::SUM => 0, default => null, }; } return match ($function) { - AggregateFunction::SUM => $result + 0, // Adding 0 triggers PHP type juggling: "3" → int, "3.5" → float + AggregateFunction::AVG, AggregateFunction::SUM => filter_var(value: $result, filter: FILTER_VALIDATE_INT) !== false + ? (int) $result + : (float) $result, default => $result, }; } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 873439ff5..eca754f8b 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -95,7 +95,7 @@ public static function sum(string $column): int|float /** * Executes an aggregate query and returns the average of the given column. */ - public static function avg(string $column): float + public static function avg(string $column): int|float { return self::queryBuilder()->avg(column: $column); } diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index 006618102..db90bd8f1 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -116,7 +116,7 @@ public function avg_returns_correct_value(): void Author::create(name: 'Author B'); Author::create(name: 'Author C'); - $this->assertSame(2.0, query(model: Author::class)->avg(column: 'id')); + $this->assertSame(2, query(model: Author::class)->avg(column: 'id')); } #[Test] From bf96854713f3b0e6ac25209e0a2d7cecb57e4762 Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 28 Mar 2026 11:29:36 +0000 Subject: [PATCH 8/9] fix(database): revert avg return type to float for cross-driver compatibility --- .../src/Builder/QueryBuilders/QueryBuilder.php | 2 +- .../src/Builder/QueryBuilders/SelectQueryBuilder.php | 11 +++++------ packages/database/src/IsDatabaseModel.php | 2 +- .../Database/Builder/AggregateQueryBuilderTest.php | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php index f357ce6ff..c3d4e46bf 100644 --- a/packages/database/src/Builder/QueryBuilders/QueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/QueryBuilder.php @@ -148,7 +148,7 @@ public function sum(string $column): int|float * query(User::class)->avg('price'); * ``` */ - public function avg(string $column): int|float + public function avg(string $column): float { return $this->select()->onDatabase(databaseTag: $this->onDatabase)->avg(column: $column); } diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index 27149ea6a..f2599a350 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -410,9 +410,9 @@ public function sum(string $column): int|float /** * Executes an aggregate query and returns the average of the given column. */ - public function avg(string $column): int|float + public function avg(string $column): float { - return $this->aggregate(function: AggregateFunction::AVG, column: $column); + return (float) $this->aggregate(function: AggregateFunction::AVG, column: $column); } /** @@ -449,15 +449,14 @@ private function aggregate(AggregateFunction $function, string $column): mixed if ($result === null) { return match ($function) { - AggregateFunction::AVG, AggregateFunction::SUM => 0, + AggregateFunction::AVG => 0.0, + AggregateFunction::SUM => 0, default => null, }; } return match ($function) { - AggregateFunction::AVG, AggregateFunction::SUM => filter_var(value: $result, filter: FILTER_VALIDATE_INT) !== false - ? (int) $result - : (float) $result, + AggregateFunction::SUM => filter_var(value: $result, filter: FILTER_VALIDATE_INT) !== false ? (int) $result : (float) $result, default => $result, }; } diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index eca754f8b..873439ff5 100644 --- a/packages/database/src/IsDatabaseModel.php +++ b/packages/database/src/IsDatabaseModel.php @@ -95,7 +95,7 @@ public static function sum(string $column): int|float /** * Executes an aggregate query and returns the average of the given column. */ - public static function avg(string $column): int|float + public static function avg(string $column): float { return self::queryBuilder()->avg(column: $column); } diff --git a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php index db90bd8f1..006618102 100644 --- a/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -116,7 +116,7 @@ public function avg_returns_correct_value(): void Author::create(name: 'Author B'); Author::create(name: 'Author C'); - $this->assertSame(2, query(model: Author::class)->avg(column: 'id')); + $this->assertSame(2.0, query(model: Author::class)->avg(column: 'id')); } #[Test] From 29cd9fa428c3b9d8aa1596cfeec433a45f03527d Mon Sep 17 00:00:00 2001 From: Layla Tichy Date: Sat, 28 Mar 2026 15:02:26 +0000 Subject: [PATCH 9/9] fix: improve SUM aggregate type detection logic --- .../database/src/Builder/QueryBuilders/SelectQueryBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php index f2599a350..831d435da 100644 --- a/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/SelectQueryBuilder.php @@ -456,7 +456,7 @@ private function aggregate(AggregateFunction $function, string $column): mixed } return match ($function) { - AggregateFunction::SUM => filter_var(value: $result, filter: FILTER_VALIDATE_INT) !== false ? (int) $result : (float) $result, + AggregateFunction::SUM => str(string: (string) $result)->contains(needle: '.') ? (float) $result : (int) $result, default => $result, }; }