Skip to content

Commit fcc7ca0

Browse files
committed
speed up construction 2.9x and foreach 1.2-1.3x
Construction: all-scalar rows (typical database rows) clone a shared template child and assign their data in one copy-on-write step instead of running the constructor and storing field by field. Mixed rows keep the full conversion path. foreach: getIterator() returns a C-level ArrayIterator when nothing needs wrapping - raw mode, or when every value is a row, tracked by a rowsOnly flag that flips false wherever a scalar or null is stored. HTML arrays with scalar values keep the wrapping generator, so foreach yields SmartStrings exactly as before. 25-row record set: 19.0 -> 6.6 microseconds to construct; a rendered 25-row list page now runs 1.15x vs hand-written htmlspecialchars code (was 1.59x). Side effect: deprecated SmartArrayRaw logs its constructor deprecation twice per result set (outer + row template) instead of once per row.
1 parent a52490e commit fcc7ca0

5 files changed

Lines changed: 121 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
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()`.
3434
- `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.
35-
- Array construction is ~1.6x faster (local benchmark, 25-row record set: 19.0 → 11.9 microseconds). Three changes: internal properties assign from a fixed key list instead of `property_exists()` checks per key, child rows share one properties array instead of rebuilding it per row, and scalar values store directly in the constructor loop instead of dispatching through `setElement()`. The fixed key list also means constructor `$properties` can no longer name arbitrary internal properties like `$data` - unknown keys are ignored, same as before.
35+
- Array construction is ~2.9x faster (local benchmark, 25-row record set: 19.0 → 6.6 microseconds). Internal properties assign from a fixed key list instead of `property_exists()` checks per key; all-scalar rows (typical database rows) clone a shared template child and assign their data in one copy-on-write step instead of running the constructor and storing field by field; scalars store directly in the constructor loop instead of dispatching through `setElement()`. The fixed key list also means constructor `$properties` can no longer name arbitrary internal properties like `$data` - unknown keys are ignored, same as before. One visible side effect: deprecated `SmartArrayRaw` logs its constructor deprecation twice per result set (outer array + row template) instead of once per row.
36+
- `foreach` is 1.2-1.3x faster when nothing needs wrapping: `getIterator()` returns a C-level `ArrayIterator` in raw mode and for record sets where every value is a row, instead of stepping through a generator per element. HTML-mode arrays with scalar values keep the wrapping generator, so foreach yields SmartStrings exactly as before.
3637
- `help()` and `debug()` print plain text on the command line instead of wrapping output in literal `<xmp>` tags. Terminal detection checks `PHP_SAPI` plus two fallbacks (Windows console `SESSIONNAME`, missing `SCRIPT_NAME`) because some hosts' CGI builds misreport SAPI. Web responses are unchanged. Matches SmartString.
3738
- `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.
3839
- `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.

src/SmartArrayBase.php

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
use stdClass;
77
use Throwable, Error, RuntimeException;
8-
use ArrayAccess, IteratorAggregate, Iterator, Countable, JsonSerializable, Closure;
8+
use ArrayAccess, ArrayIterator, IteratorAggregate, Iterator, Countable, JsonSerializable, Closure;
99
use Itools\SmartString\SmartString;
1010

1111
/**
@@ -29,6 +29,15 @@ abstract class SmartArrayBase extends stdClass implements SmartBase, ArrayAccess
2929
*/
3030
private array $data = [];
3131

32+
/**
33+
* True while every value is a child SmartArray (or the array is empty), so
34+
* getIterator() can skip SmartString wrapping and iterate the fast way.
35+
* Storing a scalar or null sets this false, and nothing sets it back (an
36+
* unset can leave it stale-false, which only means the slower,
37+
* always-correct wrapping path).
38+
*/
39+
private bool $rowsOnly = true;
40+
3241
//endregion
3342
//region Position Properties
3443

@@ -110,22 +119,41 @@ public function __construct(array $array = [], array $properties = [])
110119
$this->isLast = $properties['isLast'] ?? false;
111120

112121
// Add elements and set position metadata on child SmartArrays
113-
$count = count($array);
114-
$position = 0;
115-
$childProps = null;
122+
$count = count($array);
123+
$position = 0;
124+
$childTemplate = null;
116125
foreach ($array as $key => $value) {
117126
$position++;
118127

119128
// Fast path: scalars and nulls, the bulk of real data (encoded on access by getElement)
120129
if (is_scalar($value) || $value === null) {
121130
$this->data[$key] = $value;
131+
$this->rowsOnly = false;
122132
continue;
123133
}
124134

125-
// Nested arrays become child rows; every child gets the same properties, so build the array once
135+
// Nested arrays become child rows. The template child is built once and cloned
136+
// per row (cheaper than running the constructor), and all-scalar rows - typical
137+
// database rows - assign their data wholesale: a copy-on-write array assignment
138+
// instead of a per-field loop.
126139
if (is_array($value)) {
127-
$childProps ??= $this->getInternalProperties();
128-
$child = new static($value, $childProps);
140+
$childTemplate ??= new static([], $this->getInternalProperties());
141+
142+
$allScalar = true;
143+
foreach ($value as $fieldValue) {
144+
if (!is_scalar($fieldValue) && $fieldValue !== null) {
145+
$allScalar = false;
146+
break;
147+
}
148+
}
149+
if ($allScalar) {
150+
$child = clone $childTemplate;
151+
$child->data = $value;
152+
$child->rowsOnly = $value === [];
153+
}
154+
else {
155+
$child = new static($value, $this->getInternalProperties());
156+
}
129157
$child->position = $position;
130158
$child->isFirst = $position === 1;
131159
$child->isLast = $position === $count;
@@ -232,6 +260,7 @@ private function setElement(int|string|null $key, mixed $value): void
232260
else {
233261
$this->data[$key] = $value;
234262
}
263+
$this->rowsOnly = false;
235264
return;
236265
}
237266

@@ -1394,16 +1423,26 @@ private function isNested(): bool
13941423
}
13951424

13961425
/**
1397-
* Returns a generator that yields elements, wrapping scalars in SmartString when enabled.
1426+
* Returns an iterator over the elements, wrapping scalars in SmartString when enabled.
13981427
* Nested SmartArrays are yielded as-is (not wrapped).
13991428
*/
14001429
public function getIterator(): Iterator
14011430
{
1402-
// Return an iterator that yields encoded values for each element
1431+
// ArrayIterator iterates 1.2-1.3x faster than the wrapping generator below,
1432+
// and nothing needs wrapping in raw mode or when every value is a row
1433+
if (!$this->useSmartStrings || $this->rowsOnly) {
1434+
return new ArrayIterator($this->data);
1435+
}
1436+
return $this->wrappingIterator();
1437+
}
1438+
1439+
/**
1440+
* Yields elements with scalars and nulls wrapped in SmartString (HTML mode only).
1441+
*/
1442+
private function wrappingIterator(): Iterator
1443+
{
14031444
foreach ($this->data as $key => $value) {
1404-
yield $key => $this->useSmartStrings && !$value instanceof self
1405-
? new SmartString($value)
1406-
: $value;
1445+
yield $key => $value instanceof self ? $value : new SmartString($value);
14071446
}
14081447
}
14091448

tests/Unit/CreationTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,39 @@ public function testConstructorWrapsNestedArraysRecursively(string $class): void
3636
$this->assertInstanceOf($class, $sa->first(), 'child rows take the parent class');
3737
}
3838

39+
#[DataProvider('modeProvider')]
40+
public function testRowsAreIndependentAndKeepParentMetadata(string $class): void
41+
{
42+
// All-scalar rows are built from a shared template internally; each row must
43+
// still have its own data and the parent's root/mysqli/loadHandler
44+
$sa = $class::new([['name' => 'Amy'], ['name' => 'Bob']], ['mysqli' => ['insert_id' => 42]]);
45+
46+
$first = $sa->first();
47+
$last = $sa->last();
48+
$first->name = 'Changed';
49+
50+
$this->assertModeValue('Bob', $last->name, $class, 'writing one row does not leak into another');
51+
$this->assertSame($sa, $first->root());
52+
$this->assertSame($sa, $last->root());
53+
$this->assertSame(['insert_id' => 42], $last->mysqli());
54+
$this->assertSame(1, $first->position());
55+
$this->assertTrue($last->isLast());
56+
}
57+
58+
#[DataProvider('modeProvider')]
59+
public function testRowContainingNestedArrayConvertsRecursively(string $class): void
60+
{
61+
// Mixed rows (scalar fields plus a nested array) take the full conversion
62+
// path; the nested array still becomes a child of the same class
63+
$sa = $class::new([['name' => 'Amy', 'tags' => ['a', 'b']], ['name' => 'Bob', 'tags' => []]]);
64+
65+
$firstTags = $sa->first()->tags;
66+
$this->assertInstanceOf($class, $firstTags);
67+
$this->assertSame(['a', 'b'], $firstTags->toArray());
68+
$this->assertSame($sa, $firstTags->root(), 'grandchildren still point at the real root');
69+
$this->assertSame(2, $sa->last()->position());
70+
}
71+
3972
#[DataProvider('modeProvider')]
4073
public function testNewMatchesConstructor(string $class): void
4174
{

tests/Unit/DeprecationsTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -753,11 +753,12 @@ public function testSmartArrayRawNewForwardsTheLegacyBooleanToTheConstructorGuar
753753

754754
public function testSmartArrayRawRowsAreAlsoSmartArrayRaw(): void
755755
{
756-
// Every row is built by the deprecated constructor, so a 2-row result
757-
// logs three times: the outer array plus one per row
756+
// The outer array and the internal row template each run the deprecated
757+
// constructor once; cloned rows don't, so the count stays at two
758+
// regardless of how many rows the result has
758759
[$sa, $deprecations] = $this->captureDeprecations(fn() => new SmartArrayRaw([['id' => 1], ['id' => 2]]));
759760

760-
$this->assertCount(3, $deprecations);
761+
$this->assertCount(2, $deprecations);
761762
$this->assertInstanceOf(SmartArrayRaw::class, $sa->first());
762763
$this->assertSame(['id' => 1], $sa->first()->toArray());
763764
}

tests/Unit/IterationTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,35 @@ public function testEmptyArrayIteratesZeroTimes(string $class): void
8989
{
9090
$this->assertSame(0, iterator_count($class::new([])->getIterator()));
9191
}
92+
93+
#[DataProvider('modeProvider')]
94+
public function testRecordSetYieldsTheStoredRowObjects(string $class): void
95+
{
96+
// Rows come back by identity, not as copies - metadata like position()
97+
// answers the same whether a row came from foreach or first()
98+
$sa = $class::new([['id' => 1], ['id' => 2]]);
99+
100+
$yielded = [];
101+
foreach ($sa as $row) {
102+
$yielded[] = $row;
103+
}
104+
105+
$this->assertSame($sa->first(), $yielded[0]);
106+
$this->assertSame($sa->last(), $yielded[1]);
107+
}
108+
109+
public function testHtmlModeWrapsScalarAddedAfterConstruction(): void
110+
{
111+
// A record set iterates unwrapped (all rows), but adding a scalar later
112+
// must bring back SmartString wrapping for it
113+
$sa = SmartArrayHtml::new([['id' => 1]]);
114+
$sa->note = '<b>';
115+
116+
$types = [];
117+
foreach ($sa as $key => $value) {
118+
$types[$key] = get_debug_type($value);
119+
}
120+
121+
$this->assertSame([0 => SmartArrayHtml::class, 'note' => SmartString::class], $types);
122+
}
92123
}

0 commit comments

Comments
 (0)