@@ -14,40 +14,33 @@ Everything else is hardening and fixes.
1414
1515### Requirements
1616
17- - Now requires SmartString 3.0+ (previously any version). Composer picks it
18- up on ` composer update ` ; only a pinned ` itools/smartstring ` constraint in
19- your own composer.json needs changing.
17+ - Now requires SmartString 3.0+ (previously any version); Composer picks it
18+ up automatically unless your own composer.json pins ` itools/smartstring `
19+ lower
2020
2121### Security
2222
23- - Missing-key warnings and array-syntax deprecation notices HTML-encode the
24- key before echoing it. With a dynamic key (` ->get($_GET['sort']) ` ,
25- ` $arr[$_GET['sort']] ` ), a key containing HTML reached the browser
26- unencoded - a reflected XSS vector. The ` trigger_error() ` copy carries the
27- same encoded key.
28- - ` json_encode($smartArray) ` substitutes malformed UTF-8 in keys with �
29- (U+FFFD), not just values - one corrupt byte in a key no longer makes it
30- return false and lose the whole document.
31- - ` debug() ` and ` help() ` escape a literal ` </xmp ` in web output as ` <\/xmp ` ,
32- same escaping as CMSB's ` xmp_safe() ` . A stored value containing ` </xmp> `
33- ended the ` <xmp> ` block early, so the rest of the value parsed as live
34- HTML on any page that called ` debug() ` on it.
23+ - ** Warnings HTML-encode dynamic keys** - missing-key warnings and
24+ deprecation notices echoed keys like ` $_GET['sort'] ` unencoded, a
25+ reflected XSS vector
26+ - ** ` json_encode() ` survives malformed UTF-8 in keys** - bad bytes become
27+ � instead of the whole document returning false (values were already
28+ handled)
29+ - ** ` debug() ` and ` help() ` escape ` </xmp ` ** - a stored value containing
30+ ` </xmp> ` ended the debug block early, so the rest of it parsed as live
31+ HTML
3532
3633### Added
3734
38- - ` where($field) ` and ` whereNot($field) ` with just a field name filter by
35+ - ** ` where($field) ` / ` whereNot($field) ` ** - one-argument forms filter by
3936 PHP's ` empty() ` rule: ` where('featured') ` keeps rows where the field is
40- non-empty, ` whereNot('featured') ` keeps the rest, and every row lands in
41- exactly one of the two. The one-argument form previously ran as
42- ` where($field, null) ` , a loose match against NULL.
37+ non-empty, ` whereNot('featured') ` keeps the rest
4338
4439### Performance
4540
46- - Building arrays is ~ 3.4x faster (25-row record set: 15.5 → 4.5
47- microseconds), ` toArray() ` is ~ 4.7x faster (flat 4-field row: 108 → 23 ns),
48- and ` foreach ` is up to ~ 1.3x faster when no value needs wrapping (the gain
49- grows with row count). Numbers and how the speedups work:
50- [ docs/performance.md] ( docs/performance.md )
41+ - ** Building arrays is ~ 3.4x faster** (25-row record set: 15.5 → 4.5
42+ microseconds), ` toArray() ` ~ 4.7x, and ` foreach ` up to ~ 1.3x. Numbers and
43+ how the speedups work: [ docs/performance.md] ( docs/performance.md )
5144
5245### Renamed
5346
@@ -62,201 +55,80 @@ strikethrough and offer a one-click rename.
6255
6356### Parameter renames (named arguments only)
6457
65- These only matter if you write parameter names in calls - calls using an old
66- name fail with a clear "Unknown named parameter" Error:
67-
68- - ` sortBy(flags:) ` was ` type: ` - it always held PHP sort flags, and now
69- matches ` sort() ` and PHP's own sort functions
58+ - ` sortBy(flags:) ` was ` type: ` - named-argument calls only; the old name
59+ fails with a clear "Unknown named parameter" Error
7060
7161### Deprecated
7262
7363These still work with no runtime notice, they're just no longer featured in
7464the docs - IDEs show a strikethrough with the replacement.
7565
76- - ` get($key, $default) ` and ` set($key, $value) ` - use property access
77- instead: ` $row->name ` , ` $row->{'users.id'} ` for keys property syntax can't
78- type, ` $row->name ?? 'n/a' ` for missing-key defaults, and
79- ` $row->name = $value ` to write. ` get('') ` and ` set('', $value) ` remain the
80- only way to reach an empty-string key - the brace form is a fatal error.
81- - ` each($callback) ` - use a ` foreach ` loop instead, same behavior in plain PHP
82- - ` sprintf($format) ` - use ` map() ` with an inline format string instead:
83- ` $list->map(fn($v) => "<li>$v</li>") ` . On SmartArrayHtml, convert to raw
84- mode and encode explicitly so the finished HTML isn't re-encoded on output:
85- ` $row->asRaw()->map(fn($v) => "<td>" . htmlspecialchars((string)$v) . "</td>")->implode("\n") `
86- - ` help() ` - read the docs on GitHub instead; the guide pages and method
87- reference replaced the built-in cheat sheet (` src/help.txt ` is deleted).
88- Until removed, ` help() ` prints links to both, and runtime messages point at
89- "the SmartArray docs" rather than suggesting it.
66+ - ** ` get() ` and ` set() ` ** - use property access: ` $row->name ` ,
67+ ` $row->name ?? 'n/a' ` , ` $row->name = $value `
68+ - ** ` each() ` ** - a ` foreach ` loop does the same in plain PHP
69+ - ** ` sprintf() ` ** - use ` map() ` with an inline format string:
70+ ` $list->map(fn($v) => "<li>$v</li>") `
71+ - ** ` help() ` ** - the docs on GitHub replaced the built-in cheat sheet
9072
9173### Removed
9274
93- - ` usingSmartStrings() ` - use ` instanceof SmartArrayHtml ` to check the mode;
94- the class is the mode. Never documented, no found uses.
95- - ` setLoadHandler() ` - pass the handler as the ` loadHandler ` constructor
96- property, which is how the database layer (ZenDB) has always set it. It
97- couldn't work on record sets anyway: rows snapshot the handler during
98- construction, so one set afterward never reached them.
99- - ` newSmartNull() ` - now protected; it is the internal factory for
100- missing-value placeholders. Never documented, no found uses.
75+ - ** ` usingSmartStrings() ` , ` setLoadHandler() ` , ` newSmartNull() ` ** - never
76+ documented, no found uses; replacements in [ UPGRADING.md] ( UPGRADING.md )
10177
10278### Behavior changes
10379
104- - ` where() ` , ` whereNot() ` , ` whereInList() ` , and ` contains() ` match values
105- more precisely. Most code sees no difference: numbers still match numeric
106- strings, so ` where('id', 5) ` matches ` '5' ` and ` where('price', 1) ` matches
107- ` '1.00' ` . Three edge cases changed:
108- - Two strings must match exactly: ` '01' ` no longer matches ` '1' ` , and
109- ` where('code', '0e123') ` no longer matches ` '0e999' ` (PHP's loose ` == `
110- read both as numbers - a wrong-row risk for hash and token lookups)
111- - null only matches null, like SQL IS NULL (it used to match ` '' ` , 0,
112- and false too)
113- - true/false mean 1/0 (true used to match any truthy value, even ` 'abc' ` )
114-
115- See [ UPGRADING.md] ( UPGRADING.md ) .
116- - Row-only methods (` where() ` , ` whereNot() ` , ` whereInList() ` , ` sortBy() ` ,
117- ` indexBy() ` , ` groupBy() ` , ` column() ` , ` columnAt() ` ) throw
118- ` InvalidArgumentException ` naming the offending element when the array
119- mixes rows and scalar values, instead of silently skipping the scalars
120- (` sortBy() ` kept them). A scalar next to rows means the array was built
121- wrong - usually a wrapped API response (` ['count' => 5, 'items' => [...]] ` )
122- or a value assigned onto a result set - and skipping it hid the mistake.
123- Database results and empty arrays are unaffected. See
124- [ UPGRADING.md] ( UPGRADING.md ) .
125- - A missing field stays a SmartNull through the whole chain instead of
126- becoming an empty SmartString at the first method call. Same output as
127- before (echoes ` "" ` , ` or() ` still fires), but chains no longer dead-end:
128- ` $row->missing->trim()->implode(', ') ` works where it previously threw.
129- ` map() ` skips its callback on a missing key; NULL values in existing
130- keys still run it.
131- - ` isset() ` , ` empty() ` , and ` ?? ` treat a stored null as missing, matching
132- plain PHP arrays: on a NULL column ` isset($row->field) ` is now false and
133- ` $row->field ?? 'none' ` returns ` 'none' ` . Previously they answered "does
134- the column exist", so in HTML mode ` ?? ` fallbacks never fired on NULL
135- columns. Bracket syntax matches, and direct access is unchanged. See
136- [ UPGRADING.md] ( UPGRADING.md ) .
137- - Missing-key warnings fire only on rows inside a result set, where keys are
138- column names and a miss is almost always a typo. Top-level and derived
139- collections (` indexBy() ` /` column() ` maps, standalone arrays) render blank
140- silently, so fallbacks chain cleanly:
141- ` $authorById->{$id}->or('Unknown Author') ` . Method-argument checks
142- (` where('typo') ` ) still warn everywhere.
143- - ` SmartArray::new($data, true) ` and ` SmartArrayHtml::new($data, false) ` throw
144- like the constructors do, instead of silently ignoring the boolean - code
145- passing ` true ` expecting auto-encoding was getting raw, unencoded values.
146- Redundant booleans (` false ` on SmartArray, ` true ` on SmartArrayHtml) log a
147- deprecation and proceed. ` SmartArrayRaw::new() ` matches.
148- See [ UPGRADING.md] ( UPGRADING.md ) .
149- - All writes to a ` SmartNull ` throw "Cannot set values on SmartNull": property
150- writes (previously created a silent dynamic property that shadowed
151- chaining) and two-argument ` ->set($key, $value) ` (previously discarded the
152- value) now match the existing ` ['key'] = ` guard. One-argument
153- ` ->set($value) ` is SmartString's set: not a write, it produces that value
154- and ends the chain, like ` or() ` .
155- - Raw-mode arrays no longer answer SmartString methods on missing keys:
156- ` $row->missing->or('n/a') ` on a raw array throws the standard
157- undefined-method Error instead of returning an HTML-encoding SmartString.
158- Raw fallbacks use ` ?? ` . HTML mode is unchanged.
159- - ` set() ` , ` ->key = $value ` , and array assignment unwrap Smart values
160- (SmartString, SmartArray, SmartNull) instead of throwing, so values copy
161- between arrays in any mode without calling ` ->value() ` first. SmartNull
162- stores as null; nested SmartArrays convert to the target array's mode.
163- - ` get() ` and ` at() ` unwrap Smart arguments, so keys read from another array
164- work directly: ` $users->get($article->author_id) ` . Unwrapped keys follow
165- PHP array-key rules (null reads key ` '' ` , bool/float truncate to int).
166- - ` print_r() ` and ` var_dump() ` show just the array data, like dumping a plain
167- array - the injected pseudo-properties (the README help pointer and the
168- ` useSmartStrings ` flag) are gone. Use ` ->debug() ` for exact types and
169- metadata. ` SmartNull ` dumps as ` [value] => ` . Matches SmartString 3.0.0.
170- - Deprecated method names are real declared methods marked ` @deprecated `
171- instead of ` __call() ` shims, so IDEs show strikethroughs with the
172- replacement and ` method_exists() ` reports them. Same behavior and
173- deprecation notices as before.
174- - ` orDie() ` and ` or404() ` exit with status 1 instead of 0, so shell scripts
175- and cron jobs see the failure. Output is unchanged. Matches SmartString.
80+ - ** Value matching is more precise** in ` where() ` , ` whereNot() ` ,
81+ ` whereInList() ` , and ` contains() ` - numbers still match numeric strings,
82+ but strings compare exactly, null only matches null (like SQL IS NULL),
83+ and true/false mean 1/0. See [ UPGRADING.md] ( UPGRADING.md )
84+ - ** ` isset() ` , ` empty() ` , and ` ?? ` treat a stored NULL as missing** -
85+ matching plain PHP arrays, so ` ?? ` fallbacks now fire on NULL columns.
86+ See [ UPGRADING.md] ( UPGRADING.md )
87+ - ** Row-only methods throw on mixed arrays** - a scalar next to rows
88+ (usually a wrapped API response) throws instead of being silently
89+ skipped; database results are unaffected. See
90+ [ UPGRADING.md] ( UPGRADING.md )
91+ - ** ` SmartArray::new($data, true) ` throws** - a boolean that contradicts
92+ the class was silently ignored, returning raw values where HTML-safe
93+ ones were asked for. See [ UPGRADING.md] ( UPGRADING.md )
94+ - ** Missing fields chain cleanly** - a missing key stays a SmartNull
95+ through the whole chain, so ` $row->missing->trim()->implode(', ') ` works
96+ where it previously threw; output is unchanged (echoes ` "" ` , ` or() `
97+ still fires)
98+ - ** Missing-key warnings fire only on rows inside a result set** - where
99+ keys are column names and a miss is almost always a typo; derived
100+ collections and standalone arrays render blank silently, so fallbacks
101+ like ` $authorById->{$id}->or('Unknown Author') ` chain cleanly
102+ - ** Smart values copy between arrays** - ` set() ` , ` ->key = $value ` , and
103+ array assignment unwrap SmartString/SmartArray/SmartNull values instead
104+ of throwing, converting to the target array's mode; ` get() ` and ` at() `
105+ unwrap Smart keys the same way
176106
177107### Fixed
178108
179- - Deprecation notices and error messages name the developer's file when a call
180- routes through the companion SmartString library (the caller lookup treated
181- only its own src directory as internal, so a frame in the other library
182- could be reported as the caller)
183- - ` column(null) ` and ` column(null, null) ` match PHP's ` array_column() ` : whole
184- rows renumbered from 0, instead of throwing "unexpected arguments"
185- - ` sortBy() ` no longer throws a bare `ValueError: Array sizes are
186- inconsistent` when a row is missing the sort field. Missing fields sort
187- first (treated as null for ordering, like MySQL ORDER BY); rows are
188- returned unchanged.
189- - ` indexBy() ` no longer gives rows missing the index field a leftover numeric
190- key that looks like a real field value. Null and missing values both index
191- under ` '' ` , duplicates last-wins.
192- - ` indexBy() ` , ` groupBy() ` , and ` column($col, $indexKey) ` throw
193- ` InvalidArgumentException ` ("'price' has float values, convert them to
194- strings first") when the key field holds floats, instead of PHP's
195- float-to-int key truncation, which keyed ` 19.99 ` and ` 19.50 ` both as ` 19 `
196- (last row wins, one lost) and printed a PHP deprecation naming library
197- internals. No float-to-key conversion is safe, so the caller picks the
198- string format. Integer and boolean keys are unchanged (ints key as ints,
199- bools as 1/0).
200- - ` column($col, $indexKey) ` keys rows missing the index field under ` '' ` ,
201- same as ` column(null, $indexKey) ` and ` indexBy() ` , instead of
202- ` array_column() ` 's auto-numbered keys that look like real field values.
203- - ` asRaw() ` and ` asHtml() ` on a row keep its position metadata, so
204- ` position() ` , ` isFirst() ` , and ` isLast() ` answer the same before and after
205- converting (conversion used to reset them to position 0 with both flags
206- false).
207- - Method calls on a ` SmartNull ` keep the source's mysqli metadata, root
208- pointer, and load handler, matching its ` asRaw() ` /` asHtml() ` . Previously
209- chains through a missing key answered ` mysqli() ` with ` [] ` and ` root() `
210- with a throwaway empty array.
211- - ` $arr[null] ` (deprecated ` [] ` syntax) reads key ` '' ` like a plain PHP array,
212- instead of printing a misleading ` Replace [] ` suggestion and then throwing a
213- TypeError that named library internals. ` unset() ` and ` isset() ` already
214- worked this way.
215- - Array-syntax deprecation notices suggest one replacement style across reads,
216- writes, and ` unset() ` : ` ->key ` and ` ->key = $value ` for property-safe
217- names, ` ->{0} ` for integer keys, ` ->{'users.id'} ` for other keys.
218- Null and ` '' ` keys suggest ` ->get('') ` / ` ->set('', $value) ` - the brace
219- form is a fatal error for an empty property name.
220- - Unknown methods on ` SmartNull ` throw the same ` Error ` as the rest of the
221- library - method name, "did you mean" hint, caller's file and line -
222- instead of ` InvalidArgumentException("Method 'x' not found") ` .
223- - Clearer messages for two exceptions: ` load() ` with no handler explains
224- handlers come from the database layer (was "No loadHandler property is
225- defined"), and writing to a ` SmartNull ` says the value came from a missing
226- key or empty result. The unsupported-type message from ` set() ` no longer
227- prefixes the library's internal method name.
228- - The string-conversion warning names the actual class (`Can't convert
229- SmartArrayHtml to string` on HTML arrays) and the missing-brace hint ends
230- with a newline so it no longer runs into the "Occurred in" trace.
231- - ` load() ` accepts a column literally named ` "0" ` (the blank-name check used
232- ` empty() ` ), and a handler returning the wrong shape throws `Load handler
233- must return [ rows, mysqliProperties] or false` before destructuring, so no
234- stray "Undefined array key 1" PHP warning leaks from the library.
235- - ` load() ` with an invalid-character field name throws
236- ` InvalidArgumentException ` (was ` RuntimeException ` ), matching the
237- empty-field-name check - both are argument problems. The other ` load() `
238- setup errors still throw ` RuntimeException ` .
239- - ` debug(1) ` no longer throws ` Unsupported type: Closure ` on arrays with a
240- load handler - exactly the database results it exists to inspect. Callables
241- and other objects in the properties block print as their type (` Closure, ` )
242- instead.
243- - ` get($key, $default) ` defaults act like stored values: Smart defaults
244- (SmartString, SmartArray, SmartNull) unwrap and re-wrap for the array's
245- mode. Previously a SmartNull default threw ` InvalidArgumentException ` , and
246- cross-mode Smart defaults threw ` TypeError ` from the return declarations.
247- - ` SmartNull->help() ` wraps its output in ` <xmp> ` when no Content-Type header
248- is set, same rule as every other ` help() ` and ` debug() ` - PHP's default
249- response type is text/html. Previously it only wrapped when text/html was
250- set explicitly via ` header() ` , so on typical pages it printed as collapsed,
251- unformatted text.
252- - ` help() ` and ` debug() ` print plain text on the command line instead of
253- wrapping output in literal ` <xmp> ` tags. Terminal detection checks
254- ` PHP_SAPI ` plus two fallbacks (Windows console ` SESSIONNAME ` , missing
255- ` SCRIPT_NAME ` ) because some hosts' CGI builds misreport SAPI. Web responses
256- are unchanged. Matches SmartString.
257- - ` or404() ` outputs ` <html> ` instead of ` <html lang> ` - an empty ` lang ` reads
258- as an invalid value to accessibility checkers, and the message language is
259- caller-supplied so it can't be declared. Matches SmartString.
109+ - ** Float key values throw in ` indexBy() ` , ` groupBy() ` , and ` column() ` ** -
110+ PHP's float-to-int key truncation keyed ` 19.99 ` and ` 19.50 ` both as
111+ ` 19 ` , silently losing a row; the error asks for strings instead
112+ - ** Rows missing the sort or index field are handled** - ` sortBy() ` sorts
113+ them first (like MySQL ORDER BY) instead of throwing, and ` indexBy() `
114+ and ` column() ` key them under ` "" ` instead of a leftover numeric key
115+ that looked like real data
116+ - ** Errors name your file, not the library's** - deprecation notices and
117+ error messages report the right caller when a call routes through
118+ SmartString, and unknown methods on ` SmartNull ` throw the same helpful
119+ Error as the rest of the library
120+
121+ ### Minor
122+
123+ Also: writes to a ` SmartNull ` throw instead of silently discarding the
124+ value, raw-mode arrays throw on SmartString-style fallbacks like ` ->or() `
125+ on missing keys (use ` ?? ` instead), ` print_r() ` and ` var_dump() ` show
126+ clean array data (use ` debug() ` for exact types), ` orDie() ` and ` or404() `
127+ exit with status 1 so shell scripts see the failure, deprecated names are
128+ real declared methods so IDEs and ` method_exists() ` see them, ` help() `
129+ and ` debug() ` print plain text on the command line, and a couple dozen
130+ small fixes to error messages and ` load() ` , ` column() ` , ` get() ` , and
131+ ` debug() ` edge cases.
260132
261133## [ 2.7.0] - 2026-07-07
262134
0 commit comments