diff --git a/.gitignore b/.gitignore index fe578c737..894d481da 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ vendor/ *.db clover.xml docker-compose.override.yml +/.phpunit.result.cache diff --git a/composer.json b/composer.json index ccb9b1f78..fe2d4eea4 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "mockery/mockery": "^1.1", "spiral/dumper": "^2.7", "ramsey/uuid": "^3.8", - "spiral/code-style": "^1.0" + "spiral/code-style": "^1.0.6" }, "autoload": { "psr-4": { diff --git a/src/Factory.php b/src/Factory.php index 005562107..344740568 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -15,9 +15,9 @@ use Cycle\ORM\Exception\TypecastException; use Cycle\ORM\Mapper\Mapper; use Cycle\ORM\Relation\RelationInterface; -use Cycle\ORM\Select\ConstrainInterface; use Cycle\ORM\Select\LoaderInterface; use Cycle\ORM\Select\Repository; +use Cycle\ORM\Select\ScopeInterface; use Cycle\ORM\Select\Source; use Cycle\ORM\Select\SourceInterface; use Spiral\Core\Container; @@ -41,7 +41,7 @@ final class Factory implements FactoryInterface Schema::REPOSITORY => Repository::class, Schema::SOURCE => Source::class, Schema::MAPPER => Mapper::class, - Schema::CONSTRAIN => null, + Schema::SCOPE => null, ]; /** @@ -194,17 +194,17 @@ public function source( $schema->define($role, Schema::TABLE) ); - $constrain = $schema->define($role, Schema::CONSTRAIN) ?? $this->defaults[Schema::CONSTRAIN]; + $scope = $schema->define($role, Schema::SCOPE) ?? $this->defaults[Schema::SCOPE]; - if ($constrain === null) { + if ($scope === null) { return $source; } - if (!is_subclass_of($constrain, ConstrainInterface::class)) { - throw new TypecastException($constrain . ' does not implement ' . ConstrainInterface::class); + if (!is_subclass_of($scope, ScopeInterface::class)) { + throw new TypecastException($scope . ' does not implement ' . ScopeInterface::class); } - return $source->withConstrain(is_object($constrain) ? $constrain : $this->factory->make($constrain)); + return $source->withConstrain(is_object($scope) ? $scope : $this->factory->make($scope)); } /** diff --git a/src/ORM.php b/src/ORM.php index 87ec1e885..f9b62c3fc 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -265,7 +265,7 @@ public function getRepository($entity): RepositoryInterface if ($this->schema->define($role, Schema::TABLE) !== null) { $select = new Select($this, $role); - $select->constrain($this->getSource($role)->getConstrain()); + $select->scope($this->getSource($role)->getConstrain()); } return $this->repositories[$role] = $this->factory->repository($this, $this->schema, $role, $select); diff --git a/src/Relation/Pivoted/PivotedPromise.php b/src/Relation/Pivoted/PivotedPromise.php index 6478f2b59..c4bc5b078 100644 --- a/src/Relation/Pivoted/PivotedPromise.php +++ b/src/Relation/Pivoted/PivotedPromise.php @@ -113,9 +113,9 @@ public function __resolve() /** @var ManyToManyLoader $loader */ $loader = $loader->withContext($loader, [ - 'constrain' => $this->orm->getSource($this->target)->getConstrain(), - 'as' => $this->target, - 'method' => JoinableLoader::POSTLOAD + 'scope' => $this->orm->getSource($this->target)->getConstrain(), + 'as' => $this->target, + 'method' => JoinableLoader::POSTLOAD ]); $query = $loader->configureQuery($query, [$this->innerKey]); diff --git a/src/SchemaInterface.php b/src/SchemaInterface.php index 021c03a27..e1a7e353c 100644 --- a/src/SchemaInterface.php +++ b/src/SchemaInterface.php @@ -34,8 +34,8 @@ interface SchemaInterface public const TYPECAST = 13; public const SCHEMA = 14; - /** @deprecated Use {@see SCOPE} instead. */ - public const CONSTRAIN = 12; + /** @deprecated Use {@see SchemaInterface::SCOPE} instead. */ + public const CONSTRAIN = self::SCOPE; /** * Return all roles defined within the schema. diff --git a/src/Select.php b/src/Select.php index 942a857e3..447d5b3a2 100644 --- a/src/Select.php +++ b/src/Select.php @@ -17,6 +17,7 @@ use Cycle\ORM\Select\JoinableLoader; use Cycle\ORM\Select\QueryBuilder; use Cycle\ORM\Select\RootLoader; +use Cycle\ORM\Select\ScopeInterface; use IteratorAggregate; use Spiral\Database\Query\SelectQuery; use Spiral\Pagination\PaginableInterface; @@ -114,14 +115,19 @@ public function __clone() } /** - * Create new Selector with applied scope. By default no constrain used. - * - * @param ConstrainInterface|null $constrain - * @return Select + * @deprecated Will be dropped in next major release. Use {@see scope()} instead. */ public function constrain(ConstrainInterface $constrain = null): self { - $this->loader->setConstrain($constrain); + return $this->scope($constrain); + } + + /** + * Create new Selector with applied scope. By default no scope used. + */ + public function scope(ScopeInterface $scope = null): self + { + $this->loader->setScope($scope); return $this; } diff --git a/src/Select/AbstractLoader.php b/src/Select/AbstractLoader.php index 6cbafd73c..7e6f77b8a 100644 --- a/src/Select/AbstractLoader.php +++ b/src/Select/AbstractLoader.php @@ -52,7 +52,7 @@ abstract class AbstractLoader implements LoaderInterface public const JOIN = 3; public const LEFT_JOIN = 4; - /** @var ORMInterface|SourceProviderInterface @internal */ + /** @var ORMInterface @internal */ protected $orm; /** @var string */ @@ -60,8 +60,8 @@ abstract class AbstractLoader implements LoaderInterface /** @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, + 'load' => false, + 'scope' => true, ]; /** @var LoaderInterface[] */ @@ -132,6 +132,8 @@ public function getSource(): SourceInterface */ public function withContext(LoaderInterface $parent, array $options = []): LoaderInterface { + $options = $this->prepareOptions($options); + // check that given options are known if (!empty($wrong = array_diff(array_keys($options), array_keys($this->options)))) { throw new LoaderException( @@ -325,4 +327,14 @@ protected function getEagerRelations(): \Generator } } } + + protected function prepareOptions(array $options): array + { + if (array_key_exists('constrain', $options) && !array_key_exists('scope', $options)) { + $options['scope'] = $options['constrain']; + } + unset($options['constrain']); + + return $options; + } } diff --git a/src/Select/ConstrainInterface.php b/src/Select/ConstrainInterface.php index d54bccba5..5635be189 100644 --- a/src/Select/ConstrainInterface.php +++ b/src/Select/ConstrainInterface.php @@ -1,25 +1,17 @@ false, // true or instance to enable, false or null to disable - 'constrain' => true, + 'scope' => true, // scope to be used for the relation 'method' => null, @@ -102,6 +102,8 @@ public function getAlias(): string */ public function withContext(LoaderInterface $parent, array $options = []): LoaderInterface { + $options = $this->prepareOptions($options); + /** * @var AbstractLoader $parent * @var self $loader @@ -122,14 +124,14 @@ public function withContext(LoaderInterface $parent, array $options = []): Loade //Calculate table alias $loader->options['as'] = $loader->calculateAlias($parent); - if (array_key_exists('constrain', $options)) { - if ($loader->options['constrain'] instanceof ConstrainInterface) { - $loader->setConstrain($loader->options['constrain']); - } elseif (is_string($loader->options['constrain'])) { - $loader->setConstrain($this->orm->getFactory()->make($loader->options['constrain'])); + if (array_key_exists('scope', $options)) { + if ($loader->options['scope'] instanceof ScopeInterface) { + $loader->setScope($loader->options['scope']); + } elseif (is_string($loader->options['scope'])) { + $loader->setScope($this->orm->getFactory()->make($loader->options['scope'])); } } else { - $loader->setConstrain($this->getSource()->getConstrain()); + $loader->setScope($this->getSource()->getConstrain()); } if ($loader->isLoaded()) { @@ -218,7 +220,7 @@ public function configureQuery(SelectQuery $query, array $outerKeys = []): Selec $this->mountColumns($query, $this->options['minify'], '', true); } - if ($this->options['load'] instanceof ConstrainInterface) { + if ($this->options['load'] instanceof ScopeInterface) { $this->options['load']->apply($this->makeQueryBuilder($query)); } diff --git a/src/Select/Loader/BelongsToLoader.php b/src/Select/Loader/BelongsToLoader.php index 2b8eed3d2..87264ac98 100644 --- a/src/Select/Loader/BelongsToLoader.php +++ b/src/Select/Loader/BelongsToLoader.php @@ -34,13 +34,13 @@ class BelongsToLoader extends JoinableLoader * @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, - 'method' => self::POSTLOAD, - 'minify' => true, - 'as' => null, - 'using' => null, - 'where' => null, + 'load' => false, + 'scope' => true, + 'method' => self::POSTLOAD, + 'minify' => true, + 'as' => null, + 'using' => null, + 'where' => null, ]; /** diff --git a/src/Select/Loader/HasManyLoader.php b/src/Select/Loader/HasManyLoader.php index 7ce6f0688..1c6979e48 100644 --- a/src/Select/Loader/HasManyLoader.php +++ b/src/Select/Loader/HasManyLoader.php @@ -35,14 +35,14 @@ class HasManyLoader extends JoinableLoader * @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, - 'method' => self::POSTLOAD, - 'minify' => true, - 'as' => null, - 'using' => null, - 'where' => null, - 'orderBy' => null, + 'load' => false, + 'scope' => true, + 'method' => self::POSTLOAD, + 'minify' => true, + 'as' => null, + 'using' => null, + 'where' => null, + 'orderBy' => null, ]; /** diff --git a/src/Select/Loader/HasOneLoader.php b/src/Select/Loader/HasOneLoader.php index c9f03f990..e5d9628ca 100644 --- a/src/Select/Loader/HasOneLoader.php +++ b/src/Select/Loader/HasOneLoader.php @@ -38,13 +38,13 @@ class HasOneLoader extends JoinableLoader * @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, - 'method' => self::INLOAD, - 'minify' => true, - 'as' => null, - 'using' => null, - 'where' => null + 'load' => false, + 'scope' => true, + 'method' => self::INLOAD, + 'minify' => true, + 'as' => null, + 'using' => null, + 'where' => null ]; /** diff --git a/src/Select/Loader/ManyToManyLoader.php b/src/Select/Loader/ManyToManyLoader.php index 32c43de4d..ace3f30c5 100644 --- a/src/Select/Loader/ManyToManyLoader.php +++ b/src/Select/Loader/ManyToManyLoader.php @@ -24,7 +24,6 @@ use Cycle\ORM\Select\Traits\WhereTrait; use Spiral\Database\Injection\Parameter; use Spiral\Database\Query\SelectQuery; -use Spiral\Database\StatementInterface; class ManyToManyLoader extends JoinableLoader { @@ -37,15 +36,15 @@ class ManyToManyLoader extends JoinableLoader * @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, - 'method' => self::POSTLOAD, - 'minify' => true, - 'as' => null, - 'using' => null, - 'where' => null, - 'orderBy' => null, - 'pivot' => null, + 'load' => false, + 'scope' => true, + 'method' => self::POSTLOAD, + 'minify' => true, + 'as' => null, + 'using' => null, + 'where' => null, + 'orderBy' => null, + 'pivot' => null, ]; /** @var PivotLoader */ @@ -78,6 +77,8 @@ public function __clone() */ public function withContext(LoaderInterface $parent, array $options = []): LoaderInterface { + $options = $this->prepareOptions($options); + /** @var ManyToManyLoader $loader */ $loader = parent::withContext($parent, $options); $loader->pivot = $loader->pivot->withContext( diff --git a/src/Select/Loader/PivotLoader.php b/src/Select/Loader/PivotLoader.php index 6ef3017d8..0b2d229ee 100644 --- a/src/Select/Loader/PivotLoader.php +++ b/src/Select/Loader/PivotLoader.php @@ -33,12 +33,12 @@ class PivotLoader extends JoinableLoader * @var array */ protected $options = [ - 'load' => false, - 'constrain' => true, - 'method' => self::JOIN, - 'minify' => true, - 'as' => null, - 'using' => null + 'load' => false, + 'scope' => true, + 'method' => self::JOIN, + 'minify' => true, + 'as' => null, + 'using' => null ]; /** diff --git a/src/Select/QueryBuilder.php b/src/Select/QueryBuilder.php index 031d4e1bc..69523f34f 100644 --- a/src/Select/QueryBuilder.php +++ b/src/Select/QueryBuilder.php @@ -151,7 +151,7 @@ public function resolve(string $identifier, bool $autoload = true): string $loader = $this->findLoader(substr($identifier, 0, $split), $autoload); if ($loader !== null) { return sprintf( - '%s.%s.', + '%s.%s', $loader->getAlias(), $loader->fieldAlias(substr($identifier, $split + 1)) ); diff --git a/src/Select/QueryConstrain.php b/src/Select/QueryConstrain.php index 221d50c41..12f410053 100644 --- a/src/Select/QueryConstrain.php +++ b/src/Select/QueryConstrain.php @@ -1,42 +1,17 @@ where = $where; - $this->orderBy = $orderBy; - } +\class_alias(QueryScope::class, __NAMESPACE__ . '\QueryConstrain'); +if (false) { /** - * @inheritdoc + * @deprecated Use {@see QueryScope} instead. */ - public function apply(QueryBuilder $query): void + class QueryConstrain extends QueryScope { - $query->where($this->where)->orderBy($this->orderBy); } } diff --git a/src/Select/QueryScope.php b/src/Select/QueryScope.php new file mode 100644 index 000000000..0114b2612 --- /dev/null +++ b/src/Select/QueryScope.php @@ -0,0 +1,36 @@ +where = $where; + $this->orderBy = $orderBy; + } + + /** + * @inheritdoc + */ + public function apply(QueryBuilder $query): void + { + $query->where($this->where)->orderBy($this->orderBy); + } +} diff --git a/src/Select/RootLoader.php b/src/Select/RootLoader.php index de7b46e54..972eb5f00 100644 --- a/src/Select/RootLoader.php +++ b/src/Select/RootLoader.php @@ -17,7 +17,7 @@ use Cycle\ORM\Parser\Typecast; use Cycle\ORM\Schema; use Cycle\ORM\Select\Traits\ColumnsTrait; -use Cycle\ORM\Select\Traits\ConstrainTrait; +use Cycle\ORM\Select\Traits\ScopeTrait; use Spiral\Database\Query\SelectQuery; use Spiral\Database\StatementInterface; @@ -30,12 +30,12 @@ final class RootLoader extends AbstractLoader { use ColumnsTrait; - use ConstrainTrait; + use ScopeTrait; /** @var array */ protected $options = [ - 'load' => true, - 'constrain' => true, + 'load' => true, + 'scope' => true, ]; /** @var SelectQuery */ diff --git a/src/Select/ScopeInterface.php b/src/Select/ScopeInterface.php new file mode 100644 index 000000000..673085091 --- /dev/null +++ b/src/Select/ScopeInterface.php @@ -0,0 +1,16 @@ +constrain = $constrain; - - return $this; - } - - /** - * @return string - */ - abstract public function getAlias(): string; - - /** - * @param SelectQuery $query - * @return SelectQuery - */ - protected function applyConstrain(SelectQuery $query): SelectQuery - { - if ($this->constrain !== null) { - $this->constrain->apply(new QueryBuilder($query, $this)); - } - - return $query; + use ScopeTrait; } } diff --git a/src/Select/Traits/ScopeTrait.php b/src/Select/Traits/ScopeTrait.php new file mode 100644 index 000000000..d54f95dfe --- /dev/null +++ b/src/Select/Traits/ScopeTrait.php @@ -0,0 +1,60 @@ +constrain = $scope; + return $this; + } + + /** + * @deprecated Use {@see setScope()} instead. + * + * @return AbstractLoader|$this + */ + public function setConstrain(ConstrainInterface $constrain = null): self + { + return $this->setScope($constrain); + } + + protected function applyScope(SelectQuery $query): SelectQuery + { + if ($this->constrain !== null) { + $this->constrain->apply(new QueryBuilder($query, $this)); + } + + return $query; + } + + /** + * @deprecated Use {@see applyScope()} instead. + */ + protected function applyConstrain(SelectQuery $query): SelectQuery + { + return $this->applyScope($query); + } +} diff --git a/tests/ORM/Driver/SQLite/HasManyScopeTest.php b/tests/ORM/Driver/SQLite/HasManyScopeTest.php new file mode 100644 index 000000000..280c4bc55 --- /dev/null +++ b/tests/ORM/Driver/SQLite/HasManyScopeTest.php @@ -0,0 +1,17 @@ +orderBy('id', 'ASC'); + } +} diff --git a/tests/ORM/HasManyScopeTest.php b/tests/ORM/HasManyScopeTest.php new file mode 100644 index 000000000..057ea3886 --- /dev/null +++ b/tests/ORM/HasManyScopeTest.php @@ -0,0 +1,519 @@ +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('comments')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[0]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[2]->message); + $this->assertSame('msg 1', $a->comments[3]->message); + + $this->assertSame('msg 2.3', $b->comments[0]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[2]->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('comments')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[3]->message); + $this->assertSame('msg 3', $a->comments[2]->message); + $this->assertSame('msg 2', $a->comments[1]->message); + $this->assertSame('msg 1', $a->comments[0]->message); + + $this->assertSame('msg 2.3', $b->comments[2]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments', [ + 'method' => JoinableLoader::INLOAD + ])->orderBy('user.id')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[3]->message); + $this->assertSame('msg 3', $a->comments[2]->message); + $this->assertSame('msg 2', $a->comments[1]->message); + $this->assertSame('msg 1', $a->comments[0]->message); + + $this->assertSame('msg 2.3', $b->comments[2]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[3]->message); + $this->assertSame('msg 3', $a->comments[2]->message); + $this->assertSame('msg 2', $a->comments[1]->message); + $this->assertSame('msg 1', $a->comments[0]->message); + + $this->assertSame('msg 2.3', $b->comments[2]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments')->fetchAll(); + + $this->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[2]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[0]->message); + $this->assertSame('msg 2.3', $b->comments[1]->message); + $this->assertSame('msg 2.2', $b->comments[0]->message); + } + + public function testScopeOrderedAndWherePromised(): 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))->fetchAll(); + + $this->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[2]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[0]->message); + $this->assertSame('msg 2.3', $b->comments[1]->message); + $this->assertSame('msg 2.2', $b->comments[0]->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('comments')->fetchAll(); + + $this->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[0]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[2]->message); + $this->assertSame('msg 2.3', $b->comments[0]->message); + $this->assertSame('msg 2.2', $b->comments[1]->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('comments', [ + 'method' => JoinableLoader::INLOAD + ])->fetchAll(); + + $this->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[0]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[2]->message); + $this->assertSame('msg 2.3', $b->comments[0]->message); + $this->assertSame('msg 2.2', $b->comments[1]->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->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[0]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[2]->message); + $this->assertSame('msg 2.3', $b->comments[0]->message); + $this->assertSame('msg 2.2', $b->comments[1]->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('comments', [ + 'where' => ['@.level' => 1] + ])->fetchAll(); + + $this->assertCount(1, $a->comments); + $this->assertCount(1, $b->comments); + + $this->assertSame('msg 1', $a->comments[0]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments', [ + 'where' => ['@.level' => 1], + 'method' => JoinableLoader::INLOAD + ])->fetchAll(); + + $this->assertCount(1, $a->comments); + $this->assertCount(1, $b->comments); + + $this->assertSame('msg 1', $a->comments[0]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[0]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[2]->message); + $this->assertSame('msg 1', $a->comments[3]->message); + + $this->assertSame('msg 2.3', $b->comments[0]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[2]->message); + } + + public function testWithOrderByInLoad(): void + { + $this->orm = $this->withCommentsSchema([ + Relation::SCHEMA => [ + Relation::ORDER_BY => ['@.level' => 'ASC'], + ] + ]); + + [$a, $b] = (new Select($this->orm, User::class))->load('comments', [ + 'method' => JoinableLoader::INLOAD + ])->orderBy('user.id')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[3]->message); + $this->assertSame('msg 3', $a->comments[2]->message); + $this->assertSame('msg 2', $a->comments[1]->message); + $this->assertSame('msg 1', $a->comments[0]->message); + + $this->assertSame('msg 2.3', $b->comments[2]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments', [ + 'orderBy' => ['@.level' => 'ASC'] + ])->orderBy('user.id')->fetchAll(); + + $this->assertCount(4, $a->comments); + $this->assertCount(3, $b->comments); + + $this->assertSame('msg 4', $a->comments[3]->message); + $this->assertSame('msg 3', $a->comments[2]->message); + $this->assertSame('msg 2', $a->comments[1]->message); + $this->assertSame('msg 1', $a->comments[0]->message); + + $this->assertSame('msg 2.3', $b->comments[2]->message); + $this->assertSame('msg 2.2', $b->comments[1]->message); + $this->assertSame('msg 2.1', $b->comments[0]->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('comments')->fetchAll(); + + $this->assertCount(3, $a->comments); + $this->assertCount(2, $b->comments); + + $this->assertSame('msg 4', $a->comments[2]->message); + $this->assertSame('msg 3', $a->comments[1]->message); + $this->assertSame('msg 2', $a->comments[0]->message); + $this->assertSame('msg 2.3', $b->comments[1]->message); + $this->assertSame('msg 2.2', $b->comments[0]->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('comments')->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('comments', [ + '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('comments') + ->limit(1)->orderBy('user.id')->fetchAll(); + + $this->assertCount(1, $res); + $this->assertSame('hello@world.com', $res[0]->email); + $this->assertCount(4, $res[0]->comments); + } + + public function testLimitParentSelectionError(): void + { + $this->expectException(LoaderException::class); + + $this->orm = $this->withCommentsSchema([]); + + // do not allow limits with joined and loaded relations + (new Select($this->orm, User::class)) + ->load('comments', ['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('comments', [ + '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->assertCount(2, $res[1]->comments); + $this->assertCount(1, $res[0]->comments); + + $this->assertSame('msg 4', $res[1]->comments[0]->message); + $this->assertSame('msg 3', $res[1]->comments[1]->message); + $this->assertSame('msg 2.3', $res[0]->comments[0]->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('comments', [ + 'method' => JoinableLoader::INLOAD, + ])->orderBy('user.id', 'DESC')->fetchAll(); + } + + protected function withCommentsSchema(array $relationSchema) + { + $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, + ] + ], + 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 + ])); + } +}