diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 3e45bd8..3511204 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -201,7 +201,11 @@ public function where( string $boolean = 'AND', ): static { // Nested group: ->where(function($q) { $q->where(...)->orWhere(...); }) - if (is_callable($column)) { + // Only Closures count as nested groups. A plain string column name must + // NEVER be treated as callable — column names like 'key', 'list', or + // 'count' collide with built-in PHP function names and is_callable() + // would wrongly return true for them. + if ($column instanceof \Closure) { return $this->whereNested($column, $boolean); } @@ -535,7 +539,7 @@ public function whereNested(callable $callback, string $boolean = 'AND'): static */ public function whereExists(callable|Builder $subquery, string $boolean = 'AND', bool $not = false): static { - if (is_callable($subquery)) { + if ($subquery instanceof \Closure) { $sub = $this->newQuery(); $subquery($sub); } else { @@ -939,7 +943,10 @@ protected function addJoin( ): static { $join = new JoinClause($type, $table); - if (is_callable($firstOrCallback)) { + // Only a Closure is the advanced callback form. A plain string is the + // first join column — is_callable() would wrongly match column names + // like 'key' that collide with built-in PHP function names. + if ($firstOrCallback instanceof \Closure) { $firstOrCallback($join); } else { $join->on($firstOrCallback, $operator ?? '=', $second ?? ''); @@ -1190,17 +1197,17 @@ public function get(): Collection } /** - * Execute the query and return the first row, or false if none. + * Execute the query and return the first row, or null if none. * When a hydrator is set, the row is transformed into a model instance. * - * @return object|false + * @return object|null */ - public function first(): object|false + public function first(): ?object { $row = $this->limit(1)->connection->selectOne($this->toSql(), $this->getBindings()); - if ($row === false) { - return false; + if ($row === false || $row === null) { + return null; } if ($this->hydrator !== null) { @@ -1216,7 +1223,7 @@ public function first(): object|false * @param int|string $id * @return object|false */ - public function find(int|string $id): object|false + public function find(int|string $id): ?object { return $this->where($this->primaryKey, '=', $id)->first(); } diff --git a/src/Query/Grammars/Grammar.php b/src/Query/Grammars/Grammar.php index 0a9fa26..934a9a6 100644 --- a/src/Query/Grammars/Grammar.php +++ b/src/Query/Grammars/Grammar.php @@ -328,7 +328,13 @@ protected function compileWhereRaw(array $where): string */ protected function compileWhereIn(array $where): string { - $col = $this->wrapColumn($where['column']); + // An empty IN list is invalid SQL in MySQL/PostgreSQL (`IN ()`). + // Semantically `x IN (nothing)` is always false, so emit `1 = 0`. + if (count($where['values']) === 0) { + return '1 = 0'; + } + + $col = $this->wrapColumn($where['column']); $placeholders = implode(', ', array_fill(0, count($where['values']), '?')); return "{$col} IN ({$placeholders})"; @@ -340,6 +346,11 @@ protected function compileWhereIn(array $where): string */ protected function compileWhereNotIn(array $where): string { + // An empty NOT IN list means "exclude nothing" → always true → `1 = 1`. + if (count($where['values']) === 0) { + return '1 = 1'; + } + $col = $this->wrapColumn($where['column']); $placeholders = implode(', ', array_fill(0, count($where['values']), '?')); diff --git a/src/Support/Collection.php b/src/Support/Collection.php index 786a02d..d4ef566 100644 --- a/src/Support/Collection.php +++ b/src/Support/Collection.php @@ -347,7 +347,10 @@ public function isNotEmpty(): bool */ public function contains(callable|string $callbackOrColumn, mixed $value = null): bool { - if (is_callable($callbackOrColumn)) { + // Only a Closure is the callback form. A plain string is always a column + // name — is_callable() would wrongly match column names like 'key' or + // 'count' that collide with built-in PHP function names. + if ($callbackOrColumn instanceof \Closure) { foreach ($this->items as $item) { if ($callbackOrColumn($item)) { return true; diff --git a/tests/Integration/QueryTest.php b/tests/Integration/QueryTest.php index ef3a651..e049b35 100644 --- a/tests/Integration/QueryTest.php +++ b/tests/Integration/QueryTest.php @@ -164,7 +164,7 @@ public function test_first_returns_single_row(): void public function test_first_returns_null_when_empty(): void { - $this->assertFalse($this->table()->first()); + $this->assertNull($this->table()->first()); } public function test_value_returns_single_column(): void diff --git a/tests/Unit/BuilderTest.php b/tests/Unit/BuilderTest.php index ead4941..b58df44 100644 --- a/tests/Unit/BuilderTest.php +++ b/tests/Unit/BuilderTest.php @@ -119,6 +119,24 @@ public function test_where_not_in(): void $this->assertSql('SELECT * FROM `users` WHERE `status` NOT IN (?, ?)', $b); } + /** + * Regression: an empty IN list produces invalid SQL (`IN ()`) on + * MySQL/PostgreSQL. `x IN (nothing)` is always false, so it must + * compile to `1 = 0` (and NOT IN to `1 = 1`). + */ + public function test_where_in_empty_array(): void + { + $b = $this->builder()->whereIn('id', []); + $this->assertSql('SELECT * FROM `users` WHERE 1 = 0', $b); + $this->assertSame([], $b->getBindings()); + } + + public function test_where_not_in_empty_array(): void + { + $b = $this->builder()->whereNotIn('id', []); + $this->assertSql('SELECT * FROM `users` WHERE 1 = 1', $b); + } + public function test_where_null(): void { $this->assertSql( @@ -160,6 +178,33 @@ public function test_where_nested_group(): void ); } + /** + * Regression: column names that collide with built-in PHP function names + * (key, list, count, current, ...) were wrongly detected as callables by + * is_callable(), routing them into whereNested() and triggering + * "Calling key() on an object is deprecated". Only Closures are nested + * groups now — a plain string is always a column name. + */ + public function test_where_with_php_function_named_column(): void + { + $b = $this->builder()->where('key', 'footer_settings'); + $this->assertSql('SELECT * FROM `users` WHERE `key` = ?', $b); + $this->assertSame(['footer_settings'], $b->getBindings()); + } + + public function test_where_with_count_column(): void + { + $b = $this->builder()->where('count', '>', 5); + $this->assertSql('SELECT * FROM `users` WHERE `count` > ?', $b); + } + + public function test_nested_group_still_works_with_closure(): void + { + // A real Closure must still produce a nested group + $b = $this->builder()->where(fn($q) => $q->where('a', 1)->orWhere('b', 2)); + $this->assertSql('SELECT * FROM `users` WHERE (`a` = ? OR `b` = ?)', $b); + } + public function test_where_raw(): void { $b = $this->builder()->whereRaw('age > 18 AND active = 1'); @@ -297,4 +342,4 @@ public function test_true_false_shorthands(): void $this->builder()->false('active'), ); } -} +} \ No newline at end of file diff --git a/tests/Unit/CollectionTest.php b/tests/Unit/CollectionTest.php index 448c3b6..18bf6f9 100644 --- a/tests/Unit/CollectionTest.php +++ b/tests/Unit/CollectionTest.php @@ -160,6 +160,38 @@ public function test_reduce(): void $this->assertSame(270.0, $total); } + // ----------------------------------------------------------------------- + // contains + // ----------------------------------------------------------------------- + + public function test_contains_with_closure(): void + { + $this->assertTrue($this->makeItems()->contains(fn($u) => $u->role === 'admin')); + $this->assertFalse($this->makeItems()->contains(fn($u) => $u->role === 'ghost')); + } + + public function test_contains_with_column_and_value(): void + { + $this->assertTrue($this->makeItems()->contains('name', 'Alice')); + $this->assertFalse($this->makeItems()->contains('name', 'Nobody')); + } + + /** + * Regression: contains('key', ...) must compare against the 'key' column, + * not treat 'key' as a callable. is_callable('key') returns true because + * key() is a built-in PHP function. + */ + public function test_contains_with_php_function_named_column(): void + { + $items = Collection::make([ + ['id' => 1, 'key' => 'footer'], + ['id' => 2, 'key' => 'header'], + ]); + + $this->assertTrue($items->contains('key', 'footer')); + $this->assertFalse($items->contains('key', 'sidebar')); + } + // ----------------------------------------------------------------------- // Pluck / keyBy / groupBy // -----------------------------------------------------------------------