diff --git a/lib/Horde/Support/CaseInsensitiveArray.php b/lib/Horde/Support/CaseInsensitiveArray.php index 2ff1a5b..e43af50 100644 --- a/lib/Horde/Support/CaseInsensitiveArray.php +++ b/lib/Horde/Support/CaseInsensitiveArray.php @@ -23,6 +23,36 @@ */ class Horde_Support_CaseInsensitiveArray extends ArrayIterator { + /** + * Maps lowercased keys to their real (originally-cased) counterparts. + * Makes case-insensitive lookups O(1) instead of a linear scan. + * + * @var array + */ + protected $_caseMap = []; + + /** + */ + public function __construct($array = [], $flags = 0) + { + parent::__construct($array, $flags); + + /* @performance PHP 8.4 In testing with 5-digit arrays, array_keys wins over foreach $array as $key => $value. */ + foreach (array_keys($array) as $key) { + $this->_caseMap[strtolower($key)] = $key; + } + } + + /** + * ArrayIterator's internal storage is not exposed as a regular + * property. PHP's default (shallow) clone does not duplicate it. + * Rebuild storage explicitly so clones are independent. + */ + public function __clone() + { + parent::__construct($this->getArrayCopy(), $this->getFlags()); + } + /** */ #[ReturnTypeWillChange] @@ -40,6 +70,7 @@ public function offsetSet($offset, $value) { if (is_null($roffset = $this->_getRealOffset($offset))) { parent::offsetSet($offset, $value); + $this->_caseMap[strtolower($offset)] = $offset; } else { parent::offsetSet($roffset, $value); } @@ -60,6 +91,7 @@ public function offsetUnset($offset) { if (!is_null($offset = $this->_getRealOffset($offset))) { parent::offsetUnset($offset); + unset($this->_caseMap[strtolower($offset)]); } } @@ -77,13 +109,7 @@ protected function _getRealOffset($offset) return $offset; } - foreach (array_keys($this->getArrayCopy()) as $key) { - if (strcasecmp($key, $offset) === 0) { - return $key; - } - } - - return null; + return $this->_caseMap[strtolower($offset)] ?? null; } } diff --git a/src/CaseInsensitiveArray.php b/src/CaseInsensitiveArray.php index 0f82ef3..cb63561 100644 --- a/src/CaseInsensitiveArray.php +++ b/src/CaseInsensitiveArray.php @@ -7,7 +7,7 @@ * did not receive this file, see http://www.horde.org/licenses/bsd. * * @category Horde - * @copyright 2013-2017 Horde LLC + * @copyright 2013-2026 The Horde Project * @license http://www.horde.org/licenses/bsd BSD * @package Support */ @@ -21,15 +21,45 @@ * * @author Michael Slusarz * @category Horde - * @copyright 2013-2017 Horde LLC + * @copyright 2013-2026 Horde LLC * @license http://www.horde.org/licenses/bsd BSD * @package Support */ class CaseInsensitiveArray extends ArrayIterator { /** + * Maps lowercased keys to their real (originally-cased) counterparts. + * Makes case-insensitive lookups O(1) instead of a linear scan. + * + * @var array + */ + protected array $_caseMap = []; + + /** + */ + public function __construct($array = [], $flags = 0) + { + parent::__construct($array, $flags); + + /* @performance PHP 8.4 In testing with 5-digit arrays, array_keys wins over foreach $array as $key => $value. */ + foreach (array_keys($array) as $key) { + $this->_caseMap[strtolower($key)] = $key; + } + } + + /** + * ArrayIterator's internal storage is not exposed as a regular + * property. PHP's default (shallow) clone does not duplicate it. + * Rebuild storage explicitly so clones are independent. */ - public function offsetGet($offset) + public function __clone(): void + { + parent::__construct($this->getArrayCopy(), $this->getFlags()); + } + + /** + */ + public function offsetGet($offset): mixed { return (is_null($offset = $this->_getRealOffset($offset))) ? null @@ -42,6 +72,7 @@ public function offsetSet($offset, $value): void { if (is_null($roffset = $this->_getRealOffset($offset))) { parent::offsetSet($offset, $value); + $this->_caseMap[strtolower($offset)] = $offset; } else { parent::offsetSet($roffset, $value); } @@ -60,6 +91,7 @@ public function offsetUnset($offset): void { if (!is_null($offset = $this->_getRealOffset($offset))) { parent::offsetUnset($offset); + unset($this->_caseMap[strtolower($offset)]); } } @@ -77,12 +109,6 @@ protected function _getRealOffset($offset) return $offset; } - foreach (array_keys($this->getArrayCopy()) as $key) { - if (strcasecmp($key, $offset) === 0) { - return $key; - } - } - - return null; + return $this->_caseMap[strtolower($offset)] ?? null; } } diff --git a/test/Unit/Helper/ConsistentHashInstrumented.php b/test/Helper/ConsistentHashInstrumented.php similarity index 100% rename from test/Unit/Helper/ConsistentHashInstrumented.php rename to test/Helper/ConsistentHashInstrumented.php diff --git a/test/Unit/CaseInsensitiveArrayTest.php b/test/Unit/CaseInsensitiveArrayTest.php index 34d83a8..26a0505 100644 --- a/test/Unit/CaseInsensitiveArrayTest.php +++ b/test/Unit/CaseInsensitiveArrayTest.php @@ -1,13 +1,14 @@ assertInstanceOf($interface, $o); + return [ + 'legacy (PSR-0)' => [Horde_Support_CaseInsensitiveArray::class], + 'PSR-4' => [CaseInsensitiveArray::class], + ]; } - public static function implementsProvider() + /** + * @return ArrayIterator + */ + protected function make(string $class, array $array = []) { - return [ - ['ArrayAccess'], - ['Traversable'], - ['Countable'], - ]; + return new $class($array); + } + + #[DataProvider('classProvider')] + public function testImplementsArrayAccess(string $class) + { + $this->assertInstanceOf('ArrayAccess', $this->make($class)); + } + + #[DataProvider('classProvider')] + public function testImplementsTraversable(string $class) + { + $this->assertInstanceOf('Traversable', $this->make($class)); } - public function testOffsetGetReturnsValueAtOffset() + #[DataProvider('classProvider')] + public function testImplementsCountable(string $class) { - $o = new Horde_Support_CaseInsensitiveArray(['foo' => 'bar']); + $this->assertInstanceOf('Countable', $this->make($class)); + } + + #[DataProvider('classProvider')] + public function testOffsetGetReturnsValueAtOffset(string $class) + { + $o = $this->make($class, ['foo' => 'bar']); $this->assertEquals('bar', $o['foo']); } - public function testOffsetGetReturnsNullWhenOffsetDoesNotExist() + #[DataProvider('classProvider')] + public function testOffsetGetReturnsNullWhenOffsetDoesNotExist(string $class) { - $o = new Horde_Support_CaseInsensitiveArray(); + $o = $this->make($class); $this->assertNull($o['foo']); } - public function testCaseInsensitiveKeys() + #[DataProvider('classProvider')] + public function testCaseInsensitiveKeys(string $class) { - $o = new Horde_Support_CaseInsensitiveArray(['foo' => 'bar']); + $o = $this->make($class, ['foo' => 'bar']); $this->assertTrue(isset($o['foo'])); $this->assertTrue(isset($o['Foo'])); $this->assertTrue(isset($o['FOO'])); - $this->assertEquals( - 'bar', - $o['foo'] - ); - $this->assertEquals( - 'bar', - $o['Foo'] - ); - $this->assertEquals( - 'bar', - $o['FOO'] - ); + $this->assertEquals('bar', $o['foo']); + $this->assertEquals('bar', $o['Foo']); + $this->assertEquals('bar', $o['FOO']); unset($o['FOO']); $this->assertFalse(isset($o['foo'])); } + #[DataProvider('classProvider')] + public function testOverwriteExistingKeyWithDifferentCaseUpdatesValueOnly(string $class) + { + $o = $this->make($class, ['foo' => 'bar']); + $o['FOO'] = 'baz'; + + $this->assertEquals('baz', $o['foo']); + $this->assertEquals(['foo' => 'baz'], $o->getArrayCopy()); + } + + #[DataProvider('classProvider')] + public function testUnsetThenSetWithDifferentCaseResolvesToNewCasing(string $class) + { + $o = $this->make($class, ['foo' => 'bar']); + unset($o['foo']); + $o['Foo'] = 'baz'; + + $this->assertTrue(isset($o['FOO'])); + $this->assertEquals('baz', $o['foo']); + $this->assertEquals(['Foo' => 'baz'], $o->getArrayCopy()); + } + + #[DataProvider('classProvider')] + public function testConstructingFromPrepopulatedArrayResolvesCaseInsensitively(string $class) + { + $o = $this->make($class, ['Foo' => 'bar', 'Baz' => 'qux']); + + $this->assertEquals('bar', $o['foo']); + $this->assertEquals('bar', $o['FOO']); + $this->assertEquals('qux', $o['baz']); + $this->assertEquals(['Foo' => 'bar', 'Baz' => 'qux'], $o->getArrayCopy()); + } + + #[DataProvider('classProvider')] + public function testInsertCostScalesLinearlyNotQuadratically(string $class) + { + $populate = function (int $n) use ($class) { + $o = $this->make($class); + for ($i = 0; $i < $n; $i++) { + $o['key' . $i] = $i; + } + return $o; + }; + + // Warm up (autoloading, opcache, etc.) before timing. + $populate(2000); + + $n = 1500; + $start = microtime(true); + $populate($n); + $timeN = microtime(true) - $start; + + $start = microtime(true); + $populate($n * 4); + $time4N = microtime(true) - $start; + + // O(N) insertion should yield time(4N) ~= 4x time(N). O(N^2) + // insertion would yield ~16x. Compensate for timing + // noise but catch any regression back to quadratic cost. + $this->assertLessThan( + $timeN * 6, + $time4N, + 'Insert cost appears quadratic: time(4N)=' . $time4N + . 's is not within 6x of time(N)=' . $timeN . 's' + ); + } + + #[DataProvider('classProvider')] + public function testClonedArrayIsIndependentOfOriginal(string $class) + { + $o = $this->make($class, ['Foo' => 'bar']); + $c = clone $o; + + // Mutating one must not affect the other's backing storage. + $c['NewKey'] = 'baz'; + $o['foo'] = 'changed'; + + $this->assertEquals(['Foo' => 'changed'], $o->getArrayCopy()); + $this->assertEquals(['Foo' => 'bar', 'NewKey' => 'baz'], $c->getArrayCopy()); + + // Case-insensitive resolution must still work on the clone, + // including for keys added only after cloning. + $this->assertTrue(isset($c['newkey'])); + $this->assertFalse(isset($o['newkey'])); + $this->assertEquals('bar', $c['foo']); + } + }