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()); + } +}