diff --git a/src/Select.php b/src/Select.php index 86d506201..27fa735e3 100644 --- a/src/Select.php +++ b/src/Select.php @@ -4,6 +4,7 @@ namespace Cycle\ORM; +use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\Parameter; use Cycle\Database\Query\SelectQuery; use Cycle\ORM\Heap\Node; @@ -22,32 +23,6 @@ * * Trait provides the ability to transparently configure underlying loader query. * - * @method $this distinct() - * @method $this where(...$args) - * @method $this andWhere(...$args); - * @method $this orWhere(...$args); - * @method $this having(...$args); - * @method $this andHaving(...$args); - * @method $this orHaving(...$args); - * @method $this orderBy($expression, $direction = 'ASC'); - * @method $this forUpdate() - * @method $this whereJson(string $path, mixed $value) - * @method $this orWhereJson(string $path, mixed $value) - * @method $this whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true) - * @method $this orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true) - * @method $this whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true) - * @method $this orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true) - * @method $this whereJsonContainsKey(string $path) - * @method $this orWhereJsonContainsKey(string $path) - * @method $this whereJsonDoesntContainKey(string $path) - * @method $this orWhereJsonDoesntContainKey(string $path) - * @method $this whereJsonLength(string $path, int $length, string $operator = '=') - * @method $this orWhereJsonLength(string $path, int $length, string $operator = '=') - * @method mixed avg($identifier) Perform aggregation (AVG) based on column or expression value. - * @method mixed min($identifier) Perform aggregation (MIN) based on column or expression value. - * @method mixed max($identifier) Perform aggregation (MAX) based on column or expression value. - * @method mixed sum($identifier) Perform aggregation (SUM) based on column or expression value. - * * @template-covariant TEntity of object */ class Select implements \IteratorAggregate, \Countable, PaginableInterface @@ -89,6 +64,45 @@ public function __construct( $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader); } + /** + * Override the source table for the query. Useful when the entity schema points to one table + * but at runtime you need to read from a different one (e.g. an archive or partition). + * + * The entity mapping, column aliases and relations remain unchanged — only the + * FROM clause is replaced: + * + * // Read users from an archive table instead of the default one + * $select->from('user_archive')->where('id', 1)->fetchOne(); + * + * // Combine with relations — comments are still loaded from their own table + * $select->from('user_archive') + * ->load('comments') + * ->orderBy('id') + * ->fetchAll(); + * + * @param non-empty-string $table + * + * @return static + */ + public function from(string $table): static + { + $this->loader->getQuery()->from(\sprintf('%s AS %s', $table, $this->loader->getAlias())); + return $this; + } + + /** + * Mark query to return only distinct results. + * + * $select->distinct()->fetchAll(); + * + * @return static + */ + public function distinct(): static + { + $this->builder->distinct(); + return $this; + } + /** * Create new Selector with applied scope. By default no scope used. * @@ -138,6 +152,385 @@ public function wherePK(string|int|array|object ...$ids): self : $this->__call('where', [$pk, \current($ids)]); } + /** + * Add a WHERE condition to the query. Supports multiple calling conventions. + * + * Simple equality and comparison: + * + * // Equality (column, value) + * $select->where('id', 2); + * $select->where('status', 'active'); + * // Operator comparison (column, operator, value) + * $select->where('level', '>=', 10); + * $select->where('deleted_at', '=', null); + * $select->where('name', 'LIKE', '%john%'); + * // BETWEEN (column, 'between', from, to) + * $select->where('comments.id', 'between', 1, 4); + * + * Array syntax allows multiple conditions (AND by default): + * + * // Simple equalities + * $select->where(['id' => 2]); + * $select->where(['key1' => 1, 'key2' => 2]); + * // IN clause using Parameter + * $select->where(['id' => new Parameter([1, 2])]); + * // Operator syntax + * $select->where(['id' => ['>' => 0, '<' => 3]]); + * $select->where(['comments.id' => ['between' => [1, 4]]]); + * + * Logical grouping with "@or" / "@AND": + * + * $select->where([ + * "@or" => [ + * ['comments.message' => 'msg 1'], + * ['comments.message' => 'msg 3'], + * ], + * ]); + * + * $select->where([ + * "@AND" => [ + * ['name' => 'Valeriy'], + * ['level' => ['>=' => 10]], + * ], + * ]); + * + * Closure for nested or complex conditions: + * + * $select->where(function (\Cycle\ORM\Select\QueryBuilder $q): void { + * $q->where('id', 2); + * }); + * + * // Combining AND / OR inside a closure + * $select->where(function (\Cycle\ORM\Select\QueryBuilder $q): void { + * $q->where('comments.message', 'msg 3') + * ->orWhere(function (\Cycle\ORM\Select\QueryBuilder $q): void { + * $q->where('id', 1); + * }); + * }); + * + * Raw SQL fragments and expressions: + * + * // Fragment with bound parameters + * $select->where(new \Cycle\Database\Injection\Fragment('fp.filter_id = ?', 5)); + * // Expression as a value + * $select->where('comments.id', new \Cycle\Database\Injection\Expression('user.id')); + * + * When used with relations joined via {@see with()}, prefix columns with the relation alias: + * + * $select->with('comments')->where('comments.approved', true); + * $select->with('posts.comments')->where('posts_comments.approved', true); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure), (Fragment)] + * + * @return static + */ + public function where(mixed ...$args): static + { + $this->builder->where(...$args); + return $this; + } + + /** + * Add an AND WHERE condition to the query. Behaves identically to {@see where()}, + * but explicitly uses AND conjunction when chaining multiple conditions. + * + * $select->where('status', 'active')->andWhere('balance', '>', 0); + * $select->andWhere(['role' => 'admin', 'active' => true]); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure), (Fragment)] + * + * @return static + * + * @see where() + */ + public function andWhere(mixed ...$args): static + { + $this->builder->andWhere(...$args); + return $this; + } + + /** + * Add an OR WHERE condition to the query. Accepts the same arguments as {@see where()}, + * but uses OR conjunction. + * + * $select->where('id', 1)->orWhere('id', 2); + * + * // Closure for grouped OR conditions + * $select->where('status', 'active')->orWhere(function (\Cycle\ORM\Select\QueryBuilder $q): void { + * $q->where('role', 'admin') + * ->where('balance', '>', 0); + * }); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure), (Fragment)] + * + * @return static + * + * @see where() + */ + public function orWhere(mixed ...$args): static + { + $this->builder->orWhere(...$args); + return $this; + } + + /** + * Add a HAVING condition to the query. Typically used with {@see SelectQuery::groupBy()} + * to filter aggregated results. + * + * $select->having('COUNT(comments.id)', '>', 5); + * $select->having(['COUNT(id)' => ['>=' => 10]]); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure)] + * + * @return static + */ + public function having(mixed ...$args): static + { + $this->builder->having(...$args); + return $this; + } + + /** + * Add an AND HAVING condition to the query. + * + * $select->having('COUNT(id)', '>', 5)->andHaving('SUM(balance)', '<', 1000); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure)] + * + * @return static + * + * @see having() + */ + public function andHaving(mixed ...$args): static + { + $this->builder->andHaving(...$args); + return $this; + } + + /** + * Add an OR HAVING condition to the query. + * + * $select->having('COUNT(id)', '>', 10)->orHaving('SUM(balance)', '>', 1000); + * + * @param mixed ...$args [(column, value), (column, operator, value), (array), (closure)] + * + * @return static + * + * @see having() + */ + public function orHaving(mixed ...$args): static + { + $this->builder->orHaving(...$args); + return $this; + } + + /** + * Sort results by column, expression or multiple columns at once. + * + * $select->orderBy('id'); + * $select->orderBy('created_at', 'DESC'); + * + * // Multiple columns + * $select->orderBy([ + * 'id' => 'ASC', + * 'name' => 'DESC', + * ]); + * + * // Raw expression (direction is ignored) + * $select->orderBy(new \Cycle\Database\Injection\Fragment('RAND()')); + * + * @param non-empty-string|FragmentInterface|array $expression + * @param 'ASC'|'DESC'|null $direction Sorting direction, default ASC. + * + * @return static + */ + public function orderBy(string|FragmentInterface|array $expression, ?string $direction = 'ASC'): static + { + $this->builder->orderBy($expression, $direction); + return $this; + } + + /** + * Add a FOR UPDATE lock to the query. Selected rows will be locked for the duration + * of the current transaction, preventing other transactions from modifying them. + * + * // Inside a transaction + * $user = $select->where('id', 1)->forUpdate()->fetchOne(); + * $user->balance -= 100; + * + * @return static + */ + public function forUpdate(): static + { + $this->builder->forUpdate(); + return $this; + } + + /** + * Filter by JSON field value using exact match. + * + * $select->whereJson('settings->theme', 'dark'); + * $select->whereJson('meta->score', 10); + * + * @return static + */ + public function whereJson(string $path, mixed $value): static + { + $this->builder->whereJson($path, $value); + return $this; + } + + /** + * OR version of {@see whereJson()}. + * + * $select->whereJson('settings->theme', 'dark') + * ->orWhereJson('settings->theme', 'light'); + * + * @return static + */ + public function orWhereJson(string $path, mixed $value): static + { + $this->builder->orWhereJson($path, $value); + return $this; + } + + /** + * Filter rows where a JSON array or object contains the given value. + * + * $select->whereJsonContains('tags', 'php'); + * $select->whereJsonContains('meta->roles', 'admin'); + * + * @return static + */ + public function whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true): static + { + $this->builder->whereJsonContains($path, $value, $encode, $validate); + return $this; + } + + /** + * OR version of {@see whereJsonContains()}. + * + * $select->whereJsonContains('tags', 'php') + * ->orWhereJsonContains('tags', 'go'); + * + * @return static + */ + public function orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true): static + { + $this->builder->orWhereJsonContains($path, $value, $encode, $validate); + return $this; + } + + /** + * Filter rows where a JSON array or object does NOT contain the given value. + * + * $select->whereJsonDoesntContain('tags', 'deprecated'); + * + * @return static + */ + public function whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true): static + { + $this->builder->whereJsonDoesntContain($path, $value, $encode, $validate); + return $this; + } + + /** + * OR version of {@see whereJsonDoesntContain()}. + * + * $select->whereJsonDoesntContain('tags', 'a') + * ->orWhereJsonDoesntContain('tags', 'b'); + * + * @return static + */ + public function orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true): static + { + $this->builder->orWhereJsonDoesntContain($path, $value, $encode, $validate); + return $this; + } + + /** + * Filter rows where a key exists in a JSON object. + * + * $select->whereJsonContainsKey('settings->notifications'); + * + * @return static + */ + public function whereJsonContainsKey(string $path): static + { + $this->builder->whereJsonContainsKey($path); + return $this; + } + + /** + * OR version of {@see whereJsonContainsKey()}. + * + * $select->whereJsonContainsKey('settings->email') + * ->orWhereJsonContainsKey('settings->sms'); + * + * @return static + */ + public function orWhereJsonContainsKey(string $path): static + { + $this->builder->orWhereJsonContainsKey($path); + return $this; + } + + /** + * Filter rows where a key does NOT exist in a JSON object. + * + * $select->whereJsonDoesntContainKey('settings->legacy_flag'); + * + * @return static + */ + public function whereJsonDoesntContainKey(string $path): static + { + $this->builder->whereJsonDoesntContainKey($path); + return $this; + } + + /** + * OR version of {@see whereJsonDoesntContainKey()}. + * + * $select->whereJsonDoesntContainKey('settings->a') + * ->orWhereJsonDoesntContainKey('settings->b'); + * + * @return static + */ + public function orWhereJsonDoesntContainKey(string $path): static + { + $this->builder->orWhereJsonDoesntContainKey($path); + return $this; + } + + /** + * Filter rows by the length of a JSON array. + * + * $select->whereJsonLength('tags', 3); + * $select->whereJsonLength('tags', 5, '>'); + * + * @return static + */ + public function whereJsonLength(string $path, int $length, string $operator = '='): static + { + $this->builder->whereJsonLength($path, $length, $operator); + return $this; + } + + /** + * OR version of {@see whereJsonLength()}. + * + * $select->whereJsonLength('tags', 0) + * ->orWhereJsonLength('roles', 0); + * + * @return static + */ + public function orWhereJsonLength(string $path, int $length, string $operator = '='): static + { + $this->builder->orWhereJsonLength($path, $length, $operator); + return $this; + } + /** * Attention, column will be quoted by driver! * @@ -152,7 +545,69 @@ public function count(?string $column = null): int : \sprintf('DISTINCT(%s)', \reset($pk)); } - return (int) $this->__call('count', [$column]); + return (int) $this->builder->withQuery( + $this->buildQuery(), + )->count($column); + } + + /** + * Perform AVG aggregation on the given column or expression. + * + * $avgBalance = $select->avg('balance'); + * $avgScore = $select->where('status', 'active')->avg('score'); + * + * @param non-empty-string $identifier Column or expression to aggregate. + */ + public function avg(string $identifier): mixed + { + return $this->builder->withQuery( + $this->buildQuery(), + )->avg($identifier); + } + + /** + * Perform MIN aggregation on the given column or expression. + * + * $minBalance = $select->min('balance'); + * $minPrice = $select->where('active', true)->min('price'); + * + * @param non-empty-string $identifier Column or expression to aggregate. + */ + public function min(string $identifier): mixed + { + return $this->builder->withQuery( + $this->buildQuery(), + )->min($identifier); + } + + /** + * Perform MAX aggregation on the given column or expression. + * + * $maxBalance = $select->max('balance'); + * $maxLevel = $select->where('role', 'admin')->max('level'); + * + * @param non-empty-string $identifier Column or expression to aggregate. + */ + public function max(string $identifier): mixed + { + return $this->builder->withQuery( + $this->buildQuery(), + )->max($identifier); + } + + /** + * Perform SUM aggregation on the given column or expression. + * + * $totalBalance = $select->sum('balance'); + * $totalSpent = $select->where('year', 2025)->sum('amount'); + * + * @param non-empty-string $identifier Column or expression to aggregate. + */ + public function sum(string $identifier): mixed + { + return $this->builder->withQuery( + $this->buildQuery(), + )->sum($identifier); } /** @@ -447,13 +902,6 @@ public function loadSubclasses(bool $load = true): self */ public function __call(string $name, array $arguments): mixed { - if (\in_array(\strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM', 'COUNT'])) { - // aggregations - return $this->builder->withQuery( - $this->buildQuery(), - )->__call($name, $arguments); - } - $result = $this->builder->__call($name, $arguments); if ($result instanceof QueryBuilder) { return $this; diff --git a/tests/ORM/Functional/Driver/Common/Select/SelectAggregateTest.php b/tests/ORM/Functional/Driver/Common/Select/SelectAggregateTest.php new file mode 100644 index 000000000..9ca62afc7 --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Select/SelectAggregateTest.php @@ -0,0 +1,117 @@ +orm, User::class); + $result = $select->avg('balance'); + + $this->assertEquals(150, (float) $result); + } + + public function testAvgWithWhere(): void + { + $select = new Select($this->orm, User::class); + $result = $select->where('id', 1)->avg('balance'); + + $this->assertEquals(100, (float) $result); + } + + public function testMin(): void + { + $select = new Select($this->orm, User::class); + $result = $select->min('balance'); + + $this->assertEquals(100, (float) $result); + } + + public function testMinWithWhere(): void + { + $select = new Select($this->orm, User::class); + $result = $select->where('balance', '>', 100)->min('balance'); + + $this->assertEquals(200, (float) $result); + } + + public function testMax(): void + { + $select = new Select($this->orm, User::class); + $result = $select->max('balance'); + + $this->assertEquals(200, (float) $result); + } + + public function testMaxWithWhere(): void + { + $select = new Select($this->orm, User::class); + $result = $select->where('balance', '<', 200)->max('balance'); + + $this->assertEquals(100, (float) $result); + } + + public function testSum(): void + { + $select = new Select($this->orm, User::class); + $result = $select->sum('balance'); + + $this->assertEquals(300, (float) $result); + } + + public function testSumWithWhere(): void + { + $select = new Select($this->orm, User::class); + $result = $select->where('id', 1)->sum('balance'); + + $this->assertEquals(100, (float) $result); + } + + public function setUp(): void + { + parent::setUp(); + + $this->makeTable('user', [ + 'id_int' => 'primary', + 'email_str' => 'string', + 'balance_float' => 'float', + ]); + + $this->getDatabase()->table('user')->insertMultiple( + ['email_str', 'balance_float'], + [ + ['hello@world.com', 100], + ['another@world.com', 200], + ], + ); + + $this->orm = $this->withSchema(new Schema([ + User::class => [ + Schema::ROLE => 'user', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id' => 'id_int', 'email' => 'email_str', 'balance' => 'balance_float'], + Schema::SCHEMA => [], + Schema::TYPECAST => [ + 'id' => 'int', + 'balance' => 'float', + ], + Schema::RELATIONS => [], + ], + ])); + } +} diff --git a/tests/ORM/Functional/Driver/Common/Select/SelectFromTest.php b/tests/ORM/Functional/Driver/Common/Select/SelectFromTest.php new file mode 100644 index 000000000..7400ee41c --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Select/SelectFromTest.php @@ -0,0 +1,188 @@ +orm, User::class); + $select->from('user_archive'); + + $this->assertStringContainsString('user_archive', $select->sqlStatement()); + } + + public function testFromFetchesDataFromAnotherTable(): void + { + $select = new Select($this->orm, User::class); + $result = $select->from('user_archive')->orderBy('id')->fetchAll(); + + $this->assertCount(1, $result); + $this->assertInstanceOf(User::class, $result[0]); + $this->assertSame('archived@world.com', $result[0]->email); + } + + public function testFromWithWhere(): void + { + $select = new Select($this->orm, User::class); + $result = $select->from('user_archive')->where('id', 1)->fetchAll(); + + $this->assertCount(1, $result); + $this->assertSame(1, $result[0]->id); + $this->assertSame('archived@world.com', $result[0]->email); + } + + public function testFromDoesNotAffectOriginalTable(): void + { + $select = new Select($this->orm, User::class); + $result = $select->orderBy('id')->fetchAll(); + + $this->assertCount(2, $result); + $this->assertSame('hello@world.com', $result[0]->email); + $this->assertSame('another@world.com', $result[1]->email); + } + + public function testFromReturnsFluent(): void + { + $select = new Select($this->orm, User::class); + $result = $select->from('user_archive'); + + $this->assertSame($select, $result); + } + + public function testFromWithLoadRelation(): void + { + $select = new Select($this->orm, User::class); + $result = $select + ->from('user_archive') + ->load('comments') + ->orderBy('id') + ->fetchAll(); + + $this->assertCount(1, $result); + $this->assertSame('archived@world.com', $result[0]->email); + // Comments are loaded from the regular 'comment' table via user_id FK + $this->assertCount(2, $result[0]->comments); + $this->assertSame('msg 1', $result[0]->comments[0]->message); + $this->assertSame('msg 2', $result[0]->comments[1]->message); + } + + public function testFromPreservesAlias(): void + { + $select = new Select($this->orm, User::class); + $sql = $select->from('user_archive')->sqlStatement(); + + // The alias should remain the entity role ('user') so that column references still work + $this->assertStringContainsString('user_archive', $sql); + $this->assertStringNotContainsString('"user"."user"', $sql); + } + + public function testFromWithCount(): void + { + $select = new Select($this->orm, User::class); + $count = $select->from('user_archive')->count(); + + $this->assertSame(1, $count); + } + + public function setUp(): void + { + parent::setUp(); + + $this->makeTable('user', [ + 'id_int' => 'primary', + 'email_str' => 'string', + 'balance_float' => 'float', + ]); + + $this->makeTable('user_archive', [ + 'id_int' => 'primary', + 'email_str' => 'string', + 'balance_float' => 'float', + ]); + + $this->makeTable('comment', [ + 'id_int' => 'primary', + 'user_id_int' => 'integer', + 'message_str' => 'string', + ]); + + $this->getDatabase()->table('user')->insertMultiple( + ['email_str', 'balance_float'], + [ + ['hello@world.com', 100], + ['another@world.com', 200], + ], + ); + + $this->getDatabase()->table('user_archive')->insertMultiple( + ['email_str', 'balance_float'], + [ + ['archived@world.com', 50], + ], + ); + + $this->getDatabase()->table('comment')->insertMultiple( + ['user_id_int', 'message_str'], + [ + [1, 'msg 1'], + [1, 'msg 2'], + [2, 'msg 3'], + ], + ); + + $this->orm = $this->withSchema(new Schema([ + User::class => [ + Schema::ROLE => 'user', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'user', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id' => 'id_int', 'email' => 'email_str', 'balance' => 'balance_float'], + Schema::SCHEMA => [], + Schema::TYPECAST => [ + 'id' => 'int', + 'balance' => 'float', + ], + Schema::RELATIONS => [ + 'comments' => [ + Relation::TYPE => Relation::HAS_MANY, + Relation::TARGET => Comment::class, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => 'user_id', + ], + ], + ], + ], + Comment::class => [ + Schema::ROLE => 'comment', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id' => 'id_int', 'user_id' => 'user_id_int', 'message' => 'message_str'], + Schema::TYPECAST => [ + 'id' => 'int', + 'user_id' => 'int', + ], + Schema::SCHEMA => [], + Schema::RELATIONS => [], + ], + ])); + } +} diff --git a/tests/ORM/Functional/Driver/MySQL/Select/SelectAggregateTest.php b/tests/ORM/Functional/Driver/MySQL/Select/SelectAggregateTest.php new file mode 100644 index 000000000..cc85e110d --- /dev/null +++ b/tests/ORM/Functional/Driver/MySQL/Select/SelectAggregateTest.php @@ -0,0 +1,17 @@ +