From 9095bc0bb0bcd1e8cb3bca4ebd1eef84392ddea9 Mon Sep 17 00:00:00 2001 From: Igor Markin Date: Tue, 4 Jun 2024 23:25:40 +0300 Subject: [PATCH 1/2] Add orderBy to HasOneLoader Copied from HasManyLoader --- src/Select/Loader/HasOneLoader.php | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Select/Loader/HasOneLoader.php b/src/Select/Loader/HasOneLoader.php index fbb3f35ce..3837feaa7 100644 --- a/src/Select/Loader/HasOneLoader.php +++ b/src/Select/Loader/HasOneLoader.php @@ -4,14 +4,17 @@ namespace Cycle\ORM\Select\Loader; +use Cycle\Database\Query\SelectQuery; +use Cycle\ORM\FactoryInterface; use Cycle\ORM\Parser\AbstractNode; use Cycle\ORM\Parser\SingularNode; +use Cycle\ORM\Service\SourceProviderInterface; use Cycle\ORM\Relation; use Cycle\ORM\SchemaInterface; use Cycle\ORM\Select\JoinableLoader; use Cycle\ORM\Select\Traits\JoinOneTableTrait; +use Cycle\ORM\Select\Traits\OrderByTrait; use Cycle\ORM\Select\Traits\WhereTrait; -use Cycle\Database\Query\SelectQuery; /** * Dedicated to load HAS_ONE relations, by default loader will prefer to join data into query. @@ -25,6 +28,7 @@ class HasOneLoader extends JoinableLoader { use JoinOneTableTrait; + use OrderByTrait; use WhereTrait; /** @@ -38,8 +42,22 @@ class HasOneLoader extends JoinableLoader 'as' => null, 'using' => null, 'where' => null, + 'orderBy' => null, ]; + public function __construct( + SchemaInterface $ormSchema, + SourceProviderInterface $sourceProvider, + FactoryInterface $factory, + string $name, + string $target, + array $schema + ) { + parent::__construct($ormSchema, $sourceProvider, $factory, $name, $target, $schema); + $this->options['where'] = $schema[Relation::WHERE] ?? []; + $this->options['orderBy'] = $schema[Relation::ORDER_BY] ?? []; + } + public function configureQuery(SelectQuery $query, array $outerKeys = []): SelectQuery { if ($this->options['using'] !== null) { @@ -56,6 +74,13 @@ public function configureQuery(SelectQuery $query, array $outerKeys = []): Selec $this->options['where'] ?? $this->schema[Relation::WHERE] ?? [] ); + // user specified ORDER_BY rules + $this->setOrderBy( + $query, + $this->getAlias(), + $this->options['orderBy'] ?? $this->schema[Relation::ORDER_BY] ?? [] + ); + return parent::configureQuery($query); } From 6337389132480c6ba5a3d3298c00eed40a782573 Mon Sep 17 00:00:00 2001 From: Igor Markin Date: Thu, 13 Jun 2024 21:19:38 +0300 Subject: [PATCH 2/2] Add tests for sqlite --- tests/ORM/Fixtures/User.php | 5 + .../Relation/HasOne/HasOneScopeTest.php | 447 ++++++++++++++++++ .../Relation/HasOne/HasOneScopeTest.php | 17 + 3 files changed, 469 insertions(+) create mode 100644 tests/ORM/Functional/Driver/Common/Relation/HasOne/HasOneScopeTest.php create mode 100644 tests/ORM/Functional/Driver/SQLite/Relation/HasOne/HasOneScopeTest.php diff --git a/tests/ORM/Fixtures/User.php b/tests/ORM/Fixtures/User.php index 2abd4e9ac..1fb0778a9 100644 --- a/tests/ORM/Fixtures/User.php +++ b/tests/ORM/Fixtures/User.php @@ -28,6 +28,11 @@ class User implements ImagedInterface */ public $lastComment; + /** + * @var Comment|null + */ + public $firstComment; + /** * @var Collection|Comment[] */ diff --git a/tests/ORM/Functional/Driver/Common/Relation/HasOne/HasOneScopeTest.php b/tests/ORM/Functional/Driver/Common/Relation/HasOne/HasOneScopeTest.php new file mode 100644 index 000000000..1aef31b5a --- /dev/null +++ b/tests/ORM/Functional/Driver/Common/Relation/HasOne/HasOneScopeTest.php @@ -0,0 +1,447 @@ +makeTable('user', [ + 'id' => 'primary', + 'email' => 'string', + 'balance' => 'float', + ]); + + $this->getDatabase()->table('user')->insertMultiple( + ['email', 'balance'], + [ + ['hello@world.com', 100], + ['another@world.com', 200], + ] + ); + + $this->makeTable('comment', [ + 'id' => 'primary', + 'user_id' => 'integer', + 'level' => 'integer', + 'message' => 'string', + ]); + + $this->makeFK('comment', 'user_id', 'user', 'id'); + + $this->getDatabase()->table('comment')->insertMultiple( + ['user_id', 'level', 'message'], + [ + [1, 1, 'msg 1'], + [1, 2, 'msg 2'], + [1, 3, 'msg 3'], + [1, 4, 'msg 4'], + [2, 1, 'msg 2.1'], + [2, 2, 'msg 2.2'], + [2, 3, 'msg 2.3'], + ] + ); + } + + public function testScopeOrdered(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'DESC']), + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testScopeOrderedAsc(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 1', $a->firstComment->message); + + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testScopeOrderedAscInLoad(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment', [ + 'method' => JoinableLoader::INLOAD, + ])->orderBy('user.id')->fetchAll(); + + + $this->assertSame('msg 1', $a->firstComment->message); + + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testScopeOrderedPromisedAsc(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + ]); + + [$a, $b] = (new Select($this->orm, User::class))->fetchAll(); + + $this->assertSame('msg 1', $a->firstComment->message); + + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testScopeOrderedAndWhere(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 2', $a->firstComment->message); + $this->assertSame('msg 2.2', $b->firstComment->message); + } + + public function testScopeOrderedAndWherePromised(): void + { + $this->markTestIncomplete(); + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->fetchAll(); + + $this->assertSame('msg 2', $a->firstComment->message); + $this->assertSame('msg 2.2', $b->firstComment->message); + } + + public function testScopeOrderedAndWhereReversed(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'DESC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testScopeOrderedAndWhereReversedInload(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'DESC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment', [ + 'method' => JoinableLoader::INLOAD, + ])->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testScopeOrderedAndWhereReversedPromised(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'DESC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testScopeOrderedAndCustomWhere(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + // overwrites default one + [$a, $b] = (new Select($this->orm, User::class))->orderBy('user.id')->load('firstComment', [ + 'where' => ['@.level' => 1], + ])->fetchAll(); + + $this->assertSame('msg 1', $a->firstComment->message); + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testScopeOrderedAndCustomWhereInload(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 2]]], + ]); + + // overwrites default one + [$a, $b] = (new Select($this->orm, User::class))->orderBy('user.id')->load('firstComment', [ + 'where' => ['@.level' => 1], + 'method' => JoinableLoader::INLOAD, + ])->fetchAll(); + + $this->assertSame('msg 1', $a->firstComment->message); + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testOrderByWithScopeOrdered(): void + { + $this->orm = $this->withCommentsSchema([ + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'ASC']), + Relation::SCHEMA => [ + Relation::ORDER_BY => ['@.level' => 'DESC'], + ], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testWithOrderByInLoad(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [ + Relation::ORDER_BY => ['@.level' => 'DESC'], + ], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment', [ + 'method' => JoinableLoader::INLOAD, + ])->orderBy('user.id')->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testWithOrderByLazyLoad(): void + { + $this->markTestIncomplete(); + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [ + Relation::ORDER_BY => ['@.level' => 'DESC'], + ], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->orderBy('user.id')->fetchAll(); + + $this->assertSame('msg 4', $a->firstComment->message); + + $this->assertSame('msg 2.3', $b->firstComment->message); + } + + public function testWithOrderByAltered(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [ + Relation::ORDER_BY => ['@.level' => 'DESC'], + ], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment', [ + 'orderBy' => ['@.level' => 'ASC'], + ])->orderBy('user.id')->fetchAll(); + + $this->assertSame('msg 1', $a->firstComment->message); + + $this->assertSame('msg 2.1', $b->firstComment->message); + } + + public function testWithOrderByAndWhere(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [ + Relation::WHERE => ['@.level' => ['>=' => 2]], + Relation::ORDER_BY => ['@.level' => 'ASC'], + ], + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('firstComment')->fetchAll(); + + $this->assertSame('msg 2', $a->firstComment->message); + $this->assertSame('msg 2.2', $b->firstComment->message); + } + + public function testWithWhere(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [Relation::WHERE => ['@.level' => 4]], + ]); + + // second user has been filtered out + $res = (new Select($this->orm, User::class))->with('firstComment')->fetchAll(); + + $this->assertCount(1, $res); + $this->assertSame('hello@world.com', $res[0]->email); + } + + public function testWithWhereAltered(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => ['@.level' => 4], + ]); + + // second user has been filtered out + $res = (new Select($this->orm, User::class))->with('firstComment', [ + 'where' => ['@.level' => 1], + ])->orderBy('user.id')->fetchAll(); + + $this->assertCount(2, $res); + $this->assertSame('hello@world.com', $res[0]->email); + $this->assertSame('another@world.com', $res[1]->email); + } + + public function testLimitParentSelection(): void + { + $this->orm = $this->withCommentsSchema([ + ]); + + // second user has been filtered out + $res = (new Select($this->orm, User::class)) + ->load('firstComment') + ->limit(1)->orderBy('user.id')->fetchAll(); + + $this->assertCount(1, $res); + $this->assertSame('hello@world.com', $res[0]->email); + $this->assertEquals(1, $res[0]->firstComment->level); + } + + public function testLimitParentSelectionError(): void + { + $this->markTestIncomplete(); + $this->expectException(LoaderException::class); + + $this->orm = $this->withCommentsSchema([]); + + // do not allow limits with joined and loaded relations + (new Select($this->orm, User::class)) + ->load('firstComment', ['method' => JoinableLoader::INLOAD]) + ->limit(1)->orderBy('user.id')->fetchAll(); + } + + public function testInloadWithScopeOrderedAndWhere(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 3]]], + Schema::SCOPE => new Select\QueryScope([], ['@.level' => 'DESC']), + ]); + + // sort by users and then by comments and only include comments with level > 3 + $res = (new Select($this->orm, User::class))->load('firstComment', [ + 'method' => JoinableLoader::INLOAD, + ])->orderBy('user.id', 'DESC')->fetchAll(); + + $this->assertCount(2, $res); + $this->assertSame('hello@world.com', $res[1]->email); + $this->assertSame('another@world.com', $res[0]->email); + + $this->assertSame('msg 4', $res[1]->firstComment->message); + $this->assertSame('msg 2.3', $res[0]->firstComment->message); + } + + public function testInvalidOrderBy(): void + { + $this->expectException(StatementException::class); + + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [Relation::WHERE => ['@.level' => ['>=' => 3]]], + Schema::SCOPE => new Select\QueryScope([], ['@.column' => 'DESC']), + ]); + + // sort by users and then by comments and only include comments with level > 3 + (new Select($this->orm, User::class))->load('firstComment', [ + 'method' => JoinableLoader::INLOAD, + ])->orderBy('user.id', 'DESC')->fetchAll(); + } + + protected function withCommentsSchema(array $relationSchema): ORMInterface + { + $eSchema = []; + if (isset($relationSchema[Schema::SCOPE])) { + $eSchema[Schema::SCOPE] = $relationSchema[Schema::SCOPE]; + } + + $rSchema = $relationSchema[Relation::SCHEMA] ?? []; + + return $this->orm->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', 'email', 'balance'], + Schema::SCHEMA => [], + 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', + ] + $rSchema, + ], + 'firstComment' => [ + Relation::TYPE => Relation::HAS_ONE, + Relation::TARGET => Comment::class, + Relation::SCHEMA => [ + Relation::CASCADE => true, + Relation::INNER_KEY => 'id', + Relation::OUTER_KEY => 'user_id', + ] + $rSchema, + ], + ], + Schema::SCOPE => SortByIDScope::class, + ], + Comment::class => [ + Schema::ROLE => 'comment', + Schema::MAPPER => Mapper::class, + Schema::DATABASE => 'default', + Schema::TABLE => 'comment', + Schema::PRIMARY_KEY => 'id', + Schema::COLUMNS => ['id', 'user_id', 'level', 'message'], + Schema::SCHEMA => [], + Schema::RELATIONS => [], + ] + $eSchema, + ])); + } +} diff --git a/tests/ORM/Functional/Driver/SQLite/Relation/HasOne/HasOneScopeTest.php b/tests/ORM/Functional/Driver/SQLite/Relation/HasOne/HasOneScopeTest.php new file mode 100644 index 000000000..f683dc543 --- /dev/null +++ b/tests/ORM/Functional/Driver/SQLite/Relation/HasOne/HasOneScopeTest.php @@ -0,0 +1,17 @@ +