diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index e6b0a3436..cf237b681 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -28,6 +28,11 @@
($value is NativeType ? BSONType : $value)
+
+
+ $return[]
+
+
$cmd[$option]
@@ -168,6 +173,74 @@
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]
+
+
+ $value
+
+
+ is_array($input)
+
+
+ is_numeric($key)
+
+
+
+
+ ]]>
+
+
+ $value
+
+
+ ]]>
+
+
+ is_object($input)
+
+
+
+
+ ListIterator
+
+
options['typeMap']]]>
diff --git a/src/Codec/ArrayCodec.php b/src/Codec/ArrayCodec.php
new file mode 100644
index 000000000..4ffeb5540
--- /dev/null
+++ b/src/Codec/ArrayCodec.php
@@ -0,0 +1,111 @@
+
+ */
+final class ArrayCodec implements Codec, CodecLibraryAware
+{
+ private ?CodecLibrary $library = null;
+
+ public function attachCodecLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true array $value
+ */
+ public function canDecode($value): bool
+ {
+ return is_array($value);
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true array $value
+ */
+ public function canEncode($value): bool
+ {
+ return is_array($value);
+ }
+
+ /** @param mixed $value */
+ public function decode($value): array
+ {
+ if (! $this->canDecode($value)) {
+ throw UnsupportedValueException::invalidDecodableValue($value);
+ }
+
+ return array_map(
+ [$this->getLibrary(), 'decodeIfSupported'],
+ $value,
+ );
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is array ? array : $value)
+ */
+ public function decodeIfSupported($value)
+ {
+ return $this->canDecode($value) ? $this->decode($value) : $value;
+ }
+
+ /** @param mixed $value */
+ public function encode($value): array
+ {
+ if (! $this->canEncode($value)) {
+ throw UnsupportedValueException::invalidEncodableValue($value);
+ }
+
+ return array_map(
+ [$this->getLibrary(), 'encodeIfSupported'],
+ $value,
+ );
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is array ? array : $value)
+ */
+ public function encodeIfSupported($value)
+ {
+ return $this->canEncode($value) ? $this->encode($value) : $value;
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ if (! $this->library) {
+ $this->library = new CodecLibrary();
+ }
+
+ return $this->library;
+ }
+}
diff --git a/src/Codec/CodecLibrary.php b/src/Codec/CodecLibrary.php
index 92aa8a9dc..96e9849dd 100644
--- a/src/Codec/CodecLibrary.php
+++ b/src/Codec/CodecLibrary.php
@@ -64,7 +64,7 @@ final public function attachCodec(Codec $codec): self
{
$this->decoders[] = $codec;
$this->encoders[] = $codec;
- if ($codec instanceof KnowsCodecLibrary) {
+ if ($codec instanceof CodecLibraryAware) {
$codec->attachCodecLibrary($this);
}
@@ -75,7 +75,7 @@ final public function attachCodec(Codec $codec): self
final public function attachDecoder(Decoder $decoder): self
{
$this->decoders[] = $decoder;
- if ($decoder instanceof KnowsCodecLibrary) {
+ if ($decoder instanceof CodecLibraryAware) {
$decoder->attachCodecLibrary($this);
}
@@ -86,7 +86,7 @@ final public function attachDecoder(Decoder $decoder): self
final public function attachEncoder(Encoder $encoder): self
{
$this->encoders[] = $encoder;
- if ($encoder instanceof KnowsCodecLibrary) {
+ if ($encoder instanceof CodecLibraryAware) {
$encoder->attachCodecLibrary($this);
}
diff --git a/src/Codec/KnowsCodecLibrary.php b/src/Codec/CodecLibraryAware.php
similarity index 97%
rename from src/Codec/KnowsCodecLibrary.php
rename to src/Codec/CodecLibraryAware.php
index e98e845d1..5218775a4 100644
--- a/src/Codec/KnowsCodecLibrary.php
+++ b/src/Codec/CodecLibraryAware.php
@@ -22,7 +22,7 @@
* it was added to. The library will be injected when the codec is added to the
* library. This allows codecs to recursively encode its nested values.
*/
-interface KnowsCodecLibrary
+interface CodecLibraryAware
{
public function attachCodecLibrary(CodecLibrary $library): void;
}
diff --git a/src/Codec/LazyBSONArrayCodec.php b/src/Codec/LazyBSONArrayCodec.php
new file mode 100644
index 000000000..a36fe3d76
--- /dev/null
+++ b/src/Codec/LazyBSONArrayCodec.php
@@ -0,0 +1,110 @@
+
+ */
+final class LazyBSONArrayCodec implements Codec, CodecLibraryAware
+{
+ private ?CodecLibrary $library = null;
+
+ public function attachCodecLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true PackedArray $value
+ */
+ public function canDecode($value): bool
+ {
+ return $value instanceof PackedArray;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true LazyBSONArray $value
+ */
+ public function canEncode($value): bool
+ {
+ return $value instanceof LazyBSONArray;
+ }
+
+ /** @param mixed $value */
+ public function decode($value): LazyBSONArray
+ {
+ if (! $value instanceof PackedArray) {
+ throw UnsupportedValueException::invalidDecodableValue($value);
+ }
+
+ return new LazyBSONArray($value, $this->getLibrary());
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is PackedArray ? LazyBSONArray : $value)
+ */
+ public function decodeIfSupported($value)
+ {
+ return $this->canDecode($value) ? $this->decode($value) : $value;
+ }
+
+ /** @param mixed $value */
+ public function encode($value): PackedArray
+ {
+ if (! $value instanceof LazyBSONArray) {
+ throw UnsupportedValueException::invalidEncodableValue($value);
+ }
+
+ $return = [];
+ /** @var mixed $offsetValue */
+ foreach ($value as $offsetValue) {
+ $return[] = $this->getLibrary()->encodeIfSupported($offsetValue);
+ }
+
+ return PackedArray::fromPHP($return);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is LazyBSONArray ? PackedArray : $value)
+ */
+ public function encodeIfSupported($value)
+ {
+ return $this->canEncode($value) ? $this->encode($value) : $value;
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ if (! $this->library) {
+ $this->library = new LazyBSONCodecLibrary();
+ }
+
+ return $this->library;
+ }
+}
diff --git a/src/Codec/LazyBSONCodecLibrary.php b/src/Codec/LazyBSONCodecLibrary.php
new file mode 100644
index 000000000..c56d5d142
--- /dev/null
+++ b/src/Codec/LazyBSONCodecLibrary.php
@@ -0,0 +1,31 @@
+
+ */
+final class LazyBSONDocumentCodec implements DocumentCodec, CodecLibraryAware
+{
+ private ?CodecLibrary $library = null;
+
+ public function attachCodecLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true Document $value
+ */
+ public function canDecode($value): bool
+ {
+ return $value instanceof Document;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true LazyBSONDocument $value
+ */
+ public function canEncode($value): bool
+ {
+ return $value instanceof LazyBSONDocument;
+ }
+
+ /** @param mixed $value */
+ public function decode($value): LazyBSONDocument
+ {
+ if (! $value instanceof Document) {
+ throw UnsupportedValueException::invalidDecodableValue($value);
+ }
+
+ return new LazyBSONDocument($value, $this->getLibrary());
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is Document ? LazyBSONDocument : $value)
+ */
+ public function decodeIfSupported($value)
+ {
+ return $this->canDecode($value) ? $this->decode($value) : $value;
+ }
+
+ /** @param mixed $value */
+ public function encode($value): Document
+ {
+ if (! $value instanceof LazyBSONDocument) {
+ throw UnsupportedValueException::invalidEncodableValue($value);
+ }
+
+ $return = new stdClass();
+ /** @var mixed $fieldValue */
+ foreach ($value as $field => $fieldValue) {
+ $return->{$field} = $this->getLibrary()->encodeIfSupported($fieldValue);
+ }
+
+ return Document::fromPHP($return);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is LazyBSONDocument ? Document : $value)
+ */
+ public function encodeIfSupported($value)
+ {
+ return $this->canEncode($value) ? $this->encode($value) : $value;
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ if (! $this->library) {
+ $this->library = new LazyBSONCodecLibrary();
+ }
+
+ return $this->library;
+ }
+}
diff --git a/src/Codec/ObjectCodec.php b/src/Codec/ObjectCodec.php
new file mode 100644
index 000000000..63205fa87
--- /dev/null
+++ b/src/Codec/ObjectCodec.php
@@ -0,0 +1,119 @@
+
+ */
+final class ObjectCodec implements Codec, CodecLibraryAware
+{
+ private ?CodecLibrary $library = null;
+
+ public function attachCodecLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true stdClass $value
+ */
+ public function canDecode($value): bool
+ {
+ return $value instanceof stdClass;
+ }
+
+ /**
+ * @param mixed $value
+ * @psalm-assert-if-true stdClass $value
+ */
+ public function canEncode($value): bool
+ {
+ return $value instanceof stdClass;
+ }
+
+ /** @param mixed $value */
+ public function decode($value): stdClass
+ {
+ if (! $this->canDecode($value)) {
+ throw UnsupportedValueException::invalidDecodableValue($value);
+ }
+
+ $return = new stdClass();
+
+ /** @var mixed $item */
+ foreach (get_object_vars($value) as $key => $item) {
+ $return->{$key} = $this->getLibrary()->decodeIfSupported($item);
+ }
+
+ return $return;
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is stdClass ? stdClass : $value)
+ */
+ public function decodeIfSupported($value)
+ {
+ return $this->canDecode($value) ? $this->decode($value) : $value;
+ }
+
+ /** @param mixed $value */
+ public function encode($value): stdClass
+ {
+ if (! $this->canEncode($value)) {
+ throw UnsupportedValueException::invalidEncodableValue($value);
+ }
+
+ $return = new stdClass();
+
+ /** @var mixed $item */
+ foreach (get_object_vars($value) as $key => $item) {
+ $return->{$key} = $this->getLibrary()->encodeIfSupported($item);
+ }
+
+ return $return;
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ * @psalm-return ($value is stdClass ? stdClass : $value)
+ */
+ public function encodeIfSupported($value)
+ {
+ return $this->canEncode($value) ? $this->encode($value) : $value;
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ if (! $this->library) {
+ $this->library = new CodecLibrary();
+ }
+
+ return $this->library;
+ }
+}
diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php
new file mode 100644
index 000000000..d81de8909
--- /dev/null
+++ b/src/Model/LazyBSONArray.php
@@ -0,0 +1,330 @@
+
+ * @template-implements IteratorAggregate
+ */
+final class LazyBSONArray implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable
+{
+ /** @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;
+
+ private CodecLibrary $codecLibrary;
+
+ /**
+ * 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);
+ }
+ }
+
+ /** @param PackedArray|list|null $input */
+ public function __construct($input = null, ?CodecLibrary $codecLibrary = null)
+ {
+ if ($input === null) {
+ $this->bson = PackedArray::fromPHP([]);
+ } elseif ($input instanceof PackedArray) {
+ $this->bson = $input;
+ } elseif (is_array($input)) {
+ if (! array_is_list($input)) {
+ // Check if all keys are numeric
+ foreach ($input as $key => $value) {
+ if (! is_numeric($key)) {
+ throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']);
+ }
+ }
+ }
+
+ $this->bson = PackedArray::fromPHP([]);
+ $this->set = $input;
+ $this->exists = array_map(
+ /** @param TValue $value */
+ fn ($value): bool => true,
+ $this->set,
+ );
+ } else {
+ throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']);
+ }
+
+ $this->codecLibrary = $codecLibrary ?? new LazyBSONCodecLibrary();
+ }
+
+ /** @return array{bson: PackedArray, set: array, unset: array, codecLibrary: CodecLibrary} */
+ public function __serialize(): array
+ {
+ return [
+ 'bson' => $this->bson,
+ 'set' => $this->set,
+ 'unset' => $this->unset,
+ 'codecLibrary' => $this->codecLibrary,
+ ];
+ }
+
+ /** @param array{bson: PackedArray, set: array, unset: array, codecLibrary: CodecLibrary} $data */
+ public function __unserialize(array $data): void
+ {
+ $this->bson = $data['bson'];
+ $this->set = $data['set'];
+ $this->unset = $data['unset'];
+ $this->codecLibrary = $data['codecLibrary'];
+
+ $this->exists = array_map(
+ /** @param TValue $value */
+ fn ($value): bool => true,
+ $this->set,
+ );
+
+ foreach ($this->unset as $index => $unused) {
+ $this->exists[$index] = false;
+ }
+ }
+
+ public function count(): int
+ {
+ $this->readEntirePackedArray();
+
+ return count(array_filter($this->exists));
+ }
+
+ /** @return ListIterator */
+ public function getIterator(): ListIterator
+ {
+ // Sort keys to ensure they are in ascending order
+ ksort($this->set, SORT_NUMERIC);
+
+ $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 ListIterator to ensure we're indexing from 0 without gaps
+ return new ListIterator(
+ 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);
+ },
+ ),
+ );
+ }
+
+ public function jsonSerialize(): array
+ {
+ return iterator_to_array($this->getIterator());
+ }
+
+ /** @param mixed $offset */
+ public function offsetExists($offset): bool
+ {
+ if (! is_numeric($offset)) {
+ return false;
+ }
+
+ $offset = (int) $offset;
+
+ 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->readFromPackedArray($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_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;
+
+ // The offset could've been explicitly unset before, so we need to
+ // respect pre-existing entries in the "exists" map
+ if (! isset($this->exists[$offset])) {
+ $this->exists[$offset] = true;
+ }
+ }
+
+ $this->entirePackedArrayRead = true;
+ }
+
+ private function readFromPackedArray(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->codecLibrary->decodeIfSupported($this->bson->get($offset));
+ }
+
+ // The offset could've been explicitly unset before, so we need to
+ // respect pre-existing entries in the "exists" map
+ 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..79cc5d105
--- /dev/null
+++ b/src/Model/LazyBSONDocument.php
@@ -0,0 +1,323 @@
+
+ * @template-implements IteratorAggregate
+ */
+final class LazyBSONDocument implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable
+{
+ /** @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 = [];
+
+ private bool $entireDocumentRead = false;
+
+ private CodecLibrary $codecLibrary;
+
+ /**
+ * 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);
+ }
+ }
+
+ /** @param Document|array|object|null $input */
+ public function __construct($input = null, ?CodecLibrary $codecLibrary = 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([]);
+
+ if (is_object($input)) {
+ $input = get_object_vars($input);
+ }
+
+ foreach ($input as $key => $value) {
+ $this->set[$key] = $value;
+ $this->exists[$key] = true;
+ }
+ } else {
+ throw InvalidArgumentException::invalidType('input', $input, [Document::class, 'array', 'null']);
+ }
+
+ $this->codecLibrary = $codecLibrary ?? new LazyBSONCodecLibrary();
+ }
+
+ /** @return TValue */
+ public function __get(string $property)
+ {
+ $this->readFromDocument($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 (isset($this->exists[$name])) {
+ return $this->exists[$name];
+ }
+
+ return $this->exists[$name] = $this->bson->has($name);
+ }
+
+ /** @return array{bson: Document, set: array, unset: array, codecLibrary: CodecLibrary} */
+ public function __serialize(): array
+ {
+ return [
+ 'bson' => $this->bson,
+ 'set' => $this->set,
+ 'unset' => $this->unset,
+ 'codecLibrary' => $this->codecLibrary,
+ ];
+ }
+
+ /** @param TValue $value */
+ public function __set(string $property, $value): void
+ {
+ $this->set[$property] = $value;
+ unset($this->unset[$property]);
+ $this->exists[$property] = true;
+ }
+
+ /** @param array{bson: Document, set: array, unset: array, codecLibrary: CodecLibrary} $data */
+ public function __unserialize(array $data): void
+ {
+ $this->bson = $data['bson'];
+ $this->set = $data['set'];
+ $this->unset = $data['unset'];
+ $this->codecLibrary = $data['codecLibrary'];
+
+ $this->exists = array_map(
+ /** @param TValue $value */
+ fn ($value): bool => true,
+ $this->set,
+ );
+
+ foreach ($this->unset as $name => $unused) {
+ $this->exists[$name] = false;
+ }
+ }
+
+ public function __unset(string $name): void
+ {
+ $this->unset[$name] = true;
+ $this->exists[$name] = false;
+ unset($this->set[$name]);
+ }
+
+ public function count(): int
+ {
+ $this->readEntireDocument();
+
+ return count(array_filter($this->exists));
+ }
+
+ /** @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);
+ },
+ );
+ }
+
+ public function jsonSerialize(): array
+ {
+ return iterator_to_array($this->getIterator());
+ }
+
+ /** @param mixed $offset */
+ public function offsetExists($offset): bool
+ {
+ if (! is_string($offset)) {
+ return false;
+ }
+
+ return $this->__isset($offset);
+ }
+
+ /**
+ * @param mixed $offset
+ * @return TValue
+ */
+ #[ReturnTypeWillChange]
+ public function offsetGet($offset)
+ {
+ if (! is_string($offset)) {
+ trigger_error(sprintf('Undefined offset: %s', (string) $offset), E_USER_WARNING);
+
+ return null;
+ }
+
+ return $this->__get($offset);
+ }
+
+ /**
+ * @param mixed $offset
+ * @param TValue $value
+ */
+ public function offsetSet($offset, $value): void
+ {
+ if (! is_string($offset)) {
+ trigger_error(sprintf('Unsupported offset: %s', (string) $offset), E_USER_WARNING);
+
+ return;
+ }
+
+ $this->__set($offset, $value);
+ }
+
+ /** @param mixed $offset */
+ public function offsetUnset($offset): void
+ {
+ if (! is_string($offset)) {
+ trigger_error(sprintf('Undefined offset: %s', (string) $offset), E_USER_WARNING);
+
+ return;
+ }
+
+ $this->__unset($offset);
+ }
+
+ private function readEntireDocument(): void
+ {
+ if ($this->entireDocumentRead) {
+ return;
+ }
+
+ foreach ($this->bson as $offset => $value) {
+ $this->read[$offset] = $value;
+
+ // The offset could've been explicitly unset before, so we need to
+ // respect pre-existing entries in the "exists" map
+ if (! isset($this->exists[$offset])) {
+ $this->exists[$offset] = true;
+ }
+ }
+
+ $this->entireDocumentRead = true;
+ }
+
+ private function readFromDocument(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->codecLibrary->decodeIfSupported($this->bson->get($key));
+ }
+
+ // The offset could've been explicitly unset before, so we need to
+ // respect pre-existing entries in the "exists" map
+ if (! isset($this->exists[$key])) {
+ $this->exists[$key] = $found;
+ }
+ }
+}
diff --git a/src/Model/ListIterator.php b/src/Model/ListIterator.php
new file mode 100644
index 000000000..97d4d4cbf
--- /dev/null
+++ b/src/Model/ListIterator.php
@@ -0,0 +1,51 @@
+>
+ */
+final class ListIterator extends IteratorIterator
+{
+ private int $index = 0;
+
+ public function key(): int
+ {
+ return $this->index;
+ }
+
+ public function next(): void
+ {
+ parent::next();
+
+ $this->index++;
+ }
+
+ public function rewind(): void
+ {
+ parent::rewind();
+
+ $this->index = 0;
+ }
+}
diff --git a/tests/Codec/ArrayCodecTest.php b/tests/Codec/ArrayCodecTest.php
new file mode 100644
index 000000000..75fbc84ae
--- /dev/null
+++ b/tests/Codec/ArrayCodecTest.php
@@ -0,0 +1,106 @@
+ [
+ 'value' => ['decoded', 'encoded'],
+ 'encoded' => ['encoded', 'encoded'],
+ 'decoded' => ['decoded', 'decoded'],
+ ];
+
+ yield 'List with gaps' => [
+ 'value' => [0 => 'decoded', 2 => 'encoded'],
+ 'encoded' => [0 => 'encoded', 2 => 'encoded'],
+ 'decoded' => [0 => 'decoded', 2 => 'decoded'],
+ ];
+
+ yield 'Hash' => [
+ 'value' => ['foo' => 'decoded', 'bar' => 'encoded'],
+ 'encoded' => ['foo' => 'encoded', 'bar' => 'encoded'],
+ 'decoded' => ['foo' => 'decoded', 'bar' => 'decoded'],
+ ];
+ }
+
+ /** @dataProvider provideValues */
+ public function testDecode($value, $encoded, $decoded): void
+ {
+ $this->assertSame(
+ $decoded,
+ $this->getCodec()->decode($value),
+ );
+ }
+
+ /** @dataProvider provideValues */
+ public function testEncode($value, $encoded, $decoded): void
+ {
+ $this->assertSame(
+ $encoded,
+ $this->getCodec()->encode($value),
+ );
+ }
+
+ public function testDecodeWithWrongType(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo'));
+ $this->getCodec()->encode('foo');
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo'));
+ $this->getCodec()->decode('foo');
+ }
+
+ private function getCodec(): ArrayCodec
+ {
+ $arrayCodec = new ArrayCodec();
+ $arrayCodec->attachCodecLibrary($this->getCodecLibrary());
+
+ return $arrayCodec;
+ }
+
+ private function getCodecLibrary(): CodecLibrary
+ {
+ return new CodecLibrary(
+ /** @template-implements Codec */
+ new class implements Codec
+ {
+ use DecodeIfSupported;
+ use EncodeIfSupported;
+
+ public function canDecode($value): bool
+ {
+ return $value === 'encoded';
+ }
+
+ public function canEncode($value): bool
+ {
+ return $value === 'decoded';
+ }
+
+ public function decode($value)
+ {
+ return 'decoded';
+ }
+
+ public function encode($value)
+ {
+ return 'encoded';
+ }
+ },
+ );
+ }
+}
diff --git a/tests/Codec/CodecLibraryTest.php b/tests/Codec/CodecLibraryTest.php
index f03c57a89..842df1729 100644
--- a/tests/Codec/CodecLibraryTest.php
+++ b/tests/Codec/CodecLibraryTest.php
@@ -4,9 +4,9 @@
use MongoDB\Codec\Codec;
use MongoDB\Codec\CodecLibrary;
+use MongoDB\Codec\CodecLibraryAware;
use MongoDB\Codec\DecodeIfSupported;
use MongoDB\Codec\EncodeIfSupported;
-use MongoDB\Codec\KnowsCodecLibrary;
use MongoDB\Exception\UnsupportedValueException;
use MongoDB\Tests\TestCase;
@@ -132,7 +132,7 @@ public function encode($value)
private function getTestCodec(): Codec
{
- return new class implements Codec, KnowsCodecLibrary {
+ return new class implements Codec, CodecLibraryAware {
use DecodeIfSupported;
use EncodeIfSupported;
diff --git a/tests/Codec/LazyBSONArrayCodecTest.php b/tests/Codec/LazyBSONArrayCodecTest.php
new file mode 100644
index 000000000..32ca99754
--- /dev/null
+++ b/tests/Codec/LazyBSONArrayCodecTest.php
@@ -0,0 +1,55 @@
+ 'baz'],
+ [0, 1, 2],
+ ];
+
+ public function testDecode(): void
+ {
+ $array = (new LazyBSONArrayCodec())->decode($this->getTestArray());
+
+ $this->assertInstanceOf(LazyBSONArray::class, $array);
+ $this->assertSame('bar', $array[0]);
+ }
+
+ public function testDecodeWithWrongType(): void
+ {
+ $codec = new LazyBSONArrayCodec();
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo'));
+ $codec->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $array = new LazyBSONArray($this->getTestArray());
+ $encoded = (new LazyBSONArrayCodec())->encode($array);
+
+ $this->assertEquals(PackedArray::fromPHP(self::ARRAY), $encoded);
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $codec = new LazyBSONArrayCodec();
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo'));
+ $codec->encode('foo');
+ }
+
+ private function getTestArray(): PackedArray
+ {
+ return PackedArray::fromPHP(self::ARRAY);
+ }
+}
diff --git a/tests/Codec/LazyBSONCodecLibraryTest.php b/tests/Codec/LazyBSONCodecLibraryTest.php
new file mode 100644
index 000000000..3c48b1971
--- /dev/null
+++ b/tests/Codec/LazyBSONCodecLibraryTest.php
@@ -0,0 +1,104 @@
+ 'bar'],
+ [0, 1, 2],
+ ];
+ $document = (object) [
+ 'string' => 'bar',
+ 'document' => (object) ['foo' => 'bar'],
+ 'array' => [0, 1, 2],
+ ];
+
+ yield 'LazyBSONArray' => [
+ 'expected' => PackedArray::fromPHP($array),
+ 'value' => new LazyBSONArray($array),
+ ];
+
+ yield 'LazyBSONDocument' => [
+ 'expected' => Document::fromPHP($document),
+ 'value' => new LazyBSONDocument($document),
+ ];
+
+ yield 'array' => [
+ 'expected' => [PackedArray::fromPHP($array), Document::fromPHP($document)],
+ 'value' => [new LazyBSONArray($array), new LazyBSONDocument($document)],
+ ];
+
+ yield 'hash' => [
+ 'expected' => ['array' => PackedArray::fromPHP($array), 'document' => Document::fromPHP($document)],
+ 'value' => ['array' => new LazyBSONArray($array), 'document' => new LazyBSONDocument($document)],
+ ];
+
+ yield 'object' => [
+ 'expected' => (object) ['array' => PackedArray::fromPHP($array), 'document' => Document::fromPHP($document)],
+ 'value' => (object) ['array' => new LazyBSONArray($array), 'document' => new LazyBSONDocument($document)],
+ ];
+ }
+
+ public static function provideEncodedData(): Generator
+ {
+ $packedArray = PackedArray::fromPHP([
+ 'bar',
+ ['foo' => 'bar'],
+ [0, 1, 2],
+ ]);
+ $document = Document::fromPHP([
+ 'string' => 'bar',
+ 'document' => ['foo' => 'bar'],
+ 'array' => [0, 1, 2],
+ ]);
+
+ yield 'PackedArray' => [
+ 'expected' => new LazyBSONArray($packedArray),
+ 'value' => $packedArray,
+ ];
+
+ yield 'Document' => [
+ 'expected' => new LazyBSONDocument($document),
+ 'value' => $document,
+ ];
+
+ yield 'array' => [
+ 'expected' => [new LazyBSONArray($packedArray), new LazyBSONDocument($document)],
+ 'value' => [$packedArray, $document],
+ ];
+
+ yield 'hash' => [
+ 'expected' => ['array' => new LazyBSONArray($packedArray), 'document' => new LazyBSONDocument($document)],
+ 'value' => ['array' => $packedArray, 'document' => $document],
+ ];
+
+ yield 'object' => [
+ 'expected' => (object) ['array' => new LazyBSONArray($packedArray), 'document' => new LazyBSONDocument($document)],
+ 'value' => (object) ['array' => $packedArray, 'document' => $document],
+ ];
+ }
+
+ /** @dataProvider provideEncodedData */
+ public function testDecode($expected, $value): void
+ {
+ $this->assertEquals($expected, (new LazyBSONCodecLibrary())->decode($value));
+ }
+
+ /** @dataProvider provideDecodedData */
+ public function testEncode($expected, $value): void
+ {
+ $this->assertEquals($expected, (new LazyBSONCodecLibrary())->encode($value));
+ }
+}
diff --git a/tests/Codec/LazyBSONDocumentCodecTest.php b/tests/Codec/LazyBSONDocumentCodecTest.php
new file mode 100644
index 000000000..d623efa74
--- /dev/null
+++ b/tests/Codec/LazyBSONDocumentCodecTest.php
@@ -0,0 +1,55 @@
+ 'bar',
+ 'document' => ['bar' => 'baz'],
+ 'array' => [0, 1, 2],
+ ];
+
+ public function testDecode(): void
+ {
+ $document = (new LazyBSONDocumentCodec())->decode($this->getTestDocument());
+
+ $this->assertInstanceOf(LazyBSONDocument::class, $document);
+ $this->assertSame('bar', $document->foo);
+ }
+
+ public function testDecodeWithWrongType(): void
+ {
+ $codec = new LazyBSONDocumentCodec();
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo'));
+ $codec->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $document = new LazyBSONDocument($this->getTestDocument());
+ $encoded = (new LazyBSONDocumentCodec())->encode($document);
+
+ $this->assertEquals(Document::fromPHP(self::OBJECT), $encoded);
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $codec = new LazyBSONDocumentCodec();
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo'));
+ $codec->encode('foo');
+ }
+
+ private function getTestDocument(): Document
+ {
+ return Document::fromPHP(self::OBJECT);
+ }
+}
diff --git a/tests/Codec/ObjectCodecTest.php b/tests/Codec/ObjectCodecTest.php
new file mode 100644
index 000000000..facf8a1cc
--- /dev/null
+++ b/tests/Codec/ObjectCodecTest.php
@@ -0,0 +1,112 @@
+ [
+ 'value' => (object) ['foo' => 'decoded', 'bar' => 'encoded'],
+ 'encoded' => (object) ['foo' => 'encoded', 'bar' => 'encoded'],
+ 'decoded' => (object) ['foo' => 'decoded', 'bar' => 'decoded'],
+ ];
+
+ yield 'Extended stdClass' => [
+ 'value' => self::getExtendedObject(),
+ 'encoded' => (object) ['foo' => 'encoded', 'bar' => 'encoded'],
+ 'decoded' => (object) ['foo' => 'decoded', 'bar' => 'decoded'],
+ ];
+ }
+
+ /** @dataProvider provideValues */
+ public function testDecode($value, $encoded, $decoded): void
+ {
+ $this->assertEquals(
+ $decoded,
+ $this->getCodec()->decode($value),
+ );
+ }
+
+ /** @dataProvider provideValues */
+ public function testEncode($value, $encoded, $decoded): void
+ {
+ $this->assertEquals(
+ $encoded,
+ $this->getCodec()->encode($value),
+ );
+ }
+
+ public function testDecodeWithWrongType(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo'));
+ $this->getCodec()->decode('foo');
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo'));
+ $this->getCodec()->encode('foo');
+ }
+
+ private function getCodec(): ObjectCodec
+ {
+ $objectCodec = new ObjectCodec();
+ $objectCodec->attachCodecLibrary($this->getCodecLibrary());
+
+ return $objectCodec;
+ }
+
+ private function getCodecLibrary(): CodecLibrary
+ {
+ return new CodecLibrary(
+ /** @template-implements Codec */
+ new class implements Codec
+ {
+ use DecodeIfSupported;
+ use EncodeIfSupported;
+
+ public function canDecode($value): bool
+ {
+ return $value === 'encoded';
+ }
+
+ public function canEncode($value): bool
+ {
+ return $value === 'decoded';
+ }
+
+ public function decode($value)
+ {
+ return 'decoded';
+ }
+
+ public function encode($value)
+ {
+ return 'encoded';
+ }
+ },
+ );
+ }
+
+ private static function getExtendedObject(): stdClass
+ {
+ return new class extends stdClass {
+ public static $baz = 'oops';
+ public $foo = 'decoded';
+ public $bar = 'encoded';
+ protected $protected = 'oops';
+ private string $private = 'oops';
+ };
+ }
+}
diff --git a/tests/Model/BSONArrayTest.php b/tests/Model/BSONArrayTest.php
index d6f6c162e..1e52974b5 100644
--- a/tests/Model/BSONArrayTest.php
+++ b/tests/Model/BSONArrayTest.php
@@ -110,4 +110,15 @@ public function testSetState(): void
$this->assertInstanceOf(BSONArray::class, $array);
$this->assertSame($data, $array->getArrayCopy());
}
+
+ public function testBsonSerializeWithReverseKeys(): void
+ {
+ $array = new BSONArray([]);
+ $array[2] = 'foo';
+ $array[1] = 'bar';
+ $array[0] = 'baz';
+
+ // Since BSONArray uses array_values internally, the reverse order is expected
+ $this->assertSame(['foo', 'bar', 'baz'], $array->bsonSerialize());
+ }
}
diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php
new file mode 100644
index 000000000..9748af2be
--- /dev/null
+++ b/tests/Model/LazyBSONArrayTest.php
@@ -0,0 +1,353 @@
+ [
+ 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 testConstructAllowsGapsInList(): void
+ {
+ $array = new LazyBSONArray([0 => 'foo', 2 => 'bar']);
+
+ $this->assertSame('foo', $array[0]);
+ $this->assertFalse(isset($array[1]));
+ $this->assertSame('bar', $array[2]);
+
+ $this->assertSame(['foo', 'bar'], iterator_to_array($array));
+ }
+
+ public function testConstructRejectsHash(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new LazyBSONArray([0 => 'foo', 'foo' => 'bar']);
+ }
+
+ 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']);
+ $this->assertSame('bar', $array['0.5']);
+ $this->assertSame('bar', $array[0.5]);
+ }
+
+ /** @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(LazyBSONDocument::class, $array[1]);
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testGetArray(LazyBSONArray $array): void
+ {
+ $this->assertInstanceOf(LazyBSONArray::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']));
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testOffsetExistsForNumericOffset(LazyBSONArray $array): void
+ {
+ $this->assertTrue(isset($array['0']));
+ $this->assertTrue(isset($array['0.5']));
+ $this->assertTrue(isset($array[0.5]));
+
+ $this->assertFalse(isset($array['4']));
+ $this->assertFalse(isset($array['4.5']));
+ $this->assertFalse(isset($array[4.5]));
+ }
+
+ /** @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]);
+
+ $array[2.1] = 'yay!';
+ $this->assertSame('yay!', $array[2]);
+
+ $array['0.5'] = 'wow';
+ $this->assertSame('wow', $array[0]);
+ }
+
+ /** @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('yay!', $array[4]);
+ $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]));
+
+ // Set new value to ensure unsetting a previously modified value does not fall back to loading values from BSON
+ $array[1] = (object) ['foo' => 'baz'];
+ unset($array[1]);
+ $this->assertFalse(isset($array[1]));
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testOffsetUnsetForNumericOffsets(LazyBSONArray $array): void
+ {
+ $this->assertTrue(isset($array[0]));
+ $this->assertTrue(isset($array[1]));
+ $this->assertTrue(isset($array[2]));
+
+ unset($array['0']);
+ $this->assertFalse(isset($array[0]));
+
+ unset($array['1.5']);
+ $this->assertFalse(isset($array[1]));
+
+ unset($array[2.5]);
+ $this->assertFalse(isset($array[2]));
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testIterator(LazyBSONArray $array): void
+ {
+ $items = iterator_to_array($array);
+ $this->assertCount(3, $items);
+ $this->assertSame('bar', $items[0]);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items[1]);
+ $this->assertInstanceOf(LazyBSONArray::class, $items[2]);
+
+ $array[0] = 'baz';
+ $items = iterator_to_array($array);
+ $this->assertCount(3, $items);
+ $this->assertSame('baz', $items[0]);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items[1]);
+ $this->assertInstanceOf(LazyBSONArray::class, $items[2]);
+
+ unset($array[0]);
+ unset($array[2]);
+ $items = iterator_to_array($array);
+ $this->assertCount(1, $items);
+ $this->assertInstanceOf(LazyBSONDocument::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(LazyBSONDocument::class, $items[0]);
+ $this->assertSame('yay!', $items[1]);
+ }
+
+ public function testCount(): void
+ {
+ $array = new LazyBSONArray(PackedArray::fromPHP(['foo', 'bar', 'baz']));
+
+ $this->assertCount(3, $array);
+
+ // Overwrite existing item, count must not change
+ $array[0] = 'yay';
+ $this->assertCount(3, $array);
+
+ // Unset existing element, count must decrease
+ unset($array[1]);
+ $this->assertCount(2, $array);
+
+ // Append element, count must increase again
+ $array[] = 'yay';
+ $this->assertCount(3, $array);
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testSerialization(LazyBSONArray $array): void
+ {
+ $array[2] = [0];
+ $array[3] = 'yay!';
+ unset($array[1]);
+
+ $serialized = serialize($array);
+ $unserialized = unserialize($serialized);
+
+ $this->assertEquals(['bar', [0], 'yay!'], iterator_to_array($unserialized));
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testJsonSerialize(LazyBSONArray $array): void
+ {
+ $array[2] = [0];
+ $array[3] = 'yay!';
+ unset($array[1]);
+
+ $this->assertJsonStringEqualsJsonString('["bar",[0],"yay!"]', json_encode($array, JSON_THROW_ON_ERROR));
+ }
+
+ public function testIteratorToArrayWithReverseKeys(): void
+ {
+ $array = new LazyBSONArray();
+ $array[2] = 'foo';
+ $array[1] = 'bar';
+ $array[0] = 'baz';
+
+ $this->assertSame(['baz', 'bar', 'foo'], iterator_to_array($array));
+ }
+}
diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php
new file mode 100644
index 000000000..ebc7f3ad2
--- /dev/null
+++ b/tests/Model/LazyBSONDocumentTest.php
@@ -0,0 +1,343 @@
+ [
+ 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(LazyBSONDocument $document): void
+ {
+ $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'];
+ }
+
+ public function testOffsetGetWithInvalidOffset(): void
+ {
+ $document = new LazyBSONDocument(['foo' => 'bar']);
+
+ $this->expectWarning();
+ $this->expectWarningMessage('Undefined offset: 1');
+ $document[1];
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testGetDocument(LazyBSONDocument $document): void
+ {
+ $this->assertInstanceOf(LazyBSONDocument::class, $document->document);
+ $this->assertInstanceOf(LazyBSONDocument::class, $document['document']);
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testGetArray(LazyBSONDocument $document): void
+ {
+ $this->assertInstanceOf(LazyBSONArray::class, $document->array);
+ $this->assertInstanceOf(LazyBSONArray::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']));
+ }
+
+ public function testOffsetExistsWithInvalidOffset(): void
+ {
+ $document = new LazyBSONDocument(['foo' => 'bar']);
+
+ $this->assertFalse(isset($document[1]));
+ }
+
+ /** @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']);
+ }
+
+ public function testOffsetSetWithInvalidOffset(): void
+ {
+ $document = new LazyBSONDocument(['foo' => 'bar']);
+
+ $this->expectWarning();
+ $this->expectWarningMessage('Unsupported offset: 1');
+ $document[1] = '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));
+
+ // Set new value to ensure unsetting a previously modified value does not fall back to loading values from BSON
+ $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']));
+
+ // Set new value to ensure unsetting a previously modified value does not fall back to loading values from BSON
+ $document['document'] = (object) ['foo' => 'baz'];
+ unset($document['document']);
+ $this->assertFalse(isset($document['document']));
+ }
+
+ public function testOffsetUnsetWithInvalidOffset(): void
+ {
+ $document = new LazyBSONDocument(['foo' => 'bar']);
+
+ $this->expectWarning();
+ $this->expectWarningMessage('Undefined offset: 1');
+ unset($document[1]);
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testIterator(LazyBSONDocument $document): void
+ {
+ $items = iterator_to_array($document);
+ $this->assertCount(3, $items);
+ $this->assertSame('bar', $items['foo']);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items['document']);
+ $this->assertInstanceOf(LazyBSONArray::class, $items['array']);
+
+ $document->foo = 'baz';
+ $items = iterator_to_array($document);
+ $this->assertCount(3, $items);
+ $this->assertSame('baz', $items['foo']);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items['document']);
+ $this->assertInstanceOf(LazyBSONArray::class, $items['array']);
+
+ unset($document->foo);
+ unset($document->array);
+ $items = iterator_to_array($document);
+ $this->assertCount(1, $items);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items['document']);
+
+ $document->new = 'yay!';
+ $items = iterator_to_array($document);
+ $this->assertCount(2, $items);
+ $this->assertInstanceOf(LazyBSONDocument::class, $items['document']);
+ $this->assertSame('yay!', $items['new']);
+ }
+
+ public function testCount(): void
+ {
+ $document = new LazyBSONDocument(Document::fromPHP(['foo' => 'bar', 'bar' => 'baz']));
+
+ $this->assertCount(2, $document);
+
+ // Overwrite existing item, count must not change
+ $document['foo'] = 'yay';
+ $this->assertCount(2, $document);
+
+ // Unset existing element, count must decrease
+ unset($document['bar']);
+ $this->assertCount(1, $document);
+
+ // Append element, count must increase again
+ $document['baz'] = 'yay';
+ $this->assertCount(2, $document);
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testSerialization(LazyBSONDocument $document): void
+ {
+ $document['array'] = [0];
+ $document['new'] = 'yay!';
+ unset($document['document']);
+
+ $serialized = serialize($document);
+ $unserialized = unserialize($serialized);
+
+ $this->assertEquals(['foo' => 'bar', 'array' => [0], 'new' => 'yay!'], iterator_to_array($unserialized));
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testJsonSerialize(LazyBSONDocument $document): void
+ {
+ $document['array'] = [0];
+ $document['new'] = 'yay!';
+ unset($document['document']);
+
+ $this->assertJsonStringEqualsJsonString('{"foo":"bar","array":[0],"new":"yay!"}', json_encode($document, JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/tests/Model/ListIteratorTest.php b/tests/Model/ListIteratorTest.php
new file mode 100644
index 000000000..db1aaac3c
--- /dev/null
+++ b/tests/Model/ListIteratorTest.php
@@ -0,0 +1,30 @@
+assertEquals(['foo', 'bar', 'baz'], iterator_to_array($iterator));
+ }
+
+ public static function provideTests(): Generator
+ {
+ yield 'list' => [new ArrayIterator(['foo', 'bar', 'baz'])];
+
+ yield 'listWithGaps' => [new ArrayIterator([0 => 'foo', 2 => 'bar', 3 => 'baz'])];
+
+ yield 'hash' => [new ArrayIterator(['a' => 'foo', 'b' => 'bar', 'c' => 'baz'])];
+ }
+}