From 835a2b71d45233cb2b82adba5a2cd63a9480fc4e Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 6 Jul 2023 10:13:21 +0200 Subject: [PATCH] Add lazy models for BSON documents and arrays --- psalm-baseline.xml | 62 +++++- src/Model/LazyBSONArray.php | 270 +++++++++++++++++++++++++++ src/Model/LazyBSONDocument.php | 229 +++++++++++++++++++++++ tests/Model/LazyBSONArrayTest.php | 243 ++++++++++++++++++++++++ tests/Model/LazyBSONDocumentTest.php | 261 ++++++++++++++++++++++++++ 5 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 src/Model/LazyBSONArray.php create mode 100644 src/Model/LazyBSONDocument.php create mode 100644 tests/Model/LazyBSONArrayTest.php create mode 100644 tests/Model/LazyBSONDocumentTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 0da65b97e..4ca444af7 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -69,7 +69,7 @@ - + AsListIterator @@ -173,6 +173,66 @@ index['name']]]> + + + $offset + $offset + $offset + + + unset[$offset]) && ! isset($seen[$offset]); + }, + )]]> + unset[$offset]) && ! isset($seen[$offset]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, int $offset) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$offset] = true; + + // Return actual value (potentially overridden by offsetSet) + return $this->offsetGet($offset); + }, + )]]> + + + $seen[$offset] + + + is_array($input) + + + array_values + + + + + ]]> + + + $value + + + ]]> + + + is_object($input) + + options['typeMap']]]> diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php new file mode 100644 index 000000000..323d194db --- /dev/null +++ b/src/Model/LazyBSONArray.php @@ -0,0 +1,270 @@ + + * @template-implements IteratorAggregate + */ +class LazyBSONArray implements ArrayAccess, IteratorAggregate +{ + /** @var PackedArray */ + private PackedArray $bson; + + /** @var array */ + private array $read = []; + + /** @var array */ + private array $exists = []; + + /** @var array */ + private array $set = []; + + /** @var array */ + private array $unset = []; + + private bool $entirePackedArrayRead = false; + + /** + * Deep clone this lazy array. + */ + public function __clone() + { + $this->bson = clone $this->bson; + + foreach ($this->set as $key => $value) { + $this->set[$key] = recursive_copy($value); + } + } + + /** + * Constructs a lazy BSON array. + * + * @param PackedArray|list|null $input An input for a lazy array. + * When given a BSON array, this is treated as input. For lists + * this constructs a new BSON array using fromPHP. + */ + public function __construct($input = null) + { + if ($input === null) { + $this->bson = PackedArray::fromPHP([]); + } elseif ($input instanceof PackedArray) { + $this->bson = $input; + } elseif (is_array($input)) { + $this->bson = PackedArray::fromPHP([]); + $this->set = array_values($input); + $this->exists = array_map( + /** @param TValue $value */ + function ($value): bool { + return true; + }, + $this->set, + ); + } else { + throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']); + } + } + + /** @return AsListIterator */ + public function getIterator(): AsListIterator + { + $itemIterator = new AppendIterator(); + // Iterate through all fields in the BSON array + $itemIterator->append($this->bson->getIterator()); + // Then iterate over all fields that were set + $itemIterator->append(new ArrayIterator($this->set)); + + /** @var array $seen */ + $seen = []; + + // Use AsListIterator to ensure we're indexing from 0 without gaps + return new AsListIterator( + new CallbackIterator( + // Skip keys that were unset or handled in a previous iterator + new CallbackFilterIterator( + $itemIterator, + /** @param TValue $value */ + function ($value, int $offset) use (&$seen): bool { + return ! isset($this->unset[$offset]) && ! isset($seen[$offset]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, int $offset) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$offset] = true; + + // Return actual value (potentially overridden by offsetSet) + return $this->offsetGet($offset); + }, + ), + ); + } + + /** @param mixed $offset */ + public function offsetExists($offset): bool + { + if (! is_numeric($offset)) { + return false; + } + + $offset = (int) $offset; + + // If we've looked for the value, return the cached result + if (isset($this->exists[$offset])) { + return $this->exists[$offset]; + } + + return $this->exists[$offset] = $this->bson->has($offset); + } + + /** + * @param mixed $offset + * @return TValue + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + if (! is_numeric($offset)) { + trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING); + + return null; + } + + $offset = (int) $offset; + $this->readFromBson($offset); + + if (isset($this->unset[$offset]) || ! $this->exists[$offset]) { + trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING); + + return null; + } + + return array_key_exists($offset, $this->set) ? $this->set[$offset] : $this->read[$offset]; + } + + /** + * @param mixed $offset + * @param TValue $value + */ + public function offsetSet($offset, $value): void + { + if ($offset === null) { + $this->readEntirePackedArray(); + + $existingItems = array_merge( + array_keys($this->read), + array_keys($this->set), + ); + + $offset = $existingItems === [] ? 0 : max($existingItems) + 1; + } elseif (! is_numeric($offset)) { + trigger_error(sprintf('Unsupported offset: %s', $offset), E_USER_WARNING); + + return; + } else { + $offset = (int) $offset; + } + + $this->set[$offset] = $value; + unset($this->unset[$offset]); + $this->exists[$offset] = true; + } + + /** @param mixed $offset */ + public function offsetUnset($offset): void + { + if (! is_numeric($offset)) { + trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING); + + return; + } + + $offset = (int) $offset; + $this->unset[$offset] = true; + $this->exists[$offset] = false; + unset($this->set[$offset]); + } + + private function readEntirePackedArray(): void + { + if ($this->entirePackedArrayRead) { + return; + } + + foreach ($this->bson as $offset => $value) { + $this->read[$offset] = $value; + + if (! isset($this->exists[$offset])) { + $this->exists[$offset] = true; + } + } + + $this->entirePackedArrayRead = true; + } + + private function readFromBson(int $offset): void + { + if (array_key_exists($offset, $this->read)) { + return; + } + + // Read value if it's present in the BSON structure + $found = false; + if ($this->bson->has($offset)) { + $found = true; + $this->read[$offset] = $this->bson->get($offset); + } + + // Mark the offset as "existing" if it wasn't previously marked already + if (! isset($this->exists[$offset])) { + $this->exists[$offset] = $found; + } + } +} diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php new file mode 100644 index 000000000..0ff235a66 --- /dev/null +++ b/src/Model/LazyBSONDocument.php @@ -0,0 +1,229 @@ + + * @template-implements IteratorAggregate + */ +class LazyBSONDocument implements ArrayAccess, IteratorAggregate +{ + /** @var Document */ + private Document $bson; + + /** @var array */ + private array $read = []; + + /** @var array */ + private array $exists = []; + + /** @var array */ + private array $set = []; + + /** @var array */ + private array $unset = []; + + /** + * Deep clone this lazy document. + */ + public function __clone() + { + $this->bson = clone $this->bson; + + foreach ($this->set as $key => $value) { + $this->set[$key] = recursive_copy($value); + } + } + + /** + * Constructs a lazy BSON document. + * + * @param Document|array|object|null $input An input for a lazy object. + * When given a BSON document, this is treated as input. For arrays + * and objects this constructs a new BSON document using fromPHP. + */ + public function __construct($input = null) + { + if ($input === null) { + $this->bson = Document::fromPHP([]); + } elseif ($input instanceof Document) { + $this->bson = $input; + } elseif (is_array($input) || is_object($input)) { + $this->bson = Document::fromPHP([]); + + foreach ($input as $key => $value) { + assert(is_string($key)); + $this->set[$key] = $value; + $this->exists[$key] = true; + } + } else { + throw InvalidArgumentException::invalidType('input', $input, [Document::class, 'array', 'null']); + } + } + + /** @return TValue */ + public function __get(string $property) + { + $this->readFromBson($property); + + if (isset($this->unset[$property]) || ! $this->exists[$property]) { + trigger_error(sprintf('Undefined property: %s', $property), E_USER_WARNING); + + return null; + } + + return array_key_exists($property, $this->set) ? $this->set[$property] : $this->read[$property]; + } + + public function __isset(string $name): bool + { + // If we've looked for the value, return the cached result + if (isset($this->exists[$name])) { + return $this->exists[$name]; + } + + return $this->exists[$name] = $this->bson->has($name); + } + + /** @param TValue $value */ + public function __set(string $property, $value): void + { + $this->set[$property] = $value; + unset($this->unset[$property]); + $this->exists[$property] = true; + } + + public function __unset(string $name): void + { + $this->unset[$name] = true; + $this->exists[$name] = false; + unset($this->set[$name]); + } + + /** @return Iterator */ + public function getIterator(): CallbackIterator + { + $itemIterator = new AppendIterator(); + // Iterate through all fields in the BSON document + $itemIterator->append($this->bson->getIterator()); + // Then iterate over all fields that were set + $itemIterator->append(new ArrayIterator($this->set)); + + /** @var array $seen */ + $seen = []; + + return new CallbackIterator( + // Skip keys that were unset or handled in a previous iterator + new CallbackFilterIterator( + $itemIterator, + /** @param TValue $current */ + function ($current, string $key) use (&$seen): bool { + return ! isset($this->unset[$key]) && ! isset($seen[$key]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, string $key) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$key] = true; + + // Return actual value (potentially overridden by __set) + return $this->__get($key); + }, + ); + } + + /** @param mixed $offset */ + public function offsetExists($offset): bool + { + return $this->__isset((string) $offset); + } + + /** + * @param mixed $offset + * @return TValue + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->__get((string) $offset); + } + + /** + * @param mixed $offset + * @param TValue $value + */ + public function offsetSet($offset, $value): void + { + $this->__set((string) $offset, $value); + } + + /** @param mixed $offset */ + public function offsetUnset($offset): void + { + $this->__unset((string) $offset); + } + + private function readFromBson(string $key): void + { + if (array_key_exists($key, $this->read)) { + return; + } + + // Read value if it's present in the BSON structure + $found = false; + if ($this->bson->has($key)) { + $found = true; + $this->read[$key] = $this->bson->get($key); + } + + // Mark the offset as "existing" if it wasn't previously marked already + if (! isset($this->exists[$key])) { + $this->exists[$key] = $found; + } + } +} diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php new file mode 100644 index 000000000..d0dc872c8 --- /dev/null +++ b/tests/Model/LazyBSONArrayTest.php @@ -0,0 +1,243 @@ + [ + new LazyBSONArray([ + 'bar', + new LazyBSONDocument(['bar' => 'baz']), + new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'packedArray' => [ + new LazyBSONArray(PackedArray::fromPHP([ + 'bar', + ['bar' => 'baz'], + [0, 1, 2], + ])), + ]; + } + + public function testConstructWithoutArgument(): void + { + $instance = new LazyBSONArray(); + $this->assertSame([], iterator_to_array($instance)); + } + + public function testConstructWithWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + new LazyBSONArray('foo'); + } + + public function testConstructWithArrayUsesLiteralValues(): void + { + $array = new LazyBSONArray([ + (object) ['bar' => 'baz'], + ['bar' => 'baz'], + [0, 1, 2], + ]); + + $this->assertInstanceOf(stdClass::class, $array[0]); + $this->assertIsArray($array[1]); + $this->assertIsArray($array[2]); + } + + public function testClone(): void + { + $original = new LazyBSONArray(); + $original[0] = (object) ['foo' => 'bar']; + + $clone = clone $original; + $clone[0]->foo = 'baz'; + + self::assertSame('bar', $original[0]->foo); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGet(LazyBSONArray $array): void + { + $this->assertSame('bar', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetAfterUnset(LazyBSONArray $array): void + { + $this->assertSame('bar', $array[0]); + unset($array[0]); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: 0'); + $array[0]; + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForMissingOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: 4'); + $array[4]; + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForNumericOffset(LazyBSONArray $array): void + { + $this->assertSame('bar', $array['0']); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForUnsupportedOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: foo'); + $array['foo']; + } + + /** @dataProvider provideTestArray */ + public function testGetDocument(LazyBSONArray $array): void + { + $this->assertInstanceOf(Document::class, $array[1]); + $this->assertInstanceOf(Document::class, $array[1]); + } + + /** @dataProvider provideTestArray */ + public function testGetArray(LazyBSONArray $array): void + { + $this->assertInstanceOf(PackedArray::class, $array[2]); + $this->assertInstanceOf(PackedArray::class, $array[2]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetExists(LazyBSONArray $array): void + { + $this->assertTrue(isset($array[0])); + $this->assertFalse(isset($array[4])); + + // Unsupported offset + $this->assertFalse(isset($array['foo'])); + + // Numeric offset + $this->assertTrue(isset($array['1'])); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSet(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[4])); + $array[4] = 'yay!'; + $this->assertSame('yay!', $array[4]); + + $this->assertSame('bar', $array[0]); + $array[0] = 'baz'; + $this->assertSame('baz', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSetForNumericOffset(LazyBSONArray $array): void + { + $array['1'] = 'baz'; + $this->assertSame('baz', $array[1]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSetForUnsupportedOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Unsupported offset: foo'); + $array['foo'] = 'yay!'; + } + + /** @dataProvider provideTestArray */ + public function testAppend(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[3])); + $array[] = 'yay!'; + $this->assertSame('yay!', $array[3]); + } + + public function testAppendToEmptyArray(): void + { + $array = new LazyBSONArray(); + + $this->assertFalse(isset($array[0])); + $array[] = 'yay!'; + $this->assertSame('yay!', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testAppendWithGap(LazyBSONArray $array): void + { + // Leave offset 3 empty + $array[4] = 'yay!'; + + $this->assertFalse(isset($array[3])); + $array[] = 'bleh'; + + // Expect offset 3 to be skipped, offset 5 is used as 4 is already set + $this->assertFalse(isset($array[3])); + $this->assertSame('bleh', $array[5]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetUnset(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[4])); + $array[4] = 'yay!'; + unset($array[4]); + $this->assertFalse(isset($array[4])); + + unset($array[0]); + $this->assertFalse(isset($array[0])); + + // Change value to ensure it is unset for good + $array[1] = (object) ['foo' => 'baz']; + unset($array[1]); + $this->assertFalse(isset($array[1])); + } + + /** @dataProvider provideTestArray */ + public function testIterator(LazyBSONArray $array): void + { + $items = iterator_to_array($array); + $this->assertCount(3, $items); + $this->assertSame('bar', $items[0]); + $this->assertInstanceOf(Document::class, $items[1]); + $this->assertInstanceOf(PackedArray::class, $items[2]); + + $array[0] = 'baz'; + $items = iterator_to_array($array); + $this->assertCount(3, $items); + $this->assertSame('baz', $items[0]); + $this->assertInstanceOf(Document::class, $items[1]); + $this->assertInstanceOf(PackedArray::class, $items[2]); + + unset($array[0]); + unset($array[2]); + $items = iterator_to_array($array); + $this->assertCount(1, $items); + $this->assertInstanceOf(Document::class, $items[0]); + + // Leave a gap to ensure we're re-indexing keys + $array[5] = 'yay!'; + $items = iterator_to_array($array); + $this->assertCount(2, $items); + $this->assertInstanceOf(Document::class, $items[0]); + $this->assertSame('yay!', $items[1]); + } +} diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php new file mode 100644 index 000000000..0b58fed4e --- /dev/null +++ b/tests/Model/LazyBSONDocumentTest.php @@ -0,0 +1,261 @@ + [ + new LazyBSONDocument([ + 'foo' => 'bar', + 'document' => new LazyBSONDocument(['bar' => 'baz']), + 'array' => new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'object' => [ + new LazyBSONDocument((object) [ + 'foo' => 'bar', + 'document' => new LazyBSONDocument(['bar' => 'baz']), + 'array' => new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'document' => [ + new LazyBSONDocument(Document::fromPHP([ + 'foo' => 'bar', + 'document' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ])), + ]; + } + + public static function provideTestDocumentWithNativeArrays(): Generator + { + yield 'array' => [ + new LazyBSONDocument([ + 'document' => (object) ['bar' => 'baz'], + 'hash' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ]), + ]; + + yield 'object' => [ + new LazyBSONDocument((object) [ + 'document' => (object) ['bar' => 'baz'], + 'hash' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ]), + ]; + } + + public function testConstructWithoutArgument(): void + { + $instance = new LazyBSONDocument(); + $this->assertSame([], iterator_to_array($instance)); + } + + public function testConstructWithWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + new LazyBSONDocument('foo'); + } + + /** @dataProvider provideTestDocumentWithNativeArrays */ + public function testConstructWithArrayUsesLiteralValues($value): void + { + $document = new LazyBSONDocument($value); + + $this->assertInstanceOf(stdClass::class, $document->document); + $this->assertIsArray($document->hash); + $this->assertIsArray($document->array); + } + + public function testClone(): void + { + $original = new LazyBSONDocument(); + $original->object = (object) ['foo' => 'bar']; + + $clone = clone $original; + $clone->object->foo = 'baz'; + + self::assertSame('bar', $original->object->foo); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGet(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document->foo); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGetAfterUnset(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document->foo); + unset($document->foo); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: foo'); + $document->foo; + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGetForMissingProperty(LazyBSONDocument $document): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: bar'); + $document->bar; + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGet(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document['foo']); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGetAfterUnset(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document['foo']); + unset($document['foo']); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: foo'); + $document['foo']; + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGetForMissingOffset(LazyBSONDocument $document): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: bar'); + $document['bar']; + } + + /** @dataProvider provideTestDocument */ + public function testGetDocument(LazyBSONDocument $document): void + { + $this->assertInstanceOf(Document::class, $document->document); + $this->assertInstanceOf(Document::class, $document['document']); + } + + /** @dataProvider provideTestDocument */ + public function testGetArray(LazyBSONDocument $document): void + { + $this->assertInstanceOf(PackedArray::class, $document->array); + $this->assertInstanceOf(PackedArray::class, $document['array']); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyIsset(LazyBSONDocument $document): void + { + $this->assertTrue(isset($document->foo)); + $this->assertFalse(isset($document->bar)); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetExists(LazyBSONDocument $document): void + { + $this->assertTrue(isset($document['foo'])); + $this->assertFalse(isset($document['bar'])); + } + + /** @dataProvider provideTestDocument */ + public function testPropertySet(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document->new)); + $document->new = 'yay!'; + $this->assertSame('yay!', $document->new); + + $this->assertSame('bar', $document->foo); + $document->foo = 'baz'; + $this->assertSame('baz', $document->foo); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetSet(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document['new'])); + $document['new'] = 'yay!'; + $this->assertSame('yay!', $document['new']); + + $this->assertSame('bar', $document['foo']); + $document['foo'] = 'baz'; + $this->assertSame('baz', $document['foo']); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyUnset(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document->new)); + $document->new = 'yay!'; + unset($document->new); + $this->assertFalse(isset($document->new)); + + unset($document->foo); + $this->assertFalse(isset($document->foo)); + + // Change value to ensure it is unset for good + $document->document = (object) ['foo' => 'baz']; + unset($document->document); + $this->assertFalse(isset($document->document)); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetUnset(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document['new'])); + $document['new'] = 'yay!'; + unset($document['new']); + $this->assertFalse(isset($document['new'])); + + unset($document['foo']); + $this->assertFalse(isset($document['foo'])); + + // Change value to ensure it is unset for good + $document['document'] = (object) ['foo' => 'baz']; + unset($document['document']); + $this->assertFalse(isset($document['document'])); + } + + /** @dataProvider provideTestDocument */ + public function testIterator(LazyBSONDocument $document): void + { + $items = iterator_to_array($document); + $this->assertCount(3, $items); + $this->assertSame('bar', $items['foo']); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertInstanceOf(PackedArray::class, $items['array']); + + $document->foo = 'baz'; + $items = iterator_to_array($document); + $this->assertCount(3, $items); + $this->assertSame('baz', $items['foo']); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertInstanceOf(PackedArray::class, $items['array']); + + unset($document->foo); + unset($document->array); + $items = iterator_to_array($document); + $this->assertCount(1, $items); + $this->assertInstanceOf(Document::class, $items['document']); + + $document->new = 'yay!'; + $items = iterator_to_array($document); + $this->assertCount(2, $items); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertSame('yay!', $items['new']); + } +}