diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 02b0fd035..024016416 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -172,9 +172,11 @@
/examples
/tests/PHPUnit/ConstraintTrait.php
+ tests/Collection/CodecCollectionTest
/examples
+ tests/Collection/CodecCollectionTest
/tests/SpecTests/ClientSideEncryption/Prose*
diff --git a/psalm.xml.dist b/psalm.xml.dist
index 5a922b4ca..a1527eef7 100644
--- a/psalm.xml.dist
+++ b/psalm.xml.dist
@@ -14,4 +14,9 @@
+
+
+
+
+
diff --git a/src/Codec/Codec.php b/src/Codec/Codec.php
new file mode 100644
index 000000000..202d84837
--- /dev/null
+++ b/src/Codec/Codec.php
@@ -0,0 +1,15 @@
+
+ * @template-extends Encoder
+ */
+interface Codec extends Decoder, Encoder
+{
+}
diff --git a/src/Codec/CodecLibrary.php b/src/Codec/CodecLibrary.php
new file mode 100644
index 000000000..b4a08a641
--- /dev/null
+++ b/src/Codec/CodecLibrary.php
@@ -0,0 +1,137 @@
+ */
+ private $decoders = [];
+
+ /** @var array */
+ private $encoders = [];
+
+ /** @param Decoder|Encoder $items */
+ public function __construct(...$items)
+ {
+ array_map(
+ function ($item) {
+ if ($item instanceof Decoder) {
+ $this->attachDecoder($item);
+ }
+
+ if ($item instanceof Encoder) {
+ $this->attachEncoder($item);
+ }
+
+ // Yes, we'll silently discard everything. Please let me already have union types...
+ },
+ $items
+ );
+ }
+
+ /** @return static */
+ final public function attachCodec(Codec $codec): self
+ {
+ $this->decoders[] = $codec;
+ $this->encoders[] = $codec;
+ if ($codec instanceof KnowsCodecLibrary) {
+ $codec->attachLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @return static */
+ final public function attachDecoder(Decoder $decoder): self
+ {
+ $this->decoders[] = $decoder;
+ if ($decoder instanceof KnowsCodecLibrary) {
+ $decoder->attachLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @return static */
+ final public function attachEncoder(Encoder $encoder): self
+ {
+ $this->encoders[] = $encoder;
+ if ($encoder instanceof KnowsCodecLibrary) {
+ $encoder->attachLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @param mixed $value */
+ final public function canDecode($value): bool
+ {
+ foreach ($this->decoders as $decoder) {
+ if ($decoder->canDecode($value)) {
+ return true;
+ }
+ }
+
+ return $value === null;
+ }
+
+ /** @param mixed $value */
+ final public function canEncode($value): bool
+ {
+ foreach ($this->encoders as $encoder) {
+ if ($encoder->canEncode($value)) {
+ return true;
+ }
+ }
+
+ return $value === null;
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ final public function decode($value)
+ {
+ foreach ($this->decoders as $decoder) {
+ if (! $decoder->canDecode($value)) {
+ continue;
+ }
+
+ return $decoder->decode($value);
+ }
+
+ if ($value === null) {
+ return null;
+ }
+
+ throw new UnexpectedValueException(sprintf('No decoder found for value of type "%s"', get_debug_type($value)));
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ final public function encode($value)
+ {
+ foreach ($this->encoders as $encoder) {
+ if (! $encoder->canEncode($value)) {
+ continue;
+ }
+
+ return $encoder->encode($value);
+ }
+
+ if ($value === null) {
+ return null;
+ }
+
+ throw new UnexpectedValueException(sprintf('No encoder found for value of type "%s"', get_debug_type($value)));
+ }
+}
diff --git a/src/Codec/Decoder.php b/src/Codec/Decoder.php
new file mode 100644
index 000000000..31d1a5544
--- /dev/null
+++ b/src/Codec/Decoder.php
@@ -0,0 +1,26 @@
+
+ */
+class LazyBSONArrayCodec implements Codec, KnowsCodecLibrary
+{
+ /** @var CodecLibrary|null */
+ private $library = null;
+
+ public function attachLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /** @inheritDoc */
+ public function canDecode($value): bool
+ {
+ return $value instanceof PackedArray;
+ }
+
+ /** @inheritDoc */
+ public function canEncode($value): bool
+ {
+ return $value instanceof LazyBSONArray;
+ }
+
+ /** @inheritDoc */
+ public function decode($value): LazyBSONArray
+ {
+ if (! $value instanceof PackedArray) {
+ throw new UnexpectedValueException(sprintf('"%s" can only decode from "%s" instances', self::class, PackedArray::class));
+ }
+
+ return new LazyBSONArray($value, $this->getLibrary());
+ }
+
+ /** @inheritDoc */
+ public function encode($value): PackedArray
+ {
+ if (! $value instanceof LazyBSONArray) {
+ throw new UnexpectedValueException(sprintf('"%s" can only encode "%s" instances', self::class, LazyBSONArray::class));
+ }
+
+ $return = [];
+ foreach ($value as $offset => $offsetValue) {
+ $return[$offset] = $this->getLibrary()->canEncode($offsetValue) ? $this->getLibrary()->encode($offsetValue) : $offsetValue;
+ }
+
+ return PackedArray::fromPHP(array_values($return));
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ return $this->library ?? $this->library = new CodecLibrary(new LazyBSONDocumentCodec(), $this);
+ }
+}
diff --git a/src/Codec/LazyBSONDocumentCodec.php b/src/Codec/LazyBSONDocumentCodec.php
new file mode 100644
index 000000000..3c2274177
--- /dev/null
+++ b/src/Codec/LazyBSONDocumentCodec.php
@@ -0,0 +1,67 @@
+
+ */
+class LazyBSONDocumentCodec implements Codec, KnowsCodecLibrary
+{
+ /** @var CodecLibrary|null */
+ private $library = null;
+
+ public function attachLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ /** @inheritDoc */
+ public function canDecode($value): bool
+ {
+ return $value instanceof Document;
+ }
+
+ /** @inheritDoc */
+ public function canEncode($value): bool
+ {
+ return $value instanceof LazyBSONDocument;
+ }
+
+ /** @inheritDoc */
+ public function decode($value): LazyBSONDocument
+ {
+ if (! $value instanceof Document) {
+ throw new UnexpectedValueException(sprintf('"%s" can only decode from "%s" instances', self::class, Document::class));
+ }
+
+ return new LazyBSONDocument($value, $this->getLibrary());
+ }
+
+ /** @inheritDoc */
+ public function encode($value): Document
+ {
+ if (! $value instanceof LazyBSONDocument) {
+ throw new UnexpectedValueException(sprintf('"%s" can only encode "%s" instances', self::class, LazyBSONDocument::class));
+ }
+
+ $return = [];
+ foreach ($value as $field => $fieldValue) {
+ $return[$field] = $this->getLibrary()->canEncode($fieldValue) ? $this->getLibrary()->encode($fieldValue) : $fieldValue;
+ }
+
+ return Document::fromPHP($return);
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ return $this->library ?? $this->library = new CodecLibrary($this, new LazyBSONArrayCodec());
+ }
+}
diff --git a/src/Collection.php b/src/Collection.php
index f3d7e38ee..4a50e661b 100644
--- a/src/Collection.php
+++ b/src/Collection.php
@@ -17,7 +17,9 @@
namespace MongoDB;
+use Iterator;
use MongoDB\BSON\JavascriptInterface;
+use MongoDB\Codec\Codec;
use MongoDB\Driver\Cursor;
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
use MongoDB\Driver\Manager;
@@ -29,6 +31,7 @@
use MongoDB\Exception\UnsupportedException;
use MongoDB\Model\BSONArray;
use MongoDB\Model\BSONDocument;
+use MongoDB\Model\CallbackIterator;
use MongoDB\Model\IndexInfo;
use MongoDB\Model\IndexInfoIterator;
use MongoDB\Operation\Aggregate;
@@ -63,6 +66,7 @@
use function array_diff_key;
use function array_intersect_key;
+use function array_map;
use function current;
use function is_array;
use function strlen;
@@ -76,9 +80,15 @@ class Collection
'root' => BSONDocument::class,
];
+ /** @var array */
+ private static $codecTypeMap = ['root' => 'bson'];
+
/** @var integer */
private static $wireVersionForReadConcernWithWriteStage = 8;
+ /** @var Codec|null */
+ private $codec;
+
/** @var string */
private $collectionName;
@@ -107,6 +117,8 @@ class Collection
* CRUD (i.e. create, read, update, and delete) and index management.
*
* Supported options:
+ * * codec (MongoDB\Codec\Codec): A codec to decode raw BSON data to PHP
+ * objects and back to BSON
*
* * readConcern (MongoDB\Driver\ReadConcern): The default read concern to
* use for collection operations. Defaults to the Manager's read concern.
@@ -137,6 +149,10 @@ public function __construct(Manager $manager, string $databaseName, string $coll
throw new InvalidArgumentException('$collectionName is invalid: ' . $collectionName);
}
+ if (isset($options['codec']) && ! $options['codec'] instanceof Codec) {
+ throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], Codec::class);
+ }
+
if (isset($options['readConcern']) && ! $options['readConcern'] instanceof ReadConcern) {
throw InvalidArgumentException::invalidType('"readConcern" option', $options['readConcern'], ReadConcern::class);
}
@@ -156,6 +172,7 @@ public function __construct(Manager $manager, string $databaseName, string $coll
$this->manager = $manager;
$this->databaseName = $databaseName;
$this->collectionName = $collectionName;
+ $this->codec = $options['codec'] ?? null;
$this->readConcern = $options['readConcern'] ?? $this->manager->getReadConcern();
$this->readPreference = $options['readPreference'] ?? $this->manager->getReadPreference();
$this->typeMap = $options['typeMap'] ?? self::$defaultTypeMap;
@@ -171,6 +188,7 @@ public function __construct(Manager $manager, string $databaseName, string $coll
public function __debugInfo()
{
return [
+ 'codec' => $this->codec,
'collectionName' => $this->collectionName,
'databaseName' => $this->databaseName,
'manager' => $this->manager,
@@ -234,9 +252,7 @@ public function aggregate(array $pipeline, array $options = [])
$options['readConcern'] = $this->readConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
if ($hasWriteStage && ! isset($options['writeConcern']) && ! is_in_transaction($options)) {
$options['writeConcern'] = $this->writeConcern;
@@ -244,7 +260,10 @@ public function aggregate(array $pipeline, array $options = [])
$operation = new Aggregate($this->databaseName, $this->collectionName, $pipeline, $options);
- return $operation->execute($server);
+ return $this->decodeIterator(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -633,7 +652,7 @@ public function explain(Explainable $explainable, array $options = [])
* @see https://mongodb.com/docs/manual/crud/#read-operations
* @param array|object $filter Query by which to filter documents
* @param array $options Additional options
- * @return Cursor
+ * @return Cursor|Iterator
* @throws UnsupportedException if options are not supported by the selected server
* @throws InvalidArgumentException for parameter/option parsing errors
* @throws DriverRuntimeException for other driver errors (e.g. connection errors)
@@ -650,13 +669,14 @@ public function find($filter = [], array $options = [])
$options['readConcern'] = $this->readConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
$operation = new Find($this->databaseName, $this->collectionName, $filter, $options);
- return $operation->execute($server);
+ return $this->decodeIterator(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -683,13 +703,14 @@ public function findOne($filter = [], array $options = [])
$options['readConcern'] = $this->readConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
$operation = new FindOne($this->databaseName, $this->collectionName, $filter, $options);
- return $operation->execute($server);
+ return $this->decodeResult(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -715,13 +736,14 @@ public function findOneAndDelete($filter, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
$operation = new FindOneAndDelete($this->databaseName, $this->collectionName, $filter, $options);
- return $operation->execute($server);
+ return $this->decodeResult(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -752,13 +774,20 @@ public function findOneAndReplace($filter, $replacement, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
- $operation = new FindOneAndReplace($this->databaseName, $this->collectionName, $filter, $replacement, $options);
+ $operation = new FindOneAndReplace(
+ $this->databaseName,
+ $this->collectionName,
+ $filter,
+ $this->encodeObject($replacement, $options['codec'] ?? null),
+ $options
+ );
- return $operation->execute($server);
+ return $this->decodeResult(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -789,13 +818,14 @@ public function findOneAndUpdate($filter, $update, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
- }
+ $options = $this->handleCodecAndTypeMapOptions($options);
$operation = new FindOneAndUpdate($this->databaseName, $this->collectionName, $filter, $update, $options);
- return $operation->execute($server);
+ return $this->decodeResult(
+ $operation->execute($server),
+ $options['codec'] ?? null
+ );
}
/**
@@ -898,7 +928,14 @@ public function insertMany(array $documents, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- $operation = new InsertMany($this->databaseName, $this->collectionName, $documents, $options);
+ $options = $this->handleCodecAndTypeMapOptions($options);
+
+ $operation = new InsertMany(
+ $this->databaseName,
+ $this->collectionName,
+ $this->encodeObjectList($documents, $options['codec'] ?? null),
+ $options
+ );
$server = select_server($this->manager, $options);
return $operation->execute($server);
@@ -921,7 +958,14 @@ public function insertOne($document, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- $operation = new InsertOne($this->databaseName, $this->collectionName, $document, $options);
+ $options = $this->handleCodecAndTypeMapOptions($options);
+
+ $operation = new InsertOne(
+ $this->databaseName,
+ $this->collectionName,
+ $this->encodeObject($document, $options['codec'] ?? null),
+ $options
+ );
$server = select_server($this->manager, $options);
return $operation->execute($server);
@@ -1047,7 +1091,15 @@ public function replaceOne($filter, $replacement, array $options = [])
$options['writeConcern'] = $this->writeConcern;
}
- $operation = new ReplaceOne($this->databaseName, $this->collectionName, $filter, $replacement, $options);
+ $options = $this->handleCodecAndTypeMapOptions($options);
+
+ $operation = new ReplaceOne(
+ $this->databaseName,
+ $this->collectionName,
+ $filter,
+ $this->encodeObject($replacement, $options['codec'] ?? null),
+ $options
+ );
$server = select_server($this->manager, $options);
return $operation->execute($server);
@@ -1151,6 +1203,7 @@ public function watch(array $pipeline = [], array $options = [])
public function withOptions(array $options = [])
{
$options += [
+ 'codec' => $this->codec,
'readConcern' => $this->readConcern,
'readPreference' => $this->readPreference,
'typeMap' => $this->typeMap,
@@ -1159,4 +1212,96 @@ public function withOptions(array $options = [])
return new Collection($this->manager, $this->databaseName, $this->collectionName, $options);
}
+
+ private function decodeIterator(Iterator $cursor, ?Codec $codec): Iterator
+ {
+ if (! $codec) {
+ return $cursor;
+ }
+
+ return new CallbackIterator(
+ $cursor,
+ /**
+ * @param array|object|null $value
+ * @return array|object|null
+ */
+ function ($value) use ($codec) {
+ return $this->decodeResult($value, $codec);
+ }
+ );
+ }
+
+ /**
+ * @param mixed $result
+ * @return mixed
+ */
+ private function decodeResult($result, ?Codec $codec)
+ {
+ return $codec && $codec->canDecode($result) ? $codec->decode($result) : $result;
+ }
+
+ /**
+ * @param mixed $object
+ * @return mixed
+ */
+ private function encodeObject($object, ?Codec $codec)
+ {
+ return $codec && $codec->canEncode($object) ? $codec->encode($object) : $object;
+ }
+
+ private function encodeObjectList(array $objects, ?Codec $codec): array
+ {
+ if (! $codec) {
+ return $objects;
+ }
+
+ return array_map(
+ /**
+ * @param mixed $object
+ * @return mixed
+ */
+ function ($object) use ($codec) {
+ return $this->encodeObject($object, $codec);
+ },
+ $objects
+ );
+ }
+
+ /**
+ * @param array $options
+ * @return array{codec: ?Codec, typeMap: array, ...}
+ */
+ private function handleCodecAndTypeMapOptions(array $options): array
+ {
+ // Check types for codec and typeMap options
+ if (isset($options['codec']) && ! $options['codec'] instanceof Codec) {
+ throw InvalidArgumentException::invalidType('"codec" option', $options['codec'], Codec::class);
+ }
+
+ if (isset($options['typeMap']) && ! is_array($options['typeMap'])) {
+ throw InvalidArgumentException::invalidType('"typeMap" option', $options['typeMap'], 'array');
+ }
+
+ if (! isset($options['codec'])) {
+ // If only a typeMap option is provided, it must override any collection-level codec
+ if (isset($options['typeMap'])) {
+ return $options;
+ }
+
+ // In other cases, use the collection-level codec (potentially empty)
+ $options['codec'] = $this->codec;
+ }
+
+ // Force a codec type map if a codec is provided
+ if ($options['codec'] instanceof Codec) {
+ $options['typeMap'] = self::$codecTypeMap;
+ }
+
+ // Revert to the default type map if it wasn't provided
+ if (! isset($options['typeMap'])) {
+ $options['typeMap'] = $this->typeMap;
+ }
+
+ return $options;
+ }
}
diff --git a/src/Model/AsListIterator.php b/src/Model/AsListIterator.php
new file mode 100644
index 000000000..c01b9265b
--- /dev/null
+++ b/src/Model/AsListIterator.php
@@ -0,0 +1,39 @@
+
+ *
+ * @template-extends IteratorIterator
+ */
+final class AsListIterator extends IteratorIterator
+{
+ /** @var int */
+ private $index = 0;
+
+ public function key(): ?int
+ {
+ return $this->valid() ? $this->index : null;
+ }
+
+ public function next(): void
+ {
+ $this->index++;
+
+ parent::next();
+ }
+
+ public function rewind(): void
+ {
+ $this->index = 0;
+
+ parent::rewind();
+ }
+}
diff --git a/src/Model/CallbackIterator.php b/src/Model/CallbackIterator.php
index a900cd453..e8f7d6b82 100644
--- a/src/Model/CallbackIterator.php
+++ b/src/Model/CallbackIterator.php
@@ -49,7 +49,7 @@ public function __construct(Traversable $traversable, Closure $callback)
#[ReturnTypeWillChange]
public function current()
{
- return ($this->callback)($this->iterator->current());
+ return ($this->callback)($this->iterator->current(), $this->iterator->key());
}
/**
diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php
new file mode 100644
index 000000000..1fdce078d
--- /dev/null
+++ b/src/Model/LazyBSONArray.php
@@ -0,0 +1,273 @@
+ */
+ private $read = [];
+
+ /** @var array */
+ private $notFound = [];
+
+ /** @var array */
+ private $set = [];
+
+ /** @var array */
+ private $unset = [];
+
+ /** @var bool */
+ private $entirePackedArrayRead = false;
+
+ /**
+ * Deep clone this lazy array.
+ */
+ public function __clone()
+ {
+ $this->bson = clone $this->bson;
+
+ foreach ($this->set as $key => $value) {
+ if (is_object($value)) {
+ $this->set[$key] = clone $value;
+ }
+ }
+ }
+
+ /**
+ * Constructs a lazy BSON array.
+ *
+ * @param PackedArray|array|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, ?CodecLibrary $library = null)
+ {
+ if ($input === null) {
+ $this->bson = PackedArray::fromPHP([]);
+ } elseif ($input instanceof PackedArray) {
+ $this->bson = $input;
+ } elseif (is_array($input)) {
+ $this->bson = PackedArray::fromPHP(array_values($input));
+ } else {
+ throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']);
+ }
+
+ $this->library = $library;
+ }
+
+ public function bsonSerialize(): array
+ {
+ // Always use LazyBSONArrayCodec for BSON serialisation
+ $codec = new LazyBSONArrayCodec();
+ $codec->attachLibrary($this->getLibrary());
+
+ // @psalm-suppress InvalidReturnStatement
+ return $codec->encode($this)->toPHP();
+ }
+
+ /** @return Iterator */
+ public function getIterator(): Iterator
+ {
+ $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));
+
+ $seen = [];
+
+ // Use AsArrayIterator to ensure we're indexing from 0 without gaps
+ return new AsListIterator(
+ new CallbackIterator(
+ new CallbackFilterIterator(
+ $itemIterator,
+ /** @param mixed $value */
+ function ($value, int $offset) use (&$seen): bool {
+ // Skip keys that were unset or handled in a previous iterator
+ return ! isset($this->unset[$offset]) && ! isset($seen[$offset]);
+ }
+ ),
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ 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);
+ }
+
+ /** @param mixed $offset */
+ public function offsetExists($offset): bool
+ {
+ $offset = (int) $offset;
+ $this->ensureOffsetRead($offset);
+
+ return ! isset($this->unset[$offset]) && ! isset($this->notFound[$offset]);
+ }
+
+ /**
+ * @param mixed $offset
+ * @return mixed
+ */
+ #[ReturnTypeWillChange]
+ public function offsetGet($offset)
+ {
+ $offset = (int) $offset;
+ $this->ensureOffsetRead($offset);
+
+ if (isset($this->unset[$offset]) || isset($this->notFound[$offset])) {
+ trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING);
+ }
+
+ return array_key_exists($offset, $this->set) ? $this->set[$offset] : $this->read[$offset];
+ }
+
+ /**
+ * @param mixed $offset
+ * @param mixed $value
+ */
+ public function offsetSet($offset, $value): void
+ {
+ if ($offset === null) {
+ $this->readEntirePackedArray();
+
+ $offset = max(array_merge(
+ array_keys($this->read),
+ array_keys($this->set),
+ )) + 1;
+ } else {
+ $offset = (int) $offset;
+ }
+
+ $this->set[$offset] = $value;
+ unset($this->unset[$offset]);
+ unset($this->notFound[$offset]);
+ }
+
+ /** @param mixed $offset */
+ public function offsetUnset($offset): void
+ {
+ $offset = (int) $offset;
+ $this->unset[$offset] = true;
+ unset($this->set[$offset]);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ private function decodeBSONValue($value)
+ {
+ return $this->getLibrary()->canDecode($value)
+ ? $this->getLibrary()->decode($value)
+ : $value;
+ }
+
+ private function ensureOffsetRead(int $offset): void
+ {
+ if (isset($this->set[$offset]) || isset($this->notFound[$offset]) || array_key_exists($offset, $this->read)) {
+ return;
+ }
+
+ if (! $this->bson->has($offset)) {
+ $this->notFound[$offset] = true;
+
+ return;
+ }
+
+ $value = $this->bson->get($offset);
+
+ // Decode value using the codec library if a codec exists
+ $this->read[$offset] = $this->decodeBSONValue($value);
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ return $this->library ?? $this->library = new CodecLibrary(new LazyBSONDocumentCodec(), new LazyBSONArrayCodec());
+ }
+
+ private function readEntirePackedArray(): void
+ {
+ if ($this->entirePackedArrayRead) {
+ return;
+ }
+
+ $this->entirePackedArrayRead = true;
+
+ foreach ($this->bson as $key => $value) {
+ if (isset($this->read[$key])) {
+ continue;
+ }
+
+ $this->read[$key] = $this->decodeBSONValue($value);
+ }
+ }
+}
diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php
new file mode 100644
index 000000000..7d6c97655
--- /dev/null
+++ b/src/Model/LazyBSONDocument.php
@@ -0,0 +1,254 @@
+ */
+ private $read = [];
+
+ /** @var array */
+ private $notFound = [];
+
+ /** @var array */
+ private $set = [];
+
+ /** @var array */
+ private $unset = [];
+
+ /**
+ * Deep clone this lazy document.
+ */
+ public function __clone()
+ {
+ $this->bson = clone $this->bson;
+
+ foreach ($this->set as $key => $value) {
+ if (is_object($value)) {
+ $this->set[$key] = clone $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, ?CodecLibrary $library = 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($input);
+ } else {
+ throw InvalidArgumentException::invalidType('input', $input, [Document::class, 'array', 'null']);
+ }
+
+ $this->library = $library;
+ }
+
+ /** @return mixed */
+ public function __get(string $property)
+ {
+ $this->ensureKeyRead($property);
+
+ if (isset($this->unset[$property]) || isset($this->notFound[$property])) {
+ trigger_error(sprintf('Undefined property: %s', $property), E_USER_WARNING);
+ }
+
+ return array_key_exists($property, $this->set) ? $this->set[$property] : $this->read[$property];
+ }
+
+ public function __isset(string $name): bool
+ {
+ $this->ensureKeyRead($name);
+
+ return ! isset($this->unset[$name]) && ! isset($this->notFound[$name]);
+ }
+
+ /** @param mixed $value */
+ public function __set(string $property, $value): void
+ {
+ $this->set[$property] = $value;
+ unset($this->unset[$property]);
+ unset($this->notFound[$property]);
+ }
+
+ public function __unset(string $name): void
+ {
+ $this->unset[$name] = true;
+ unset($this->set[$name]);
+ }
+
+ public function bsonSerialize(): object
+ {
+ // Always use LazyBSONDocumentCodec for BSON serialisation
+ $codec = new LazyBSONDocumentCodec();
+ $codec->attachLibrary($this->getLibrary());
+
+ // @psalm-suppress InvalidReturnStatement
+ return $codec->encode($this)->toPHP();
+ }
+
+ /** @return Iterator */
+ public function getIterator(): Iterator
+ {
+ $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));
+
+ $seen = [];
+
+ return new CallbackIterator(
+ new CallbackFilterIterator(
+ $itemIterator,
+ /** @param mixed $current */
+ function ($current, string $key) use (&$seen): bool {
+ // Skip keys that were unset or handled in a previous iterator
+ return ! isset($this->unset[$key]) && ! isset($seen[$key]);
+ }
+ ),
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ 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(): object
+ {
+ return (object) iterator_to_array($this);
+ }
+
+ /** @param mixed $offset */
+ public function offsetExists($offset): bool
+ {
+ return $this->__isset((string) $offset);
+ }
+
+ /**
+ * @param mixed $offset
+ * @return mixed
+ */
+ #[ReturnTypeWillChange]
+ public function offsetGet($offset)
+ {
+ return $this->__get((string) $offset);
+ }
+
+ /**
+ * @param mixed $offset
+ * @param mixed $value
+ */
+ public function offsetSet($offset, $value): void
+ {
+ $this->__set((string) $offset, $value);
+ }
+
+ /** @param mixed $offset */
+ public function offsetUnset($offset): void
+ {
+ $this->__unset((string) $offset);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ private function decodeBSONValue($value)
+ {
+ return $this->getLibrary()->canDecode($value)
+ ? $this->getLibrary()->decode($value)
+ : $value;
+ }
+
+ private function ensureKeyRead(string $key): void
+ {
+ if (isset($this->set[$key]) || isset($this->notFound[$key]) || array_key_exists($key, $this->read)) {
+ return;
+ }
+
+ if (! $this->bson->has($key)) {
+ $this->notFound[$key] = true;
+
+ return;
+ }
+
+ $value = $this->bson->get($key);
+
+ // Decode value using the codec library if a codec exists
+ $this->read[$key] = $this->decodeBSONValue($value);
+ }
+
+ private function getLibrary(): CodecLibrary
+ {
+ return $this->library ?? $this->library = new CodecLibrary(new LazyBSONDocumentCodec(), new LazyBSONArrayCodec());
+ }
+}
diff --git a/stubs/BSON/BSON.stub.php b/stubs/BSON/BSON.stub.php
new file mode 100644
index 000000000..5b723b25b
--- /dev/null
+++ b/stubs/BSON/BSON.stub.php
@@ -0,0 +1,14 @@
+getCodecLibrary();
+
+ $this->assertTrue($codec->canDecode('cigam'));
+ $this->assertFalse($codec->canDecode('magic'));
+
+ $this->assertSame('magic', $codec->decode('cigam'));
+ }
+
+ public function testDecodeNull(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertTrue($codec->canDecode(null));
+ $this->assertNull($codec->decode(null));
+ }
+
+ public function testDecodeUnsupportedValue(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('No decoder found for value of type "string"');
+
+ $this->getCodecLibrary()->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertTrue($codec->canEncode('magic'));
+ $this->assertFalse($codec->canEncode('cigam'));
+
+ $this->assertSame('cigam', $codec->encode('magic'));
+ }
+
+ public function testEncodeNull(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertTrue($codec->canEncode(null));
+ $this->assertNull($codec->encode(null));
+ }
+
+ public function testEncodeUnsupportedValue(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('No encoder found for value of type "string"');
+
+ $this->getCodecLibrary()->encode('foo');
+ }
+
+ private function getCodecLibrary(): CodecLibrary
+ {
+ return new CodecLibrary(
+ /** @template-implements Codec */
+ new class implements Codec
+ {
+ public function canDecode($value): bool
+ {
+ return $value === 'cigam';
+ }
+
+ public function canEncode($value): bool
+ {
+ return $value === 'magic';
+ }
+
+ public function decode($value)
+ {
+ return 'magic';
+ }
+
+ public function encode($value)
+ {
+ return 'cigam';
+ }
+ }
+ );
+ }
+
+ public function testLibraryAttachesToCodecs(): void
+ {
+ $codec = $this->getTestCodec();
+ $library = $this->getCodecLibrary();
+
+ $library->attachCodec($codec);
+ $this->assertSame($library, $codec->library);
+ }
+
+ public function testLibraryAttachesToCodecsWhenCreating(): void
+ {
+ $codec = $this->getTestCodec();
+ $library = new CodecLibrary($codec);
+
+ $this->assertSame($library, $codec->library);
+ }
+
+ private function getTestCodec(): Codec
+ {
+ return new class implements Codec, KnowsCodecLibrary {
+ public $library;
+
+ public function attachLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ public function canDecode($value): bool
+ {
+ return false;
+ }
+
+ public function canEncode($value): bool
+ {
+ return false;
+ }
+
+ public function decode($value)
+ {
+ return null;
+ }
+
+ public function encode($value)
+ {
+ return null;
+ }
+ };
+ }
+}
diff --git a/tests/Codec/LazyBSONArrayCodecTest.php b/tests/Codec/LazyBSONArrayCodecTest.php
new file mode 100644
index 000000000..f2c9fe4a3
--- /dev/null
+++ b/tests/Codec/LazyBSONArrayCodecTest.php
@@ -0,0 +1,58 @@
+ '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->expectException(UnexpectedValueException::class);
+ $codec->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $array = new LazyBSONArray($this->getTestArray());
+ $encoded = (new LazyBSONArrayCodec())->encode($array);
+
+ $this->assertEquals(
+ self::ARRAY,
+ $encoded->toPHP(['root' => 'array', 'array' => 'array', 'document' => 'array'])
+ );
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $codec = new LazyBSONArrayCodec();
+
+ $this->expectException(UnexpectedValueException::class);
+ $codec->encode('foo');
+ }
+
+ private function getTestArray(): PackedArray
+ {
+ return PackedArray::fromPHP(self::ARRAY);
+ }
+}
diff --git a/tests/Codec/LazyBSONDocumentCodecTest.php b/tests/Codec/LazyBSONDocumentCodecTest.php
new file mode 100644
index 000000000..7a64dcb65
--- /dev/null
+++ b/tests/Codec/LazyBSONDocumentCodecTest.php
@@ -0,0 +1,58 @@
+ '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->expectException(UnexpectedValueException::class);
+ $codec->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $document = new LazyBSONDocument($this->getTestDocument());
+ $encoded = (new LazyBSONDocumentCodec())->encode($document);
+
+ $this->assertEquals(
+ self::OBJECT,
+ $encoded->toPHP(['root' => 'array', 'array' => 'array', 'document' => 'array'])
+ );
+ }
+
+ public function testEncodeWithWrongType(): void
+ {
+ $codec = new LazyBSONDocumentCodec();
+
+ $this->expectException(UnexpectedValueException::class);
+ $codec->encode('foo');
+ }
+
+ private function getTestDocument(): Document
+ {
+ return Document::fromPHP(self::OBJECT);
+ }
+}
diff --git a/tests/Collection/CodecCollectionTest.php b/tests/Collection/CodecCollectionTest.php
new file mode 100644
index 000000000..5b00f36a5
--- /dev/null
+++ b/tests/Collection/CodecCollectionTest.php
@@ -0,0 +1,247 @@
+codec = $this->createCodec();
+
+ $this->codecCollection = new Collection($this->manager, $this->getDatabaseName(), $this->getCollectionName(), ['codec' => $this->codec]);
+ }
+
+ public function testWithoutCollectionCodec(): void
+ {
+ $document = new CollectionTestModel((object) ['foo' => 'bar']);
+ $this->collection->insertOne($document);
+
+ $this->assertMatchesDocument(
+ ['data' => ['foo' => 'bar']],
+ $this->collection->findOne()
+ );
+ }
+
+ public function testOperationCodecOverridesCollection(): void
+ {
+ $this->collection->insertOne(['_id' => 1, 'foo' => 'bar']);
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 1, 'foo' => 'bar']),
+ $this->collection->findOne(['_id' => 1], ['codec' => $this->createCodec()])
+ );
+ }
+
+ public function testOperationTypeMapOverridesCollectionCodec(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertIsArray($this->codecCollection->findOne(['_id' => 2], ['typeMap' => ['root' => 'array']]));
+ }
+
+ public function testOperationCodecOverridesCollectionCodec(): void
+ {
+ $this->createTestFixtures();
+
+ $codec = $this->createCodec('operation');
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz'], 'operation'),
+ $this->codecCollection->findOne(['_id' => 2], ['codec' => $codec])
+ );
+ }
+
+ public function testOperationCodecTakesPrecedenceOverOperationTypemap(): void
+ {
+ $this->createTestFixtures();
+
+ $codec = $this->createCodec('operation');
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz'], 'operation'),
+ $this->codecCollection->findOne(['_id' => 2], ['codec' => $codec, 'typeMap' => ['root' => 'array']])
+ );
+ }
+
+ public function testInsertOne(): void
+ {
+ $document = new CollectionTestModel((object) ['foo' => 'bar']);
+ $this->codecCollection->insertOne($document);
+
+ $this->assertMatchesDocument(
+ ['foo' => 'bar'],
+ $this->collection->findOne()
+ );
+ }
+
+ public function testInsertMany(): void
+ {
+ $document = new CollectionTestModel((object) ['foo' => 'bar']);
+ $this->codecCollection->insertMany([$document, $document]);
+
+ $result = iterator_to_array($this->collection->find());
+ $this->assertCount(2, $result);
+ $this->assertMatchesDocument(
+ ['foo' => 'bar'],
+ $result[0]
+ );
+ $this->assertMatchesDocument(
+ ['foo' => 'bar'],
+ $result[1]
+ );
+ }
+
+ public function testFindOne(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ $this->codecCollection->findOne(['_id' => 2])
+ );
+ }
+
+ public function testFind(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ [
+ new CollectionTestModel((object) ['_id' => 1, 'foo' => 'bar']),
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ ],
+ iterator_to_array($this->codecCollection->find(['_id' => ['$lt' => 3]]))
+ );
+ }
+
+ public function testAggregate(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ [
+ new CollectionTestModel((object) ['_id' => 1, 'foo' => 'bar']),
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ ],
+ iterator_to_array($this->codecCollection->aggregate([['$match' => ['_id' => ['$lt' => 3]]]]))
+ );
+ }
+
+ public function testFindOneAndDelete(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ $this->codecCollection->findOneAndDelete(['_id' => 2])
+ );
+ }
+
+ public function testFindOneAndReplace(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ $this->codecCollection->findOneAndReplace(['_id' => 2], new CollectionTestModel((object) ['_id' => 2, 'baz' => 'foo']))
+ );
+ }
+
+ public function testFindOneAndUpdate(): void
+ {
+ $this->createTestFixtures();
+
+ $this->assertEquals(
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ $this->codecCollection->findOneAndUpdate(['_id' => 2], ['$set' => ['baz' => 'foo']])
+ );
+ }
+
+ public function testReplaceOne(): void
+ {
+ $this->createTestFixtures();
+
+ $document = new CollectionTestModel((object) ['_id' => 2, 'baz' => 'foo']);
+ $this->codecCollection->replaceOne(['_id' => 2], $document);
+
+ $this->assertSameDocument(
+ ['_id' => 2, 'baz' => 'foo'],
+ $this->collection->findOne(['_id' => 2])
+ );
+ }
+
+ private function createCodec(?string $marker = null): Codec
+ {
+ return new class ($marker) implements Codec {
+ /** @var string|null */
+ private $marker;
+
+ public function __construct(?string $marker = null)
+ {
+ $this->marker = $marker;
+ }
+
+ public function canDecode($value): bool
+ {
+ return $value instanceof Document;
+ }
+
+ public function canEncode($value): bool
+ {
+ return $value instanceof CollectionTestModel;
+ }
+
+ public function decode($value): ?CollectionTestModel
+ {
+ if (! $value instanceof Document) {
+ return null;
+ }
+
+ return new CollectionTestModel($value->toPHP(), $this->marker);
+ }
+
+ public function encode($value): ?Document
+ {
+ if (! $value instanceof CollectionTestModel) {
+ return null;
+ }
+
+ return Document::fromPHP($value->data);
+ }
+ };
+ }
+
+ private function createTestFixtures(): void
+ {
+ $this->codecCollection->insertMany([
+ new CollectionTestModel((object) ['_id' => 1, 'foo' => 'bar']),
+ new CollectionTestModel((object) ['_id' => 2, 'bar' => 'baz']),
+ ]);
+ }
+}
+
+/** @phpcsIgnore PSR1.Classes.ClassDeclaration.MultipleClasses */
+class CollectionTestModel
+{
+ public $data;
+ public $marker;
+
+ public function __construct(object $data, ?string $marker = null)
+ {
+ $this->data = $data;
+ }
+}
diff --git a/tests/Collection/CollectionFunctionalTest.php b/tests/Collection/CollectionFunctionalTest.php
index 0310a0c2a..dc1a28a52 100644
--- a/tests/Collection/CollectionFunctionalTest.php
+++ b/tests/Collection/CollectionFunctionalTest.php
@@ -3,7 +3,11 @@
namespace MongoDB\Tests\Collection;
use Closure;
+use Generator;
+use MongoDB\BSON\Document;
use MongoDB\BSON\Javascript;
+use MongoDB\Codec\Codec;
+use MongoDB\Codec\LazyBSONDocumentCodec;
use MongoDB\Collection;
use MongoDB\Database;
use MongoDB\Driver\BulkWrite;
@@ -13,13 +17,16 @@
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\Exception\UnsupportedException;
use MongoDB\MapReduceResult;
+use MongoDB\Model\LazyBSONDocument;
use MongoDB\Operation\Count;
use MongoDB\Tests\CommandObserver;
use TypeError;
use function array_filter;
+use function array_map;
use function call_user_func;
use function is_scalar;
+use function iterator_to_array;
use function json_encode;
use function strchr;
use function usort;
@@ -274,19 +281,44 @@ public function testExplain(): void
$this->assertArrayHasKey('queryPlanner', $result);
}
- public function testFindOne(): void
+ /** @dataProvider withCodecOptions */
+ public function testFind(Closure $expected, ?Codec $codec, string $assertion): void
+ {
+ $this->createFixtures(3);
+
+ $filter = ['_id' => ['$gt' => 1]];
+ $options = [
+ 'codec' => $codec,
+ 'sort' => ['x' => -1],
+ ];
+
+ $expectedObject = [['_id' => 3, 'x' => 33], ['_id' => 2, 'x' => 22]];
+
+ $method = $assertion == 'equals' ? 'assertEquals' : 'assertSameDocuments';
+
+ $this->$method(
+ array_map($expected, $expectedObject),
+ iterator_to_array($this->collection->find($filter, $options))
+ );
+ }
+
+ /** @dataProvider withCodecOptions */
+ public function testFindOne(Closure $expected, ?Codec $codec, string $assertion): void
{
$this->createFixtures(5);
$filter = ['_id' => ['$lt' => 5]];
$options = [
+ 'codec' => $codec,
'skip' => 1,
'sort' => ['x' => -1],
];
- $expected = ['_id' => 3, 'x' => 33];
+ $expectedObject = ['_id' => 3, 'x' => 33];
+
+ $method = $assertion == 'equals' ? 'assertEquals' : 'assertSameDocument';
- $this->assertSameDocument($expected, $this->collection->findOne($filter, $options));
+ $this->$method($expected($expectedObject), $this->collection->findOne($filter, $options));
}
public function testFindWithinTransaction(): void
@@ -806,4 +838,23 @@ private function createFixtures(int $n, array $executeBulkWriteOptions = []): vo
$this->assertEquals($n, $result->getInsertedCount());
}
+
+ public static function withCodecOptions(): Generator
+ {
+ yield 'No codec' => [
+ 'expected' => function ($expected) {
+ return $expected;
+ },
+ 'codec' => null,
+ 'assertion' => 'document',
+ ];
+
+ yield 'LazyBSONDocumentCodec' => [
+ 'expected' => function ($expected) {
+ return new LazyBSONDocument(Document::fromPHP($expected));
+ },
+ 'codec' => new LazyBSONDocumentCodec(),
+ 'assertion' => 'equals',
+ ];
+ }
}
diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php
new file mode 100644
index 000000000..43d544dc2
--- /dev/null
+++ b/tests/Model/LazyBSONArrayTest.php
@@ -0,0 +1,193 @@
+ 'baz'],
+ [0, 1, 2],
+ ];
+
+ public static function provideTestArray(): Generator
+ {
+ yield 'array' => [new LazyBSONArray(self::ARRAY)];
+
+ yield 'packedArray' => [new LazyBSONArray(PackedArray::fromPHP(self::ARRAY))];
+ }
+
+ 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 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 testGetDocument(LazyBSONArray $array): void
+ {
+ $this->assertInstanceOf(LazyBSONDocument::class, $array[1]);
+ $this->assertInstanceOf(LazyBSONDocument::class, $array[1]);
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testGetArray(LazyBSONArray $array): void
+ {
+ $this->assertInstanceOf(LazyBSONArray::class, $array[2]);
+ $this->assertInstanceOf(LazyBSONArray::class, $array[2]);
+ }
+
+ /** @dataProvider provideTestArray */
+ public function testOffsetExists(LazyBSONArray $array): void
+ {
+ $this->assertTrue(isset($array[0]));
+ $this->assertFalse(isset($array[4]));
+ }
+
+ /** @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 testAppend(LazyBSONArray $array): void
+ {
+ $this->assertFalse(isset($array[3]));
+ $array[] = 'yay!';
+ $this->assertSame('yay!', $array[3]);
+ }
+
+ /** @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(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 testJsonSerialize(): void
+ {
+ $document = new LazyBSONArray([
+ 'bar',
+ new LazyBSONArray([1, 2, 3]),
+ new LazyBSONDocument([1, 2, 3]),
+ new LazyBSONArray([]),
+ ]);
+
+ $expectedJson = '["bar",[1,2,3],{"0":1,"1":2,"2":3},[]]';
+
+ $this->assertSame($expectedJson, json_encode($document));
+ }
+}
diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php
new file mode 100644
index 000000000..67b6dbc4b
--- /dev/null
+++ b/tests/Model/LazyBSONDocumentTest.php
@@ -0,0 +1,250 @@
+ 'bar',
+ 'document' => ['bar' => 'baz'],
+ 'array' => [0, 1, 2],
+ ];
+
+ public static function provideTestDocument(): Generator
+ {
+ yield 'array' => [new LazyBSONDocument(self::OBJECT)];
+
+ yield 'object' => [new LazyBSONDocument((object) self::OBJECT)];
+
+ yield 'document' => [new LazyBSONDocument(Document::fromPHP(self::OBJECT))];
+ }
+
+ public function testConstructWithoutArgument(): void
+ {
+ $instance = new LazyBSONDocument();
+ $this->assertSame([], iterator_to_array($instance));
+ }
+
+ public function testConstructWithWrongType(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ new LazyBSONDocument('foo');
+ }
+
+ 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(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']));
+ }
+
+ /** @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(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']);
+ }
+
+ /** @dataProvider provideTestDocument */
+ public function testBsonSerializeCastsToObject(LazyBSONDocument $document): void
+ {
+ $expected = (object) self::OBJECT;
+ $expected->document = (object) $expected->document;
+
+ $this->assertEquals($expected, $document->bsonSerialize());
+ }
+
+ public function testJsonSerialize(): void
+ {
+ $document = new LazyBSONDocument([
+ 'foo' => 'bar',
+ 'array' => new LazyBSONArray([1, 2, 3]),
+ 'object' => new LazyBSONDocument([1, 2, 3]),
+ 'nested' => new LazyBSONDocument([new LazyBSONDocument([new LazyBSONDocument()])]),
+ ]);
+
+ $expectedJson = '{"foo":"bar","array":[1,2,3],"object":{"0":1,"1":2,"2":3},"nested":{"0":{"0":{}}}}';
+
+ $this->assertSame($expectedJson, json_encode($document));
+ }
+
+ public function testJsonSerializeCastsToObject(): void
+ {
+ $data = [0 => 'foo', 2 => 'bar'];
+
+ $document = new LazyBSONDocument($data);
+ $this->assertEquals((object) [0 => 'foo', 2 => 'bar'], $document->jsonSerialize());
+ }
+}