From f571d03737efe8f76ac2f965ceeb315de9e63a92 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 13 Jun 2025 15:11:56 +0200 Subject: [PATCH 1/3] Properly handle `\ArrayAccess` as value Convert it to a regular array in the needed places --- src/Clause/Condition/Condition.php | 12 ++++++++++-- src/Query.php | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/Clause/Condition/Condition.php b/src/Clause/Condition/Condition.php index cf01f17..b912152 100644 --- a/src/Clause/Condition/Condition.php +++ b/src/Clause/Condition/Condition.php @@ -242,7 +242,11 @@ public function getConditionSql(QueryGeneratorState $state): string ], $condition, ); - } elseif (is_array($this->value) || $this->value instanceof Collection) { + } elseif ( + is_array($this->value) || + $this->value instanceof Collection || + $this->value instanceof \ArrayIterator + ) { if (count($this->value) > 0) { $condition = str_replace( '?', @@ -274,7 +278,11 @@ public function injectConditionValues(QueryGeneratorState $state): void $state->addSubQueryValues($this->value); } elseif ($this->value === null) { // sql is converted to `IS NULL` - } elseif (is_array($this->value) || $this->value instanceof Collection) { + } elseif ( + is_array($this->value) || + $this->value instanceof Collection || + $this->value instanceof \ArrayIterator + ) { $values = Query::toDatabaseFormat($this->value); // empty list will result in no emitted values, this links up with diff --git a/src/Query.php b/src/Query.php index 7eedef7..529831d 100644 --- a/src/Query.php +++ b/src/Query.php @@ -492,7 +492,7 @@ private function getConditionValues( * @param mixed $value Any value * @psalm-param T $value Any value * @return mixed Database usable format - * @psalm-return (T is Collection ? int[] : (T is null ? null : (T is bool ? int : (T is array ? mixed[] : (T is BackedEnum ? string|int : (T is int ? int : string)))))) Database usable format + * @psalm-return (T is Collection ? int[] : (T is null ? null : (T is bool ? int : (T is array ? mixed[] : (T is \ArrayIterator ? mixed[] : (T is BackedEnum ? string|int : (T is int ? int : string))))))) Database usable format * @internal */ public static function toDatabaseFormat(mixed $value): mixed @@ -528,6 +528,12 @@ public static function toDatabaseFormat(mixed $value): mixed return $value->value; } + if ($value instanceof \ArrayIterator) { + // convert to array + /** @var mixed[] $value */ + $value = iterator_to_array($value); + } + if (is_array($value)) { return array_map( /** @param mixed $itemValue From 5ae7ab81b28c5f88877f20e9fba3780fc9e73f25 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 13 Jun 2025 16:21:09 +0200 Subject: [PATCH 2/3] Make clauses more consistent in SQL and PHP form Generating SQL with a clause or applying a clause to a collection should result in the same outcome. There is a difference in behavior for empty `Multiple` clause for condition clauses and ordering clauses. - The implementation of empty `Multiple` clauses was wrong for SQL, it should not match anything. This now matches the PHP implementation. - `Multiple` ordering clauses were not properly applied to a query, the logic for condition was used, which is not right --- docs/clauses.md | 128 +++++++++++++++++++++++ docs/presenters.md | 2 + src/Clause/Multiple.php | 41 ++++++-- src/Query.php | 69 ++++++++++-- src/Query/QueryGeneratorState.php | 18 ++++ src/Query/QueryGeneratorStateContext.php | 35 +++++++ tests/PresenterTest.php | 19 ++-- website/sidebars.js | 1 + 8 files changed, 288 insertions(+), 25 deletions(-) create mode 100644 docs/clauses.md create mode 100644 src/Query/QueryGeneratorStateContext.php diff --git a/docs/clauses.md b/docs/clauses.md new file mode 100644 index 0000000..2295861 --- /dev/null +++ b/docs/clauses.md @@ -0,0 +1,128 @@ +--- +id: clauses +title: Clauses +slug: /clauses +--- + +Clauses are Access' way to store query conditions, order by information and +filters. These clauses can be converted to SQL, be used to filter manipulate +collections. + +There are three types of clauses: conditions, filters, and orders. + +## Condition clauses + +Condition clauses can be used to add `WHERE` conditions to a query, there are a +bunch of them builtin. A lot of these clauses will speak for themselves. + +- `Access\Clauses\Condition\Equals` +- `Access\Clauses\Condition\GreaterThan` +- `Access\Clauses\Condition\GreaterThanOrEquals` +- `Access\Clauses\Condition\IsNotNull` +- `Access\Clauses\Condition\IsNull` +- `Access\Clauses\Condition\LessThen` +- `Access\Clauses\Condition\LessThanOrEquals` +- `Access\Clauses\Condition\NotEquals` +- `Access\Clauses\Condition\In` +- `Access\Clauses\Condition\NotIn` + +The condition clauses can be used in code and in SQL. + +```php +// in query form +$query = new Select(...); +$query->where(Equals('id', 1)); + +// SELECT * FROM ... WHERE id = 1 +``` + +```php +// in code form +$users = $userRepo->findAllCollection(...) +$users->applyClause(Equals('id', 1)); +``` + +The code form will still fetch all the users from the database, so this form is +mostly useful you want to select a couple of records from the collection, but +also want to keep the original collection around. Pre-fetching a bunch of +records to process later to prevent `n+1` queries is a great example of this. +This is how the clauses are used in the [presenters](presenters). + +## Ordering clauses + +To order the projects you can use the `Access\Clause\OrderBy` clauses. There +are only three order by clauses: + +- `Access\Clause\OrderBy\Ascending` +- `Access\Clause\OrderBy\Descending` +- `Access\Clause\OrderBy\Random` + +The condition clauses can be used in code and in SQL. + +```php +// in query form +$query = new Select(...); +$query->orderBy(Ascending('id')); + +// SELECT * FROM ... ORDER BY id ASC +``` + +```php +// in code form +$users = $userRepo->findAllCollection(...) +$users->applyClause(Ascending('id')); +``` + +## Filtering clauses + +Currently there is a single filter clauses, and it only has a code form, it +can't be used in a query. + +- `Access\Clause\Filter\Unique`: Will filter out entities with a duplicate + field value + +## Multiple clauses + +And if you want to mix multiple clauses together, if, for example, you want the +list to contain published projects and also want to order them. The special +`Access\Clauses\Multiple` clause will help you here, it combines multiple +conditions together and all conditions need to be true. Or if you provide +multiple order by clauses they all will be used (you can sorting on status and +then on name, for example). There is also the `Access\Clauses\MultipleOr` clause +if you want only one of the condition clauses to be true. You can add as many +clauses to the `Multiple` as you like, and you can mix order by and condition +clauses. + +```php +// in query form +$query = new Select(...); +$query->where(new Multiple( + Equals('id', 1), + Equals('name', 'Dave'), +)); + +// SELECT * FROM ... WHERE id = 1 AND name = "Dave" +``` + +When applying `Multiple` clauses to a collection, conditions and orders can be +mixed. This is how presenter can use a "single" clause parameter for multiple +purposes. + +```php +$users = $userRepo->findAllCollection(...) +$users->applyClause(new Multiple( + Equals('name', 'Dave'), + Ascending('id'), +)); +``` + +### Empty multiple clauses + +When a `Multiple` clause is empty there is a bit of special handling. When used +in the context of a condition, it will _not_ match any entities and `1 = 2` is +used in a query. This is to prevent accidental overfetching, when building +`Multiple` clauses programmatically. When there are mixed clauses inside the +`Multiple` clause, if it contains a single condition it is considered a +condition clause, or if it's completely empty (no "type" can be determined). + +When used in the context of ordering, it will just be ignored. diff --git a/docs/presenters.md b/docs/presenters.md index 13d98fa..1699732 100644 --- a/docs/presenters.md +++ b/docs/presenters.md @@ -387,6 +387,8 @@ class UserWithOrderedPublishedProjectsPresenter extends EntityPresenter } ``` +More information about [clauses can be found here](clauses). + ## `Presenter` instance ### Dependency injection diff --git a/src/Clause/Multiple.php b/src/Clause/Multiple.php index 7197527..91e55c1 100644 --- a/src/Clause/Multiple.php +++ b/src/Clause/Multiple.php @@ -207,17 +207,38 @@ protected function getMultipleSql(string $combineWith, QueryGeneratorState $stat { $conditionParts = []; + // without any clauses, we can't determine if this is a multiple condition. + // to be on the safe side, we assume it is a multiple condition + $isMultipleCondition = count($this->clauses) === 0; + foreach ($this->clauses as $clause) { - if ($clause instanceof ConditionInterface) { + if ( + $state->getContext() === QueryGeneratorStateContext::Condition && + $clause instanceof ConditionInterface + ) { + $isMultipleCondition = true; + $conditionParts[] = $clause->getConditionSql($state); + } elseif ( + $state->getContext() === QueryGeneratorStateContext::OrderBy && + $clause instanceof OrderByInterface + ) { $conditionParts[] = $clause->getConditionSql($state); } } if (empty($conditionParts)) { - // empty conditions make no sense... - // droppping the whole condition is risky because you may - // over-select a whole bunch of records, better is to under-select. - return '1 = 2'; + if ($state->getContext()->allowEmptyMultiple()) { + return ''; + } + + if ($isMultipleCondition) { + // empty conditions make no sense... + // droppping the whole condition is risky because you may + // over-select a whole bunch of records, better is to under-select. + return '1 = 2'; + } + + return ''; } $combinedConditions = implode($combineWith, $conditionParts); @@ -237,7 +258,15 @@ protected function getMultipleSql(string $combineWith, QueryGeneratorState $stat public function injectConditionValues(QueryGeneratorState $state): void { foreach ($this->clauses as $clause) { - if ($clause instanceof ConditionInterface) { + if ( + $state->getContext() === QueryGeneratorStateContext::Condition && + $clause instanceof ConditionInterface + ) { + $clause->injectConditionValues($state); + } elseif ( + $state->getContext() === QueryGeneratorStateContext::OrderBy && + $clause instanceof OrderByInterface + ) { $clause->injectConditionValues($state); } } diff --git a/src/Query.php b/src/Query.php index 529831d..772ac55 100644 --- a/src/Query.php +++ b/src/Query.php @@ -31,6 +31,7 @@ use Access\Query\QueryGeneratorState; use Access\Query\Cursor\Cursor; use Access\Query\IncludeSoftDeletedFilter; +use Access\Query\QueryGeneratorStateContext; use BackedEnum; /** @@ -448,6 +449,7 @@ public function getValues(?DriverInterface $driver = null): array $this->getConditionValues( $driver, + QueryGeneratorStateContext::Condition, $indexedValues, $joinConditions, self::PREFIX_JOIN . $i . self::PREFIX_JOIN, @@ -456,9 +458,29 @@ public function getValues(?DriverInterface $driver = null): array $where = $this->preprocessConditions($this->where, $this->softDeleteCondition); - $this->getConditionValues($driver, $indexedValues, $where, self::PREFIX_WHERE); - $this->getConditionValues($driver, $indexedValues, $this->having, self::PREFIX_HAVING); - $this->getConditionValues($driver, $indexedValues, $this->orderBy, self::PREFIX_ORDER); + $this->getConditionValues( + $driver, + QueryGeneratorStateContext::Condition, + $indexedValues, + $where, + self::PREFIX_WHERE, + ); + + $this->getConditionValues( + $driver, + QueryGeneratorStateContext::Condition, + $indexedValues, + $this->having, + self::PREFIX_HAVING, + ); + + $this->getConditionValues( + $driver, + QueryGeneratorStateContext::OrderBy, + $indexedValues, + $this->orderBy, + self::PREFIX_ORDER, + ); return $indexedValues; } @@ -472,11 +494,17 @@ public function getValues(?DriverInterface $driver = null): array */ private function getConditionValues( DriverInterface $driver, + QueryGeneratorStateContext $context, &$indexedValues, array $definition, string $prefix, ): void { - $state = new QueryGeneratorState($driver, $prefix, self::PREFIX_SUBQUERY_CONDITION); + $state = new QueryGeneratorState( + $driver, + $context, + $prefix, + self::PREFIX_SUBQUERY_CONDITION, + ); foreach ($definition as $condition) { $condition->injectConditionValues($state); @@ -611,6 +639,7 @@ protected function getJoinSql(DriverInterface $driver): string $onSql = $this->getConditionSql( $driver, + QueryGeneratorStateContext::Condition, 'ON', $joinConditions, self::PREFIX_JOIN . $i . self::PREFIX_JOIN, @@ -640,7 +669,13 @@ protected function getWhereSql(DriverInterface $driver): string { $where = $this->preprocessConditions($this->where, $this->softDeleteCondition); - return $this->getConditionSql($driver, 'WHERE', $where, self::PREFIX_WHERE); + return $this->getConditionSql( + $driver, + QueryGeneratorStateContext::Condition, + 'WHERE', + $where, + self::PREFIX_WHERE, + ); } /** @@ -653,7 +688,13 @@ protected function getWhereSql(DriverInterface $driver): string */ protected function getHavingSql(DriverInterface $driver): string { - return $this->getConditionSql($driver, 'HAVING', $this->having, self::PREFIX_HAVING); + return $this->getConditionSql( + $driver, + QueryGeneratorStateContext::Condition, + 'HAVING', + $this->having, + self::PREFIX_HAVING, + ); } /** @@ -669,6 +710,7 @@ protected function getHavingSql(DriverInterface $driver): string */ private function getConditionSql( DriverInterface $driver, + QueryGeneratorStateContext $context, string $what, array $definition, string $prefix, @@ -679,12 +721,24 @@ private function getConditionSql( } $conditions = []; - $state = new QueryGeneratorState($driver, $prefix, self::PREFIX_SUBQUERY_CONDITION); + $state = new QueryGeneratorState( + $driver, + $context, + $prefix, + self::PREFIX_SUBQUERY_CONDITION, + ); foreach ($definition as $definitionPart) { $conditions[] = $definitionPart->getConditionSql($state); } + $conditions = array_filter($conditions, fn($condition) => !empty($condition)); + + if (count($conditions) === 0) { + // no conditions, return empty string + return ''; + } + $condition = implode($combinator, $conditions); $sqlCondition = " {$what} {$condition}"; @@ -720,6 +774,7 @@ protected function getOrderBySql(DriverInterface $driver): string { return $this->getConditionSql( $driver, + QueryGeneratorStateContext::OrderBy, 'ORDER BY', $this->orderBy, self::PREFIX_ORDER, diff --git a/src/Query/QueryGeneratorState.php b/src/Query/QueryGeneratorState.php index 55bdcca..4eea642 100644 --- a/src/Query/QueryGeneratorState.php +++ b/src/Query/QueryGeneratorState.php @@ -29,6 +29,11 @@ class QueryGeneratorState */ private DriverInterface $driver; + /** + * Context for the query generator state + */ + private QueryGeneratorStateContext $context; + /** * Tracked indexed values * @@ -65,10 +70,12 @@ class QueryGeneratorState */ public function __construct( DriverInterface $driver, + QueryGeneratorStateContext $context, string $conditionPrefix, string $subQueryConditionPrefix, ) { $this->driver = $driver; + $this->context = $context; $this->conditionPrefix = $conditionPrefix; $this->subQueryConditionPrefix = $subQueryConditionPrefix; } @@ -128,8 +135,19 @@ public function incrementSubQueryIndex(): void $this->subQueryIndex++; } + /** + * Get the driver for this state + */ public function getDriver(): DriverInterface { return $this->driver; } + + /** + * Get the context for this state + */ + public function getContext(): QueryGeneratorStateContext + { + return $this->context; + } } diff --git a/src/Query/QueryGeneratorStateContext.php b/src/Query/QueryGeneratorStateContext.php new file mode 100644 index 0000000..fe4617c --- /dev/null +++ b/src/Query/QueryGeneratorStateContext.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Access\Query; + +/** + * Query generator state context + * + * @internal + * + * @author Tim + */ +enum QueryGeneratorStateContext +{ + case Condition; + case OrderBy; + + public function allowEmptyMultiple(): bool + { + return match ($this) { + self::Condition => false, + self::OrderBy => true, + }; + } +} diff --git a/tests/PresenterTest.php b/tests/PresenterTest.php index c3e5251..6e0c0e8 100644 --- a/tests/PresenterTest.php +++ b/tests/PresenterTest.php @@ -821,26 +821,18 @@ public function testEmptyMulitpleOrClause(): void public function testMulitpleEmptyClause(): void { - [$db, $userOne, $projectOne, $projectTwo] = $this->createAndSetupEntities(); + [$db, $userOne] = $this->createAndSetupEntities(); $expected = [ 'id' => $userOne->getId(), - 'projects' => [ - [ - 'id' => $projectOne->getId(), - 'name' => $projectOne->getName(), - ], - [ - 'id' => $projectTwo->getId(), - 'name' => $projectTwo->getName(), - ], - ], + 'projects' => [], ]; $presenter = new Presenter($db); $presenter->addDependency(new Clause\Multiple()); $result = $presenter->presentEntity(UserWithClausePresenter::class, $userOne); + // no projects, an empty multiple condition can't match anything $this->assertEquals($expected, $result); } @@ -1005,8 +997,8 @@ public function testMultipleFilterUnique(): void $user = $this->createUser($db, 'Name'); - $p1 = $this->createProject($db, $user, 'Same name'); $this->createProject($db, $user, 'Same name'); + $p1 = $this->createProject($db, $user, 'Same name'); $p3 = $this->createProject($db, $user, 'Other name'); $expected = [ @@ -1024,7 +1016,10 @@ public function testMultipleFilterUnique(): void ]; $presenter = new Presenter($db); + $presenter->addDependency( + // the clauses are first applied to the query, + // and then they are applied to the resulting colleciton new Clause\Multiple( new Clause\Filter\Unique('id'), new Clause\Filter\Unique('name'), diff --git a/website/sidebars.js b/website/sidebars.js index 24fd329..2b55945 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -18,6 +18,7 @@ module.exports = { 'queries', 'collections', 'presenters', + 'clauses', 'transactions', 'locks', 'profiler', From ee078ecf23726cfce7a93f7b2c3102ed00a1497d Mon Sep 17 00:00:00 2001 From: Tim Date: Thu, 3 Jul 2025 10:07:16 +0200 Subject: [PATCH 3/3] Include clauses in presenter queries Previously the presenters would only use the clauses to filter and/or order the found entities after the query was run. This might be wasteful if the clause is used to only select a small subset of the query result. Using the clause in the query makes the query less wasteful, this behavior is skipped if the entity pool has a request for different clauses for the same entities. --- src/Clause/ClauseInterface.php | 7 + src/Clause/Condition/Condition.php | 42 ++ src/Clause/Filter/Unique.php | 18 + src/Clause/Multiple.php | 48 +++ src/Clause/OrderBy/OrderBy.php | 30 ++ src/Presenter.php | 61 ++- src/Presenter/EntityPool.php | 19 +- src/Query/QueryGeneratorState.php | 13 + src/Repository.php | 27 +- tests/ClauseTest.php | 612 +++++++++++++++++++++++++++++ tests/Query/SelectTest.php | 34 ++ 11 files changed, 890 insertions(+), 21 deletions(-) diff --git a/src/Clause/ClauseInterface.php b/src/Clause/ClauseInterface.php index a478f6c..8d101b1 100644 --- a/src/Clause/ClauseInterface.php +++ b/src/Clause/ClauseInterface.php @@ -20,4 +20,11 @@ */ interface ClauseInterface { + /** + * Is another clause equal to this one + * + * @param ClauseInterface $clause Clause to compare with + * @return bool Are the clauses equal + */ + public function equals(ClauseInterface $clause): bool; } diff --git a/src/Clause/Condition/Condition.php b/src/Clause/Condition/Condition.php index b912152..9536e2a 100644 --- a/src/Clause/Condition/Condition.php +++ b/src/Clause/Condition/Condition.php @@ -13,13 +13,16 @@ namespace Access\Clause\Condition; +use Access\Clause\ClauseInterface; use Access\Clause\ConditionInterface; use Access\Clause\Field; use Access\Collection; +use Access\Database; use Access\Entity; use Access\Exception; use Access\Query; use Access\Query\QueryGeneratorState; +use Access\Query\QueryGeneratorStateContext; use Access\Query\Select; /** @@ -95,6 +98,45 @@ public function getField(): Field return $this->field; } + /** + * Is this condition equal to another clause + * + * @param ClauseInterface $clause Clause to compare with + * @return bool Are the clauses equal + */ + public function equals(ClauseInterface $clause): bool + { + if ($this::class !== $clause::class) { + return false; + } + + /** @var static $clause */ + + $driver = Database::getDriverOrDefault(null); + + $stateOne = new QueryGeneratorState( + $driver, + QueryGeneratorStateContext::Condition, + 'a', + 'b', + ); + + $sqlOne = $this->getConditionSql($stateOne); + $this->injectConditionValues($stateOne); + + $stateTwo = new QueryGeneratorState( + $driver, + QueryGeneratorStateContext::Condition, + 'a', + 'b', + ); + + $sqlTwo = $clause->getConditionSql($stateTwo); + $clause->injectConditionValues($stateTwo); + + return $sqlOne === $sqlTwo && $stateOne->equals($stateTwo); + } + /** * {@inheritdoc} */ diff --git a/src/Clause/Filter/Unique.php b/src/Clause/Filter/Unique.php index 490320d..b8c1f5b 100644 --- a/src/Clause/Filter/Unique.php +++ b/src/Clause/Filter/Unique.php @@ -13,6 +13,7 @@ namespace Access\Clause\Filter; +use Access\Clause\ClauseInterface; use Access\Clause\Filter\Filter; use Access\Entity; @@ -30,6 +31,23 @@ public function __construct(string $fieldName) $this->fieldName = $fieldName; } + /** + * Is this filter equal to another clause + * + * @param ClauseInterface $clause Clause to compare with + * @return bool Are the clauses equal + */ + public function equals(ClauseInterface $clause): bool + { + if ($this::class !== $clause::class) { + return false; + } + + /** @var static $clause */ + + return $this->fieldName === $clause->fieldName; + } + /** * Create the finder function for this filter clause * diff --git a/src/Clause/Multiple.php b/src/Clause/Multiple.php index 91e55c1..0e35c3c 100644 --- a/src/Clause/Multiple.php +++ b/src/Clause/Multiple.php @@ -16,8 +16,10 @@ use Access\Clause\ClauseInterface; use Access\Clause\ConditionInterface; use Access\Collection; +use Access\Database; use Access\Entity; use Access\Query\QueryGeneratorState; +use Access\Query\QueryGeneratorStateContext; /** * Multiple clauses to mixed and/or match @@ -53,6 +55,46 @@ public function __construct(ClauseInterface ...$clauses) $this->clauses = $clauses; } + /** + * Is this multiple clause equal to another clause + * + * @param ClauseInterface $clause Clause to compare with + * @return bool Are the clauses equal + */ + public function equals(ClauseInterface $clause): bool + { + if ($this::class !== $clause::class) { + return false; + } + + /** @var static $clause */ + + // dummy driver to generate the SQL + $driver = Database::getDriverOrDefault(null); + + $stateOne = new QueryGeneratorState( + $driver, + QueryGeneratorStateContext::Condition, + 'a', + 'b', + ); + + $sqlOne = $this->getConditionSql($stateOne); + $this->injectConditionValues($stateOne); + + $stateTwo = new QueryGeneratorState( + $driver, + QueryGeneratorStateContext::Condition, + 'a', + 'b', + ); + + $sqlTwo = $clause->getConditionSql($stateTwo); + $clause->injectConditionValues($stateTwo); + + return $sqlOne === $sqlTwo && $stateOne->equals($stateTwo); + } + /** * Return the number of clauses currently available */ @@ -241,6 +283,12 @@ protected function getMultipleSql(string $combineWith, QueryGeneratorState $stat return ''; } + if ($state->getContext() === QueryGeneratorStateContext::OrderBy) { + // we are in the order context, just combine them with a comma, + // without wrapping them in parentheses. + return implode(', ', $conditionParts); + } + $combinedConditions = implode($combineWith, $conditionParts); if (count($conditionParts) > 1) { diff --git a/src/Clause/OrderBy/OrderBy.php b/src/Clause/OrderBy/OrderBy.php index 2a905cc..2df21bc 100644 --- a/src/Clause/OrderBy/OrderBy.php +++ b/src/Clause/OrderBy/OrderBy.php @@ -13,13 +13,16 @@ namespace Access\Clause\OrderBy; +use Access\Clause\ClauseInterface; use Access\Clause\Condition\Raw; use Access\Clause\Field; use Access\Clause\OrderByInterface; use Access\Collection; +use Access\Database; use Access\Entity; use Access\Query; use Access\Query\QueryGeneratorState; +use Access\Query\QueryGeneratorStateContext; /** * Sort clause @@ -69,6 +72,33 @@ protected function __construct(string|Field|Raw $fieldName, Direction|string $di $this->direction = $direction; } + /** + * Is this order by clause equal to another clause + * + * @param ClauseInterface $clause Clause to compare with + * @return bool Are the clauses equal + */ + public function equals(ClauseInterface $clause): bool + { + if ($this::class !== $clause::class) { + return false; + } + + /** @var static $clause */ + + $driver = Database::getDriverOrDefault(null); + + $stateOne = new QueryGeneratorState($driver, QueryGeneratorStateContext::OrderBy, 'a', 'b'); + $sqlOne = $this->getConditionSql($stateOne); + $this->injectConditionValues($stateOne); + + $stateTwo = new QueryGeneratorState($driver, QueryGeneratorStateContext::OrderBy, 'a', 'b'); + $sqlTwo = $clause->getConditionSql($stateTwo); + $clause->injectConditionValues($stateTwo); + + return $sqlOne === $sqlTwo && $stateOne->equals($stateTwo); + } + public function getField(): Field|Raw { return $this->field; diff --git a/src/Presenter.php b/src/Presenter.php index e156565..4b0dad0 100644 --- a/src/Presenter.php +++ b/src/Presenter.php @@ -200,11 +200,11 @@ public function mark( * Collect all markers left by present calls * * @return array - * @psalm-return array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} + * @psalm-return array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} */ private function collectMarkers(array $presentation): array { - /** @psalm-var array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} $markers */ + /** @psalm-var array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} $markers */ $markers = [ 'presenters' => [], 'futures' => [], @@ -213,7 +213,7 @@ private function collectMarkers(array $presentation): array ]; array_walk_recursive($presentation, function (mixed $item) use (&$markers) { - /** @psalm-var array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} $markers */ + /** @psalm-var array{presenters: array>, futures: array>, entities: array>, custom: mixed[]} $markers */ if ($item instanceof CustomMarkerInterface) { $markers['custom'][] = $item; @@ -226,11 +226,25 @@ private function collectMarkers(array $presentation): array $entityKlass = $item->getEntityKlass(); if (!isset($markers['entities'][$entityKlass][$item->getFieldName()])) { - $markers['entities'][$entityKlass][$item->getFieldName()] = []; + $markers['entities'][$entityKlass][$item->getFieldName()] = [ + 'ids' => [], + 'clause' => $item->getClause(), + ]; + } + + $currentClause = $markers['entities'][$entityKlass][$item->getFieldName()]['clause']; + $newClause = $item->getClause(); + + if ( + ($currentClause !== null && + ($newClause !== null && !$newClause->equals($currentClause))) || + $currentClause === null + ) { + $markers['entities'][$entityKlass][$item->getFieldName()]['clause'] = null; } array_push( - $markers['entities'][$entityKlass][$item->getFieldName()], + $markers['entities'][$entityKlass][$item->getFieldName()]['ids'], ...$item->getRefIds(), ); @@ -247,11 +261,26 @@ private function collectMarkers(array $presentation): array $presenterKlass = $item->getPresenterKlass(); if (!isset($markers['presenters'][$presenterKlass][$item->getFieldName()])) { - $markers['presenters'][$presenterKlass][$item->getFieldName()] = []; + $markers['presenters'][$presenterKlass][$item->getFieldName()] = [ + 'ids' => [], + 'clause' => $item->getClause(), + ]; + } + + $currentClause = + $markers['presenters'][$presenterKlass][$item->getFieldName()]['clause']; + $newClause = $item->getClause(); + + if ( + ($currentClause !== null && + ($newClause !== null && !$newClause->equals($currentClause))) || + $currentClause === null + ) { + $markers['presenters'][$presenterKlass][$item->getFieldName()]['clause'] = null; } array_push( - $markers['presenters'][$presenterKlass][$item->getFieldName()], + $markers['presenters'][$presenterKlass][$item->getFieldName()]['ids'], ...$item->getRefIds(), ); } @@ -269,8 +298,13 @@ private function collectMarkers(array $presentation): array private function resolveMarkers(array &$presentation, array $markers): void { foreach ($markers['entities'] as $entityKlass => $info) { - foreach ($info as $fieldName => $ids) { - $this->entityPool->getCollection($entityKlass, $fieldName, $ids); + foreach ($info as $fieldName => $refInfo) { + $this->entityPool->getCollection( + $entityKlass, + $fieldName, + $refInfo['ids'], + $refInfo['clause'], + ); } } @@ -317,9 +351,14 @@ private function resolvePresentationMarkers( ): void { /** @psalm-var class-string $presenterKlass */ foreach ($markers as $presenterKlass => $info) { - foreach ($info as $fieldName => $ids) { + foreach ($info as $fieldName => $refInfo) { $entityKlass = $presenterKlass::getEntityKlass(); - $collection = $this->entityPool->getCollection($entityKlass, $fieldName, $ids); + $collection = $this->entityPool->getCollection( + $entityKlass, + $fieldName, + $refInfo['ids'], + $refInfo['clause'], + ); $presenter = $this->createEntityPresenter($presenterKlass); array_walk_recursive($presentation, function (mixed &$item) use ( diff --git a/src/Presenter/EntityPool.php b/src/Presenter/EntityPool.php index 63678c2..da407b2 100644 --- a/src/Presenter/EntityPool.php +++ b/src/Presenter/EntityPool.php @@ -13,6 +13,7 @@ namespace Access\Presenter; +use Access\Clause\ClauseInterface; use Access\Collection; use Access\Database; use Access\Entity; @@ -73,8 +74,12 @@ public function provideCollection( * @param int[] $ids List of IDs * @return Collection */ - public function getCollection(string $entityKlass, string $fieldName, array $ids): Collection - { + public function getCollection( + string $entityKlass, + string $fieldName, + array $ids, + ?ClauseInterface $clause = null, + ): Collection { $currentCollection = $this->getOrCreateCurrentCollection($entityKlass, $fieldName); $currentIds = $currentCollection->map( @@ -87,9 +92,13 @@ public function getCollection(string $entityKlass, string $fieldName, array $ids if (!empty($newIds)) { $repo = $this->db->getRepository($entityKlass); - $newCollection = $repo->findByAsCollection([ - $fieldName => $newIds, - ]); + $newCollection = $repo->findByAsCollection( + [ + $fieldName => $newIds, + ], + null, + $clause, + ); $currentCollection->merge($newCollection); } diff --git a/src/Query/QueryGeneratorState.php b/src/Query/QueryGeneratorState.php index 4eea642..7ee79e7 100644 --- a/src/Query/QueryGeneratorState.php +++ b/src/Query/QueryGeneratorState.php @@ -150,4 +150,17 @@ public function getContext(): QueryGeneratorStateContext { return $this->context; } + + /** + * Indicate whether this state is equal to another + */ + public function equals(QueryGeneratorState $other): bool + { + return $this->context === $other->context && + $this->conditionPrefix === $other->conditionPrefix && + $this->subQueryConditionPrefix === $other->subQueryConditionPrefix && + $this->conditionIndex === $other->conditionIndex && + $this->subQueryIndex === $other->subQueryIndex && + $this->indexedValues === $other->indexedValues; + } } diff --git a/src/Repository.php b/src/Repository.php index 603d7c2..327cd61 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -14,9 +14,12 @@ namespace Access; use Access\Batch; +use Access\Clause\ClauseInterface; use Access\Clause\Condition\Equals; use Access\Clause\Condition\In; use Access\Clause\Condition\Raw; +use Access\Clause\ConditionInterface; +use Access\Clause\OrderByInterface; use Access\Collection; use Access\Database; use Access\Entity; @@ -117,8 +120,11 @@ public function findOneBy(array $fields): ?Entity * @param ?int $limit A a limit to the query * @return \Generator - yields Entity */ - public function findBy(array $fields, ?int $limit = null): \Generator - { + public function findBy( + array $fields, + ?int $limit = null, + ?ClauseInterface $clause = null, + ): \Generator { $query = new Query\Select($this->klass); /** @var mixed $value */ @@ -137,6 +143,14 @@ public function findBy(array $fields, ?int $limit = null): \Generator $query->limit($limit); } + if ($clause instanceof ConditionInterface) { + $query->where($clause); + } + + if ($clause instanceof OrderByInterface) { + $query->orderBy($clause); + } + yield from $this->select($query); } @@ -148,9 +162,12 @@ public function findBy(array $fields, ?int $limit = null): \Generator * @return Collection Collection with `Entity`s * @psalm-return Collection Collection with `Entity`s */ - public function findByAsCollection(array $fields, ?int $limit = null): Collection - { - $iterator = $this->findBy($fields, $limit); + public function findByAsCollection( + array $fields, + ?int $limit = null, + ?ClauseInterface $clause = null, + ): Collection { + $iterator = $this->findBy($fields, $limit, $clause); /** @var Collection $collection */ $collection = new Collection($this->db, $iterator); diff --git a/tests/ClauseTest.php b/tests/ClauseTest.php index 31a789b..d97e0b3 100644 --- a/tests/ClauseTest.php +++ b/tests/ClauseTest.php @@ -14,10 +14,28 @@ namespace Tests; use Access\Clause\Condition\Equals; +use Access\Clause\Condition\NotEquals; +use Access\Clause\Condition\GreaterThan; +use Access\Clause\Condition\LessThan; +use Access\Clause\Condition\In; +use Access\Clause\Condition\NotIn; +use Access\Clause\Condition\IsNull; +use Access\Clause\Condition\IsNotNull; +use Access\Clause\Condition\Raw; +use Access\Clause\Condition\Relation; +use Access\Clause\Field; +use Access\Clause\Filter\Unique; use Access\Clause\Multiple; use Access\Clause\MultipleOr; +use Access\Clause\OrderBy\Ascending; +use Access\Clause\OrderBy\Descending; +use Access\Clause\OrderBy\Random; use Tests\AbstractBaseTestCase; +/** + * @psalm-suppress InternalClass + * @psalm-suppress InternalMethod + */ class ClauseTest extends AbstractBaseTestCase { public function testCountableMultiple(): void @@ -43,4 +61,598 @@ public function testCountableMultipleOr(): void $multiple->add(new Equals('field', 'value')); $this->assertEquals(2, count($multiple)); } + + public function testClauseEquals(): void + { + $one = new Equals('field', 'value'); + + $this->assertTrue( + $one->equals($one), + 'Equals clauses with same field and value should be equal', + ); + } + + public function testClauseEqualsMultiple(): void + { + $one = new Multiple(new Equals('field', 'value'), new Equals('field', 'value')); + + $this->assertTrue( + $one->equals($one), + 'Equals clauses with same field and value should be equal', + ); + } + + public function testConditionEqualsWithSameFieldAndValue(): void + { + $one = new Equals('field', 'value'); + $two = new Equals('field', 'value'); + + $this->assertTrue( + $one->equals($two), + 'Equals conditions with same field and value should be equal', + ); + } + + public function testConditionEqualsWithDifferentValues(): void + { + $one = new Equals('field', 'value1'); + $two = new Equals('field', 'value2'); + + $this->assertFalse( + $one->equals($two), + 'Equals conditions with different values should not be equal', + ); + } + + public function testConditionEqualsWithDifferentFields(): void + { + $one = new Equals('field1', 'value'); + $two = new Equals('field2', 'value'); + + $this->assertFalse( + $one->equals($two), + 'Equals conditions with different fields should not be equal', + ); + } + + public function testConditionEqualsWithFieldObjects(): void + { + $one = new Equals(new Field('field'), 'value'); + $two = new Equals(new Field('field'), 'value'); + + $this->assertTrue( + $one->equals($two), + 'Equals conditions with Field objects should be equal when field names match', + ); + } + + public function testDifferentConditionTypesNotEqual(): void + { + $equals = new Equals('field', 'value'); + $notEquals = new NotEquals('field', 'value'); + + $this->assertFalse( + $equals->equals($notEquals), + 'Different condition types should not be equal', + ); + } + + public function testComparisonConditionsEquality(): void + { + $greaterThan1 = new GreaterThan('field', 10); + $greaterThan2 = new GreaterThan('field', 10); + $greaterThan3 = new GreaterThan('field', 20); + + $this->assertTrue( + $greaterThan1->equals($greaterThan2), + 'GreaterThan conditions with same parameters should be equal', + ); + $this->assertFalse( + $greaterThan1->equals($greaterThan3), + 'GreaterThan conditions with different values should not be equal', + ); + + $lessThan = new LessThan('field', 10); + $this->assertFalse( + $greaterThan1->equals($lessThan), + 'GreaterThan and LessThan should not be equal', + ); + } + + public function testInConditionsEquality(): void + { + $in1 = new In('field', [1, 2, 3]); + $in2 = new In('field', [1, 2, 3]); + $in3 = new In('field', [1, 2, 4]); + + $this->assertTrue($in1->equals($in2), 'In conditions with same arrays should be equal'); + $this->assertFalse( + $in1->equals($in3), + 'In conditions with different arrays should not be equal', + ); + + $notIn1 = new NotIn('field', [1, 2, 3]); + $this->assertFalse($in1->equals($notIn1), 'In and NotIn should not be equal'); + } + + public function testNullConditionsEquality(): void + { + $isNull1 = new IsNull('field'); + $isNull2 = new IsNull('field'); + $isNull3 = new IsNull('other_field'); + + $this->assertTrue( + $isNull1->equals($isNull2), + 'IsNull conditions with same field should be equal', + ); + $this->assertFalse( + $isNull1->equals($isNull3), + 'IsNull conditions with different fields should not be equal', + ); + + $isNotNull1 = new IsNotNull('field'); + $this->assertFalse( + $isNull1->equals($isNotNull1), + 'IsNull and IsNotNull should not be equal', + ); + } + + public function testRawConditionsEquality(): void + { + $raw1 = new Raw('custom_sql = ?', 'value'); + $raw2 = new Raw('custom_sql = ?', 'value'); + $raw3 = new Raw('other_sql = ?', 'value'); + + $this->assertTrue( + $raw1->equals($raw2), + 'Raw conditions with same SQL and value should be equal', + ); + $this->assertFalse( + $raw1->equals($raw3), + 'Raw conditions with different SQL should not be equal', + ); + } + + public function testRelationConditionsEquality(): void + { + $rel1 = new Relation('field1', 'field2'); + $rel2 = new Relation('field1', 'field2'); + $rel3 = new Relation('field1', 'field3'); + + $this->assertTrue( + $rel1->equals($rel2), + 'Relation conditions with same fields should be equal', + ); + $this->assertFalse( + $rel1->equals($rel3), + 'Relation conditions with different fields should not be equal', + ); + } + + public function testOrderByEquality(): void + { + $ascending1 = new Ascending('field'); + $ascending2 = new Ascending('field'); + $ascending3 = new Ascending('other_field'); + + $this->assertTrue( + $ascending1->equals($ascending2), + 'Ascending order by with same field should be equal', + ); + $this->assertFalse( + $ascending1->equals($ascending3), + 'Ascending order by with different fields should not be equal', + ); + + $desc1 = new Descending('field'); + $this->assertFalse( + $ascending1->equals($desc1), + 'Ascending and Descending should not be equal', + ); + } + + public function testRandomOrderByEquality(): void + { + $random1 = new Random(); + $random2 = new Random(); + + $this->assertTrue($random1->equals($random2), 'Random order by clauses should be equal'); + } + + public function testFilterEquality(): void + { + $unique1 = new Unique('field'); + $unique2 = new Unique('field'); + $unique3 = new Unique('other_field'); + + $this->assertTrue( + $unique1->equals($unique2), + 'Unique filters with same field should be equal', + ); + $this->assertFalse( + $unique1->equals($unique3), + 'Unique filters with different fields should not be equal', + ); + } + + public function testMultipleClauseEquality(): void + { + $multiple1 = new Multiple(new Equals('field1', 'value1'), new GreaterThan('field2', 10)); + $multiple2 = new Multiple(new Equals('field1', 'value1'), new GreaterThan('field2', 10)); + $multiple3 = new Multiple(new Equals('field1', 'value2'), new GreaterThan('field2', 10)); + + $this->assertTrue( + $multiple1->equals($multiple2), + 'Multiple clauses with same conditions should be equal', + ); + $this->assertFalse( + $multiple1->equals($multiple3), + 'Multiple clauses with different conditions should not be equal', + ); + } + + public function testMultipleOrClauseEquality(): void + { + $multipleOr1 = new MultipleOr( + new Equals('field1', 'value1'), + new Equals('field2', 'value2'), + ); + $multipleOr2 = new MultipleOr( + new Equals('field1', 'value1'), + new Equals('field2', 'value2'), + ); + + $this->assertTrue( + $multipleOr1->equals($multipleOr2), + 'MultipleOr clauses with same conditions should be equal', + ); + + $multiple = new Multiple(new Equals('field1', 'value1'), new Equals('field2', 'value2')); + + $this->assertFalse( + $multipleOr1->equals($multiple), + 'MultipleOr and Multiple should not be equal', + ); + } + + public function testEmptyMultipleClauseEquality(): void + { + $empty1 = new Multiple(); + $empty2 = new Multiple(); + + $this->assertTrue($empty1->equals($empty2), 'Empty Multiple clauses should be equal'); + + $emptyOr1 = new MultipleOr(); + $emptyOr2 = new MultipleOr(); + + $this->assertTrue($emptyOr1->equals($emptyOr2), 'Empty MultipleOr clauses should be equal'); + $this->assertFalse( + $empty1->equals($emptyOr1), + 'Empty Multiple and MultipleOr should not be equal - they are different types', + ); + } + + public function testCrossTypeClauseInequality(): void + { + $condition = new Equals('field', 'value'); + $orderBy = new Ascending('field'); + $filter = new Unique('field'); + + $this->assertFalse( + $condition->equals($orderBy), + 'Condition and OrderBy should not be equal', + ); + $this->assertFalse($condition->equals($filter), 'Condition and Filter should not be equal'); + $this->assertFalse($orderBy->equals($filter), 'OrderBy and Filter should not be equal'); + } + + public function testConditionEqualityWithNullValues(): void + { + $null1 = new Equals('field', null); + $null2 = new Equals('field', null); + $notNull = new Equals('field', 'value'); + + $this->assertTrue($null1->equals($null2), 'Conditions with null values should be equal'); + $this->assertFalse( + $null1->equals($notNull), + 'Null and non-null conditions should not be equal', + ); + } + + public function testConditionEqualityWithArrayValues(): void + { + $array1 = new In('field', []); + $array2 = new In('field', []); + $nonEmpty = new In('field', [1, 2, 3]); + + $this->assertTrue($array1->equals($array2), 'Conditions with empty arrays should be equal'); + $this->assertFalse( + $array1->equals($nonEmpty), + 'Empty and non-empty array conditions should not be equal', + ); + } + + public function testOrderByWithFieldObjects(): void + { + $ascending1 = new Ascending(new Field('field')); + $ascending2 = new Ascending(new Field('field')); + $ascending3 = new Ascending(new Field('other_field')); + + $this->assertTrue( + $ascending1->equals($ascending2), + 'Ascending order by with Field objects should be equal when field names match', + ); + $this->assertFalse( + $ascending1->equals($ascending3), + 'Ascending order by with different Field objects should not be equal', + ); + } + + public function testComplexMultipleClauseEquality(): void + { + $inner1 = new Multiple(new Equals('field1', 'value1'), new GreaterThan('field2', 10)); + $inner2 = new Multiple(new Equals('field1', 'value1'), new GreaterThan('field2', 10)); + $outer1 = new Multiple($inner1, new LessThan('field3', 20)); + $outer2 = new Multiple($inner2, new LessThan('field3', 20)); + + $this->assertTrue( + $outer1->equals($outer2), + 'Complex nested Multiple clauses should be equal when all conditions match', + ); + } + + public function testMixedClauseTypesInMultiple(): void + { + $mixed1 = new Multiple( + new Equals('field1', 'value1'), + new Ascending('field2'), + new Unique('field3'), + ); + $mixed2 = new Multiple( + new Equals('field1', 'value1'), + new Ascending('field2'), + new Unique('field3'), + ); + + $this->assertTrue( + $mixed1->equals($mixed2), + 'Multiple clauses with mixed clause types should be equal when all match', + ); + } + + public function testConditionWithFieldReference(): void + { + $field1 = new Field('field1'); + $field2 = new Field('field2'); + + $relation1 = new Relation($field1, $field2); + $relation2 = new Relation($field1, $field2); + $relation3 = new Relation($field1, new Field('field3')); + + $this->assertTrue( + $relation1->equals($relation2), + 'Relations with same Field objects should be equal', + ); + $this->assertFalse( + $relation1->equals($relation3), + 'Relations with different second Field objects should not be equal', + ); + } + + public function testMultipleOrWithDifferentOrder(): void + { + $multipleOr1 = new MultipleOr( + new Equals('field1', 'value1'), + new Equals('field2', 'value2'), + ); + $multipleOr2 = new MultipleOr( + new Equals('field2', 'value2'), + new Equals('field1', 'value1'), + ); + + // Order matters in Multiple clauses due to SQL generation + $this->assertFalse( + $multipleOr1->equals($multipleOr2), + 'MultipleOr clauses with different order should not be equal', + ); + } + + public function testConditionEqualityWithIteratorValues(): void + { + $arrayIterator1 = new \ArrayIterator([1, 2, 3]); + $arrayIterator2 = new \ArrayIterator([1, 2, 3]); + + $in1 = new In('field', $arrayIterator1); + $in2 = new In('field', $arrayIterator2); + + $this->assertTrue( + $in1->equals($in2), + 'In conditions with ArrayIterator values should be equal when contents match', + ); + } + + public function testMultipleClauseWithSingleItem(): void + { + $single1 = new Multiple(new Equals('field', 'value')); + $single2 = new Multiple(new Equals('field', 'value')); + $direct = new Equals('field', 'value'); + + $this->assertTrue( + $single1->equals($single2), + 'Multiple clauses with single item should be equal', + ); + $this->assertFalse( + $single1->equals($direct), + 'Multiple clause with single item should not equal direct condition', + ); + } + + public function testEqualityValues(): void + { + // Test 1: Different scalar values should not be equal + $condition1 = new Equals('field', 'value1'); + $condition2 = new Equals('field', 'value2'); + $this->assertFalse( + $condition1->equals($condition2), + 'Conditions with different scalar values should not be equal', + ); + + // Test 2: Different numeric values should not be equal + $greaterThan1 = new GreaterThan('score', 10); + $greaterThan2 = new GreaterThan('score', 20); + $this->assertFalse( + $greaterThan1->equals($greaterThan2), + 'GreaterThan conditions with different numeric values should not be equal', + ); + + // Test 3: Different array contents should not be equal + $in1 = new In('tags', ['php', 'mysql']); + $in2 = new In('tags', ['php', 'sqlite']); + $this->assertFalse( + $in1->equals($in2), + 'In conditions with different array contents should not be equal', + ); + + // Test 4: Complex multiple clauses with different values should not be equal + $multiple1 = new Multiple(new Equals('status', 'active'), new GreaterThan('score', 100)); + $multiple2 = new Multiple(new Equals('status', 'active'), new GreaterThan('score', 200)); + $this->assertFalse( + $multiple1->equals($multiple2), + 'Multiple clauses with different nested values should not be equal', + ); + + // Test 5: OrderBy with different fields should not be equal + $ascending1 = new Ascending('created_at'); + $ascending2 = new Ascending('updated_at'); + $this->assertFalse( + $ascending1->equals($ascending2), + 'OrderBy clauses with different fields should not be equal', + ); + + // Test 6: Verify that identical clauses still work correctly + $mixed1 = new Multiple( + new Equals('type', 'user'), + new In('role', ['admin', 'editor']), + new GreaterThan('last_login', '2023-01-01'), + ); + $mixed2 = new Multiple( + new Equals('type', 'user'), + new In('role', ['admin', 'editor']), + new GreaterThan('last_login', '2023-01-01'), + ); + $this->assertTrue($mixed1->equals($mixed2), 'Identical complex clauses should be equal'); + } + + public function testStrictTypeCheckingInEquals(): void + { + // Test that different clause types are never equal, even if they might generate similar SQL + + // Test Multiple vs MultipleOr - they should never be equal + $multiple = new Multiple(new Equals('field', 'value')); + $multipleOr = new MultipleOr(new Equals('field', 'value')); + $this->assertFalse( + $multiple->equals($multipleOr), + 'Multiple and MultipleOr should never be equal - different types', + ); + $this->assertFalse( + $multipleOr->equals($multiple), + 'MultipleOr and Multiple should never be equal - different types', + ); + + // Test different condition types with same field and value + $equals = new Equals('field', null); + $isNull = new IsNull('field'); + $this->assertFalse( + $equals->equals($isNull), + 'Equals(field, null) and IsNull(field) should not be equal - different types', + ); + + $notEquals = new NotEquals('field', null); + $isNotNull = new IsNotNull('field'); + $this->assertFalse( + $notEquals->equals($isNotNull), + 'NotEquals(field, null) and IsNotNull(field) should not be equal - different types', + ); + + // Test different OrderBy types + $ascending = new Ascending('field'); + $descending = new Descending('field'); + $this->assertFalse( + $ascending->equals($descending), + 'Ascending and Descending should never be equal - different types', + ); + + // Test condition inheritance hierarchy - ensure subclasses are distinct + $greaterThan = new GreaterThan('field', 10); + $lessThan = new LessThan('field', 10); + $this->assertFalse( + $greaterThan->equals($lessThan), + 'GreaterThan and LessThan should never be equal - different types', + ); + + // Test In vs NotIn + $in = new In('field', [1, 2, 3]); + $notIn = new NotIn('field', [1, 2, 3]); + $this->assertFalse( + $in->equals($notIn), + 'In and NotIn should never be equal - different types', + ); + } + + public function testSameTypeEquality(): void + { + // Ensure that same types with same parameters are still equal after strict checking + + // Multiple with same conditions + $multiple1 = new Multiple(new Equals('a', 1), new GreaterThan('b', 2)); + $multiple2 = new Multiple(new Equals('a', 1), new GreaterThan('b', 2)); + $this->assertTrue($multiple1->equals($multiple2), 'Same Multiple types should be equal'); + + // MultipleOr with same conditions + $multipleOr1 = new MultipleOr(new Equals('a', 1), new LessThan('b', 5)); + $multipleOr2 = new MultipleOr(new Equals('a', 1), new LessThan('b', 5)); + $this->assertTrue( + $multipleOr1->equals($multipleOr2), + 'Same MultipleOr types should be equal', + ); + + // Same condition types + $equals1 = new Equals('field', 'value'); + $equals2 = new Equals('field', 'value'); + $this->assertTrue($equals1->equals($equals2), 'Same Equals conditions should be equal'); + + // Same OrderBy types + $asc1 = new Ascending('field'); + $asc2 = new Ascending('field'); + $this->assertTrue($asc1->equals($asc2), 'Same Ascending orders should be equal'); + } + + public function testEmptyClauseTypeDistinction(): void + { + // Test that even empty clauses of different types are not equal + $emptyMultiple = new Multiple(); + $emptyMultipleOr = new MultipleOr(); + + $this->assertFalse( + $emptyMultiple->equals($emptyMultipleOr), + 'Empty Multiple and empty MultipleOr should not be equal - different types', + ); + $this->assertFalse( + $emptyMultipleOr->equals($emptyMultiple), + 'Empty MultipleOr and empty Multiple should not be equal - different types', + ); + + // But same types should be equal + $emptyMultiple2 = new Multiple(); + $emptyMultipleOr2 = new MultipleOr(); + + $this->assertTrue( + $emptyMultiple->equals($emptyMultiple2), + 'Empty Multiple clauses of same type should be equal', + ); + $this->assertTrue( + $emptyMultipleOr->equals($emptyMultipleOr2), + 'Empty MultipleOr clauses of same type should be equal', + ); + } } diff --git a/tests/Query/SelectTest.php b/tests/Query/SelectTest.php index 321a5d6..f4ae6df 100644 --- a/tests/Query/SelectTest.php +++ b/tests/Query/SelectTest.php @@ -5,6 +5,7 @@ namespace Tests\Query; use Access\Clause\Condition\Relation; +use Access\Clause\Multiple; use Access\Clause\OrderBy\Ascending; use PHPUnit\Framework\TestCase; @@ -495,4 +496,37 @@ public function testIncludeSoftDeletedWithJoin(): void $this->assertEquals([], $query->getValues()); } + + public function testMultipleOrderBy(): void + { + $query = new Select(Project::class, 'p'); + $query->orderBy(new Multiple(new Ascending('name'), new Ascending('id'))); + + $this->assertEquals( + 'SELECT `p`.* FROM `projects` AS `p` ORDER BY `name` ASC, `id` ASC', + $query->getSql(), + ); + + $this->assertEquals([], $query->getValues()); + } + + public function testExtraMultipleOrderBy(): void + { + $query = new Select(Project::class, 'p'); + $query->orderBy('id DESC'); + $query->orderBy( + new Multiple( + new Ascending('name'), + new Multiple(new Ascending('name'), new Ascending('id')), + ), + ); + $query->orderBy('id DESC'); + + $this->assertEquals( + 'SELECT `p`.* FROM `projects` AS `p` ORDER BY id DESC, `name` ASC, `name` ASC, `id` ASC, id DESC', + $query->getSql(), + ); + + $this->assertEquals([], $query->getValues()); + } }