Skip to content

Commit 2a50587

Browse files
committed
isset(), empty(), and ?? treat stored nulls as missing, like plain arrays
- __isset() and offsetExists() use isset() instead of array_key_exists() - NULL columns: isset() false, empty() true, ?? returns its fallback (before, HTML mode echoed "" and the fallback never fired) - Direct access unchanged: $row->field still returns the stored null - get($key, $default) unchanged: still returns stored nulls, not the default - ?? fallbacks skip HTML encoding - use ->or() when they carry user data - Details and migration searches in CHANGELOG and UPGRADING
1 parent d3dd53d commit 2a50587

8 files changed

Lines changed: 69 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
- `column(null)` and `column(null, null)` now match PHP's `array_column()`: whole rows renumbered from 0, instead of throwing "unexpected arguments"
3232
- Array-syntax deprecation notices suggest one replacement style across reads, writes, `isset()`, and `unset()`: `->key` and `->key = $value` for property-safe names, `->{0}` for integer keys, `->{'users.id'}` for other keys. Reads used to suggest `->get(0)` while existence checks suggested `->{0}`, so one `empty()` call printed two notices with different advice, and writes suggested the now-deprecated `->set()`. Null and `''` keys are the exception and suggest `->get('')` / `->set('', $value)` - the brace form is a fatal error for an empty property name.
3333
- `isset($array['key'])` and `empty($array['key'])` now follow `$onOffsetAccess` like reads, writes, and `unset()` - notice by default, exception in `'throw'` mode. Existence checks were the one silent form of the deprecated `[]` syntax; if `[]` support is removed in a future version, `isset()` on the object would silently return false instead of erroring, so these call sites need migrating with the rest. Property-syntax checks (`isset($array->key)`) are unaffected and stay signal-free. Internal existence checks now call `array_key_exists()` directly, removing two method calls from every `get()`.
34+
- `isset()`, `empty()`, and `??` treat a stored null as missing, matching plain PHP arrays: on a NULL column, `isset($row->field)` is now false and `$row->field ?? 'none'` returns `'none'`. Previously they answered "does the column exist", so in HTML mode `??` fallbacks never fired on NULL columns (the wrapped null echoed as `""`). Bracket syntax (`isset($row['field'])`) matches. Direct access is unchanged: `$row->field` still returns the stored null, wrapped in HTML mode, with no warning. Ask `$row->keys()->contains('field')` when you need "does the key exist, even if NULL". Note `??` substitutes its fallback before the library runs, so the fallback skips HTML encoding - use `->or()` for display fallbacks that carry user data. See UPGRADING.md.
3435
- `or404()` outputs `<html>` instead of `<html lang>` - an empty `lang` reads as an invalid value to accessibility checkers, and the message language is caller-supplied so it can't be declared. Matches SmartString.
3536
- `orDie()` and `or404()` now exit with status 1 instead of 0, so shell scripts and cron jobs see the failure. Output is unchanged. Matches SmartString.
3637
- Developer-mistake exceptions (bad types, wrong context, misuse) now throw `CallerException`, which reports your file and line instead of the library's internals - the same class SmartString uses. It extends `InvalidArgumentException`, so existing catch blocks keep working, except six throws that previously used `RuntimeException`: `load()` misuse (no handler, non-callable handler, bad or empty field name, called on a record set), `orRedirect()` after headers sent, and writing to a `SmartNull`. See UPGRADING.md. `orThrow()` still throws `RuntimeException` by contract.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ Note: All methods return a new `SmartArray` object unless otherwise specified.
465465
| Value Access | $obj->key | Get a value using property syntax |
466466
| | $obj->{'users.id'} | Get keys property syntax can't type (dots, dashes, numeric keys) |
467467
| | $obj->key = $value | Set a value using property syntax |
468-
| | $obj->key ?? 'default' | Fallback for possibly-missing keys, same as plain PHP |
468+
| | $obj->key ?? 'default' | Fallback for missing keys and NULL values, same as plain PHP |
469469
| | first() | Get the first element |
470470
| | last() | Get the last element |
471471
| | at(index) | Get element by position, ignoring keys (0=first, -1=last) |

UPGRADING.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,42 @@ Full lists of what changed per release: [CHANGELOG.md](CHANGELOG.md).
120120
>
121121
> Regex: `->sortBy\([^)]*type:`
122122
123+
### `isset()`, `empty()`, and `??` treat stored NULL as missing
124+
125+
> These now match plain PHP arrays: a column whose value is NULL reads as
126+
> missing, so `isset()` answers false, `empty()` answers true, and `??`
127+
> returns its fallback. Previously they answered "does the column exist",
128+
> which meant `??` fallbacks never fired on NULL columns in HTML mode - the
129+
> wrapped null echoed as `""`:
130+
>
131+
> ```php
132+
> $row = SmartArrayHtml::new(['nickname' => null]);
133+
>
134+
> echo $row->nickname ?? 'none'; // before: "" - after: none
135+
> isset($row->nickname); // before: true - after: false
136+
> empty($row->nickname); // before: false - after: true
137+
> ```
138+
>
139+
> Bracket syntax (`isset($row['field'])`) changes the same way. Direct
140+
> access is unchanged: `$row->nickname` still returns the stored null
141+
> (wrapped in HTML mode) with no warning.
142+
>
143+
> Fix:
144+
>
145+
> - Check templates using `??` on nullable columns - they print the fallback
146+
> where they used to print nothing. That's usually the intent; if the
147+
> fallback carries user data, use `->or()` instead, which HTML-encodes.
148+
> A `??` fallback skips encoding because PHP substitutes it before the
149+
> library runs.
150+
> - When migrating deprecated `get($key, $default)` calls to
151+
> `$row->key ?? $default`: `get()` returns a stored NULL instead of the
152+
> default, the `??` form returns the default. Same results everywhere
153+
> except NULL columns.
154+
> - To ask "does the key exist, even if NULL", use
155+
> `$row->keys()->contains('field')`.
156+
>
157+
> Regex: `->\w+ \?\?` - also search `isset(` and `empty(` on row fields
158+
123159
### Silent changes
124160
125161
> - `print_r()` and `var_dump()` show just the array data, like dumping a

src/DeprecatedAliases.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,14 @@ public function offsetGet(mixed $offset): static|SmartNull|SmartString|string|in
417417

418418
/**
419419
* Check if a key exists, for isset($array['key']) and empty($array['key']).
420+
* Stored nulls read as missing, same as __isset() and plain PHP arrays.
420421
*
421422
* @deprecated Use isset($array->key) instead of isset($array['key'])
422423
*/
423424
public function offsetExists(mixed $offset): bool
424425
{
425426
$this->triggerArrayAccessDeprecation($offset, 'exists');
426-
return array_key_exists($offset, $this->data);
427+
return isset($this->data[$offset]);
427428
}
428429

429430
/**

src/SmartArrayBase.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,11 +1079,15 @@ public function __set(string $name, mixed $value): void
10791079
}
10801080

10811081
/**
1082-
* Magic method for isset($array->key) and empty($array->key)
1082+
* Magic method for isset($array->key), empty($array->key), and $array->key ?? $default.
1083+
*
1084+
* Stored nulls read as missing, matching plain PHP arrays, so ?? fallbacks fire on
1085+
* them. Direct access still returns the stored null; use ->keys()->contains('key')
1086+
* to ask whether the key itself exists.
10831087
*/
10841088
public function __isset(string $name): bool
10851089
{
1086-
return array_key_exists($name, $this->data);
1090+
return isset($this->data[$name]);
10871091
}
10881092

10891093
/**

src/help.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Value Access
5151
$obj->key Get a value using property syntax
5252
$obj->{'users.id'} Get keys property syntax can't type (dots, dashes, numeric)
5353
$obj->key = value Set a value using property syntax
54-
$obj->key ?? 'default' Fallback for possibly-missing keys, same as plain PHP
54+
$obj->key ?? 'default' Fallback for missing keys and NULL values, same as plain PHP
5555
->first() Get the first element
5656
->last() Get the last element
5757
->at(index) Get element by position, ignoring keys (0 is first, -1 is last)

tests/Integration/DocsExamplesTest.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,11 +662,12 @@ public function testHelpTxtExampleWorkflowFiltersOnRawValuesThenSorts(): void
662662

663663
public function testHelpTxtValueAccessReadsByKeyAndPosition(): void
664664
{
665-
$row = SmartArray::new(['a' => 1, 'b' => 2, 'c' => 3, 'users.id' => 5]);
665+
$row = SmartArray::new(['a' => 1, 'b' => 2, 'c' => 3, 'd' => null, 'users.id' => 5]);
666666

667667
$this->assertSame(1, $row->a, 'property syntax');
668668
$this->assertSame(5, $row->{'users.id'}, 'brace syntax for keys property syntax cannot type');
669-
$this->assertSame('fallback', $row->missing ?? 'fallback', '?? fallback for possibly-missing keys');
669+
$this->assertSame('fallback', $row->missing ?? 'fallback', '?? fallback for missing keys');
670+
$this->assertSame('fallback', $row->d ?? 'fallback', '?? fallback for stored NULL values');
670671
$this->assertSame(1, $row->first());
671672
$this->assertSame(5, $row->last());
672673
$this->assertSame(1, $row->at(0), 'at(0) is the first element');

tests/Unit/ReadAccessTest.php

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,27 +361,40 @@ public function testOffsetGetMissingKeyWarnsAfterTheDeprecationNotice(string $cl
361361
//region __isset / offsetExists
362362

363363
#[DataProvider('modeProvider')]
364-
public function testIssetChecksKeyExistenceNotNullness(string $class): void
364+
public function testIssetTreatsStoredNullAsMissing(string $class): void
365365
{
366366
$sa = $class::new(['name' => 'Bob', 'middle' => null]);
367367

368-
// Unlike plain PHP arrays, isset() on a stored null is true: it checks
369-
// key existence (so with SmartStrings on, ?? only fires for missing keys)
368+
// Like plain PHP arrays, isset() on a stored null is false, so ??
369+
// fallbacks fire on stored nulls and missing keys alike
370370
$this->assertTrue(isset($sa->name));
371-
$this->assertTrue(isset($sa->middle));
371+
$this->assertFalse(isset($sa->middle));
372372
$this->assertFalse(isset($sa->zzz));
373373

374374
// Array-syntax existence checks follow $onOffsetAccess like reads and
375375
// writes do (default 'notify'); only the property forms are signal-free
376376
[, $output] = $this->captureOutput(function () use ($sa) {
377-
$this->assertTrue($sa->offsetExists('middle'));
377+
$this->assertFalse($sa->offsetExists('middle'));
378378
$this->assertFalse($sa->offsetExists('zzz'));
379-
$this->assertTrue(isset($sa['middle']));
379+
$this->assertTrue(isset($sa['name']));
380380
});
381381
$this->assertSame(3, substr_count($output, 'Deprecated:'), 'one notice per array-syntax check');
382382
$this->assertStringContainsString("Replace ['middle'] with ->middle", $output);
383383
$this->assertStringContainsString("Replace ['zzz'] with ->zzz", $output);
384384
}
385385

386+
#[DataProvider('modeProvider')]
387+
public function testNullCoalescingFiresOnStoredNullAndMissingKeys(string $class): void
388+
{
389+
$sa = $class::new(['name' => 'Bob', 'middle' => null]);
390+
391+
// ?? short-circuits on __isset() before any value is fetched or wrapped,
392+
// so the fallback comes through as-is (a raw string) in both modes, and
393+
// missing keys produce no undefined-key warning
394+
$this->assertSame('Bob', (string)($sa->name ?? '(fallback)'));
395+
$this->assertSame('(fallback)', $sa->middle ?? '(fallback)');
396+
$this->assertSame('(fallback)', $sa->zzz ?? '(fallback)');
397+
}
398+
386399
//endregion
387400
}

0 commit comments

Comments
 (0)