From 12d16ce81635b22768e03fbbcef49d8c69c1575e Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 9 Oct 2026 11:13:22 +0600 Subject: [PATCH] Fix callback conditions in present() ifExists() being dropped or rewritten and add whereNotIn() to the Entity builder --- src/Phaseolies/Database/Entity/Builder.php | 136 +++++++++--- .../Database/Entity/Query/Debuggable.php | 2 +- .../Query/InteractsWithConditionBinding.php | 6 +- .../Query/InteractsWithNestedRelations.php | 29 +-- .../Model/Query/EntityModelQueryBehavior.php | 84 ++++++++ .../RelationshipExistenceCallbackBehavior.php | 199 ++++++++++++++++++ ...RelationshipExistenceCallbackMysqlTest.php | 17 ++ ...RelationshipExistenceCallbackPgsqlTest.php | 17 ++ ...elationshipExistenceCallbackSqliteTest.php | 13 ++ 9 files changed, 443 insertions(+), 60 deletions(-) create mode 100644 tests/Model/RelationExists/RelationshipExistenceCallbackBehavior.php create mode 100644 tests/Model/RelationExists/RelationshipExistenceCallbackMysqlTest.php create mode 100644 tests/Model/RelationExists/RelationshipExistenceCallbackPgsqlTest.php create mode 100644 tests/Model/RelationExists/RelationshipExistenceCallbackSqliteTest.php diff --git a/src/Phaseolies/Database/Entity/Builder.php b/src/Phaseolies/Database/Entity/Builder.php index 022af0e0..94401621 100644 --- a/src/Phaseolies/Database/Entity/Builder.php +++ b/src/Phaseolies/Database/Entity/Builder.php @@ -551,10 +551,12 @@ private function handleRelationshipExists(string $relation, ?callable $callback, $relationType = $model->getLastRelationType(); $relatedModel = $model->getLastRelatedModel(); + $bindings = []; + if ($relationType === 'bindToMany') { - $subquery = $this->buildManyToManySubquery($model, $relatedModel, $callback); + $subquery = $this->buildManyToManySubquery($model, $relatedModel, $callback, $bindings); } else { - $subquery = $this->buildDirectRelationshipSubquery($model, $relatedModel, $callback); + $subquery = $this->buildDirectRelationshipSubquery($model, $relatedModel, $callback, $bindings); } $subquery .= ' LIMIT 1'; @@ -562,7 +564,7 @@ private function handleRelationshipExists(string $relation, ?callable $callback, $this->conditions[] = [ 'type' => $type, 'subquery' => $subquery, - 'bindings' => [], + 'bindings' => $bindings, 'boolean' => $boolean ]; @@ -575,9 +577,10 @@ private function handleRelationshipExists(string $relation, ?callable $callback, * @param Model $model * @param mixed $relatedModel * @param callable|null $callback + * @param array $bindings * @return string */ - private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?callable $callback): string + private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?callable $callback, array &$bindings): string { $pivotTable = $model->getLastPivotTable(); $foreignKey = $model->getLastForeignKey(); @@ -602,7 +605,7 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal $subquery .= " WHERE {$quote($pivotTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}"; if ($callback) { - $subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable); + $subquery .= $this->compileCallbackConditions($relatedQuery, $relatedTable, $bindings); } if ($softDeleteConstraint !== null) { @@ -618,9 +621,10 @@ private function buildManyToManySubquery(Model $model, mixed $relatedModel, ?cal * @param Model $model * @param mixed $relatedModel * @param callable|null $callback + * @param array $bindings * @return string */ - private function buildDirectRelationshipSubquery(Model $model, mixed $relatedModel, ?callable $callback): string + private function buildDirectRelationshipSubquery(Model $model, mixed $relatedModel, ?callable $callback, array &$bindings): string { $foreignKey = $model->getLastForeignKey(); $localKey = $model->getLastLocalKey(); @@ -634,7 +638,7 @@ private function buildDirectRelationshipSubquery(Model $model, mixed $relatedMod WHERE {$quote($relatedTable)}.{$quote($foreignKey)} = {$quote($this->table)}.{$quote($localKey)}"; if ($callback) { - $subquery = $this->addCallbackConditions($subquery, $relatedQuery, $relatedTable); + $subquery .= $this->compileCallbackConditions($relatedQuery, $relatedTable, $bindings); } if (($softDeleteConstraint = $relatedQuery->compileSoftDeleteConstraint($relatedTable)) !== null) { @@ -664,39 +668,52 @@ private function buildRelatedSubqueryScope(mixed $relatedModel, ?callable $callb } /** - * Add callback conditions to the subquery + * Compile the conditions a callback added to a related query into an + * " AND (...)" fragment for a hand-built subquery. * - * @param string $subquery - * @param self $subQueryBuilder + * @param self $relatedQuery * @param string $relatedTable + * @param array $bindings * @return string */ - private function addCallbackConditions(string $subquery, self $subQueryBuilder, string $relatedTable): string + private function compileCallbackConditions(self $relatedQuery, string $relatedTable, array &$bindings): string { - $quote = fn($identifier) => $this->quoteIdentifier($identifier); - $escapeValue = fn($value) => $this->escapeValue($value); + $this->qualifyConditionColumns($relatedQuery, $relatedTable); - foreach ($subQueryBuilder->conditions as $condition) { - if (isset($condition['type'])) { - continue; - } + [$whereSql, $whereBindings] = $relatedQuery->buildConditionWhereClause(); - $column = $condition[1]; - $operator = $condition[2]; - $value = $condition[3]; + if ($whereSql === '') { + return ''; + } - // Ensure column is properly qualified with table name if not already - if (strpos($column, '.') === false) { - $column = "{$quote($relatedTable)}.{$quote($column)}"; - } else { - // Quote qualified column names - $column = $quote($column); - } + $bindings = array_merge($bindings, $whereBindings); - $subquery .= $this->buildConditionClause($column, $operator, $value, $escapeValue); - } + return " AND ({$whereSql})"; + } - return $subquery; + /** + * Qualify the plain column names of a query's conditions, including + * those of nested groups, with the given table. + * + * @param self $query + * @param string $table + * @return void + */ + private function qualifyConditionColumns(self $query, string $table): void + { + foreach ($query->conditions as &$condition) { + $type = $condition['type'] ?? null; + + if ($type === 'NESTED') { + $this->qualifyConditionColumns($condition['query'], $table); + } elseif ($type === null && is_string($condition[1] ?? null)) { + if (preg_match('/^[A-Za-z_]\w*$/', $condition[1])) { + $condition[1] = $this->quoteIdentifier("{$table}.{$condition[1]}"); + } elseif (preg_match('/^[A-Za-z_]\w*\.[A-Za-z_]\w*$/', $condition[1])) { + $condition[1] = $this->quoteIdentifier($condition[1]); + } + } + } } /** @@ -716,13 +733,13 @@ private function buildConditionClause(string $column, string $operator, $value, } elseif ($operator === '!=') { return " AND {$column} IS NOT NULL"; } - } elseif ($operator === 'IN') { + } elseif ($operator === 'IN' || $operator === 'NOT IN') { if (empty($value)) { - // Empty IN clause is always false - return " AND 1=0"; + // Empty IN clause is always false, empty NOT IN always true + return $operator === 'IN' ? " AND 1=0" : " AND 1=1"; } else { $escapedValues = $escapeValue($value); - return " AND {$column} IN {$escapedValues}"; + return " AND {$column} {$operator} {$escapedValues}"; } } elseif ($operator === 'LIKE' || $operator === 'NOT LIKE') { $escapedValue = $escapeValue($value); @@ -1928,6 +1945,55 @@ public function orWhereIn(string $field, array $values): self return $this; } + /** + * Add a WHERE NOT IN condition + * + * An empty list excludes nothing. + * + * @param string $field + * @param array $values + * @return self + */ + public function whereNotIn(string $field, array $values): self + { + return $this->addNotInCondition('AND', $field, $values); + } + + /** + * Add a OR WHERE NOT IN condition + * + * @param string $field + * @param array $values + * @return self + */ + public function orWhereNotIn(string $field, array $values): self + { + return $this->addNotInCondition('OR', $field, $values); + } + + /** + * Add a NOT IN condition with the given boolean + * + * @param string $boolean + * @param string $field + * @param array $values + * @return self + */ + private function addNotInCondition(string $boolean, string $field, array $values): self + { + $this->assertValidIdentifier($field); + + if (strpos($field, '.') === false) { + $field = "{$this->tableReference()}.{$field}"; + } + + $values = array_values($values); + $placeholders = implode(',', array_fill(0, count($values), '?')); + $this->conditions[] = [$boolean, $field, 'NOT IN', $values, "($placeholders)"]; + + return $this; + } + /** * Load one-to-one relationships @@ -2334,7 +2400,7 @@ protected function bindNestedValues(PDOStatement $stmt, self $nestedQuery, int & if (in_array($nestedCondition[2], ['BETWEEN', 'NOT BETWEEN'])) { $stmt->bindValue($index++, $nestedCondition[3], $this->getPdoParamType($nestedCondition[3])); $stmt->bindValue($index++, $nestedCondition[4], $this->getPdoParamType($nestedCondition[4])); - } elseif ($nestedCondition[2] === 'IN') { + } elseif (in_array($nestedCondition[2], ['IN', 'NOT IN'])) { foreach ($nestedCondition[3] as $value) { $stmt->bindValue($index++, $value, $this->getPdoParamType($value)); } diff --git a/src/Phaseolies/Database/Entity/Query/Debuggable.php b/src/Phaseolies/Database/Entity/Query/Debuggable.php index cd4d244c..4ddacb4f 100644 --- a/src/Phaseolies/Database/Entity/Query/Debuggable.php +++ b/src/Phaseolies/Database/Entity/Query/Debuggable.php @@ -108,7 +108,7 @@ protected function getBindings(): array continue; } - if ($condition[2] === 'IN') { + if (in_array($condition[2], ['IN', 'NOT IN'])) { $bindings = array_merge($bindings, $condition[3]); continue; } diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithConditionBinding.php b/src/Phaseolies/Database/Entity/Query/InteractsWithConditionBinding.php index ae1113d5..f8b76955 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithConditionBinding.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithConditionBinding.php @@ -86,11 +86,13 @@ protected function buildCondition(array $condition): array break; case 'IN': + case 'NOT IN': if (empty($value)) { - $sql = "1=0"; // Empty IN clause is always false + // Empty IN clause is always false, empty NOT IN always true + $sql = $operator === 'IN' ? "1=0" : "1=1"; } else { $placeholders = implode(',', array_fill(0, count($value), '?')); - $sql = "{$column} IN ({$placeholders})"; + $sql = "{$column} {$operator} ({$placeholders})"; $bindings = $value; } break; diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php index 9f42b1fe..b6d27909 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php @@ -20,13 +20,15 @@ private function handleNestedRelationshipExists(string $nestedRelation, ?callabl $relations = explode('.', $nestedRelation); $model = $this->getModel(); - $subquery = $this->buildNestedRelationshipExistsSubquery($model, $relations, $callback); + $bindings = []; + + $subquery = $this->buildNestedRelationshipExistsSubquery($model, $relations, $callback, $bindings); $subquery .= ' LIMIT 1'; $this->conditions[] = [ 'type' => $type, 'subquery' => $subquery, - 'bindings' => [], + 'bindings' => $bindings, 'boolean' => $boolean ]; @@ -40,12 +42,12 @@ private function handleNestedRelationshipExists(string $nestedRelation, ?callabl * @param Model $model * @param array $relations * @param callable|null $callback + * @param array $bindings * @return string */ - private function buildNestedRelationshipExistsSubquery(Model $model, array $relations, ?callable $callback): string + private function buildNestedRelationshipExistsSubquery(Model $model, array $relations, ?callable $callback, array &$bindings): string { $quote = fn($identifier) => $this->quoteIdentifier($identifier); - $escapeValue = fn($val) => $this->escapeValue($val); $subqueryParts = []; $currentModel = $model; @@ -130,24 +132,7 @@ private function buildNestedRelationshipExistsSubquery(Model $model, array $rela if ($callback && $previousTable) { $callback($subQueryBuilder); - foreach ($subQueryBuilder->conditions as $condition) { - if (isset($condition['type'])) { - continue; - } - - $column = $condition[1]; - $operator = $condition[2]; - $value = $condition[3]; - - // Qualify column with table name - if (strpos($column, '.') === false) { - $column = "{$quote($previousTable)}.{$quote($column)}"; - } else { - $column = $quote($column); - } - - $subquery .= $this->buildConditionClause($column, $operator, $value, $escapeValue); - } + $subquery .= $this->compileCallbackConditions($subQueryBuilder, $previousTable, $bindings); } if (($softDeleteConstraint = $subQueryBuilder->compileSoftDeleteConstraint($previousTable)) !== null) { diff --git a/tests/Model/Query/EntityModelQueryBehavior.php b/tests/Model/Query/EntityModelQueryBehavior.php index a2bf3fd9..ffa68ae8 100644 --- a/tests/Model/Query/EntityModelQueryBehavior.php +++ b/tests/Model/Query/EntityModelQueryBehavior.php @@ -658,6 +658,90 @@ public function testWhereIn(): void $this->assertCount(3, $users); } + public function testWhereNotIn(): void + { + $total = MockUser::query()->count(); + + $users = MockUser::whereNotIn('id', [1, 2])->get(); + + $this->assertCount($total - 2, $users); + $this->assertNotContains(1, array_column($users->toArray(), 'id')); + $this->assertNotContains(2, array_column($users->toArray(), 'id')); + + // Values that match nothing exclude nothing + $this->assertCount($total, MockUser::whereNotIn('id', [100, 101])->get()); + $this->assertSame($total, MockUser::whereNotIn('id', [100])->count()); + + // A list with gaps in its keys still binds every value + $this->assertCount($total - 2, MockUser::whereNotIn('id', [3 => 1, 7 => 2])->get()); + } + + public function testWhereNotInWithAnEmptyListExcludesNothing(): void + { + $total = MockUser::query()->count(); + + $this->assertCount($total, MockUser::whereNotIn('id', [])->get()); + $this->assertCount(1, MockUser::where('id', 1)->whereNotIn('id', [])->get()); + } + + public function testOrWhereNotIn(): void + { + $total = MockUser::query()->count(); + + // id = 1 OR id NOT IN (1, 2, 3) => everyone except users 2 and 3 + $users = MockUser::where('id', 1)->orWhereNotIn('id', [1, 2, 3])->get(); + + $this->assertCount($total - 2, $users); + $this->assertContains(1, array_column($users->toArray(), 'id')); + + // An empty list in an OR group matches every row + $this->assertCount($total, MockUser::where('id', 1)->orWhereNotIn('id', [])->get()); + } + + public function testWhereNotInCombinesWithOtherConditionsAndBindingsKeepTheirOrder(): void + { + $users = MockUser::where('id', '>', 0) + ->whereNotIn('id', [1]) + ->whereIn('id', [1, 2, 3]) + ->where('id', '<', 3) + ->get(); + + $this->assertSame([2], array_map('intval', array_column($users->toArray(), 'id'))); + } + + public function testWhereNotInInsideANestedGroupAndWithAggregatesAndBulkWrites(): void + { + $total = MockUser::query()->count(); + + $this->assertSame( + $total - 1, + MockUser::where(fn($q) => $q->whereNotIn('id', [1])->orWhereNotIn('id', [1]))->count() + ); + + $this->assertSame( + (int) MockUser::query()->sum('id') - 1, + (int) MockUser::whereNotIn('id', [1])->sum('id') + ); + + MockUser::whereNotIn('id', [1])->update(['name' => 'Not In Updated']); + + $this->assertSame($total - 1, MockUser::where('name', 'Not In Updated')->count()); + $this->assertNotSame('Not In Updated', MockUser::find(1)->name); + } + + public function testWhereNotInIsQualifiedWhenJoining(): void + { + // posts also has an id column, so an unqualified NOT IN would be ambiguous + $rows = MockUser::query() + ->select('users.id') + ->join('posts', 'users.id', '=', 'posts.user_id') + ->whereNotIn('id', [2]) + ->get(); + + $this->assertNotEmpty($rows->toArray()); + $this->assertNotContains(2, array_map('intval', array_column($rows->toArray(), 'id'))); + } + public function testWhereBetween() { $users = MockUser::query() diff --git a/tests/Model/RelationExists/RelationshipExistenceCallbackBehavior.php b/tests/Model/RelationExists/RelationshipExistenceCallbackBehavior.php new file mode 100644 index 00000000..2a92d979 --- /dev/null +++ b/tests/Model/RelationExists/RelationshipExistenceCallbackBehavior.php @@ -0,0 +1,199 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'articles' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'author_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'notes' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'string'], + ['name' => 'removed_at', 'type' => 'datetime', 'nullable' => true], + ], + 'labels' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'deleted_at', 'type' => 'datetime', 'nullable' => true], + ], + 'article_label' => [ + ['name' => 'article_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'label_id', 'type' => 'integer', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + $stamp = '2024-01-01 00:00:00'; + + return [ + 'authors' => [ + ['name' => 'Ann'], + ['name' => 'Ben'], + ['name' => 'Cid'], + ], + 'articles' => [ + ['author_id' => 1, 'title' => 'Alpha', 'views' => 10, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 1, 'title' => 'Beta', 'views' => 20, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Gamma', 'views' => 30, 'created_at' => $stamp, 'updated_at' => $stamp], + ['author_id' => 2, 'title' => 'Delta', 'views' => 40, 'created_at' => $stamp, 'updated_at' => $stamp], + ], + 'notes' => [ + ['article_id' => 1, 'body' => 'first'], + ['article_id' => 1, 'body' => 'second'], + ['article_id' => 3, 'body' => 'third'], + ], + 'labels' => [ + ['name' => 'php'], + ['name' => 'sql'], + ['name' => 'legacy'], + ], + 'article_label' => [ + ['article_id' => 1, 'label_id' => 1], + ['article_id' => 1, 'label_id' => 2], + ['article_id' => 2, 'label_id' => 3], + ['article_id' => 3, 'label_id' => 3], + ], + ]; + } + + private function ids(iterable $models): array + { + $ids = []; + foreach ($models as $model) { + $ids[] = (int) $model->id; + } + sort($ids); + + return $ids; + } + + // Notes: article 1 => first, second; article 3 => third + + public function testPlainConditionStillFilters(): void + { + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', 'first'))->get())); + } + + public function testOrWhereInsideCallbackIsHonoured(): void + { + $query = MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third')); + + $this->assertSame([1, 3], $this->ids($query->get())); + } + + public function testNestedGroupInsideCallbackIsHonoured(): void + { + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where(fn($g) => $g->where('body', 'first')))->get())); + $this->assertSame([3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where(fn($g) => $g->where('body', 'first')->where('id', 3)->orWhere('body', 'third')))->get())); + } + + public function testRawConditionInsideCallbackIsHonoured(): void + { + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereRaw('body = ?', ['first']))->get())); + } + + public function testNullChecksInsideCallbackAreHonoured(): void + { + $this->assertSame([], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereNull('body'))->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereNotNull('body'))->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', '!=', null))->get())); + } + + public function testOtherOperatorsStillWork(): void + { + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereIn('body', ['first']))->get())); + $this->assertSame([3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', '>', 'second'))->get())); + $this->assertSame([3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereNotIn('body', ['first', 'second']))->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereNotIn('body', []))->get())); + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', 'like', 'fir%'))->get())); + $this->assertSame([3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereBetween('id', [3, 3]))->get())); + $this->assertSame([], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->whereIn('body', []))->get())); + } + + public function testValuesAreBoundNotInlined(): void + { + $this->assertSame([], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', "x' OR '1'='1"))->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', "it's")->orWhere('body', 'third')->orWhere('body', 'first'))->get())); + } + + public function testBindingsKeepTheirOrderAroundTheCallback(): void + { + $query = MockSoftArticle::query() + ->where('title', 'Gamma') + ->present('notes', fn($q) => $q->where('body', 'third')) + ->where('views', 30); + + $this->assertSame([3], $this->ids($query->get())); + + $query = MockSoftArticle::query() + ->where('title', 'Alpha') + ->orPresent('notes', fn($q) => $q->where('body', 'third')) + ->where('views', '>', 0); + + $this->assertSame([1, 3], $this->ids($query->get())); + } + + public function testCountAndPaginationKeepTheBindings(): void + { + $query = MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third')); + + $this->assertSame(2, $query->count()); + } + + public function testManyToManyCallbackIsHonoured(): void + { + // Labels: article 1 => php, sql; article 2 => legacy; article 3 => legacy + $this->assertSame([1, 2, 3], $this->ids(MockSoftArticle::query()->present('labels', fn($q) => $q->where('name', 'php')->orWhere('name', 'legacy'))->get())); + $this->assertSame([2, 3], $this->ids(MockSoftArticle::query()->present('labels', fn($q) => $q->where(fn($g) => $g->where('name', 'legacy')))->get())); + $this->assertSame([], $this->ids(MockSoftArticle::query()->present('labels', fn($q) => $q->whereNull('name'))->get())); + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('labels', fn($q) => $q->whereRaw('name = ?', ['sql']))->get())); + } + + public function testNestedRelationCallbackIsHonoured(): void + { + // Ann owns articles 1 and 2, Ben owns 3 and 4 + $this->assertSame([1, 2], $this->ids(MockSoftAuthor::query()->present('articles.notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third'))->get())); + $this->assertSame([1], $this->ids(MockSoftAuthor::query()->present('articles.notes', fn($q) => $q->where(fn($g) => $g->where('body', 'first')))->get())); + $this->assertSame([2], $this->ids(MockSoftAuthor::query()->present('articles.notes', fn($q) => $q->whereRaw('body = ?', ['third']))->get())); + $this->assertSame([], $this->ids(MockSoftAuthor::query()->present('articles.notes', fn($q) => $q->whereNull('body'))->get())); + } + + public function testIfExistsUsesTheSameCallbackRules(): void + { + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->ifExists('notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third'))->get())); + } + + public function testSoftDeletedRelatedRowsStayExcludedAlongsideTheCallback(): void + { + \Tests\Support\Model\MockSoftNote::query()->where('body', 'third')->delete(); + + $this->assertSame([1], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->where('body', 'first')->orWhere('body', 'third'))->get())); + $this->assertSame([1, 3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->withTrashed()->where('body', 'first')->orWhere('body', 'third'))->get())); + $this->assertSame([3], $this->ids(MockSoftArticle::query()->present('notes', fn($q) => $q->onlyTrashed()->where('body', 'first')->orWhere('body', 'third'))->get())); + } +} diff --git a/tests/Model/RelationExists/RelationshipExistenceCallbackMysqlTest.php b/tests/Model/RelationExists/RelationshipExistenceCallbackMysqlTest.php new file mode 100644 index 00000000..df6224d6 --- /dev/null +++ b/tests/Model/RelationExists/RelationshipExistenceCallbackMysqlTest.php @@ -0,0 +1,17 @@ +