Skip to content

Commit e065500

Browse files
committed
SmartNull: stay missing through the whole chain
A missing field used to become an empty SmartString at the first method call, committing the chain to a type based on a guess. Now transforms hand the same SmartNull back, so "still missing" travels the whole chain and the caller picks the ending: ->or('n/a') for a value, ->implode() for a collection. Output is unchanged - echoes "", or() still fires - but chains no longer dead-end when string and array methods mix. map() skips its callback on a missing key, since there's no value to pass it; a NULL value in an existing key still runs the callback. set() takes one argument as SmartString's value-producing set and keeps the write guard for two.
1 parent b0bad07 commit e065500

5 files changed

Lines changed: 184 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ the docs - IDEs show a strikethrough with the replacement.
8787

8888
### Behavior changes
8989

90+
- A missing field stays a SmartNull through the whole chain instead of
91+
becoming an empty SmartString at the first method call. Same output as
92+
before (echoes `""`, `or()` still fires), but chains no longer dead-end:
93+
`$row->missing->trim()->implode(', ')` works where it previously threw.
94+
`map()` skips its callback on a missing key; NULL values in existing
95+
keys still run it.
9096
- `isset()`, `empty()`, and `??` treat a stored null as missing, matching
9197
plain PHP arrays: on a NULL column `isset($row->field)` is now false and
9298
`$row->field ?? 'none'` returns `'none'`. Previously they answered "does
@@ -107,8 +113,10 @@ the docs - IDEs show a strikethrough with the replacement.
107113
See [UPGRADING.md](UPGRADING.md).
108114
- All writes to a `SmartNull` throw "Cannot set values on SmartNull": property
109115
writes (previously created a silent dynamic property that shadowed
110-
chaining) and `->set()` (previously discarded the value) now match the
111-
existing `['key'] =` guard.
116+
chaining) and two-argument `->set($key, $value)` (previously discarded the
117+
value) now match the existing `['key'] =` guard. One-argument
118+
`->set($value)` is SmartString's set: not a write, it produces that value
119+
and ends the chain, like `or()`.
112120
- Raw-mode arrays no longer answer SmartString methods on missing keys:
113121
`$row->missing->or('n/a')` on a raw array throws the standard
114122
undefined-method Error instead of returning an HTML-encoding SmartString.

‎docs/ai-reference.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,11 @@ depends on WHERE you read (changed in 3.0):
151151
SmartNull behavior: `echo` → `""`; `value()` → null; `count()` → 0;
152152
`foreach` iterates zero times; `toArray()` → `[]`; `json_encode()` → null;
153153
SmartArray methods return empty results; SmartString methods (HTML mode)
154-
behave as on null; guards (`or404()` etc.) FIRE (empty = missing); any
155-
write to it throws `RuntimeException` ("Cannot set values on SmartNull").
154+
behave as on null, except transforms return the same SmartNull (chain stays
155+
missing, accepts value or collection endings) and `map()` skips its callback
156+
(a NULL value in an existing key still runs it); guards (`or404()` etc.)
157+
FIRE (empty = missing); one-argument `set($value)` produces that value; all
158+
other writes throw `RuntimeException` ("Cannot set values on SmartNull").
156159
It carries the source's mysqli metadata and load handler.
157160

158161
## Iteration and Keys

‎docs/internal/design-decisions.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ in signatures, docblocks, the changelog, and tests.
1818
the value side, `foreach`/`count()`/`keys()` on the array side. An empty
1919
SmartArray from `first()` would fatal on `->value()`.
2020

21+
- **SmartNull propagates through SmartString transforms (2026-08-04).** In
22+
HTML mode, `__call` tries public SmartString methods first and classifies
23+
by result: a still-null result means nothing was produced, so the
24+
SmartNull itself returns and the chain stays open for a value or a
25+
collection ending. `map()` returns the SmartNull without running its
26+
callback, since a missing key has no value to pass (a NULL value in an
27+
existing key still runs it). Rejected alternatives: renaming `map()` back
28+
to `apply()` (dodges one collision but keeps the name-based routing that
29+
broke `map`/`htmlEncode`/`set`, and the next shared name re-breaks it);
30+
routing shared names to SmartString by class ("string wins" fixes the
31+
three methods but breaks `first()->map()` collection chains the same
32+
way); running map's callback with null, the v2 `apply()` behavior (typed
33+
callbacks throw TypeError, side effects fire for rows that don't exist,
34+
and a value-returning callback turns a collection-shaped SmartNull into
35+
a SmartString mid-chain). The classifier is `isNull()`, not
36+
`isMissing()`: `isMissing()` counts `""`, which would discard a produced
37+
empty string and wrongly re-fire `ifNull()` later in the chain. The
38+
`isPublic()` reflection check exists because `method_exists()` reports
39+
private methods - that false positive is how `htmlEncode()` broke.
40+
2141
- **Missing-key warnings are rows-only (2026-08-04).** Key access warns
2242
only on rows inside a result set, where keys are column names and a miss
2343
is almost always a typo; top-level, derived (indexBy()/column() maps),

‎src/SmartNull.php‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace Itools\SmartArray;
55

66
use Iterator, ArrayAccess, Countable;
7+
use ReflectionMethod;
78
use RuntimeException;
89
use Itools\SmartString\SmartString;
910
use JetBrains\PhpStorm\Deprecated;
@@ -220,15 +221,23 @@ public function __get(string $name): SmartNull
220221
/**
221222
* All writes throw: a SmartNull marks a missing key or empty result, so
222223
* there is nothing real to write to and the value would be silently lost.
223-
* Same guard for property, set(), and array syntax.
224+
* Same guard for property syntax, array syntax, and two-argument set().
224225
*/
225226
public function __set(string $name, mixed $value): void
226227
{
227228
$this->throwCannotSet();
228229
}
229230

230-
public function set(int|string $key, mixed $value): never
231+
/**
232+
* One argument is SmartString's set($value): produce that value and end the
233+
* chain, like or(). Two arguments is SmartArray's set($key, $value), a
234+
* write, and all writes throw (see __set above).
235+
*/
236+
public function set(mixed ...$args): mixed
231237
{
238+
if (count($args) === 1 && $this->useSmartStrings) {
239+
return $this->__call('set', $args);
240+
}
232241
$this->throwCannotSet();
233242
}
234243

@@ -240,24 +249,42 @@ private function throwCannotSet(): never
240249
/**
241250
* Emulate response methods for SmartArray and SmartString.
242251
*
243-
* Since when we access a non-existent element we don't know if we were expecting a SmartArray or SmartString,
244-
* we return this object that can handle both.
252+
* A missing key doesn't tell us whether the caller expected a value or a
253+
* collection, so this object answers for both. SmartString methods (HTML mode
254+
* only) are tried first, and the result decides what comes back: a still-null
255+
* result means nothing was produced, so the SmartNull itself returns and the
256+
* chain stays open for either ending, ->or('n/a') for a value or ->implode()
257+
* for a collection. Produced results (or() fallbacks, int() and other scalars)
258+
* return as usual. map() propagates without running its callback: a missing
259+
* key has no value to pass it, while a NULL value in an existing key still
260+
* runs the callback.
245261
*
246-
* Unknown methods are forwarded too, so they throw the same undefined-method
262+
* Everything else delegates to an empty SmartArray/SmartArrayHtml of the same
263+
* mode. Unknown methods are forwarded too, so they throw the same undefined-method
247264
* Error as the rest of the library ("did you mean" hint + caller's file:line).
248265
*
249266
* @param $name
250267
* @param mixed ...$arguments
251-
* @return array|false|float|int|SmartString|string|null
268+
* @return array|false|float|int|SmartNull|SmartString|string|null
252269
*/
253270
public function __call($name, array $arguments): mixed
254271
{
255272
// SmartString methods only delegate in HTML mode: raw values are plain scalars
256273
// with no methods, so a miss answers SmartString calls the same way - with the
257-
// standard undefined-method Error
258-
if ($this->useSmartStrings && !method_exists(SmartArrayBase::class, $name) && method_exists(SmartString::class, $name)) {
259-
return SmartString::new(null)->$name(...$arguments);
274+
// standard undefined-method Error. The isPublic() check keeps private helpers
275+
// out: method_exists() reports them, but they aren't part of the API
276+
$isSmartStringMethod = $this->useSmartStrings
277+
&& method_exists(SmartString::class, $name)
278+
&& (new ReflectionMethod(SmartString::class, $name))->isPublic();
279+
280+
if ($isSmartStringMethod) {
281+
if ($name === 'map') {
282+
return $this;
283+
}
284+
$result = SmartString::new(null)->$name(...$arguments);
285+
return $result instanceof SmartString && $result->isNull() ? $this : $result;
260286
}
287+
261288
return $this->useSmartStrings
262289
? (new SmartArrayHtml([], $this->getInternalProperties()))->$name(...$arguments)
263290
: (new SmartArray([], $this->getInternalProperties()))->$name(...$arguments);

‎tests/Unit/SmartNullTest.php‎

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@
2020
* access, and method calls all keep working, and the chain resolves to an
2121
* empty/null value of the right type for the mode it was born in.
2222
*
23-
* Method calls are handled by __call, which routes three ways:
24-
* - names on SmartArrayBase -> an empty SmartArray/SmartArrayHtml (mode inherited)
25-
* - names only on SmartString -> SmartString::new(null)
26-
* - names on neither -> the library's undefined-method Error
23+
* Method calls are handled by __call. In HTML mode, public SmartString methods
24+
* are tried first and the result decides what comes back: value producers like
25+
* or() return their fallback, terminals like int() return their scalar, and
26+
* transforms like trim() propagate the same SmartNull so the chain stays open
27+
* for either a value or a collection ending. map() propagates without running
28+
* its callback (a missing key has no value to pass). Everything else delegates
29+
* to an empty SmartArray/SmartArrayHtml of the same mode, and unknown names
30+
* throw the library's undefined-method Error.
2731
*/
2832
class SmartNullTest extends SmartArrayTestCase
2933
{
@@ -224,17 +228,93 @@ public function testDelegatedArraysCarryTheSourceMetadata(string $class): void
224228
//endregion
225229
//region Method delegation: SmartString methods
226230

227-
public function testSmartStringOnlyMethodsDelegateToANullSmartStringInHtmlMode(): void
231+
public function testTransformsPropagateTheSameSmartNullInHtmlMode(): void
228232
{
233+
// A missing key stays missing through a chain: there is nothing to
234+
// transform, so the SmartNull itself comes back and the chain stays
235+
// open for either ending, ->or() for a value or ->implode() for a collection
229236
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
230237

231-
$trimmed = $smartNull->trim();
232-
$this->assertInstanceOf(SmartString::class, $trimmed);
233-
$this->assertNull($trimmed->value(), 'delegation target is SmartString::new(null)');
238+
$transforms = ['trim' => [], 'maxChars' => [5], 'dateFormat' => ['Y-m-d'], 'numberFormat' => [], 'add' => [5]];
239+
foreach ($transforms as $method => $args) {
240+
$this->assertSame($smartNull, $smartNull->$method(...$args), "->$method() propagates");
241+
}
242+
243+
$this->assertSame('n/a', $smartNull->trim()->maxChars(5)->or('n/a')->value(), 'chain resolves at the end');
244+
$this->assertSame('', $smartNull->trim()->implode(', ')->value(), 'collection ending still works after a transform');
245+
}
246+
247+
public function testValueProducersEndTheChainInHtmlMode(): void
248+
{
249+
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
234250

235251
$fallback = $smartNull->or('n/a');
236252
$this->assertInstanceOf(SmartString::class, $fallback);
237253
$this->assertSame('n/a', $fallback->value());
254+
$this->assertSame('n/a', $smartNull->ifNull('n/a')->value());
255+
}
256+
257+
public function testOrThrowThrowsInHtmlMode(): void
258+
{
259+
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
260+
261+
$this->expectException(RuntimeException::class);
262+
$this->expectExceptionMessage('no user found');
263+
264+
$smartNull->orThrow('no user found');
265+
}
266+
267+
public function testMapPropagatesWithoutRunningTheCallbackInHtmlMode(): void
268+
{
269+
// map() takes a user callback, and a missing key has no value to pass it
270+
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
271+
$calls = 0;
272+
273+
$result = $smartNull->map(function ($value) use (&$calls) {
274+
$calls++;
275+
return 'computed';
276+
});
277+
278+
$this->assertSame($smartNull, $result);
279+
$this->assertSame(0, $calls, 'the callback never runs on a missing key');
280+
$this->assertSame('n/a', $smartNull->map('strtoupper')->or('n/a')->value(), 'chain stays open after map');
281+
}
282+
283+
public function testMapStillRunsOnAKeyThatExistsWithANullValue(): void
284+
{
285+
// The boundary map() propagation must not cross: NULL is a present value
286+
// (SmartString(null), the ordinary path), only an absent key is a SmartNull
287+
$row = SmartArrayHtml::new(['bio' => null]);
288+
$result = $row->bio->map(fn($value) => $value ?? 'default');
289+
290+
$this->assertInstanceOf(SmartString::class, $result);
291+
$this->assertSame('default', $result->value());
292+
}
293+
294+
public function testMapOnACollectionShapedSmartNullKeepsCollectionChainsWorking(): void
295+
{
296+
// first() on an empty result, then a per-element map: the SmartNull
297+
// propagates, so collection endings and iteration still degrade gracefully
298+
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
299+
300+
$result = $smartNull->map(fn($value) => strtoupper((string)$value))->implode(', ');
301+
$this->assertInstanceOf(SmartString::class, $result);
302+
$this->assertSame('', $result->value());
303+
}
304+
305+
public function testMapDelegatesToAnEmptyArrayInRawMode(): void
306+
{
307+
// Raw mode has no SmartString delegation: map is SmartArray's per-element
308+
// map, which returns an empty array of the same mode
309+
$result = $this->smartNullFrom(SmartArray::class)->map(fn($value) => $value);
310+
311+
$this->assertSame(SmartArray::class, get_class($result));
312+
$this->assertSame([], $result->toArray());
313+
}
314+
315+
public function testHtmlEncodeReturnsEmptyStringInHtmlMode(): void
316+
{
317+
$this->assertSame('', $this->smartNullFrom(SmartArrayHtml::class)->htmlEncode());
238318
}
239319

240320
public function testSmartStringTypeCastsReturnEmptyScalarsInHtmlMode(): void
@@ -373,9 +453,10 @@ public function testArrayWriteThrowsRuntimeException(string $class): void
373453
}
374454

375455
#[DataProvider('modeProvider')]
376-
public function testSetThrowsTheSameGuardAsArraySyntax(string $class): void
456+
public function testTwoArgumentSetThrowsTheSameGuardAsArraySyntax(string $class): void
377457
{
378-
// All writes throw the same guard: property, set(), and array syntax
458+
// Two arguments is SmartArray's set($key, $value), a write, and all
459+
// writes throw the same guard: property, set(), and array syntax
379460
$smartNull = $this->smartNullFrom($class);
380461

381462
$this->expectException(RuntimeException::class);
@@ -384,6 +465,28 @@ public function testSetThrowsTheSameGuardAsArraySyntax(string $class): void
384465
$smartNull->set('key', 'value');
385466
}
386467

468+
public function testOneArgumentSetProducesTheValueInHtmlMode(): void
469+
{
470+
// One argument is SmartString's set($value): not a write, it produces
471+
// a new value and ends the chain, like or()
472+
$smartNull = $this->smartNullFrom(SmartArrayHtml::class);
473+
474+
$result = $smartNull->set('fallback');
475+
$this->assertInstanceOf(SmartString::class, $result);
476+
$this->assertSame('fallback', $result->value());
477+
478+
$this->assertSame($smartNull, $smartNull->set(null), 'set(null) produces nothing, so the chain stays missing');
479+
}
480+
481+
public function testOneArgumentSetThrowsInRawMode(): void
482+
{
483+
// Raw mode has no SmartString delegation, so the write guard answers
484+
$this->expectException(RuntimeException::class);
485+
$this->expectExceptionMessage('Cannot set values on SmartNull - this value came from a missing key or empty result, check ->isNotEmpty() first');
486+
487+
$this->smartNullFrom(SmartArray::class)->set('value');
488+
}
489+
387490
#[DataProvider('modeProvider')]
388491
public function testPropertyWriteThrowsTheSameGuardAsArraySyntax(string $class): void
389492
{

0 commit comments

Comments
 (0)