Skip to content

Commit e9d499d

Browse files
committed
where, whereNot, whereInList, contains: stricter matching for strings, null, and bools
Most calls behave the same: numbers still match numeric strings, so where('id', 5) matches '5' and where('price', 1) matches '1.00'. Three edge cases now match fewer rows: - Two strings must match exactly: '01' no longer matches '1', and where('code', '0e123') no longer matches '0e999' (PHP's loose == read both as numbers - a wrong-row risk for hash lookups) - null only matches null, like SQL IS NULL (it used to match '', 0, and false) - true/false mean 1/0 (true used to match any truthy value, even 'abc') All four methods share one helper (valueMatches). Checked the full matrix against MariaDB: same answers as a SQL WHERE except strings stay case-sensitive and 'abc' never equals 0. New tests pin each rule, and a partition test checks where() + whereNot() always split a set exactly in two. Docs, changelog, and UPGRADING updated.
1 parent d358cc8 commit e9d499d

8 files changed

Lines changed: 239 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ the docs - IDEs show a strikethrough with the replacement.
9898

9999
### Behavior changes
100100

101+
- `where()`, `whereNot()`, `whereInList()`, and `contains()` match values
102+
more precisely. Most code sees no difference: numbers still match numeric
103+
strings, so `where('id', 5)` matches `'5'` and `where('price', 1)` matches
104+
`'1.00'`. Three edge cases changed:
105+
- Two strings must match exactly: `'01'` no longer matches `'1'`, and
106+
`where('code', '0e123')` no longer matches `'0e999'` (PHP's loose `==`
107+
read both as numbers - a wrong-row risk for hash and token lookups)
108+
- null only matches null, like SQL IS NULL (it used to match `''`, 0,
109+
and false too)
110+
- true/false mean 1/0 (true used to match any truthy value, even `'abc'`)
111+
112+
See [UPGRADING.md](UPGRADING.md).
101113
- A missing field stays a SmartNull through the whole chain instead of
102114
becoming an empty SmartString at the first method call. Same output as
103115
before (echoes `""`, `or()` still fires), but chains no longer dead-end:

UPGRADING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,30 @@ automatically unless your composer.json pins `itools/smartstring` lower.*
109109
>
110110
> Regex: `->\w+ \?\?` - also search `isset(` and `empty(` on row fields
111111
112+
### Matching rules for `where()`, `whereNot()`, `whereInList()`, and `contains()`
113+
114+
> Most calls behave the same: numbers still match numeric strings, so
115+
> `where('id', 5)` matches `'5'` and `where('price', 1)` matches `'1.00'`.
116+
> Three edge cases now match fewer rows:
117+
>
118+
> ```php
119+
> $rows->where('code', '0e123'); // before: also matched '0e999' (PHP read both strings as numbers)
120+
> // after: strings must match exactly ('01' vs '1' changed the same way)
121+
>
122+
> $rows->where('field', null); // before: matched null, '', 0, and false
123+
> // after: matches only null, like SQL IS NULL
124+
>
125+
> $rows->where('active', true); // before: matched anything truthy, even 'abc'
126+
> // after: true means 1, so it matches 1 and '1'
127+
> ```
128+
>
129+
> Fix:
130+
>
131+
> - For empty/non-empty checks, use `where($field)` / `whereNot($field)`
132+
> - When you mean a number, pass a number: `where('price', (float)$_GET['price'])`
133+
>
134+
> Regex: `->(where|whereNot|contains)\([^)]*(null|true|false)\s*\)`
135+
112136
### Silent changes
113137
114138
> - `print_r()` and `var_dump()` show just the array data, like dumping a

docs/ai-reference.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ All three return `SmartNull` silently when there is no such element
216216
count(): int // also works via count($collection) (Countable)
217217
isEmpty(): bool // no elements
218218
isNotEmpty(): bool // any elements
219-
contains(mixed $value): bool // any element == $value (loose; Smart args unwrap)
219+
contains(mixed $value): bool // any element matches $value (where() rules; Smart args unwrap)
220220
```
221221

222222
Field-level checks (`isMissing()`, `isEmpty()`, `or()`, ...) are SmartString
@@ -242,8 +242,8 @@ All return a new collection; nested-only methods throw
242242

243243
| Method | Behavior |
244244
|------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
245-
| `where(string $field, mixed $value = null): static` | Nested only. Keeps rows where `$field == $value` (loose; `'1'` matches 1; Smart args unwrap). Rows without the field are dropped. Chain calls for AND. Warns when `$field` is missing from the first row. Single-arg `where($field)` keeps rows where the field is non-empty (PHP `empty()` rule: NULL, false, 0, "0", "", missing are empty). NOTE: `where($f)` and `where($f, null)` differ - the latter is a loose == null match |
246-
| `whereNot(string $field, mixed $value = null): static` | Nested only. Drops rows where `$field == $value`; rows WITHOUT the field are kept. Single-arg `whereNot($field)` keeps rows where the field is empty or missing (exact complement of `where($field)`) |
245+
| `where(string $field, mixed $value = null): static` | Nested only. Keeps rows where `$field` matches `$value`: strings match as exact text (`'0e12'` never matches `'0e99'`), numbers match numerically in either direction (`'1'` matches 1, 1 matches `'1.00'`), null matches only null (SQL IS NULL), bools compare as 1/0 on either side. Smart args unwrap. Rows without the field are dropped. Chain calls for AND. Warns when `$field` is missing from the first row. Single-arg `where($field)` keeps rows where the field is non-empty (PHP `empty()` rule: NULL, false, 0, "0", "", missing are empty). NOTE: `where($f)` and `where($f, null)` differ - the latter matches only stored NULLs |
246+
| `whereNot(string $field, mixed $value = null): static` | Nested only. Drops rows where `$field` matches `$value` (same matching rules as `where()`); rows WITHOUT the field are kept. Single-arg `whereNot($field)` keeps rows where the field is empty or missing (exact complement of `where($field)`) |
247247
| `whereInList(string $field, mixed $value): static` | Nested only. Keeps rows where tab-separated `$field` contains `$value` as a whole value (`"\tmenu\tfooter\t"` format, CMS Builder checkbox/multi-select fields) or equals it as a plain single value. Never substring matching |
248248
| `filter(?callable $callback = null): static` | Both shapes. Callback receives raw `($value, $key)`, keeps on true. No callback: removes falsy (`""`, 0, null, false). Keys preserved like `array_filter()` - chain `values()` for a clean JSON array |
249249
| `sort(int $flags = SORT_REGULAR): static` | Flat only. Sorts ascending by value, renumbers keys. `$flags` choose comparison only; `SORT_ASC`/`SORT_DESC` throw `InvalidArgumentException` (sort descending in SQL) |

docs/filtering-and-sorting.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ $active = $users->where('status', 'Active'); // Jean and
2626
$admins = $users->where('status', 'Active')->where('role', 'admin'); // just Jean
2727
```
2828

29-
Databases and forms often hand numbers back as strings, so `where()`
30-
compares loosely: `'1'` matches `1`. When you need a strict match,
31-
`filter()` (below) takes a callback where you can compare with `===`.
29+
Databases and forms often hand numbers back as strings, so numbers and
30+
numeric strings match: `'1'` matches `1`, and `where('price', 1)` matches a
31+
DECIMAL column's `'1.00'`. Everything else is what you'd expect: two strings
32+
must match exactly (case-sensitive), null only matches null, and true/false
33+
mean 1/0. When you need full type-sensitive matching, `filter()` (below)
34+
takes a callback where you can compare with `===`.
3235

3336
With just a field name, `where()` keeps the rows where that field has a
3437
truthy value, following PHP's `empty()` rules (NULL, false, 0, `"0"`, and
@@ -127,7 +130,8 @@ echo $tags->sort()->implode(', '); // Apache, MySQL, PHP
127130

128131
Use `unique()` to drop repeated values from a flat list, keeping the first
129132
of each, and `contains()` to ask whether a value is in the list at all.
130-
Both compare loosely, so `1` and `'1'` count as the same value:
133+
Both treat `1` and `'1'` as the same value (`contains()` follows the same
134+
matching rules as `where()`):
131135

132136
```php
133137
$tags = SmartArrayHtml::new(['PHP', 'MySQL', 'PHP', 'Apache']);

docs/method-reference.md

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ and objects are always truthy in PHP, so test for missing values with
5454

5555
*These return plain values, typically used in if statements.*
5656

57-
| Method | Description |
58-
|----------------------|--------------------------------------------------------------------------------|
59-
| `->count()` | Returns the number of elements |
60-
| `->isEmpty()` | Returns true when there are no elements |
61-
| `->isNotEmpty()` | Returns true when there are any elements |
62-
| `->contains($value)` | Returns true when any element loosely equals `$value` (==, so `"5"` matches 5) |
57+
| Method | Description |
58+
|----------------------|----------------------------------------------------------------------------------------------|
59+
| `->count()` | Returns the number of elements |
60+
| `->isEmpty()` | Returns true when there are no elements |
61+
| `->isNotEmpty()` | Returns true when there are any elements |
62+
| `->contains($value)` | Returns true when any element matches `$value` (same rules as `where()`, so `"5"` matches 5) |
6363

6464
### [Row Position](outputting-html.md#loop-layout-isfirst-islast-position)
6565

@@ -76,15 +76,15 @@ wrappers, and loop layout.*
7676

7777
*These return a new collection and leave the original unchanged.*
7878

79-
| Method | Description |
80-
|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
81-
| `->where($field, $value)` | Keeps rows where `$field` loosely equals `$value` (==, so `'1'` matches 1); chain calls to match several fields. With `$field` alone, keeps rows where it's non-empty (PHP `empty()` rule) |
82-
| `->whereNot($field, $value)` | Drops rows where `$field` loosely equals `$value`; rows without the field are kept. With `$field` alone, keeps rows where it's empty or missing |
83-
| `->whereInList($field, $value)` | Keeps rows whose tab-separated `$field` contains `$value` (CMS Builder checkbox and multi-select format); matches whole values, never substrings |
84-
| `->filter($callback)` | Keeps elements where `$callback` returns true (closures receive plain PHP values as `($value, $key)`); with no callback, removes falsy values (PHP falsy rule: `""`, `"0"`, 0, NULL, false); keys are kept |
85-
| `->sort($flags)` | Sorts a flat list ascending by value, renumbering keys; `$flags` choose how values compare (default `SORT_REGULAR`); `SORT_ASC`/`SORT_DESC` throw, sort descending in SQL |
86-
| `->sortBy($field, $flags)` | Sorts rows ascending by `$field`; pass `SORT_NATURAL` to sort numbers the way people read them |
87-
| `->unique()` | Removes duplicate values from a flat list, keeping the first of each and preserving keys (compares as text, so 1 and `'1'` match); chain `->values()` to renumber |
79+
| Method | Description |
80+
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
81+
| `->where($field, $value)` | Keeps rows where `$field` matches `$value` (`'1'` matches 1, but two strings must match exactly, and null only matches null); chain calls to match several fields. With `$field` alone, keeps rows where it's non-empty (PHP `empty()` rule) |
82+
| `->whereNot($field, $value)` | Drops rows where `$field` matches `$value` (same rules as `where()`); rows without the field are kept. With `$field` alone, keeps rows where it's empty or missing |
83+
| `->whereInList($field, $value)` | Keeps rows whose tab-separated `$field` contains `$value` (CMS Builder checkbox and multi-select format); matches whole values, never substrings |
84+
| `->filter($callback)` | Keeps elements where `$callback` returns true (closures receive plain PHP values as `($value, $key)`); with no callback, removes falsy values (PHP falsy rule: `""`, `"0"`, 0, NULL, false); keys are kept |
85+
| `->sort($flags)` | Sorts a flat list ascending by value, renumbering keys; `$flags` choose how values compare (default `SORT_REGULAR`); `SORT_ASC`/`SORT_DESC` throw, sort descending in SQL |
86+
| `->sortBy($field, $flags)` | Sorts rows ascending by `$field`; pass `SORT_NATURAL` to sort numbers the way people read them |
87+
| `->unique()` | Removes duplicate values from a flat list, keeping the first of each and preserving keys (compares as text, so 1 and `'1'` match); chain `->values()` to renumber |
8888

8989
### [Transforming and Grouping](transforming-and-grouping.md)
9090

src/SmartArrayBase.php

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,23 @@ public static function getRawValue(mixed $value): mixed
498498
};
499499
}
500500

501+
/**
502+
* How where(), whereNot(), and contains() decide two values match:
503+
* numbers match numeric strings (5 matches '5'), two strings must match
504+
* exactly, null only matches null, and true/false mean 1/0.
505+
* Callers unwrap Smart values with getRawValue() first.
506+
*/
507+
private static function valueMatches(mixed $rowValue, mixed $value): bool
508+
{
509+
$value = is_bool($value) ? (int)$value : $value;
510+
$rowValue = is_bool($rowValue) ? (int)$rowValue : $rowValue;
511+
return match (true) {
512+
$value === null || $rowValue === null => $value === $rowValue,
513+
is_string($value) && is_string($rowValue) => $value === $rowValue,
514+
default => $value == $rowValue, // PHP 8 numeric comparison, e.g. 1 == '1.00'
515+
};
516+
}
517+
501518
//endregion
502519
//region Array Information
503520

@@ -526,15 +543,22 @@ public function isNotEmpty(): bool
526543
}
527544

528545
/**
529-
* Check if array contains a specific value (loose == comparison).
546+
* Check if array contains a specific value.
530547
*
531-
* Loose comparison means types don't need to match: contains('1') matches
532-
* 1 and true, and contains(null) matches '' and false. For strict matching
533-
* use in_array($value, $arr->toArray(), true).
548+
* Values match the same way where() does: contains(5) matches '5', but two
549+
* strings must match exactly. For strict type checks use
550+
* in_array($value, $arr->toArray(), true).
534551
*/
535552
public function contains(mixed $value): bool
536553
{
537-
return in_array(self::getRawValue($value), $this->toArray());
554+
$value = self::getRawValue($value);
555+
foreach ($this->toArray() as $element) {
556+
if (self::valueMatches($element, $value)) {
557+
return true;
558+
}
559+
}
560+
561+
return false;
538562
}
539563

540564
//endregion
@@ -643,7 +667,12 @@ public function filter(?callable $callback = null): static
643667
* Returns a new SmartArray containing only elements where a field matches a value.
644668
* Only works on nested arrays (throws on flat).
645669
*
646-
* Uses loose comparison (==) to allow matching between different types (e.g., '1' == 1).
670+
* How values match:
671+
* - Numbers match numeric strings: where('id', 5) matches '5', where('price', 1) matches '1.00'
672+
* - Two strings must match exactly: where('zip', '01000') won't match '1000'
673+
* - null only matches null, like SQL IS NULL (use where('field') for non-empty checks)
674+
* - true/false mean 1/0, like MySQL checkbox columns
675+
*
647676
* Chain multiple where() calls to filter by multiple fields.
648677
*
649678
* With just a field name, keeps rows where that field is non-empty
@@ -684,7 +713,7 @@ public function where(array|string $field, mixed $value = null): static
684713
// repeated 4x, see the first where() loop for why
685714
$matches = [];
686715
foreach ($this->toArray() as $key => $row) {
687-
if (is_array($row) && array_key_exists($field, $row) && $row[$field] == $value) { // intentional loose comparison
716+
if (is_array($row) && array_key_exists($field, $row) && self::valueMatches($row[$field], $value)) {
688717
$matches[$key] = $row;
689718
}
690719
}
@@ -715,7 +744,7 @@ public function where(array|string $field, mixed $value = null): static
715744
* Returns a new SmartArray excluding elements where a field matches a value.
716745
* The inverse of where(). Only works on nested arrays (throws on flat).
717746
*
718-
* Uses loose comparison (==) to match where() behavior.
747+
* Matches values the same way where() does.
719748
*
720749
* With just a field name, keeps rows where that field is empty
721750
* (PHP empty() rule: NULL, false, 0, "0", "", and missing fields are empty).
@@ -750,7 +779,7 @@ public function whereNot(string $field, mixed $value = null): static
750779
// repeated 4x, see the first where() loop for why
751780
$matches = [];
752781
foreach ($this->toArray() as $key => $row) {
753-
if (is_array($row) && (!array_key_exists($field, $row) || $row[$field] != $value)) { // intentional loose comparison
782+
if (is_array($row) && (!array_key_exists($field, $row) || !self::valueMatches($row[$field], $value))) {
754783
$matches[$key] = $row;
755784
}
756785
}
@@ -764,6 +793,7 @@ public function whereNot(string $field, mixed $value = null): static
764793
* multi-select fields). Does not perform substring matching.
765794
*
766795
* Handles both delimited format ("\tmenu\tfooter\t") and plain single values ("menu").
796+
* Plain single values match the same way where() does.
767797
*
768798
* $menuPages = $pages->whereInList('show_on', 'menu');
769799
* $footerPages = $pages->whereInList('show_on', 'footer');
@@ -788,7 +818,11 @@ public function whereInList(string $field, mixed $value): static
788818
if (!isset($row[$field])) {
789819
continue;
790820
}
791-
if ($row[$field] == $value || (is_string($row[$field]) && str_contains($row[$field], "\t$value\t"))) { // intentional loose comparison
821+
$fieldValue = $row[$field];
822+
$isMatch = is_string($fieldValue)
823+
? $fieldValue === $value || str_contains($fieldValue, "\t$value\t") // exact text, like where()
824+
: $fieldValue == $value; // non-string fields match numerically, e.g. int 2 matches '2'
825+
if ($isMatch) {
792826
$matches[$key] = $row;
793827
}
794828
}

0 commit comments

Comments
 (0)