diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -38,6 +38,8 @@ 'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php', 'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php', 'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php', + 'CaseInsensitiveArray' => 'utils/CaseInsensitiveArray.php', + 'CaseInsensitiveArrayTestCase' => 'utils/__tests__/CaseInsensitiveArrayTestCase.php', 'CommandException' => 'future/exec/CommandException.php', 'ConduitClient' => 'conduit/ConduitClient.php', 'ConduitClientException' => 'conduit/ConduitClientException.php', @@ -545,6 +547,8 @@ 'AphrontScopedUnguardedWriteCapability' => 'Phobject', 'AphrontWriteGuard' => 'Phobject', 'BaseHTTPFuture' => 'Future', + 'CaseInsensitiveArray' => 'PhutilArray', + 'CaseInsensitiveArrayTestCase' => 'PhutilTestCase', 'CommandException' => 'Exception', 'ConduitClient' => 'Phobject', 'ConduitClientException' => 'Exception', diff --git a/src/utils/CaseInsensitiveArray.php b/src/utils/CaseInsensitiveArray.php new file mode 100644 --- /dev/null +++ b/src/utils/CaseInsensitiveArray.php @@ -0,0 +1,121 @@ +toArray()); // array('key' => 'foobar') + * ``` + * + * Note that it is not possible to reuse case variants of a key. That is, if + * the array contains key `xyz` then it is not possible to use any of the + * following case variants as an array key: `xyZ`, `xYz`, `xYZ`, `Xyz`, `XyZ`, + * `XYz`, `XYZ`. In order to use a case variant as a key, it is necessary to + * first unset the original case variant. + * + * ```lang=php + * $array = new CaseInsensitiveArray(array('key' => 'foo', 'KEY' => 'bar')); + * var_dump($array->toArray()); // array('key' => 'bar') + * + * $array['KEY'] = 'baz'; + * var_dump($array->toArray()); // array('key' => 'baz') + * + * unset($array['key']); + * $array['KEY'] = 'baz'; + * var_dump($array->toArray()); // array('KEY' => 'baz') + * ``` + */ +final class CaseInsensitiveArray extends PhutilArray { + + /** + * Mapping between original and case-invariant keys. + * + * All keys in the parent `PhutilArray` are indexed using the case-invariant + * key rather than the original key. + * + * @var map + */ + private $keys = array(); + + /** + * Construct a new array object. + * + * @param array The input array. + */ + public function __construct(array $data = array()) { + foreach ($data as $key => $value) { + $this->offsetSet($key, $value); + } + } + + public function getKeys() { + return array_values($this->keys); + } + + public function offsetExists($key) { + $key = $this->transformKey($key); + return array_key_exists($key, $this->keys); + } + + public function offsetGet($key) { + $key = $this->transformKey($key); + return parent::offsetGet($this->keys[$key]); + } + + public function offsetSet($key, $value) { + $transformed_key = $this->transformKey($key); + + if (isset($this->keys[$transformed_key])) { + // If the key already exists, reuse it and override the + // existing value. + $key = $this->keys[$transformed_key]; + } else { + // If the key doesn't yet, create a new array element. + $this->keys[$transformed_key] = $key; + } + + parent::offsetSet($key, $value); + } + + public function offsetUnset($key) { + $key = $this->transformKey($key); + + parent::offsetUnset($this->keys[$key]); + unset($this->keys[$key]); + } + + /** + * Transform an array key. + * + * This method transforms an array key to be case-invariant. We //could// + * just call [[http://php.net/manual/en/function.strtolower.php | + * `strtolower`]] directly, but this method allows us to contain the key + * transformation logic within a single method, should it ever change. + * + * Theoretically, we should be able to use any of the following functions + * for the purpose of key transformations: + * + * - [[http://php.net/manual/en/function.strtolower.php | `strtolower`]] + * - [[http://php.net/manual/en/function.strtoupper.php | `strtoupper`]] + * - Some creative use of other + * [[http://php.net/manual/en/book.strings.php | string transformation]] + * functions. + * + * @param string The input key. + * @return string The transformed key. + */ + private function transformKey($key) { + return phutil_utf8_strtolower($key); + } + +} diff --git a/src/utils/__tests__/CaseInsensitiveArrayTestCase.php b/src/utils/__tests__/CaseInsensitiveArrayTestCase.php new file mode 100644 --- /dev/null +++ b/src/utils/__tests__/CaseInsensitiveArrayTestCase.php @@ -0,0 +1,109 @@ +assertEqual(0, count($array)); + + $array['key'] = 'foo'; + $this->assertEqual(1, count($array)); + + $array['KEY'] = 'bar'; + $this->assertEqual(1, count($array)); + } + + public function testGetKeys() { + $input = array( + 'key' => true, + 'KEY' => true, + 'kEy' => true, + + 'foo' => false, + 'value' => false, + ); + $expected = array( + 'key', + 'foo', + 'value', + ); + + $array = new CaseInsensitiveArray($input); + $this->assertEqual($expected, $array->getKeys()); + } + + public function testOffsetExists() { + $input = array('key' => 'value'); + $expectations = array( + 'key' => true, + 'KEY' => true, + 'kEy' => true, + + 'foo' => false, + 'value' => false, + ); + + $array = new CaseInsensitiveArray($input); + + foreach ($expectations as $key => $expectation) { + if ($expectation) { + $this->assertTrue(isset($array[$key])); + } else { + $this->assertFalse(isset($array[$key])); + } + } + } + + public function testOffsetGet() { + $input = array('key' => 'value'); + $expectations = array( + 'key' => 'value', + 'KEY' => 'value', + 'kEy' => 'value', + + 'foo' => null, + 'value' => null, + ); + + $array = new CaseInsensitiveArray($input); + + foreach ($expectations as $key => $expectation) { + $this->assertEqual($expectation, @$array[$key]); + } + } + + public function testOffsetSet() { + $input = array(); + $data = array( + 'key' => 'foo', + 'KEY' => 'bar', + 'kEy' => 'baz', + ); + $expected = array('key' => 'baz'); + + $array = new CaseInsensitiveArray($input); + + foreach ($data as $key => $value) { + $array[$key] = $value; + } + + $this->assertEqual($expected, $array->toArray()); + } + + public function testOffsetUnset() { + $input = array('key' => 'value'); + $data = array( + 'KEY', + ); + $expected = array(); + + $array = new CaseInsensitiveArray($input); + + foreach ($data as $key) { + unset($array[$key]); + } + + $this->assertEqual($expected, $array->toArray()); + } + +}