Skip to content

Commit 2cf806c

Browse files
committed
fix: reject null values in where() to prevent silent SQL logic errors
where('col', null) generated "column = NULL" which is always false in SQL. Now throws QueryException pointing to whereNull()/whereNotNull().
1 parent 1b4f9bc commit 2cf806c

2 files changed

Lines changed: 130 additions & 1 deletion

File tree

src/Query/QueryBuilder.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,46 @@ public function where(string|array $column, mixed $operatorOrValue = null, mixed
131131
// Array syntax: where(['active' => 1, 'role' => 'admin'])
132132
if (is_array($column)) {
133133
foreach ($column as $col => $val) {
134+
if ($val === null) {
135+
throw new QueryException(
136+
message: 'Query failed',
137+
debugMessage: sprintf(
138+
'Cannot use null value for column "%s" in where(). Use whereNull(\'%s\') or whereNotNull(\'%s\') instead.',
139+
$col,
140+
$col,
141+
$col
142+
)
143+
);
144+
}
134145
$this->where($col, '=', $val);
135146
}
136147
return $this;
137148
}
138149

139150
// Must have at least 2 arguments for string column
151+
// Also catches where('col', null) — 2-argument form with null value
140152
if ($operatorOrValue === null) {
141153
throw new QueryException(
142154
message: 'Query failed',
143-
debugMessage: 'where() requires at least 2 arguments: where(column, value) or where(column, operator, value)'
155+
debugMessage: sprintf(
156+
'Cannot use null value in where(). SQL "column = NULL" is always false. Use whereNull(\'%s\') or whereNotNull(\'%s\') instead.',
157+
$column,
158+
$column
159+
)
160+
);
161+
}
162+
163+
// 3-argument form: where('col', '=', null) — reject null values
164+
// Detect by checking if $operatorOrValue looks like an operator
165+
if ($value === null && in_array(strtoupper(trim((string) $operatorOrValue)), self::ALLOWED_OPERATORS, true)) {
166+
throw new QueryException(
167+
message: 'Query failed',
168+
debugMessage: sprintf(
169+
'Cannot use null value in where(). SQL "column %s NULL" is always false. Use whereNull(\'%s\') or whereNotNull(\'%s\') instead.',
170+
strtoupper(trim((string) $operatorOrValue)),
171+
$column,
172+
$column
173+
)
144174
);
145175
}
146176

tests/Unit/EdgeCaseTest.php

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,4 +484,103 @@ public function testDeleteWithoutLimitOrOrderByStillWorks(): void
484484

485485
$this->assertSame(1, $affected);
486486
}
487+
488+
// =========================================================================
489+
// WHERE NULL BUG FIX TEST
490+
// Bug: where('column', null) generated "column = NULL" which is always false
491+
// in SQL. Users must use whereNull()/whereNotNull() instead.
492+
// =========================================================================
493+
494+
public function testWhereTwoArgNullThrowsException(): void
495+
{
496+
$db = Database::sqlite(':memory:');
497+
498+
$this->expectException(QueryException::class);
499+
500+
$db->table('users')->where('status', null);
501+
}
502+
503+
public function testWhereThreeArgNullThrowsException(): void
504+
{
505+
$db = Database::sqlite(':memory:');
506+
507+
$this->expectException(QueryException::class);
508+
509+
$db->table('users')->where('status', '=', null);
510+
}
511+
512+
public function testWhereArraySyntaxNullThrowsException(): void
513+
{
514+
$db = Database::sqlite(':memory:');
515+
516+
$this->expectException(QueryException::class);
517+
518+
$db->table('users')->where(['status' => null]);
519+
}
520+
521+
public function testWhereNullExceptionSuggestsWhereNull(): void
522+
{
523+
$db = Database::sqlite(':memory:');
524+
525+
try {
526+
$db->table('users')->where('status', null);
527+
$this->fail('Expected QueryException was not thrown');
528+
} catch (QueryException $e) {
529+
$debug = $e->getDebugMessage() ?? '';
530+
$this->assertStringContainsString('whereNull', $debug);
531+
$this->assertStringContainsString('status', $debug);
532+
}
533+
}
534+
535+
public function testWhereThreeArgNullExceptionSuggestsWhereNull(): void
536+
{
537+
$db = Database::sqlite(':memory:');
538+
539+
try {
540+
$db->table('users')->where('deleted_at', '=', null);
541+
$this->fail('Expected QueryException was not thrown');
542+
} catch (QueryException $e) {
543+
$debug = $e->getDebugMessage() ?? '';
544+
$this->assertStringContainsString('whereNull', $debug);
545+
$this->assertStringContainsString('deleted_at', $debug);
546+
}
547+
}
548+
549+
public function testWhereIsNullThrowsExceptionSuggestsWhereNull(): void
550+
{
551+
$db = Database::sqlite(':memory:');
552+
553+
try {
554+
$db->table('users')->where('deleted_at', 'IS', null);
555+
$this->fail('Expected QueryException was not thrown');
556+
} catch (QueryException $e) {
557+
$debug = $e->getDebugMessage() ?? '';
558+
$this->assertStringContainsString('whereNull', $debug);
559+
}
560+
}
561+
562+
public function testWhereIsNotNullThrowsExceptionSuggestsWhereNotNull(): void
563+
{
564+
$db = Database::sqlite(':memory:');
565+
566+
try {
567+
$db->table('users')->where('deleted_at', 'IS NOT', null);
568+
$this->fail('Expected QueryException was not thrown');
569+
} catch (QueryException $e) {
570+
$debug = $e->getDebugMessage() ?? '';
571+
$this->assertStringContainsString('whereNotNull', $debug);
572+
}
573+
}
574+
575+
public function testWhereWithNonNullValuesStillWorks(): void
576+
{
577+
$db = Database::sqlite(':memory:');
578+
$db->execute('CREATE TABLE users (id INTEGER PRIMARY KEY, status TEXT)');
579+
$db->insert('users', ['status' => 'active']);
580+
581+
$result = $db->table('users')->where('status', 'active')->first();
582+
583+
$this->assertNotNull($result);
584+
$this->assertSame('active', $result['status']);
585+
}
487586
}

0 commit comments

Comments
 (0)