Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions lib/Horde/Support/CaseInsensitiveArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
Expand All @@ -60,6 +91,7 @@ public function offsetUnset($offset)
{
if (!is_null($offset = $this->_getRealOffset($offset))) {
parent::offsetUnset($offset);
unset($this->_caseMap[strtolower($offset)]);
}
}

Expand All @@ -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;
}

}
46 changes: 36 additions & 10 deletions src/CaseInsensitiveArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -21,15 +21,45 @@
*
* @author Michael Slusarz <slusarz@horde.org>
* @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<string, string>
*/
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
Expand All @@ -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);
}
Expand All @@ -60,6 +91,7 @@ public function offsetUnset($offset): void
{
if (!is_null($offset = $this->_getRealOffset($offset))) {
parent::offsetUnset($offset);
unset($this->_caseMap[strtolower($offset)]);
}
}

Expand All @@ -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;
}
}
165 changes: 136 additions & 29 deletions test/Unit/CaseInsensitiveArrayTest.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<?php

/**
* Copyright 2013-2026 Horde LLC (http://www.horde.org/)
* Copyright 2013-2026 The Horde Project (http://www.horde.org/)
*
* @category Horde
* @internal
* @license http://www.horde.org/licenses/bsd
* @package Support
* @subpackage UnitTests
* @copyright 2013-2026 The Horde Project
*/

namespace Horde\Support\Test;
Expand All @@ -16,69 +17,175 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use Horde_Support_CaseInsensitiveArray;
use Horde\Support\CaseInsensitiveArray;
use ArrayIterator;

/**
* Exercises both the PSR-0 legacy class (Horde_Support_CaseInsensitiveArray)
* and the PSR-4 class (Horde\Support\CaseInsensitiveArray) with the same
* test logic. They should only differ by legacy signatures.
*
* @category Horde
* @internal
* @license http://www.horde.org/licenses/bsd
* @package Support
* @subpackage UnitTests
*/
#[CoversClass(Horde_Support_CaseInsensitiveArray::class)]
#[CoversClass(CaseInsensitiveArray::class)]
class CaseInsensitiveArrayTest extends TestCase
{
#[DataProvider('implementsProvider')]
public function testImplements($interface)
public static function classProvider()
{
$o = new Horde_Support_CaseInsensitiveArray();
$this->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']);
}

}
Loading