Skip to content

Commit 8080011

Browse files
committed
src: inline the value-match comparison and drop no-op subclass proxies
- inline valueMatches() into contains()/where()/whereNot(): string arm first, query-value bool cast hoisted above each loop, same results - skip getRawValue() when the value is already scalar or null - replace the type-narrowing proxy methods in SmartArray/SmartArrayHtml with @method tags; deprecated get()/nth() keep real bodies - add ValueMatchParityTest so the three comparison copies can't drift
1 parent d89fae8 commit 8080011

5 files changed

Lines changed: 140 additions & 111 deletions

File tree

src/SmartArray.php

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@
2020
* - Nested arrays return SmartArray, use ->toArray() for raw arrays
2121
* - Missing keys return SmartNull, use ->value() for raw null
2222
*
23-
* Full API and docs: SmartArrayBase. Methods are redeclared here only when this
24-
* mode narrows the return type (raw values here, SmartStrings in SmartArrayHtml);
25-
* only new(), asRaw(), and asHtml() have per-class behavior.
23+
* Full API and docs: SmartArrayBase. The @method tags below narrow return types
24+
* to this mode (raw values here, SmartStrings in SmartArrayHtml); only new(),
25+
* asRaw(), and asHtml() have per-class behavior.
2626
*
2727
* PhpStorm: repeated single-type @implements lines - it keeps only one object
2828
* member per generic union, so foreach over a union loses the second type
2929
* @implements IteratorAggregate<mixed, SmartArray>
3030
* @implements IteratorAggregate<mixed, string|int|float|bool|null>
31+
*
32+
* @method static|SmartNull|string|int|float|bool|null first()
33+
* @method static|SmartNull|string|int|float|bool|null last()
34+
* @method static|SmartNull|string|int|float|bool|null at(int|SmartString|SmartNull $index)
35+
* @method string implode(string $separator = '')
36+
* @method static|SmartNull|string|int|float|bool|null offsetGet(mixed $offset)
3137
*/
3238
class SmartArray extends SmartArrayBase
3339
{
@@ -93,45 +99,9 @@ public function asHtml(): SmartArrayHtml
9399
return new SmartArrayHtml($this->toArray(), $this->getInternalProperties(withPosition: true));
94100
}
95101

96-
//endregion
97-
//region Value Access
98-
99-
/** {@inheritDoc} */
100-
public function first(): static|SmartNull|string|int|float|bool|null
101-
{
102-
return parent::first();
103-
}
104-
105-
/** {@inheritDoc} */
106-
public function last(): static|SmartNull|string|int|float|bool|null
107-
{
108-
return parent::last();
109-
}
110-
111-
/** {@inheritDoc} */
112-
public function at(int|SmartString|SmartNull $index): static|SmartNull|string|int|float|bool|null
113-
{
114-
return parent::at($index);
115-
}
116-
117-
//endregion
118-
//region Array Transformation
119-
120-
/** {@inheritDoc} */
121-
public function implode(string $separator = ''): string
122-
{
123-
return parent::implode($separator);
124-
}
125-
126102
//endregion
127103
//region Deprecated Access
128104

129-
/** {@inheritDoc} */
130-
public function offsetGet(mixed $offset): static|SmartNull|string|int|float|bool|null
131-
{
132-
return parent::offsetGet($offset);
133-
}
134-
135105
/**
136106
* {@inheritDoc}
137107
* @deprecated Use property access: ->key, or ->{'users.id'} for keys property syntax

src/SmartArrayBase.php

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
/**
1616
* SmartArrayBase - Base implementation for SmartArray and SmartArrayHtml.
1717
*
18-
* Uses wide return types that child classes narrow via covariance.
18+
* Uses wide return types that SmartArray and SmartArrayHtml narrow per mode.
1919
* Do not instantiate directly - use SmartArray or SmartArrayHtml.
2020
*
2121
* Extends stdClass to enable clean IDE property autocomplete. Without this,
@@ -503,25 +503,6 @@ public static function getRawValue(mixed $value): mixed
503503
throw new InvalidArgumentException("Unsupported value type: " . get_debug_type($value));
504504
}
505505

506-
/**
507-
* How where(), whereNot(), and contains() decide two values match: the same
508-
* answers a SQL WHERE gives, except strings stay case-sensitive and a
509-
* non-numeric string never equals 0. Numbers match numeric strings (5 matches
510-
* '5'), two strings must match exactly, null only matches null, and true/false
511-
* mean 1/0 (so, like MySQL, '01' and ' 1' match true and '00' matches false).
512-
* Callers unwrap Smart values with getRawValue() first.
513-
*/
514-
private static function valueMatches(mixed $rowValue, mixed $value): bool
515-
{
516-
$value = is_bool($value) ? (int)$value : $value;
517-
$rowValue = is_bool($rowValue) ? (int)$rowValue : $rowValue;
518-
return match (true) {
519-
$value === null || $rowValue === null => $value === $rowValue,
520-
is_string($value) && is_string($rowValue) => $value === $rowValue,
521-
default => $value == $rowValue, // PHP 8 numeric comparison, e.g. 1 == '1.00'
522-
};
523-
}
524-
525506
//endregion
526507
//region Array Information
527508

@@ -558,9 +539,19 @@ public function isNotEmpty(): bool
558539
*/
559540
public function contains(mixed $value): bool
560541
{
561-
$value = self::getRawValue($value);
542+
$value = is_scalar($value) || $value === null ? $value : self::getRawValue($value); // fast path: skip getRawValue() for plain values
543+
$value = is_bool($value) ? (int)$value : $value;
544+
// This comparison repeats 3x across contains()/where()/whereNot() on purpose: a
545+
// shared helper would need a per-row call, which costs more than the comparison
546+
// itself. ValueMatchParityTest keeps the copies identical.
562547
foreach ($this->toArray() as $element) {
563-
if (self::valueMatches($element, $value)) {
548+
$element = is_bool($element) ? (int)$element : $element;
549+
$isMatch = match (true) {
550+
is_string($value) && is_string($element) => $value === $element,
551+
$value === null || $element === null => $value === $element,
552+
default => $value == $element, // PHP 8 numeric comparison, e.g. 1 == '1.00'
553+
};
554+
if ($isMatch) {
564555
return true;
565556
}
566557
}
@@ -726,11 +717,22 @@ public function where(array|string $field, mixed $value = null): static
726717
// Two-argument syntax: where('field', value)
727718
if (is_string($field) && func_num_args() === 2) {
728719
$this->warnIfMissing($field);
729-
$value = self::getRawValue($value);
730-
// repeated 4x, see the first where() loop for why
720+
$value = is_scalar($value) || $value === null ? $value : self::getRawValue($value); // fast path: skip getRawValue() for plain values
721+
$value = is_bool($value) ? (int)$value : $value;
722+
// loop repeated 4x, comparison repeated 3x - see the first where() loop and contains() for why
731723
$matches = [];
732724
foreach ($this->toArray() as $key => $row) {
733-
if (array_key_exists($field, $row) && self::valueMatches($row[$field], $value)) {
725+
if (!array_key_exists($field, $row)) {
726+
continue;
727+
}
728+
$rowValue = $row[$field];
729+
$rowValue = is_bool($rowValue) ? (int)$rowValue : $rowValue;
730+
$isMatch = match (true) {
731+
is_string($value) && is_string($rowValue) => $value === $rowValue,
732+
$value === null || $rowValue === null => $value === $rowValue,
733+
default => $value == $rowValue, // PHP 8 numeric comparison, e.g. 1 == '1.00'
734+
};
735+
if ($isMatch) {
734736
$matches[$key] = $row;
735737
}
736738
}
@@ -781,11 +783,23 @@ public function whereNot(string $field, mixed $value = null): static
781783
return $result;
782784
}
783785

784-
$value = self::getRawValue($value);
785-
// repeated 4x, see the first where() loop for why
786+
$value = is_scalar($value) || $value === null ? $value : self::getRawValue($value); // fast path: skip getRawValue() for plain values
787+
$value = is_bool($value) ? (int)$value : $value;
788+
// loop repeated 4x, comparison repeated 3x - see the first where() loop and contains() for why
786789
$matches = [];
787790
foreach ($this->toArray() as $key => $row) {
788-
if (!array_key_exists($field, $row) || !self::valueMatches($row[$field], $value)) {
791+
if (!array_key_exists($field, $row)) {
792+
$matches[$key] = $row;
793+
continue;
794+
}
795+
$rowValue = $row[$field];
796+
$rowValue = is_bool($rowValue) ? (int)$rowValue : $rowValue;
797+
$isMatch = match (true) {
798+
is_string($value) && is_string($rowValue) => $value === $rowValue,
799+
$value === null || $rowValue === null => $value === $rowValue,
800+
default => $value == $rowValue, // PHP 8 numeric comparison, e.g. 1 == '1.00'
801+
};
802+
if (!$isMatch) {
789803
$matches[$key] = $row;
790804
}
791805
}
@@ -816,9 +830,11 @@ public function whereInList(string $field, mixed $value): static
816830
{
817831
$this->assertNestedArray();
818832
$this->warnIfMissing($field);
819-
$value = self::getRawValue($value);
820-
if (!is_scalar($value) && $value !== null) {
821-
throw new InvalidArgumentException("whereInList(): expected a single value to match, got " . get_debug_type($value));
833+
if (!is_scalar($value) && $value !== null) { // fast path: skip getRawValue() for plain values
834+
$value = self::getRawValue($value);
835+
if (!is_scalar($value) && $value !== null) {
836+
throw new InvalidArgumentException("whereInList(): expected a single value to match, got " . get_debug_type($value));
837+
}
822838
}
823839
$value = (string) $value;
824840
$matches = [];
@@ -1281,7 +1297,7 @@ public function load(string $field): static|SmartNull
12811297
*
12821298
* @internal
12831299
*/
1284-
public function root(): self
1300+
public function root(): SmartArrayBase
12851301
{
12861302
return $this->root;
12871303
}

src/SmartArrayHtml.php

Lines changed: 9 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,20 @@
1919
* - Nested arrays return SmartArrayHtml, use ->toArray() for raw arrays and values
2020
* - Missing keys return SmartNull, use ->value() for raw null
2121
*
22-
* Full API and docs: SmartArrayBase. Methods are redeclared here only when this
23-
* mode narrows the return type (SmartStrings here, raw values in SmartArray);
24-
* only new(), asRaw(), and asHtml() have per-class behavior.
22+
* Full API and docs: SmartArrayBase. The @method tags below narrow return types
23+
* to this mode (SmartStrings here, raw values in SmartArray); only new(),
24+
* asRaw(), and asHtml() have per-class behavior.
2525
*
2626
* PhpStorm: repeated single-type @implements lines - it keeps only one object
2727
* member per generic union, so foreach over a union loses the second type
2828
* @implements IteratorAggregate<mixed, SmartArrayHtml>
2929
* @implements IteratorAggregate<mixed, SmartString>
30+
*
31+
* @method static|SmartNull|SmartString first()
32+
* @method static|SmartNull|SmartString last()
33+
* @method static|SmartNull|SmartString at(int|SmartString|SmartNull $index)
34+
* @method SmartString implode(string $separator = '')
35+
* @method static|SmartNull|SmartString offsetGet(mixed $offset)
3036
*/
3137
class SmartArrayHtml extends SmartArrayBase
3238
{
@@ -92,45 +98,9 @@ public function asHtml(): SmartArrayHtml
9298
return $this;
9399
}
94100

95-
//endregion
96-
//region Value Access
97-
98-
/** {@inheritDoc} */
99-
public function first(): static|SmartNull|SmartString
100-
{
101-
return parent::first();
102-
}
103-
104-
/** {@inheritDoc} */
105-
public function last(): static|SmartNull|SmartString
106-
{
107-
return parent::last();
108-
}
109-
110-
/** {@inheritDoc} */
111-
public function at(int|SmartString|SmartNull $index): static|SmartNull|SmartString
112-
{
113-
return parent::at($index);
114-
}
115-
116-
//endregion
117-
//region Array Transformation
118-
119-
/** {@inheritDoc} */
120-
public function implode(string $separator = ''): SmartString
121-
{
122-
return parent::implode($separator);
123-
}
124-
125101
//endregion
126102
//region Deprecated Access
127103

128-
/** {@inheritDoc} */
129-
public function offsetGet(mixed $offset): static|SmartNull|SmartString
130-
{
131-
return parent::offsetGet($offset);
132-
}
133-
134104
/**
135105
* {@inheritDoc}
136106
* @deprecated Use property access: ->key, or ->{'users.id'} for keys property syntax

tests/Unit/DeprecationsTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ public function testStaticCallOfAnInstanceMethodIsPhpsOwnError(): void
707707
{
708708
// Declared methods never reach __callStatic()
709709
$this->assertSame(
710-
'Non-static method Itools\SmartArray\SmartArray::first() cannot be called statically',
710+
'Non-static method Itools\SmartArray\SmartArrayBase::first() cannot be called statically',
711711
$this->firstLineOfError(static fn() => SmartArray::first()),
712712
);
713713
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Itools\SmartArray\Tests\Unit;
5+
6+
use Itools\SmartArray\SmartArrayBase;
7+
use Itools\SmartArray\Tests\Support\SmartArrayTestCase;
8+
use ReflectionMethod;
9+
10+
/**
11+
* Call-site-form enforcement: contains(), where(), and whereNot() each carry an
12+
* inlined copy of the value-match comparison (row bool-to-int cast plus $isMatch
13+
* match block) because a shared helper would need a per-row call. This test
14+
* reads the source of each method and fails if the copies drift apart, so an
15+
* edit to one copy can't silently change how the others match.
16+
*/
17+
class ValueMatchParityTest extends SmartArrayTestCase
18+
{
19+
private const INLINED_COPY_METHODS = ['contains', 'where', 'whereNot'];
20+
21+
public function testInlinedComparisonCopiesAreIdentical(): void
22+
{
23+
$copies = [];
24+
foreach (self::INLINED_COPY_METHODS as $method) {
25+
$copies[$method] = $this->extractComparisonBlock($method);
26+
}
27+
28+
$reference = $copies['contains'];
29+
foreach ($copies as $method => $copy) {
30+
$this->assertSame($reference, $copy, "$method() comparison block differs from contains() - the inlined copies must stay identical");
31+
}
32+
}
33+
34+
public function testEachMethodHoistsTheValueCastOutOfItsLoop(): void
35+
{
36+
foreach (self::INLINED_COPY_METHODS as $method) {
37+
$body = $this->normalize($this->methodSource($method));
38+
$this->assertStringContainsString(
39+
'$value = is_bool($value) ? (int)$value : $value;',
40+
$body,
41+
"$method() lost the loop-invariant \$value bool-to-int cast above its row loop",
42+
);
43+
}
44+
}
45+
46+
/**
47+
* The per-row comparison: the row-value bool-to-int cast plus the $isMatch
48+
* match block, whitespace collapsed and the row variable renamed so
49+
* contains()'s $element compares equal to where()'s $rowValue.
50+
*/
51+
private function extractComparisonBlock(string $method): string
52+
{
53+
$source = $this->methodSource($method);
54+
$pattern = '/\$(\w+)\s+=\s+is_bool\(\$\1\)\s*\?\s*\(int\)\$\1\s*:\s*\$\1;\s+\$isMatch\s+=\s+match\s*\(true\)\s*\{.*?\};/s';
55+
$found = preg_match_all($pattern, $source, $matches, PREG_SET_ORDER);
56+
$this->assertSame(1, $found, "$method() should contain exactly one inlined per-row comparison block");
57+
58+
[$block, $rowVar] = $matches[0];
59+
return $this->normalize(str_replace('$' . $rowVar, '$rowValue', $block));
60+
}
61+
62+
private function methodSource(string $method): string
63+
{
64+
$reflection = new ReflectionMethod(SmartArrayBase::class, $method);
65+
$lines = file($reflection->getFileName());
66+
return implode('', array_slice($lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1));
67+
}
68+
69+
private function normalize(string $code): string
70+
{
71+
return trim(preg_replace('/\s+/', ' ', $code));
72+
}
73+
}

0 commit comments

Comments
 (0)