Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions src/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ?? '');
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand Down
13 changes: 12 additions & 1 deletion src/Query/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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})";
Expand All @@ -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']), '?'));

Expand Down
5 changes: 4 additions & 1 deletion src/Support/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/Integration/QueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 46 additions & 1 deletion tests/Unit/BuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -297,4 +342,4 @@ public function test_true_false_shorthands(): void
$this->builder()->false('active'),
);
}
}
}
32 changes: 32 additions & 0 deletions tests/Unit/CollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down
Loading