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 @@ +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(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); + } + /** * 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 bc7115014..831d435da 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; @@ -32,6 +33,8 @@ use function Tempest\Container\get; use function Tempest\Database\inspect; use function Tempest\Mapper\map; +use function Tempest\Support\arr; +use function Tempest\Support\str; /** * @template TModel @@ -396,6 +399,68 @@ 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(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(input: [$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 => str(string: (string) $result)->contains(needle: '.') ? (float) $result : (int) $result, + default => $result, + }; + } + private function clone(): self { return clone $this; diff --git a/packages/database/src/IsDatabaseModel.php b/packages/database/src/IsDatabaseModel.php index 71def875e..873439ff5 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: $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); + } + /** * 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 new file mode 100644 index 000000000..006618102 --- /dev/null +++ b/tests/Integration/Database/Builder/AggregateQueryBuilderTest.php @@ -0,0 +1,231 @@ +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')); + } +}