LazyArray - Add helper class for lazily-loaded lists
authorTim Otten <totten@civicrm.org>
Thu, 20 May 2021 02:34:16 +0000 (19:34 -0700)
committerTim Otten <totten@civicrm.org>
Mon, 7 Jun 2021 03:18:52 +0000 (20:18 -0700)
CRM/Utils/Array.php
CRM/Utils/LazyArray.php [new file with mode: 0644]
tests/phpunit/CRM/Utils/LazyArrayTest.php [new file with mode: 0644]

index f2260f4502d42dcada60b89721db1166dfcbf291..06aa5a8faf634d85fc23cb7cd5431a0c0e3a038a 100644 (file)
  */
 class CRM_Utils_Array {
 
+  /**
+   * Cast a value to an array.
+   *
+   * This is similar to PHP's `(array)`, but it also converts iterators.
+   *
+   * @param mixed $value
+   * @return array
+   */
+  public static function cast($value) {
+    if (is_array($value)) {
+      return $value;
+    }
+    if ($value instanceof CRM_Utils_LazyArray || $value instanceof ArrayObject) {
+      // iterator_to_array() would work here, but getArrayCopy() doesn't require actual iterations.
+      return $value->getArrayCopy();
+    }
+    if (is_iterable($value)) {
+      return iterator_to_array($value);
+    }
+    if (is_scalar($value)) {
+      return [$value];
+    }
+    throw new \RuntimeException(sprintf("Cannot cast %s to array", gettype($value)));
+  }
+
   /**
    * Returns $list[$key] if such element exists, or a default value otherwise.
    *
diff --git a/CRM/Utils/LazyArray.php b/CRM/Utils/LazyArray.php
new file mode 100644 (file)
index 0000000..4ea6ab1
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * A lazy-array works much like a regular array or ArrayObject. However, it is
+ * initially empty - and it is only populated if used.
+ */
+class CRM_Utils_LazyArray implements ArrayAccess, IteratorAggregate, Countable {
+
+  /**
+   * A function which generates a list of values.
+   *
+   * @var callable
+   *   function(): iterable
+   */
+  private $func;
+
+  /**
+   * Cached values
+   *
+   * @var array|null
+   */
+  private $cache;
+
+  /**
+   * CRM_Utils_LazyList constructor.
+   *
+   * @param callable $func
+   *   Function which provides a list of values (array/iterator/generator).
+   */
+  public function __construct($func) {
+    $this->func = $func;
+  }
+
+  /**
+   * Determine if the content has been fetched.
+   *
+   * @return bool
+   */
+  public function isLoaded() {
+    return $this->cache !== NULL;
+  }
+
+  public function load($force = FALSE) {
+    if ($this->cache === NULL || $force) {
+      $this->cache = CRM_Utils_Array::cast(call_user_func($this->func));
+    }
+    return $this;
+  }
+
+  public function offsetExists($offset) {
+    return isset($this->load()->cache[$offset]);
+  }
+
+  public function &offsetGet($offset) {
+    return $this->load()->cache[$offset];
+  }
+
+  public function offsetSet($offset, $value) {
+    if ($offset === NULL) {
+      $this->load()->cache[] = $value;
+    }
+    else {
+      $this->load()->cache[$offset] = $value;
+    }
+  }
+
+  public function offsetUnset($offset) {
+    unset($this->load()->cache[$offset]);
+  }
+
+  public function getIterator() {
+    return new ArrayIterator($this->load()->cache);
+  }
+
+  /**
+   * @return array
+   */
+  public function getArrayCopy() {
+    return $this->load()->cache;
+  }
+
+  public function count() {
+    return count($this->load()->cache);
+  }
+
+}
diff --git a/tests/phpunit/CRM/Utils/LazyArrayTest.php b/tests/phpunit/CRM/Utils/LazyArrayTest.php
new file mode 100644 (file)
index 0000000..c48b145
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+/**
+ * Class CRM_Utils_LazyArrayTest
+ * @group headless
+ */
+class CRM_Utils_LazyArrayTest extends CiviUnitTestCase {
+
+  public function testAssoc() {
+    $l = $this->createFruitBasket();
+    $this->assertFalse($l->isLoaded());
+
+    $this->assertEquals('apple', $l['a']);
+    $this->assertEquals('banana', $l['b']);
+    $this->assertTrue($l->isLoaded());
+    $this->assertEquals(3, count($l));
+    $this->assertTrue(isset($l['c']));
+    $this->assertFalse(isset($l['d']));
+
+    $l['a'] = 'apricot';
+    $this->assertEquals('apricot', $l['a']);
+    $this->assertEquals(3, count($l));
+
+    $l['d'] = 'date';
+    $this->assertEquals('date', $l['d']);
+    $this->assertEquals(4, count($l));
+
+    $keys = [];
+    foreach ($l as $key => $value) {
+      $keys[] = $key;
+    }
+    $this->assertEquals(['a', 'b', 'c', 'd'], $keys);
+    $this->assertEquals(['a', 'b', 'c', 'd'], array_keys(CRM_Utils_Array::cast($l)));
+  }
+
+  public function testNumeric() {
+    $l = $this->createSeaRecords();
+    $this->assertFalse($l->isLoaded());
+
+    $this->assertEquals('aegean', $l[0]['name']);
+    $this->assertEquals('caspian', $l[2]['name']);
+    $this->assertTrue($l->isLoaded());
+    $this->assertEquals(3, count($l));
+    $this->assertTrue(isset($l[2]));
+    $this->assertFalse(isset($l[3]));
+
+    $l[2]['name'] = 'coral';
+    $this->assertEquals(['name' => 'coral', 'area' => 371], $l['2']);
+    $this->assertEquals(3, count($l));
+
+    $l[] = ['name' => 'weddell', 'area' => 2800];
+    $this->assertEquals('weddell', $l[3]['name']);
+    $this->assertEquals(4, count($l));
+
+    $keys = [];
+    foreach ($l as $key => $value) {
+      $keys[] = $key;
+    }
+    $this->assertEquals([0, 1, 2, 3], $keys);
+    $this->assertEquals([0, 1, 2, 3], array_keys(CRM_Utils_Array::cast($l)));
+  }
+
+  public function testBasicInspections() {
+    $l = $this->createFruitBasket();
+    $this->assertFalse($l->isLoaded());
+
+    $this->assertTrue($l !== NULL);
+    $this->assertTrue($l instanceof CRM_Utils_LazyArray);
+    $this->assertTrue(is_iterable($l));
+    $this->assertTrue(!is_array($l));
+
+    $this->assertFalse($l->isLoaded());
+
+    $this->assertEquals(3, count($l));
+    $this->assertTrue($l->isLoaded());
+  }
+
+  public function testCopy() {
+    $l = $this->createFruitBasket();
+    $copy = $l->getArrayCopy();
+    $copy['d'] = 'date';
+
+    $this->assertEquals(3, count($l));
+    $this->assertEquals(4, count($copy));
+  }
+
+  /**
+   * @return \CRM_Utils_LazyArray
+   */
+  private function createFruitBasket(): \CRM_Utils_LazyArray {
+    return new CRM_Utils_LazyArray(function () {
+      yield 'a' => 'apple';
+      yield 'b' => 'banana';
+      yield 'c' => 'cherry';
+    });
+  }
+
+  /**
+   * @return \CRM_Utils_LazyArray
+   */
+  private function createSeaRecords(): \CRM_Utils_LazyArray {
+    return new CRM_Utils_LazyArray(function () {
+      return [
+        ['name' => 'aegean', 'area' => 214],
+        ['name' => 'baltic', 'area' => 377],
+        ['name' => 'caspian', 'area' => 371],
+      ];
+    });
+  }
+
+}