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