From 9064c9d86e5baf711f6bb38e6691de85b80a70c9 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 6 Jul 2023 10:13:09 +0200 Subject: [PATCH 01/27] Add AsListIterator to remove gaps in arrays --- psalm-baseline.xml | 5 ++++ src/Model/AsListIterator.php | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/Model/AsListIterator.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e6b0a3436..0da65b97e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -68,6 +68,11 @@ protocol]['options']]]> + + + AsListIterator + + $this[$key] diff --git a/src/Model/AsListIterator.php b/src/Model/AsListIterator.php new file mode 100644 index 000000000..2a5bd6f69 --- /dev/null +++ b/src/Model/AsListIterator.php @@ -0,0 +1,51 @@ +> + */ +final class AsListIterator extends IteratorIterator +{ + private int $index = 0; + + public function key(): int + { + return $this->index; + } + + public function next(): void + { + $this->index++; + + parent::next(); + } + + public function rewind(): void + { + $this->index = 0; + + parent::rewind(); + } +} From a214d5875d4877157fbaf1cbead22718d41d0d02 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 6 Jul 2023 10:13:21 +0200 Subject: [PATCH 02/27] Add lazy models for BSON documents and arrays --- psalm-baseline.xml | 62 ++++++- src/Model/LazyBSONArray.php | 267 +++++++++++++++++++++++++++ src/Model/LazyBSONDocument.php | 229 +++++++++++++++++++++++ tests/Model/AsListIteratorTest.php | 30 +++ tests/Model/LazyBSONArrayTest.php | 243 ++++++++++++++++++++++++ tests/Model/LazyBSONDocumentTest.php | 261 ++++++++++++++++++++++++++ 6 files changed, 1091 insertions(+), 1 deletion(-) create mode 100644 src/Model/LazyBSONArray.php create mode 100644 src/Model/LazyBSONDocument.php create mode 100644 tests/Model/AsListIteratorTest.php create mode 100644 tests/Model/LazyBSONArrayTest.php create mode 100644 tests/Model/LazyBSONDocumentTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 0da65b97e..4ca444af7 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -69,7 +69,7 @@ - + AsListIterator @@ -173,6 +173,66 @@ index['name']]]> + + + $offset + $offset + $offset + + + unset[$offset]) && ! isset($seen[$offset]); + }, + )]]> + unset[$offset]) && ! isset($seen[$offset]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, int $offset) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$offset] = true; + + // Return actual value (potentially overridden by offsetSet) + return $this->offsetGet($offset); + }, + )]]> + + + $seen[$offset] + + + is_array($input) + + + array_values + + + + + ]]> + + + $value + + + ]]> + + + is_object($input) + + options['typeMap']]]> diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php new file mode 100644 index 000000000..cc8204e83 --- /dev/null +++ b/src/Model/LazyBSONArray.php @@ -0,0 +1,267 @@ + + * @template-implements IteratorAggregate + */ +class LazyBSONArray implements ArrayAccess, IteratorAggregate +{ + /** @var PackedArray */ + private PackedArray $bson; + + /** @var array */ + private array $read = []; + + /** @var array */ + private array $exists = []; + + /** @var array */ + private array $set = []; + + /** @var array */ + private array $unset = []; + + private bool $entirePackedArrayRead = false; + + /** + * Deep clone this lazy array. + */ + public function __clone() + { + $this->bson = clone $this->bson; + + foreach ($this->set as $key => $value) { + $this->set[$key] = recursive_copy($value); + } + } + + /** + * Constructs a lazy BSON array. + * + * @param PackedArray|list|null $input An input for a lazy array. + * When given a BSON array, this is treated as input. For lists + * this constructs a new BSON array using fromPHP. + */ + public function __construct($input = null) + { + if ($input === null) { + $this->bson = PackedArray::fromPHP([]); + } elseif ($input instanceof PackedArray) { + $this->bson = $input; + } elseif (is_array($input)) { + $this->bson = PackedArray::fromPHP([]); + $this->set = array_values($input); + $this->exists = array_map( + /** @param TValue $value */ + fn ($value): bool => true, + $this->set, + ); + } else { + throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']); + } + } + + /** @return AsListIterator */ + public function getIterator(): AsListIterator + { + $itemIterator = new AppendIterator(); + // Iterate through all fields in the BSON array + $itemIterator->append($this->bson->getIterator()); + // Then iterate over all fields that were set + $itemIterator->append(new ArrayIterator($this->set)); + + /** @var array $seen */ + $seen = []; + + // Use AsListIterator to ensure we're indexing from 0 without gaps + return new AsListIterator( + new CallbackIterator( + // Skip keys that were unset or handled in a previous iterator + new CallbackFilterIterator( + $itemIterator, + /** @param TValue $value */ + function ($value, int $offset) use (&$seen): bool { + return ! isset($this->unset[$offset]) && ! isset($seen[$offset]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, int $offset) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$offset] = true; + + // Return actual value (potentially overridden by offsetSet) + return $this->offsetGet($offset); + }, + ), + ); + } + + /** @param mixed $offset */ + public function offsetExists($offset): bool + { + if (! is_numeric($offset)) { + return false; + } + + $offset = (int) $offset; + + // If we've looked for the value, return the cached result + if (isset($this->exists[$offset])) { + return $this->exists[$offset]; + } + + return $this->exists[$offset] = $this->bson->has($offset); + } + + /** + * @param mixed $offset + * @return TValue + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + if (! is_numeric($offset)) { + trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING); + + return null; + } + + $offset = (int) $offset; + $this->readFromBson($offset); + + if (isset($this->unset[$offset]) || ! $this->exists[$offset]) { + trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING); + + return null; + } + + return array_key_exists($offset, $this->set) ? $this->set[$offset] : $this->read[$offset]; + } + + /** + * @param mixed $offset + * @param TValue $value + */ + public function offsetSet($offset, $value): void + { + if ($offset === null) { + $this->readEntirePackedArray(); + + $existingItems = [ + ...array_keys($this->read), + ...array_keys($this->set), + ]; + + $offset = $existingItems === [] ? 0 : max($existingItems) + 1; + } elseif (! is_numeric($offset)) { + trigger_error(sprintf('Unsupported offset: %s', $offset), E_USER_WARNING); + + return; + } else { + $offset = (int) $offset; + } + + $this->set[$offset] = $value; + unset($this->unset[$offset]); + $this->exists[$offset] = true; + } + + /** @param mixed $offset */ + public function offsetUnset($offset): void + { + if (! is_numeric($offset)) { + trigger_error(sprintf('Undefined offset: %s', $offset), E_USER_WARNING); + + return; + } + + $offset = (int) $offset; + $this->unset[$offset] = true; + $this->exists[$offset] = false; + unset($this->set[$offset]); + } + + private function readEntirePackedArray(): void + { + if ($this->entirePackedArrayRead) { + return; + } + + foreach ($this->bson as $offset => $value) { + $this->read[$offset] = $value; + + if (! isset($this->exists[$offset])) { + $this->exists[$offset] = true; + } + } + + $this->entirePackedArrayRead = true; + } + + private function readFromBson(int $offset): void + { + if (array_key_exists($offset, $this->read)) { + return; + } + + // Read value if it's present in the BSON structure + $found = false; + if ($this->bson->has($offset)) { + $found = true; + $this->read[$offset] = $this->bson->get($offset); + } + + // Mark the offset as "existing" if it wasn't previously marked already + if (! isset($this->exists[$offset])) { + $this->exists[$offset] = $found; + } + } +} diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php new file mode 100644 index 000000000..0ff235a66 --- /dev/null +++ b/src/Model/LazyBSONDocument.php @@ -0,0 +1,229 @@ + + * @template-implements IteratorAggregate + */ +class LazyBSONDocument implements ArrayAccess, IteratorAggregate +{ + /** @var Document */ + private Document $bson; + + /** @var array */ + private array $read = []; + + /** @var array */ + private array $exists = []; + + /** @var array */ + private array $set = []; + + /** @var array */ + private array $unset = []; + + /** + * Deep clone this lazy document. + */ + public function __clone() + { + $this->bson = clone $this->bson; + + foreach ($this->set as $key => $value) { + $this->set[$key] = recursive_copy($value); + } + } + + /** + * Constructs a lazy BSON document. + * + * @param Document|array|object|null $input An input for a lazy object. + * When given a BSON document, this is treated as input. For arrays + * and objects this constructs a new BSON document using fromPHP. + */ + public function __construct($input = null) + { + if ($input === null) { + $this->bson = Document::fromPHP([]); + } elseif ($input instanceof Document) { + $this->bson = $input; + } elseif (is_array($input) || is_object($input)) { + $this->bson = Document::fromPHP([]); + + foreach ($input as $key => $value) { + assert(is_string($key)); + $this->set[$key] = $value; + $this->exists[$key] = true; + } + } else { + throw InvalidArgumentException::invalidType('input', $input, [Document::class, 'array', 'null']); + } + } + + /** @return TValue */ + public function __get(string $property) + { + $this->readFromBson($property); + + if (isset($this->unset[$property]) || ! $this->exists[$property]) { + trigger_error(sprintf('Undefined property: %s', $property), E_USER_WARNING); + + return null; + } + + return array_key_exists($property, $this->set) ? $this->set[$property] : $this->read[$property]; + } + + public function __isset(string $name): bool + { + // If we've looked for the value, return the cached result + if (isset($this->exists[$name])) { + return $this->exists[$name]; + } + + return $this->exists[$name] = $this->bson->has($name); + } + + /** @param TValue $value */ + public function __set(string $property, $value): void + { + $this->set[$property] = $value; + unset($this->unset[$property]); + $this->exists[$property] = true; + } + + public function __unset(string $name): void + { + $this->unset[$name] = true; + $this->exists[$name] = false; + unset($this->set[$name]); + } + + /** @return Iterator */ + public function getIterator(): CallbackIterator + { + $itemIterator = new AppendIterator(); + // Iterate through all fields in the BSON document + $itemIterator->append($this->bson->getIterator()); + // Then iterate over all fields that were set + $itemIterator->append(new ArrayIterator($this->set)); + + /** @var array $seen */ + $seen = []; + + return new CallbackIterator( + // Skip keys that were unset or handled in a previous iterator + new CallbackFilterIterator( + $itemIterator, + /** @param TValue $current */ + function ($current, string $key) use (&$seen): bool { + return ! isset($this->unset[$key]) && ! isset($seen[$key]); + }, + ), + /** + * @param TValue $value + * @return TValue + */ + function ($value, string $key) use (&$seen) { + // Mark key as seen, skipping any future occurrences + $seen[$key] = true; + + // Return actual value (potentially overridden by __set) + return $this->__get($key); + }, + ); + } + + /** @param mixed $offset */ + public function offsetExists($offset): bool + { + return $this->__isset((string) $offset); + } + + /** + * @param mixed $offset + * @return TValue + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->__get((string) $offset); + } + + /** + * @param mixed $offset + * @param TValue $value + */ + public function offsetSet($offset, $value): void + { + $this->__set((string) $offset, $value); + } + + /** @param mixed $offset */ + public function offsetUnset($offset): void + { + $this->__unset((string) $offset); + } + + private function readFromBson(string $key): void + { + if (array_key_exists($key, $this->read)) { + return; + } + + // Read value if it's present in the BSON structure + $found = false; + if ($this->bson->has($key)) { + $found = true; + $this->read[$key] = $this->bson->get($key); + } + + // Mark the offset as "existing" if it wasn't previously marked already + if (! isset($this->exists[$key])) { + $this->exists[$key] = $found; + } + } +} diff --git a/tests/Model/AsListIteratorTest.php b/tests/Model/AsListIteratorTest.php new file mode 100644 index 000000000..79b041e6e --- /dev/null +++ b/tests/Model/AsListIteratorTest.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'])]; + } +} diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php new file mode 100644 index 000000000..d0dc872c8 --- /dev/null +++ b/tests/Model/LazyBSONArrayTest.php @@ -0,0 +1,243 @@ + [ + new LazyBSONArray([ + 'bar', + new LazyBSONDocument(['bar' => 'baz']), + new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'packedArray' => [ + new LazyBSONArray(PackedArray::fromPHP([ + 'bar', + ['bar' => 'baz'], + [0, 1, 2], + ])), + ]; + } + + public function testConstructWithoutArgument(): void + { + $instance = new LazyBSONArray(); + $this->assertSame([], iterator_to_array($instance)); + } + + public function testConstructWithWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + new LazyBSONArray('foo'); + } + + public function testConstructWithArrayUsesLiteralValues(): void + { + $array = new LazyBSONArray([ + (object) ['bar' => 'baz'], + ['bar' => 'baz'], + [0, 1, 2], + ]); + + $this->assertInstanceOf(stdClass::class, $array[0]); + $this->assertIsArray($array[1]); + $this->assertIsArray($array[2]); + } + + public function testClone(): void + { + $original = new LazyBSONArray(); + $original[0] = (object) ['foo' => 'bar']; + + $clone = clone $original; + $clone[0]->foo = 'baz'; + + self::assertSame('bar', $original[0]->foo); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGet(LazyBSONArray $array): void + { + $this->assertSame('bar', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetAfterUnset(LazyBSONArray $array): void + { + $this->assertSame('bar', $array[0]); + unset($array[0]); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: 0'); + $array[0]; + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForMissingOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: 4'); + $array[4]; + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForNumericOffset(LazyBSONArray $array): void + { + $this->assertSame('bar', $array['0']); + } + + /** @dataProvider provideTestArray */ + public function testOffsetGetForUnsupportedOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined offset: foo'); + $array['foo']; + } + + /** @dataProvider provideTestArray */ + public function testGetDocument(LazyBSONArray $array): void + { + $this->assertInstanceOf(Document::class, $array[1]); + $this->assertInstanceOf(Document::class, $array[1]); + } + + /** @dataProvider provideTestArray */ + public function testGetArray(LazyBSONArray $array): void + { + $this->assertInstanceOf(PackedArray::class, $array[2]); + $this->assertInstanceOf(PackedArray::class, $array[2]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetExists(LazyBSONArray $array): void + { + $this->assertTrue(isset($array[0])); + $this->assertFalse(isset($array[4])); + + // Unsupported offset + $this->assertFalse(isset($array['foo'])); + + // Numeric offset + $this->assertTrue(isset($array['1'])); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSet(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[4])); + $array[4] = 'yay!'; + $this->assertSame('yay!', $array[4]); + + $this->assertSame('bar', $array[0]); + $array[0] = 'baz'; + $this->assertSame('baz', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSetForNumericOffset(LazyBSONArray $array): void + { + $array['1'] = 'baz'; + $this->assertSame('baz', $array[1]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetSetForUnsupportedOffset(LazyBSONArray $array): void + { + $this->expectWarning(); + $this->expectWarningMessage('Unsupported offset: foo'); + $array['foo'] = 'yay!'; + } + + /** @dataProvider provideTestArray */ + public function testAppend(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[3])); + $array[] = 'yay!'; + $this->assertSame('yay!', $array[3]); + } + + public function testAppendToEmptyArray(): void + { + $array = new LazyBSONArray(); + + $this->assertFalse(isset($array[0])); + $array[] = 'yay!'; + $this->assertSame('yay!', $array[0]); + } + + /** @dataProvider provideTestArray */ + public function testAppendWithGap(LazyBSONArray $array): void + { + // Leave offset 3 empty + $array[4] = 'yay!'; + + $this->assertFalse(isset($array[3])); + $array[] = 'bleh'; + + // Expect offset 3 to be skipped, offset 5 is used as 4 is already set + $this->assertFalse(isset($array[3])); + $this->assertSame('bleh', $array[5]); + } + + /** @dataProvider provideTestArray */ + public function testOffsetUnset(LazyBSONArray $array): void + { + $this->assertFalse(isset($array[4])); + $array[4] = 'yay!'; + unset($array[4]); + $this->assertFalse(isset($array[4])); + + unset($array[0]); + $this->assertFalse(isset($array[0])); + + // Change value to ensure it is unset for good + $array[1] = (object) ['foo' => 'baz']; + unset($array[1]); + $this->assertFalse(isset($array[1])); + } + + /** @dataProvider provideTestArray */ + public function testIterator(LazyBSONArray $array): void + { + $items = iterator_to_array($array); + $this->assertCount(3, $items); + $this->assertSame('bar', $items[0]); + $this->assertInstanceOf(Document::class, $items[1]); + $this->assertInstanceOf(PackedArray::class, $items[2]); + + $array[0] = 'baz'; + $items = iterator_to_array($array); + $this->assertCount(3, $items); + $this->assertSame('baz', $items[0]); + $this->assertInstanceOf(Document::class, $items[1]); + $this->assertInstanceOf(PackedArray::class, $items[2]); + + unset($array[0]); + unset($array[2]); + $items = iterator_to_array($array); + $this->assertCount(1, $items); + $this->assertInstanceOf(Document::class, $items[0]); + + // Leave a gap to ensure we're re-indexing keys + $array[5] = 'yay!'; + $items = iterator_to_array($array); + $this->assertCount(2, $items); + $this->assertInstanceOf(Document::class, $items[0]); + $this->assertSame('yay!', $items[1]); + } +} diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php new file mode 100644 index 000000000..0b58fed4e --- /dev/null +++ b/tests/Model/LazyBSONDocumentTest.php @@ -0,0 +1,261 @@ + [ + new LazyBSONDocument([ + 'foo' => 'bar', + 'document' => new LazyBSONDocument(['bar' => 'baz']), + 'array' => new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'object' => [ + new LazyBSONDocument((object) [ + 'foo' => 'bar', + 'document' => new LazyBSONDocument(['bar' => 'baz']), + 'array' => new LazyBSONArray([0, 1, 2]), + ]), + ]; + + yield 'document' => [ + new LazyBSONDocument(Document::fromPHP([ + 'foo' => 'bar', + 'document' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ])), + ]; + } + + public static function provideTestDocumentWithNativeArrays(): Generator + { + yield 'array' => [ + new LazyBSONDocument([ + 'document' => (object) ['bar' => 'baz'], + 'hash' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ]), + ]; + + yield 'object' => [ + new LazyBSONDocument((object) [ + 'document' => (object) ['bar' => 'baz'], + 'hash' => ['bar' => 'baz'], + 'array' => [0, 1, 2], + ]), + ]; + } + + public function testConstructWithoutArgument(): void + { + $instance = new LazyBSONDocument(); + $this->assertSame([], iterator_to_array($instance)); + } + + public function testConstructWithWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + new LazyBSONDocument('foo'); + } + + /** @dataProvider provideTestDocumentWithNativeArrays */ + public function testConstructWithArrayUsesLiteralValues($value): void + { + $document = new LazyBSONDocument($value); + + $this->assertInstanceOf(stdClass::class, $document->document); + $this->assertIsArray($document->hash); + $this->assertIsArray($document->array); + } + + public function testClone(): void + { + $original = new LazyBSONDocument(); + $original->object = (object) ['foo' => 'bar']; + + $clone = clone $original; + $clone->object->foo = 'baz'; + + self::assertSame('bar', $original->object->foo); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGet(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document->foo); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGetAfterUnset(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document->foo); + unset($document->foo); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: foo'); + $document->foo; + } + + /** @dataProvider provideTestDocument */ + public function testPropertyGetForMissingProperty(LazyBSONDocument $document): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: bar'); + $document->bar; + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGet(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document['foo']); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGetAfterUnset(LazyBSONDocument $document): void + { + $this->assertSame('bar', $document['foo']); + unset($document['foo']); + + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: foo'); + $document['foo']; + } + + /** @dataProvider provideTestDocument */ + public function testOffsetGetForMissingOffset(LazyBSONDocument $document): void + { + $this->expectWarning(); + $this->expectWarningMessage('Undefined property: bar'); + $document['bar']; + } + + /** @dataProvider provideTestDocument */ + public function testGetDocument(LazyBSONDocument $document): void + { + $this->assertInstanceOf(Document::class, $document->document); + $this->assertInstanceOf(Document::class, $document['document']); + } + + /** @dataProvider provideTestDocument */ + public function testGetArray(LazyBSONDocument $document): void + { + $this->assertInstanceOf(PackedArray::class, $document->array); + $this->assertInstanceOf(PackedArray::class, $document['array']); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyIsset(LazyBSONDocument $document): void + { + $this->assertTrue(isset($document->foo)); + $this->assertFalse(isset($document->bar)); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetExists(LazyBSONDocument $document): void + { + $this->assertTrue(isset($document['foo'])); + $this->assertFalse(isset($document['bar'])); + } + + /** @dataProvider provideTestDocument */ + public function testPropertySet(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document->new)); + $document->new = 'yay!'; + $this->assertSame('yay!', $document->new); + + $this->assertSame('bar', $document->foo); + $document->foo = 'baz'; + $this->assertSame('baz', $document->foo); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetSet(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document['new'])); + $document['new'] = 'yay!'; + $this->assertSame('yay!', $document['new']); + + $this->assertSame('bar', $document['foo']); + $document['foo'] = 'baz'; + $this->assertSame('baz', $document['foo']); + } + + /** @dataProvider provideTestDocument */ + public function testPropertyUnset(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document->new)); + $document->new = 'yay!'; + unset($document->new); + $this->assertFalse(isset($document->new)); + + unset($document->foo); + $this->assertFalse(isset($document->foo)); + + // Change value to ensure it is unset for good + $document->document = (object) ['foo' => 'baz']; + unset($document->document); + $this->assertFalse(isset($document->document)); + } + + /** @dataProvider provideTestDocument */ + public function testOffsetUnset(LazyBSONDocument $document): void + { + $this->assertFalse(isset($document['new'])); + $document['new'] = 'yay!'; + unset($document['new']); + $this->assertFalse(isset($document['new'])); + + unset($document['foo']); + $this->assertFalse(isset($document['foo'])); + + // Change value to ensure it is unset for good + $document['document'] = (object) ['foo' => 'baz']; + unset($document['document']); + $this->assertFalse(isset($document['document'])); + } + + /** @dataProvider provideTestDocument */ + public function testIterator(LazyBSONDocument $document): void + { + $items = iterator_to_array($document); + $this->assertCount(3, $items); + $this->assertSame('bar', $items['foo']); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertInstanceOf(PackedArray::class, $items['array']); + + $document->foo = 'baz'; + $items = iterator_to_array($document); + $this->assertCount(3, $items); + $this->assertSame('baz', $items['foo']); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertInstanceOf(PackedArray::class, $items['array']); + + unset($document->foo); + unset($document->array); + $items = iterator_to_array($document); + $this->assertCount(1, $items); + $this->assertInstanceOf(Document::class, $items['document']); + + $document->new = 'yay!'; + $items = iterator_to_array($document); + $this->assertCount(2, $items); + $this->assertInstanceOf(Document::class, $items['document']); + $this->assertSame('yay!', $items['new']); + } +} From f9fefe4757ef4871eb329ed9510c429be22694cb Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 7 Jul 2023 08:45:39 +0200 Subject: [PATCH 03/27] Introduce codecs to work with lazy BSON objects --- psalm-baseline.xml | 10 ++ src/Codec/ArrayCodec.php | 119 ++++++++++++++++++++ src/Codec/LazyBSONArrayCodec.php | 110 +++++++++++++++++++ src/Codec/LazyBSONCodecLibrary.php | 31 ++++++ src/Codec/LazyBSONDocumentCodec.php | 110 +++++++++++++++++++ src/Codec/ObjectCodec.php | 119 ++++++++++++++++++++ src/Model/LazyBSONArray.php | 10 +- src/Model/LazyBSONDocument.php | 10 +- tests/Codec/ArrayCodecTest.php | 126 ++++++++++++++++++++++ tests/Codec/LazyBSONArrayCodecTest.php | 58 ++++++++++ tests/Codec/LazyBSONCodecLibraryTest.php | 104 ++++++++++++++++++ tests/Codec/LazyBSONDocumentCodecTest.php | 58 ++++++++++ tests/Codec/ObjectCodecTest.php | 124 +++++++++++++++++++++ tests/Model/LazyBSONArrayTest.php | 21 ++-- tests/Model/LazyBSONDocumentTest.php | 21 ++-- 15 files changed, 1005 insertions(+), 26 deletions(-) create mode 100644 src/Codec/ArrayCodec.php create mode 100644 src/Codec/LazyBSONArrayCodec.php create mode 100644 src/Codec/LazyBSONCodecLibrary.php create mode 100644 src/Codec/LazyBSONDocumentCodec.php create mode 100644 src/Codec/ObjectCodec.php create mode 100644 tests/Codec/ArrayCodecTest.php create mode 100644 tests/Codec/LazyBSONArrayCodecTest.php create mode 100644 tests/Codec/LazyBSONCodecLibraryTest.php create mode 100644 tests/Codec/LazyBSONDocumentCodecTest.php create mode 100644 tests/Codec/ObjectCodecTest.php diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 4ca444af7..13e800cf0 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -28,6 +28,16 @@ ($value is NativeType ? BSONType : $value) + + + $return[] + + + + + $return[$field] + + $cmd[$option] diff --git a/src/Codec/ArrayCodec.php b/src/Codec/ArrayCodec.php new file mode 100644 index 000000000..eed75b3c3 --- /dev/null +++ b/src/Codec/ArrayCodec.php @@ -0,0 +1,119 @@ + + */ +final class ArrayCodec implements Codec, KnowsCodecLibrary +{ + 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( + /** + * @param mixed $item + * @return mixed + */ + fn ($item) => $this->getLibrary()->decodeIfSupported($item), + $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( + /** + * @param mixed $item + * @return mixed + */ + fn ($item) => $this->getLibrary()->encodeIfSupported($item), + $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/LazyBSONArrayCodec.php b/src/Codec/LazyBSONArrayCodec.php new file mode 100644 index 000000000..bd53cc19f --- /dev/null +++ b/src/Codec/LazyBSONArrayCodec.php @@ -0,0 +1,110 @@ + + */ +final class LazyBSONArrayCodec implements Codec, KnowsCodecLibrary +{ + 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, KnowsCodecLibrary +{ + 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 = []; + /** @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..604b17e55 --- /dev/null +++ b/src/Codec/ObjectCodec.php @@ -0,0 +1,119 @@ + + */ +final class ObjectCodec implements Codec, KnowsCodecLibrary +{ + 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 index cc8204e83..57d807a8a 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -23,6 +23,8 @@ use CallbackFilterIterator; use IteratorAggregate; use MongoDB\BSON\PackedArray; +use MongoDB\Codec\CodecLibrary; +use MongoDB\Codec\LazyBSONCodecLibrary; use MongoDB\Exception\InvalidArgumentException; use ReturnTypeWillChange; @@ -68,6 +70,8 @@ class LazyBSONArray implements ArrayAccess, IteratorAggregate private bool $entirePackedArrayRead = false; + private CodecLibrary $codecLibrary; + /** * Deep clone this lazy array. */ @@ -87,7 +91,7 @@ public function __clone() * 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) + public function __construct($input = null, ?CodecLibrary $codecLibrary = null) { if ($input === null) { $this->bson = PackedArray::fromPHP([]); @@ -104,6 +108,8 @@ public function __construct($input = null) } else { throw InvalidArgumentException::invalidType('input', $input, [PackedArray::class, 'array', 'null']); } + + $this->codecLibrary = $codecLibrary ?? new LazyBSONCodecLibrary(); } /** @return AsListIterator */ @@ -256,7 +262,7 @@ private function readFromBson(int $offset): void $found = false; if ($this->bson->has($offset)) { $found = true; - $this->read[$offset] = $this->bson->get($offset); + $this->read[$offset] = $this->codecLibrary->decodeIfSupported($this->bson->get($offset)); } // Mark the offset as "existing" if it wasn't previously marked already diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 0ff235a66..d376bc967 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -24,6 +24,8 @@ use Iterator; use IteratorAggregate; use MongoDB\BSON\Document; +use MongoDB\Codec\CodecLibrary; +use MongoDB\Codec\LazyBSONCodecLibrary; use MongoDB\Exception\InvalidArgumentException; use ReturnTypeWillChange; @@ -65,6 +67,8 @@ class LazyBSONDocument implements ArrayAccess, IteratorAggregate /** @var array */ private array $unset = []; + private CodecLibrary $codecLibrary; + /** * Deep clone this lazy document. */ @@ -84,7 +88,7 @@ public function __clone() * 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) + public function __construct($input = null, ?CodecLibrary $codecLibrary = null) { if ($input === null) { $this->bson = Document::fromPHP([]); @@ -101,6 +105,8 @@ public function __construct($input = null) } else { throw InvalidArgumentException::invalidType('input', $input, [Document::class, 'array', 'null']); } + + $this->codecLibrary = $codecLibrary ?? new LazyBSONCodecLibrary(); } /** @return TValue */ @@ -218,7 +224,7 @@ private function readFromBson(string $key): void $found = false; if ($this->bson->has($key)) { $found = true; - $this->read[$key] = $this->bson->get($key); + $this->read[$key] = $this->codecLibrary->decodeIfSupported($this->bson->get($key)); } // Mark the offset as "existing" if it wasn't previously marked already diff --git a/tests/Codec/ArrayCodecTest.php b/tests/Codec/ArrayCodecTest.php new file mode 100644 index 000000000..41cb282c3 --- /dev/null +++ b/tests/Codec/ArrayCodecTest.php @@ -0,0 +1,126 @@ +assertSame(['decoded', 'decoded'], $this->getCodec()->decode($value)); + } + + public function testDecodeListWithGaps(): void + { + $value = [ + 0 => 'decoded', + 2 => 'encoded', + ]; + + $this->assertSame([0 => 'decoded', 2 => 'decoded'], $this->getCodec()->decode($value)); + } + + public function testDecodeHash(): void + { + $value = [ + 'foo' => 'decoded', + 'bar' => 'encoded', + ]; + + $this->assertSame(['foo' => 'decoded', 'bar' => 'decoded'], $this->getCodec()->decode($value)); + } + + public function testDecodeWithWrongType(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo')); + $this->getCodec()->encode('foo'); + } + + public function testEncode(): void + { + $value = [ + 'decoded', + 'encoded', + ]; + + $this->assertSame(['encoded', 'encoded'], $this->getCodec()->encode($value)); + } + + public function testEncodeListWithGaps(): void + { + $value = [ + 0 => 'decoded', + 2 => 'encoded', + ]; + + $this->assertSame([0 => 'encoded', 2 => 'encoded'], $this->getCodec()->encode($value)); + } + + public function testEncodeHash(): void + { + $value = [ + 'foo' => 'decoded', + 'bar' => 'encoded', + ]; + + $this->assertSame(['foo' => 'encoded', 'bar' => 'encoded'], $this->getCodec()->encode($value)); + } + + 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/LazyBSONArrayCodecTest.php b/tests/Codec/LazyBSONArrayCodecTest.php new file mode 100644 index 000000000..459b8f633 --- /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->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo')); + $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->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..13cbc80a2 --- /dev/null +++ b/tests/Codec/LazyBSONCodecLibraryTest.php @@ -0,0 +1,104 @@ + 'bar'], + [0, 1, 2], + ]; + $document = (object) [ + 'string' => 'bar', + 'document' => ['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)], + 'value' => [new LazyBSONArray($array)], + ]; + + yield 'hash' => [ + 'expected' => ['foo' => PackedArray::fromPHP($array)], + 'value' => ['foo' => new LazyBSONArray($array)], + ]; + + yield 'object' => [ + 'expected' => (object) ['foo' => PackedArray::fromPHP($array)], + 'value' => (object) ['foo' => new LazyBSONArray($array)], + ]; + } + + 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)], + 'value' => [$packedArray], + ]; + + yield 'hash' => [ + 'expected' => ['foo' => new LazyBSONArray($packedArray)], + 'value' => ['foo' => $packedArray], + ]; + + yield 'object' => [ + 'expected' => (object) ['foo' => new LazyBSONArray($packedArray)], + 'value' => (object) ['foo' => $packedArray], + ]; + } + + /** @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..86c4b547c --- /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->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo')); + $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->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..910b19dd9 --- /dev/null +++ b/tests/Codec/ObjectCodecTest.php @@ -0,0 +1,124 @@ + 'decoded', + 'bar' => 'encoded', + ]; + + $this->assertEquals( + (object) ['foo' => 'decoded', 'bar' => 'decoded'], + $this->getCodec()->decode($value), + ); + } + + public function testDecodeExtendedObject(): void + { + $value = $this->getExtendedObject(); + + $this->assertEquals( + (object) ['foo' => 'decoded', 'bar' => 'decoded'], + $this->getCodec()->decode($value), + ); + } + + public function testDecodeWithWrongType(): void + { + $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo')); + $this->getCodec()->decode('foo'); + } + + public function testEncodeObject(): void + { + $value = (object) [ + 'foo' => 'decoded', + 'bar' => 'encoded', + ]; + + $this->assertEquals( + (object) ['foo' => 'encoded', 'bar' => 'encoded'], + $this->getCodec()->encode($value), + ); + } + + public function testEncodeExtendedObject(): void + { + $value = $this->getExtendedObject(); + + $this->assertEquals( + (object) ['foo' => 'encoded', 'bar' => 'encoded'], + $this->getCodec()->encode($value), + ); + } + + 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 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/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index d0dc872c8..a9b402019 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -3,7 +3,6 @@ namespace MongoDB\Tests\Model; use Generator; -use MongoDB\BSON\Document; use MongoDB\BSON\PackedArray; use MongoDB\Exception\InvalidArgumentException; use MongoDB\Model\LazyBSONArray; @@ -112,15 +111,15 @@ public function testOffsetGetForUnsupportedOffset(LazyBSONArray $array): void /** @dataProvider provideTestArray */ public function testGetDocument(LazyBSONArray $array): void { - $this->assertInstanceOf(Document::class, $array[1]); - $this->assertInstanceOf(Document::class, $array[1]); + $this->assertInstanceOf(LazyBSONDocument::class, $array[1]); + $this->assertInstanceOf(LazyBSONDocument::class, $array[1]); } /** @dataProvider provideTestArray */ public function testGetArray(LazyBSONArray $array): void { - $this->assertInstanceOf(PackedArray::class, $array[2]); - $this->assertInstanceOf(PackedArray::class, $array[2]); + $this->assertInstanceOf(LazyBSONArray::class, $array[2]); + $this->assertInstanceOf(LazyBSONArray::class, $array[2]); } /** @dataProvider provideTestArray */ @@ -217,27 +216,27 @@ public function testIterator(LazyBSONArray $array): void $items = iterator_to_array($array); $this->assertCount(3, $items); $this->assertSame('bar', $items[0]); - $this->assertInstanceOf(Document::class, $items[1]); - $this->assertInstanceOf(PackedArray::class, $items[2]); + $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(Document::class, $items[1]); - $this->assertInstanceOf(PackedArray::class, $items[2]); + $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(Document::class, $items[0]); + $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(Document::class, $items[0]); + $this->assertInstanceOf(LazyBSONDocument::class, $items[0]); $this->assertSame('yay!', $items[1]); } } diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 0b58fed4e..2ea0733e7 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -4,7 +4,6 @@ use Generator; use MongoDB\BSON\Document; -use MongoDB\BSON\PackedArray; use MongoDB\Exception\InvalidArgumentException; use MongoDB\Model\LazyBSONArray; use MongoDB\Model\LazyBSONDocument; @@ -147,15 +146,15 @@ public function testOffsetGetForMissingOffset(LazyBSONDocument $document): void /** @dataProvider provideTestDocument */ public function testGetDocument(LazyBSONDocument $document): void { - $this->assertInstanceOf(Document::class, $document->document); - $this->assertInstanceOf(Document::class, $document['document']); + $this->assertInstanceOf(LazyBSONDocument::class, $document->document); + $this->assertInstanceOf(LazyBSONDocument::class, $document['document']); } /** @dataProvider provideTestDocument */ public function testGetArray(LazyBSONDocument $document): void { - $this->assertInstanceOf(PackedArray::class, $document->array); - $this->assertInstanceOf(PackedArray::class, $document['array']); + $this->assertInstanceOf(LazyBSONArray::class, $document->array); + $this->assertInstanceOf(LazyBSONArray::class, $document['array']); } /** @dataProvider provideTestDocument */ @@ -236,26 +235,26 @@ public function testIterator(LazyBSONDocument $document): void $items = iterator_to_array($document); $this->assertCount(3, $items); $this->assertSame('bar', $items['foo']); - $this->assertInstanceOf(Document::class, $items['document']); - $this->assertInstanceOf(PackedArray::class, $items['array']); + $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(Document::class, $items['document']); - $this->assertInstanceOf(PackedArray::class, $items['array']); + $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(Document::class, $items['document']); + $this->assertInstanceOf(LazyBSONDocument::class, $items['document']); $document->new = 'yay!'; $items = iterator_to_array($document); $this->assertCount(2, $items); - $this->assertInstanceOf(Document::class, $items['document']); + $this->assertInstanceOf(LazyBSONDocument::class, $items['document']); $this->assertSame('yay!', $items['new']); } } From 01fcf4b1f8c27015dc0b1ba503be129986c01a98 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 08:43:33 +0200 Subject: [PATCH 04/27] Remove assert for keys and use get_object_vars when building LazyBSONDocument from an object --- src/Model/LazyBSONDocument.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index d376bc967..2a5baa49d 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -30,10 +30,9 @@ use ReturnTypeWillChange; use function array_key_exists; -use function assert; +use function get_object_vars; use function is_array; use function is_object; -use function is_string; use function MongoDB\recursive_copy; use function sprintf; use function trigger_error; @@ -84,7 +83,7 @@ public function __clone() /** * Constructs a lazy BSON document. * - * @param Document|array|object|null $input An input for a lazy object. + * @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. */ @@ -97,8 +96,11 @@ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) } 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) { - assert(is_string($key)); $this->set[$key] = $value; $this->exists[$key] = true; } From ffedb9a11b48297e81112576a4f3c9544274276e Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 08:52:34 +0200 Subject: [PATCH 05/27] Use null-coalesce assignments --- src/Model/LazyBSONArray.php | 7 +------ src/Model/LazyBSONDocument.php | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 57d807a8a..6eb0f3657 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -159,12 +159,7 @@ public function offsetExists($offset): bool $offset = (int) $offset; - // If we've looked for the value, return the cached result - if (isset($this->exists[$offset])) { - return $this->exists[$offset]; - } - - return $this->exists[$offset] = $this->bson->has($offset); + return $this->exists[$offset] ??= $this->bson->has($offset); } /** diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 2a5baa49d..5580be7ca 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -127,12 +127,7 @@ public function __get(string $property) public function __isset(string $name): bool { - // If we've looked for the value, return the cached result - if (isset($this->exists[$name])) { - return $this->exists[$name]; - } - - return $this->exists[$name] = $this->bson->has($name); + return $this->exists[$name] ??= $this->bson->has($name); } /** @param TValue $value */ From 2b92818be6e41d1c52ee4cdc15cfe0c8e5de66b4 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 09:09:04 +0200 Subject: [PATCH 06/27] Only support string offsets for LazyBSONDocument --- src/Model/LazyBSONDocument.php | 31 ++++++++++++++++++++--- tests/Model/LazyBSONDocumentTest.php | 38 +++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 5580be7ca..69febf716 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -33,6 +33,7 @@ use function get_object_vars; use function is_array; use function is_object; +use function is_string; use function MongoDB\recursive_copy; use function sprintf; use function trigger_error; @@ -183,7 +184,11 @@ function ($value, string $key) use (&$seen) { /** @param mixed $offset */ public function offsetExists($offset): bool { - return $this->__isset((string) $offset); + if (! is_string($offset)) { + return false; + } + + return $this->__isset($offset); } /** @@ -193,7 +198,13 @@ public function offsetExists($offset): bool #[ReturnTypeWillChange] public function offsetGet($offset) { - return $this->__get((string) $offset); + if (! is_string($offset)) { + trigger_error(sprintf('Undefined offset: %s', (string) $offset), E_USER_WARNING); + + return null; + } + + return $this->__get($offset); } /** @@ -202,13 +213,25 @@ public function offsetGet($offset) */ public function offsetSet($offset, $value): void { - $this->__set((string) $offset, $value); + 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 { - $this->__unset((string) $offset); + if (! is_string($offset)) { + trigger_error(sprintf('Undefined offset: %s', (string) $offset), E_USER_WARNING); + + return; + } + + $this->__unset($offset); } private function readFromBson(string $key): void diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 2ea0733e7..1237c1dff 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -73,10 +73,8 @@ public function testConstructWithWrongType(): void } /** @dataProvider provideTestDocumentWithNativeArrays */ - public function testConstructWithArrayUsesLiteralValues($value): void + public function testConstructWithArrayUsesLiteralValues($document): void { - $document = new LazyBSONDocument($value); - $this->assertInstanceOf(stdClass::class, $document->document); $this->assertIsArray($document->hash); $this->assertIsArray($document->array); @@ -143,6 +141,15 @@ public function testOffsetGetForMissingOffset(LazyBSONDocument $document): void $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 { @@ -171,6 +178,13 @@ public function testOffsetExists(LazyBSONDocument $document): void $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 { @@ -195,6 +209,15 @@ public function testOffsetSet(LazyBSONDocument $document): void $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 { @@ -229,6 +252,15 @@ public function testOffsetUnset(LazyBSONDocument $document): void $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 { From ddd75fe8d72162a7781119d6d065f40e48983b7b Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 09:10:20 +0200 Subject: [PATCH 07/27] Use object in LazyBSONCodecLibrary test --- tests/Codec/LazyBSONCodecLibraryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Codec/LazyBSONCodecLibraryTest.php b/tests/Codec/LazyBSONCodecLibraryTest.php index 13cbc80a2..7c7a7a4a2 100644 --- a/tests/Codec/LazyBSONCodecLibraryTest.php +++ b/tests/Codec/LazyBSONCodecLibraryTest.php @@ -21,7 +21,7 @@ public static function provideDecodedData(): Generator ]; $document = (object) [ 'string' => 'bar', - 'document' => ['foo' => 'bar'], + 'document' => (object) ['foo' => 'bar'], 'array' => [0, 1, 2], ]; From 18a5f2a81e79fb1c20a655220d8f5e2a383f3519 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 09:11:00 +0200 Subject: [PATCH 08/27] Remove useless doc comments --- src/Model/LazyBSONArray.php | 8 +------- src/Model/LazyBSONDocument.php | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 6eb0f3657..0e0c70139 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -84,13 +84,7 @@ public function __clone() } } - /** - * Constructs a lazy BSON array. - * - * @param PackedArray|list|null $input An input for a lazy array. - * When given a BSON array, this is treated as input. For lists - * this constructs a new BSON array using fromPHP. - */ + /** @param PackedArray|list|null $input */ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) { if ($input === null) { diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 69febf716..934287bbb 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -81,13 +81,7 @@ public function __clone() } } - /** - * 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. - */ + /** @param Document|array|object|null $input */ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) { if ($input === null) { From 11621f5a1814b3908e74d5bb4941d6dcba1d3835 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 20 Jul 2023 09:39:44 +0200 Subject: [PATCH 09/27] Make lazy BSON classes final --- src/Model/LazyBSONArray.php | 2 +- src/Model/LazyBSONDocument.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 0e0c70139..c58d0eee7 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -51,7 +51,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -class LazyBSONArray implements ArrayAccess, IteratorAggregate +final class LazyBSONArray implements ArrayAccess, IteratorAggregate { /** @var PackedArray */ private PackedArray $bson; diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 934287bbb..3ef89876d 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -50,7 +50,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -class LazyBSONDocument implements ArrayAccess, IteratorAggregate +final class LazyBSONDocument implements ArrayAccess, IteratorAggregate { /** @var Document */ private Document $bson; From 3426ce1d355f1b9103af2f5d8005acaa2f790aad Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 21 Jul 2023 10:28:12 +0200 Subject: [PATCH 10/27] Implement Countable in lazy BSON structures --- src/Model/LazyBSONArray.php | 12 ++++++++++- src/Model/LazyBSONDocument.php | 31 +++++++++++++++++++++++++++- tests/Model/LazyBSONArrayTest.php | 19 +++++++++++++++++ tests/Model/LazyBSONDocumentTest.php | 19 +++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index c58d0eee7..f91639c69 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -21,6 +21,7 @@ use ArrayAccess; use ArrayIterator; use CallbackFilterIterator; +use Countable; use IteratorAggregate; use MongoDB\BSON\PackedArray; use MongoDB\Codec\CodecLibrary; @@ -28,10 +29,12 @@ use MongoDB\Exception\InvalidArgumentException; use ReturnTypeWillChange; +use function array_filter; use function array_key_exists; use function array_keys; use function array_map; use function array_values; +use function count; use function is_array; use function is_numeric; use function max; @@ -51,7 +54,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -final class LazyBSONArray implements ArrayAccess, IteratorAggregate +final class LazyBSONArray implements ArrayAccess, Countable, IteratorAggregate { /** @var PackedArray */ private PackedArray $bson; @@ -106,6 +109,13 @@ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) $this->codecLibrary = $codecLibrary ?? new LazyBSONCodecLibrary(); } + public function count(): int + { + $this->readEntirePackedArray(); + + return count(array_filter($this->exists)); + } + /** @return AsListIterator */ public function getIterator(): AsListIterator { diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 3ef89876d..b18cc1c31 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -21,6 +21,7 @@ use ArrayAccess; use ArrayIterator; use CallbackFilterIterator; +use Countable; use Iterator; use IteratorAggregate; use MongoDB\BSON\Document; @@ -29,7 +30,9 @@ use MongoDB\Exception\InvalidArgumentException; use ReturnTypeWillChange; +use function array_filter; use function array_key_exists; +use function count; use function get_object_vars; use function is_array; use function is_object; @@ -50,7 +53,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -final class LazyBSONDocument implements ArrayAccess, IteratorAggregate +final class LazyBSONDocument implements ArrayAccess, Countable, IteratorAggregate { /** @var Document */ private Document $bson; @@ -67,6 +70,8 @@ final class LazyBSONDocument implements ArrayAccess, IteratorAggregate /** @var array */ private array $unset = []; + private bool $entireDocumentRead = false; + private CodecLibrary $codecLibrary; /** @@ -140,6 +145,13 @@ public function __unset(string $name): void unset($this->set[$name]); } + public function count(): int + { + $this->readEntireDocument(); + + return count(array_filter($this->exists)); + } + /** @return Iterator */ public function getIterator(): CallbackIterator { @@ -228,6 +240,23 @@ public function offsetUnset($offset): void $this->__unset($offset); } + private function readEntireDocument(): void + { + if ($this->entireDocumentRead) { + return; + } + + foreach ($this->bson as $offset => $value) { + $this->read[$offset] = $value; + + if (! isset($this->exists[$offset])) { + $this->exists[$offset] = true; + } + } + + $this->entireDocumentRead = true; + } + private function readFromBson(string $key): void { if (array_key_exists($key, $this->read)) { diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index a9b402019..86480b1d0 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -239,4 +239,23 @@ public function testIterator(LazyBSONArray $array): void $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); + } } diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 1237c1dff..e585a4c43 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -289,4 +289,23 @@ public function testIterator(LazyBSONDocument $document): void $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); + } } From b08bd8edcecb1188f9b8a61c644d2e15071f05fc Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 21 Jul 2023 10:49:45 +0200 Subject: [PATCH 11/27] Make lazy BSON classes serializable --- src/Model/LazyBSONArray.php | 30 +++++++++++++++++++++++++++ src/Model/LazyBSONDocument.php | 31 ++++++++++++++++++++++++++++ tests/Model/LazyBSONArrayTest.php | 15 ++++++++++++++ tests/Model/LazyBSONDocumentTest.php | 15 ++++++++++++++ 4 files changed, 91 insertions(+) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index f91639c69..30c328195 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -109,6 +109,36 @@ public function __construct($input = null, ?CodecLibrary $codecLibrary = 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(); diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index b18cc1c31..4fff279cf 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -32,6 +32,7 @@ use function array_filter; use function array_key_exists; +use function array_map; use function count; use function get_object_vars; use function is_array; @@ -130,6 +131,17 @@ public function __isset(string $name): bool 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 { @@ -138,6 +150,25 @@ public function __set(string $property, $value): void $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; diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index 86480b1d0..fdde168c2 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -11,6 +11,8 @@ use stdClass; use function iterator_to_array; +use function serialize; +use function unserialize; class LazyBSONArrayTest extends TestCase { @@ -258,4 +260,17 @@ public function testCount(): void $array[] = 'yay'; $this->assertCount(3, $array); } + + public function testSerialization(): void + { + $array = new LazyBSONArray(PackedArray::fromPHP(['foo', 'bar', 'baz'])); + $array[0] = 'foobar'; + $array[3] = 'yay!'; + unset($array[1]); + + $serialized = serialize($array); + $unserialized = unserialize($serialized); + + $this->assertEquals(['foobar', 'baz', 'yay!'], iterator_to_array($unserialized)); + } } diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index e585a4c43..22f37853a 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -11,6 +11,8 @@ use stdClass; use function iterator_to_array; +use function serialize; +use function unserialize; class LazyBSONDocumentTest extends TestCase { @@ -308,4 +310,17 @@ public function testCount(): void $document['baz'] = 'yay'; $this->assertCount(2, $document); } + + public function testSerialization(): void + { + $document = new LazyBSONDocument(Document::fromPHP(['foo' => 'bar', 'bar' => 'baz'])); + $document['foo'] = 'foobar'; + $document['baz'] = 'yay!'; + unset($document['bar']); + + $serialized = serialize($document); + $unserialized = unserialize($serialized); + + $this->assertEquals(['foo' => 'foobar', 'baz' => 'yay!'], iterator_to_array($unserialized)); + } } From 67083435824849829beac48bbbdbf6a120b22c37 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Fri, 21 Jul 2023 12:52:07 +0200 Subject: [PATCH 12/27] Implement JsonSerializable in lazy BSON classes --- src/Model/LazyBSONArray.php | 9 ++++++++- src/Model/LazyBSONDocument.php | 9 ++++++++- tests/Model/LazyBSONArrayTest.php | 13 +++++++++++++ tests/Model/LazyBSONDocumentTest.php | 13 +++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 30c328195..de5a81a82 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -23,6 +23,7 @@ use CallbackFilterIterator; use Countable; use IteratorAggregate; +use JsonSerializable; use MongoDB\BSON\PackedArray; use MongoDB\Codec\CodecLibrary; use MongoDB\Codec\LazyBSONCodecLibrary; @@ -37,6 +38,7 @@ use function count; use function is_array; use function is_numeric; +use function iterator_to_array; use function max; use function MongoDB\recursive_copy; use function sprintf; @@ -54,7 +56,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -final class LazyBSONArray implements ArrayAccess, Countable, IteratorAggregate +final class LazyBSONArray implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable { /** @var PackedArray */ private PackedArray $bson; @@ -184,6 +186,11 @@ function ($value, int $offset) use (&$seen) { ); } + public function jsonSerialize(): array + { + return iterator_to_array($this->getIterator()); + } + /** @param mixed $offset */ public function offsetExists($offset): bool { diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 4fff279cf..4ecc617b0 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -24,6 +24,7 @@ use Countable; use Iterator; use IteratorAggregate; +use JsonSerializable; use MongoDB\BSON\Document; use MongoDB\Codec\CodecLibrary; use MongoDB\Codec\LazyBSONCodecLibrary; @@ -38,6 +39,7 @@ use function is_array; use function is_object; use function is_string; +use function iterator_to_array; use function MongoDB\recursive_copy; use function sprintf; use function trigger_error; @@ -54,7 +56,7 @@ * @template-implements ArrayAccess * @template-implements IteratorAggregate */ -final class LazyBSONDocument implements ArrayAccess, Countable, IteratorAggregate +final class LazyBSONDocument implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable { /** @var Document */ private Document $bson; @@ -218,6 +220,11 @@ function ($value, string $key) use (&$seen) { ); } + public function jsonSerialize(): array + { + return iterator_to_array($this->getIterator()); + } + /** @param mixed $offset */ public function offsetExists($offset): bool { diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index fdde168c2..e5bc07514 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -11,9 +11,12 @@ use stdClass; use function iterator_to_array; +use function json_encode; use function serialize; use function unserialize; +use const JSON_THROW_ON_ERROR; + class LazyBSONArrayTest extends TestCase { public static function provideTestArray(): Generator @@ -273,4 +276,14 @@ public function testSerialization(): void $this->assertEquals(['foobar', 'baz', 'yay!'], iterator_to_array($unserialized)); } + + public function testJsonSerialize(): void + { + $array = new LazyBSONArray(PackedArray::fromPHP(['foo', 'bar', 'baz'])); + $array[0] = 'foobar'; + $array[3] = 'yay!'; + unset($array[1]); + + $this->assertJsonStringEqualsJsonString('["foobar","baz","yay!"]', json_encode($array, JSON_THROW_ON_ERROR)); + } } diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 22f37853a..136b3851e 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -11,9 +11,12 @@ use stdClass; use function iterator_to_array; +use function json_encode; use function serialize; use function unserialize; +use const JSON_THROW_ON_ERROR; + class LazyBSONDocumentTest extends TestCase { public static function provideTestDocument(): Generator @@ -323,4 +326,14 @@ public function testSerialization(): void $this->assertEquals(['foo' => 'foobar', 'baz' => 'yay!'], iterator_to_array($unserialized)); } + + public function testJsonSerialize(): void + { + $document = new LazyBSONDocument(Document::fromPHP(['foo' => 'bar', 'bar' => 'baz'])); + $document['foo'] = 'foobar'; + $document['baz'] = 'yay!'; + unset($document['bar']); + + $this->assertJsonStringEqualsJsonString('{"foo":"foobar","baz":"yay!"}', json_encode($document, JSON_THROW_ON_ERROR)); + } } From 74f48ae559d45ce811cad30cf2635f0ead066d11 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 24 Jul 2023 09:33:55 +0200 Subject: [PATCH 13/27] Rename AsListIterator to ListIterator --- psalm-baseline.xml | 4 ++-- src/Model/LazyBSONArray.php | 8 ++++---- src/Model/{AsListIterator.php => ListIterator.php} | 2 +- .../{AsListIteratorTest.php => ListIteratorTest.php} | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) rename src/Model/{AsListIterator.php => ListIterator.php} (95%) rename tests/Model/{AsListIteratorTest.php => ListIteratorTest.php} (83%) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 13e800cf0..a394cab26 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -78,9 +78,9 @@ protocol]['options']]]> - + - AsListIterator + ListIterator diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index de5a81a82..0863d33c4 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -148,8 +148,8 @@ public function count(): int return count(array_filter($this->exists)); } - /** @return AsListIterator */ - public function getIterator(): AsListIterator + /** @return ListIterator */ + public function getIterator(): ListIterator { $itemIterator = new AppendIterator(); // Iterate through all fields in the BSON array @@ -160,8 +160,8 @@ public function getIterator(): AsListIterator /** @var array $seen */ $seen = []; - // Use AsListIterator to ensure we're indexing from 0 without gaps - return new AsListIterator( + // 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( diff --git a/src/Model/AsListIterator.php b/src/Model/ListIterator.php similarity index 95% rename from src/Model/AsListIterator.php rename to src/Model/ListIterator.php index 2a5bd6f69..d960d33b3 100644 --- a/src/Model/AsListIterator.php +++ b/src/Model/ListIterator.php @@ -26,7 +26,7 @@ * @template TValue * @template-extends IteratorIterator> */ -final class AsListIterator extends IteratorIterator +final class ListIterator extends IteratorIterator { private int $index = 0; diff --git a/tests/Model/AsListIteratorTest.php b/tests/Model/ListIteratorTest.php similarity index 83% rename from tests/Model/AsListIteratorTest.php rename to tests/Model/ListIteratorTest.php index 79b041e6e..db1aaac3c 100644 --- a/tests/Model/AsListIteratorTest.php +++ b/tests/Model/ListIteratorTest.php @@ -4,17 +4,17 @@ use ArrayIterator; use Generator; -use MongoDB\Model\AsListIterator; +use MongoDB\Model\ListIterator; use MongoDB\Tests\TestCase; use function iterator_to_array; -class AsListIteratorTest extends TestCase +class ListIteratorTest extends TestCase { /** @dataProvider provideTests */ public function testIteration($source): void { - $iterator = new AsListIterator($source); + $iterator = new ListIterator($source); $this->assertEquals(['foo', 'bar', 'baz'], iterator_to_array($iterator)); } From 98c0313fee79dd46e2fdba548f53cd28be4ee2f6 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 14:17:44 +0200 Subject: [PATCH 14/27] Remove null-coalesce assignment --- psalm-baseline.xml | 15 +++++---------- src/Codec/LazyBSONDocumentCodec.php | 5 +++-- src/Model/LazyBSONArray.php | 6 +++++- src/Model/LazyBSONDocument.php | 6 +++++- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index a394cab26..79165175e 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -33,11 +33,6 @@ $return[] - - - $return[$field] - - $cmd[$option] @@ -78,11 +73,6 @@ protocol]['options']]]> - - - ListIterator - - $this[$key] @@ -243,6 +233,11 @@ is_object($input) + + + ListIterator + + options['typeMap']]]> diff --git a/src/Codec/LazyBSONDocumentCodec.php b/src/Codec/LazyBSONDocumentCodec.php index 8747bb9c6..6c754bc39 100644 --- a/src/Codec/LazyBSONDocumentCodec.php +++ b/src/Codec/LazyBSONDocumentCodec.php @@ -20,6 +20,7 @@ use MongoDB\BSON\Document; use MongoDB\Exception\UnsupportedValueException; use MongoDB\Model\LazyBSONDocument; +use stdClass; /** * Codec for lazy decoding of BSON Document instances @@ -80,10 +81,10 @@ public function encode($value): Document throw UnsupportedValueException::invalidEncodableValue($value); } - $return = []; + $return = new stdClass(); /** @var mixed $fieldValue */ foreach ($value as $field => $fieldValue) { - $return[$field] = $this->getLibrary()->encodeIfSupported($fieldValue); + $return->{$field} = $this->getLibrary()->encodeIfSupported($fieldValue); } return Document::fromPHP($return); diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 0863d33c4..b3af56752 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -200,7 +200,11 @@ public function offsetExists($offset): bool $offset = (int) $offset; - return $this->exists[$offset] ??= $this->bson->has($offset); + if (isset($this->exists[$offset])) { + return $this->exists[$offset]; + } + + return $this->exists[$offset] = $this->bson->has($offset); } /** diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 4ecc617b0..16dcd3e7a 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -130,7 +130,11 @@ public function __get(string $property) public function __isset(string $name): bool { - return $this->exists[$name] ??= $this->bson->has($name); + 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} */ From b1bf34730b0de682a36a21d68a26c31879b085cf Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 14:18:45 +0200 Subject: [PATCH 15/27] Rename readFrom* methods for consistency --- src/Model/LazyBSONArray.php | 4 ++-- src/Model/LazyBSONDocument.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index b3af56752..fad03ef2a 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -221,7 +221,7 @@ public function offsetGet($offset) } $offset = (int) $offset; - $this->readFromBson($offset); + $this->readFromPackedArray($offset); if (isset($this->unset[$offset]) || ! $this->exists[$offset]) { trigger_error(sprintf('Undefined offset: %d', $offset), E_USER_WARNING); @@ -292,7 +292,7 @@ private function readEntirePackedArray(): void $this->entirePackedArrayRead = true; } - private function readFromBson(int $offset): void + private function readFromPackedArray(int $offset): void { if (array_key_exists($offset, $this->read)) { return; diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 16dcd3e7a..9609ac49a 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -117,7 +117,7 @@ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) /** @return TValue */ public function __get(string $property) { - $this->readFromBson($property); + $this->readFromDocument($property); if (isset($this->unset[$property]) || ! $this->exists[$property]) { trigger_error(sprintf('Undefined property: %s', $property), E_USER_WARNING); @@ -299,7 +299,7 @@ private function readEntireDocument(): void $this->entireDocumentRead = true; } - private function readFromBson(string $key): void + private function readFromDocument(string $key): void { if (array_key_exists($key, $this->read)) { return; From 8cb8d7c3a4e2bc212067776a3a0ee18e21d81fdf Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 14:24:56 +0200 Subject: [PATCH 16/27] Defer index update in ListIterator until after parent operation --- src/Codec/ArrayCodec.php | 12 ++---------- src/Model/ListIterator.php | 8 ++++---- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/Codec/ArrayCodec.php b/src/Codec/ArrayCodec.php index eed75b3c3..228502f8a 100644 --- a/src/Codec/ArrayCodec.php +++ b/src/Codec/ArrayCodec.php @@ -62,11 +62,7 @@ public function decode($value): array } return array_map( - /** - * @param mixed $item - * @return mixed - */ - fn ($item) => $this->getLibrary()->decodeIfSupported($item), + [$this->getLibrary(), 'decodeIfSupported'], $value, ); } @@ -89,11 +85,7 @@ public function encode($value): array } return array_map( - /** - * @param mixed $item - * @return mixed - */ - fn ($item) => $this->getLibrary()->encodeIfSupported($item), + [$this->getLibrary(), 'encodeIfSupported'], $value, ); } diff --git a/src/Model/ListIterator.php b/src/Model/ListIterator.php index d960d33b3..97d4d4cbf 100644 --- a/src/Model/ListIterator.php +++ b/src/Model/ListIterator.php @@ -37,15 +37,15 @@ public function key(): int public function next(): void { - $this->index++; - parent::next(); + + $this->index++; } public function rewind(): void { - $this->index = 0; - parent::rewind(); + + $this->index = 0; } } From 466473aa9c049eed88ce2fea1e969b76258779c8 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 14:31:40 +0200 Subject: [PATCH 17/27] Use data provider for tests --- tests/Codec/ArrayCodecTest.php | 78 ++++++++++++--------------------- tests/Codec/ObjectCodecTest.php | 54 +++++++++-------------- 2 files changed, 50 insertions(+), 82 deletions(-) diff --git a/tests/Codec/ArrayCodecTest.php b/tests/Codec/ArrayCodecTest.php index 41cb282c3..75fbc84ae 100644 --- a/tests/Codec/ArrayCodecTest.php +++ b/tests/Codec/ArrayCodecTest.php @@ -2,6 +2,7 @@ namespace MongoDB\Tests\Codec; +use Generator; use MongoDB\Codec\ArrayCodec; use MongoDB\Codec\Codec; use MongoDB\Codec\CodecLibrary; @@ -12,70 +13,49 @@ class ArrayCodecTest extends TestCase { - public function testDecodeList(): void + public static function provideValues(): Generator { - $value = [ - 'decoded', - 'encoded', + yield 'List' => [ + 'value' => ['decoded', 'encoded'], + 'encoded' => ['encoded', 'encoded'], + 'decoded' => ['decoded', 'decoded'], ]; - $this->assertSame(['decoded', 'decoded'], $this->getCodec()->decode($value)); - } - - public function testDecodeListWithGaps(): void - { - $value = [ - 0 => 'decoded', - 2 => 'encoded', + yield 'List with gaps' => [ + 'value' => [0 => 'decoded', 2 => 'encoded'], + 'encoded' => [0 => 'encoded', 2 => 'encoded'], + 'decoded' => [0 => 'decoded', 2 => 'decoded'], ]; - $this->assertSame([0 => 'decoded', 2 => 'decoded'], $this->getCodec()->decode($value)); - } - - public function testDecodeHash(): void - { - $value = [ - 'foo' => 'decoded', - 'bar' => 'encoded', + yield 'Hash' => [ + 'value' => ['foo' => 'decoded', 'bar' => 'encoded'], + 'encoded' => ['foo' => 'encoded', 'bar' => 'encoded'], + 'decoded' => ['foo' => 'decoded', 'bar' => 'decoded'], ]; - - $this->assertSame(['foo' => 'decoded', 'bar' => 'decoded'], $this->getCodec()->decode($value)); - } - - public function testDecodeWithWrongType(): void - { - $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo')); - $this->getCodec()->encode('foo'); } - public function testEncode(): void + /** @dataProvider provideValues */ + public function testDecode($value, $encoded, $decoded): void { - $value = [ - 'decoded', - 'encoded', - ]; - - $this->assertSame(['encoded', 'encoded'], $this->getCodec()->encode($value)); + $this->assertSame( + $decoded, + $this->getCodec()->decode($value), + ); } - public function testEncodeListWithGaps(): void + /** @dataProvider provideValues */ + public function testEncode($value, $encoded, $decoded): void { - $value = [ - 0 => 'decoded', - 2 => 'encoded', - ]; - - $this->assertSame([0 => 'encoded', 2 => 'encoded'], $this->getCodec()->encode($value)); + $this->assertSame( + $encoded, + $this->getCodec()->encode($value), + ); } - public function testEncodeHash(): void + public function testDecodeWithWrongType(): void { - $value = [ - 'foo' => 'decoded', - 'bar' => 'encoded', - ]; - - $this->assertSame(['foo' => 'encoded', 'bar' => 'encoded'], $this->getCodec()->encode($value)); + $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo')); + $this->getCodec()->encode('foo'); } public function testEncodeWithWrongType(): void diff --git a/tests/Codec/ObjectCodecTest.php b/tests/Codec/ObjectCodecTest.php index 910b19dd9..239f04bab 100644 --- a/tests/Codec/ObjectCodecTest.php +++ b/tests/Codec/ObjectCodecTest.php @@ -2,6 +2,7 @@ namespace MongoDB\Tests\Codec; +use Generator; use MongoDB\Codec\Codec; use MongoDB\Codec\CodecLibrary; use MongoDB\Codec\DecodeIfSupported; @@ -13,56 +14,43 @@ class ObjectCodecTest extends TestCase { - public function testDecodeObject(): void + public static function provideValues(): Generator { - $value = (object) [ - 'foo' => 'decoded', - 'bar' => 'encoded', + yield 'Object' => [ + 'value' => (object) ['foo' => 'decoded', 'bar' => 'encoded'], + 'encoded' => (object) ['foo' => 'encoded', 'bar' => 'encoded'], + 'decoded' => (object) ['foo' => 'decoded', 'bar' => 'decoded'], ]; - $this->assertEquals( - (object) ['foo' => 'decoded', 'bar' => 'decoded'], - $this->getCodec()->decode($value), - ); + yield 'Extended stdClass' => [ + 'value' => self::getExtendedObject(), + 'encoded' => (object) ['foo' => 'encoded', 'bar' => 'encoded'], + 'decoded' => (object) ['foo' => 'decoded', 'bar' => 'decoded'], + ]; } - public function testDecodeExtendedObject(): void + /** @dataProvider provideValues */ + public function testDecode($value, $encoded, $decoded): void { - $value = $this->getExtendedObject(); - $this->assertEquals( - (object) ['foo' => 'decoded', 'bar' => 'decoded'], + $decoded, $this->getCodec()->decode($value), ); } - public function testDecodeWithWrongType(): void - { - $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo')); - $this->getCodec()->decode('foo'); - } - - public function testEncodeObject(): void + /** @dataProvider provideValues */ + public function testEncode($value, $encoded, $decoded): void { - $value = (object) [ - 'foo' => 'decoded', - 'bar' => 'encoded', - ]; - $this->assertEquals( - (object) ['foo' => 'encoded', 'bar' => 'encoded'], + $encoded, $this->getCodec()->encode($value), ); } - public function testEncodeExtendedObject(): void + public function testDecodeWithWrongType(): void { - $value = $this->getExtendedObject(); - - $this->assertEquals( - (object) ['foo' => 'encoded', 'bar' => 'encoded'], - $this->getCodec()->encode($value), - ); + $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo')); + $this->getCodec()->decode('foo'); } public function testEncodeWithWrongType(): void @@ -111,7 +99,7 @@ public function encode($value) ); } - private function getExtendedObject(): stdClass + private static function getExtendedObject(): stdClass { return new class extends stdClass { public static $baz = 'oops'; From 476080fee5ddba83607c800903adf0f1ce838dd6 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 14:39:54 +0200 Subject: [PATCH 18/27] Improve assertions in lazy codec tests --- tests/Codec/LazyBSONArrayCodecTest.php | 5 +---- tests/Codec/LazyBSONDocumentCodecTest.php | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/Codec/LazyBSONArrayCodecTest.php b/tests/Codec/LazyBSONArrayCodecTest.php index 459b8f633..32ca99754 100644 --- a/tests/Codec/LazyBSONArrayCodecTest.php +++ b/tests/Codec/LazyBSONArrayCodecTest.php @@ -37,10 +37,7 @@ 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']), - ); + $this->assertEquals(PackedArray::fromPHP(self::ARRAY), $encoded); } public function testEncodeWithWrongType(): void diff --git a/tests/Codec/LazyBSONDocumentCodecTest.php b/tests/Codec/LazyBSONDocumentCodecTest.php index 86c4b547c..d623efa74 100644 --- a/tests/Codec/LazyBSONDocumentCodecTest.php +++ b/tests/Codec/LazyBSONDocumentCodecTest.php @@ -37,10 +37,7 @@ 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']), - ); + $this->assertEquals(Document::fromPHP(self::OBJECT), $encoded); } public function testEncodeWithWrongType(): void From 61c586da5f42787b0572b307564a108341cc745d Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 15:10:25 +0200 Subject: [PATCH 19/27] Remove duplicate assertions --- tests/Model/LazyBSONArrayTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index e5bc07514..79bc0b639 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -117,14 +117,12 @@ public function testOffsetGetForUnsupportedOffset(LazyBSONArray $array): void 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 */ From 21d5342309efc22009df78d0f7427655c11e43cb Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 26 Jul 2023 15:11:23 +0200 Subject: [PATCH 20/27] Clarify comment in tests --- tests/Model/LazyBSONArrayTest.php | 2 +- tests/Model/LazyBSONDocumentTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index 79bc0b639..4f400323f 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -207,7 +207,7 @@ public function testOffsetUnset(LazyBSONArray $array): void unset($array[0]); $this->assertFalse(isset($array[0])); - // Change value to ensure it is unset for good + // Set new value to ensure unset also clears values not read from BSON $array[1] = (object) ['foo' => 'baz']; unset($array[1]); $this->assertFalse(isset($array[1])); diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 136b3851e..988f0b60e 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -234,7 +234,7 @@ public function testPropertyUnset(LazyBSONDocument $document): void unset($document->foo); $this->assertFalse(isset($document->foo)); - // Change value to ensure it is unset for good + // Set new value to ensure unset also clears values not read from BSON $document->document = (object) ['foo' => 'baz']; unset($document->document); $this->assertFalse(isset($document->document)); @@ -251,7 +251,7 @@ public function testOffsetUnset(LazyBSONDocument $document): void unset($document['foo']); $this->assertFalse(isset($document['foo'])); - // Change value to ensure it is unset for good + // Set new value to ensure unset also clears values not read from BSON $document['document'] = (object) ['foo' => 'baz']; unset($document['document']); $this->assertFalse(isset($document['document'])); From 2aad7f3b977629507095f6f0dfe505a18633e1b3 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Thu, 27 Jul 2023 08:50:28 +0200 Subject: [PATCH 21/27] Rename KnowsCodecLibrary interface to CodecLibraryAware --- src/Codec/ArrayCodec.php | 2 +- src/Codec/CodecLibrary.php | 6 +++--- src/Codec/{KnowsCodecLibrary.php => CodecLibraryAware.php} | 2 +- src/Codec/LazyBSONArrayCodec.php | 2 +- src/Codec/LazyBSONDocumentCodec.php | 2 +- src/Codec/ObjectCodec.php | 2 +- tests/Codec/CodecLibraryTest.php | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) rename src/Codec/{KnowsCodecLibrary.php => CodecLibraryAware.php} (97%) diff --git a/src/Codec/ArrayCodec.php b/src/Codec/ArrayCodec.php index 228502f8a..4ffeb5540 100644 --- a/src/Codec/ArrayCodec.php +++ b/src/Codec/ArrayCodec.php @@ -27,7 +27,7 @@ * * @template-implements Codec */ -final class ArrayCodec implements Codec, KnowsCodecLibrary +final class ArrayCodec implements Codec, CodecLibraryAware { private ?CodecLibrary $library = null; 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 index bd53cc19f..a36fe3d76 100644 --- a/src/Codec/LazyBSONArrayCodec.php +++ b/src/Codec/LazyBSONArrayCodec.php @@ -26,7 +26,7 @@ * * @template-implements Codec */ -final class LazyBSONArrayCodec implements Codec, KnowsCodecLibrary +final class LazyBSONArrayCodec implements Codec, CodecLibraryAware { private ?CodecLibrary $library = null; diff --git a/src/Codec/LazyBSONDocumentCodec.php b/src/Codec/LazyBSONDocumentCodec.php index 6c754bc39..fcb540790 100644 --- a/src/Codec/LazyBSONDocumentCodec.php +++ b/src/Codec/LazyBSONDocumentCodec.php @@ -27,7 +27,7 @@ * * @template-implements DocumentCodec */ -final class LazyBSONDocumentCodec implements DocumentCodec, KnowsCodecLibrary +final class LazyBSONDocumentCodec implements DocumentCodec, CodecLibraryAware { private ?CodecLibrary $library = null; diff --git a/src/Codec/ObjectCodec.php b/src/Codec/ObjectCodec.php index 604b17e55..63205fa87 100644 --- a/src/Codec/ObjectCodec.php +++ b/src/Codec/ObjectCodec.php @@ -27,7 +27,7 @@ * * @template-implements Codec */ -final class ObjectCodec implements Codec, KnowsCodecLibrary +final class ObjectCodec implements Codec, CodecLibraryAware { private ?CodecLibrary $library = null; 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; From c3ab0975de1b7a57078959caea741355fb6a3596 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 31 Jul 2023 10:15:42 +0200 Subject: [PATCH 22/27] Allow lists with gap when building LazyBSONArrays --- psalm-baseline.xml | 9 ++++++--- src/Model/LazyBSONArray.php | 13 +++++++++++-- tests/Model/LazyBSONArrayTest.php | 20 ++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 79165175e..cf237b681 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -212,12 +212,15 @@ $seen[$offset] + + $value + is_array($input) - - array_values - + + is_numeric($key) + diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index fad03ef2a..de521de01 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -31,10 +31,10 @@ use ReturnTypeWillChange; use function array_filter; +use function array_is_list; use function array_key_exists; use function array_keys; use function array_map; -use function array_values; use function count; use function is_array; use function is_numeric; @@ -97,8 +97,17 @@ public function __construct($input = null, ?CodecLibrary $codecLibrary = null) } 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 = array_values($input); + $this->set = $input; $this->exists = array_map( /** @param TValue $value */ fn ($value): bool => true, diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index 4f400323f..a268af966 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -63,6 +63,26 @@ public function testConstructWithArrayUsesLiteralValues(): void $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(); From 85999607f15b46fa357473fa8403ee30173808da Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 31 Jul 2023 10:17:44 +0200 Subject: [PATCH 23/27] Add clarifying comment when marking fields as existing --- src/Model/LazyBSONArray.php | 5 ++++- src/Model/LazyBSONDocument.php | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index de521de01..b0efb187c 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -293,6 +293,8 @@ private function readEntirePackedArray(): void 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" list if (! isset($this->exists[$offset])) { $this->exists[$offset] = true; } @@ -314,7 +316,8 @@ private function readFromPackedArray(int $offset): void $this->read[$offset] = $this->codecLibrary->decodeIfSupported($this->bson->get($offset)); } - // Mark the offset as "existing" if it wasn't previously marked already + // The offset could've been explicitly unset before, so we need to + // respect pre-existing entries in the "exists" list if (! isset($this->exists[$offset])) { $this->exists[$offset] = $found; } diff --git a/src/Model/LazyBSONDocument.php b/src/Model/LazyBSONDocument.php index 9609ac49a..af0d8251a 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -291,6 +291,8 @@ private function readEntireDocument(): void 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" list if (! isset($this->exists[$offset])) { $this->exists[$offset] = true; } @@ -312,7 +314,8 @@ private function readFromDocument(string $key): void $this->read[$key] = $this->codecLibrary->decodeIfSupported($this->bson->get($key)); } - // Mark the offset as "existing" if it wasn't previously marked already + // The offset could've been explicitly unset before, so we need to + // respect pre-existing entries in the "exists" list if (! isset($this->exists[$key])) { $this->exists[$key] = $found; } From 82775ec066063f86c42aa10fa30589cd0a3fed2d Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 31 Jul 2023 11:17:29 +0200 Subject: [PATCH 24/27] Use object in codec test --- tests/Codec/LazyBSONCodecLibraryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Codec/LazyBSONCodecLibraryTest.php b/tests/Codec/LazyBSONCodecLibraryTest.php index 7c7a7a4a2..65a024c19 100644 --- a/tests/Codec/LazyBSONCodecLibraryTest.php +++ b/tests/Codec/LazyBSONCodecLibraryTest.php @@ -16,7 +16,7 @@ public static function provideDecodedData(): Generator { $array = [ 'bar', - ['foo' => 'bar'], + (object) ['foo' => 'bar'], [0, 1, 2], ]; $document = (object) [ From 9aa174d5025090ef54d944e22cfe6b6b51c40b74 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Mon, 31 Jul 2023 11:18:48 +0200 Subject: [PATCH 25/27] Add comment explaining manual creation of nested lazy structures --- tests/Model/LazyBSONArrayTest.php | 4 ++++ tests/Model/LazyBSONDocumentTest.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index a268af966..f37b9cd98 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -21,6 +21,10 @@ class LazyBSONArrayTest extends TestCase { public static function provideTestArray(): Generator { + // Creating a lazy BSON structure from PHP data does not convert any + // nested arrays or objects to their lazy BSON variants. In order to + // make tests reusable, we need to create the lazy BSON variants + // manually yield 'array' => [ new LazyBSONArray([ 'bar', diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 988f0b60e..48692db3f 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -21,6 +21,10 @@ class LazyBSONDocumentTest extends TestCase { public static function provideTestDocument(): Generator { + // Creating a lazy BSON structure from PHP data does not convert any + // nested arrays or objects to their lazy BSON variants. In order to + // make tests reusable, we need to create the lazy BSON variants + // manually yield 'array' => [ new LazyBSONDocument([ 'foo' => 'bar', From 9f9254b25baae3a06711b2f582a6dfb873292e21 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 2 Aug 2023 10:11:01 +0200 Subject: [PATCH 26/27] Ensure keys are sorted correctly in LazyBSONArray --- src/Model/LazyBSONArray.php | 5 +++++ tests/Model/BSONArrayTest.php | 11 +++++++++++ tests/Model/LazyBSONArrayTest.php | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index b0efb187c..8ff1cc217 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -39,12 +39,14 @@ use function is_array; use function is_numeric; use function iterator_to_array; +use function ksort; use function max; use function MongoDB\recursive_copy; use function sprintf; use function trigger_error; use const E_USER_WARNING; +use const SORT_NUMERIC; /** * Model class for a BSON array. @@ -160,6 +162,9 @@ public function count(): int /** @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()); 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 index f37b9cd98..a0cadba4b 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -308,4 +308,14 @@ public function testJsonSerialize(): void $this->assertJsonStringEqualsJsonString('["foobar","baz","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)); + } } From cf9af04c658d99905f30006dd47bdb2897d35796 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Wed, 2 Aug 2023 10:35:59 +0200 Subject: [PATCH 27/27] Address code review feedback --- src/Model/LazyBSONArray.php | 4 +- src/Model/LazyBSONDocument.php | 6 +-- tests/Codec/LazyBSONCodecLibraryTest.php | 28 +++++------ tests/Codec/ObjectCodecTest.php | 2 +- tests/Model/LazyBSONArrayTest.php | 62 ++++++++++++++++++------ tests/Model/LazyBSONDocumentTest.php | 30 ++++++------ 6 files changed, 82 insertions(+), 50 deletions(-) diff --git a/src/Model/LazyBSONArray.php b/src/Model/LazyBSONArray.php index 8ff1cc217..d81de8909 100644 --- a/src/Model/LazyBSONArray.php +++ b/src/Model/LazyBSONArray.php @@ -299,7 +299,7 @@ private function readEntirePackedArray(): void $this->read[$offset] = $value; // The offset could've been explicitly unset before, so we need to - // respect pre-existing entries in the "exists" list + // respect pre-existing entries in the "exists" map if (! isset($this->exists[$offset])) { $this->exists[$offset] = true; } @@ -322,7 +322,7 @@ private function readFromPackedArray(int $offset): void } // The offset could've been explicitly unset before, so we need to - // respect pre-existing entries in the "exists" list + // 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 index af0d8251a..79cc5d105 100644 --- a/src/Model/LazyBSONDocument.php +++ b/src/Model/LazyBSONDocument.php @@ -165,7 +165,7 @@ public function __unserialize(array $data): void $this->codecLibrary = $data['codecLibrary']; $this->exists = array_map( - /** @param TValue $value */ + /** @param TValue $value */ fn ($value): bool => true, $this->set, ); @@ -292,7 +292,7 @@ private function readEntireDocument(): void $this->read[$offset] = $value; // The offset could've been explicitly unset before, so we need to - // respect pre-existing entries in the "exists" list + // respect pre-existing entries in the "exists" map if (! isset($this->exists[$offset])) { $this->exists[$offset] = true; } @@ -315,7 +315,7 @@ private function readFromDocument(string $key): void } // The offset could've been explicitly unset before, so we need to - // respect pre-existing entries in the "exists" list + // respect pre-existing entries in the "exists" map if (! isset($this->exists[$key])) { $this->exists[$key] = $found; } diff --git a/tests/Codec/LazyBSONCodecLibraryTest.php b/tests/Codec/LazyBSONCodecLibraryTest.php index 65a024c19..3c48b1971 100644 --- a/tests/Codec/LazyBSONCodecLibraryTest.php +++ b/tests/Codec/LazyBSONCodecLibraryTest.php @@ -36,18 +36,18 @@ public static function provideDecodedData(): Generator ]; yield 'array' => [ - 'expected' => [PackedArray::fromPHP($array)], - 'value' => [new LazyBSONArray($array)], + 'expected' => [PackedArray::fromPHP($array), Document::fromPHP($document)], + 'value' => [new LazyBSONArray($array), new LazyBSONDocument($document)], ]; yield 'hash' => [ - 'expected' => ['foo' => PackedArray::fromPHP($array)], - 'value' => ['foo' => new LazyBSONArray($array)], + 'expected' => ['array' => PackedArray::fromPHP($array), 'document' => Document::fromPHP($document)], + 'value' => ['array' => new LazyBSONArray($array), 'document' => new LazyBSONDocument($document)], ]; yield 'object' => [ - 'expected' => (object) ['foo' => PackedArray::fromPHP($array)], - 'value' => (object) ['foo' => new LazyBSONArray($array)], + 'expected' => (object) ['array' => PackedArray::fromPHP($array), 'document' => Document::fromPHP($document)], + 'value' => (object) ['array' => new LazyBSONArray($array), 'document' => new LazyBSONDocument($document)], ]; } @@ -64,29 +64,29 @@ public static function provideEncodedData(): Generator 'array' => [0, 1, 2], ]); - yield 'packedArray' => [ + yield 'PackedArray' => [ 'expected' => new LazyBSONArray($packedArray), 'value' => $packedArray, ]; - yield 'document' => [ + yield 'Document' => [ 'expected' => new LazyBSONDocument($document), 'value' => $document, ]; yield 'array' => [ - 'expected' => [new LazyBSONArray($packedArray)], - 'value' => [$packedArray], + 'expected' => [new LazyBSONArray($packedArray), new LazyBSONDocument($document)], + 'value' => [$packedArray, $document], ]; yield 'hash' => [ - 'expected' => ['foo' => new LazyBSONArray($packedArray)], - 'value' => ['foo' => $packedArray], + 'expected' => ['array' => new LazyBSONArray($packedArray), 'document' => new LazyBSONDocument($document)], + 'value' => ['array' => $packedArray, 'document' => $document], ]; yield 'object' => [ - 'expected' => (object) ['foo' => new LazyBSONArray($packedArray)], - 'value' => (object) ['foo' => $packedArray], + 'expected' => (object) ['array' => new LazyBSONArray($packedArray), 'document' => new LazyBSONDocument($document)], + 'value' => (object) ['array' => $packedArray, 'document' => $document], ]; } diff --git a/tests/Codec/ObjectCodecTest.php b/tests/Codec/ObjectCodecTest.php index 239f04bab..facf8a1cc 100644 --- a/tests/Codec/ObjectCodecTest.php +++ b/tests/Codec/ObjectCodecTest.php @@ -70,7 +70,7 @@ private function getCodec(): ObjectCodec private function getCodecLibrary(): CodecLibrary { return new CodecLibrary( - /** @template-implements Codec */ + /** @template-implements Codec */ new class implements Codec { use DecodeIfSupported; diff --git a/tests/Model/LazyBSONArrayTest.php b/tests/Model/LazyBSONArrayTest.php index a0cadba4b..9748af2be 100644 --- a/tests/Model/LazyBSONArrayTest.php +++ b/tests/Model/LazyBSONArrayTest.php @@ -69,10 +69,7 @@ public function testConstructWithArrayUsesLiteralValues(): void public function testConstructAllowsGapsInList(): void { - $array = new LazyBSONArray([ - 0 => 'foo', - 2 => 'bar', - ]); + $array = new LazyBSONArray([0 => 'foo', 2 => 'bar']); $this->assertSame('foo', $array[0]); $this->assertFalse(isset($array[1])); @@ -127,6 +124,8 @@ public function testOffsetGetForMissingOffset(LazyBSONArray $array): void 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 */ @@ -157,9 +156,18 @@ public function testOffsetExists(LazyBSONArray $array): void // 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])); - // Numeric offset - $this->assertTrue(isset($array['1'])); + $this->assertFalse(isset($array['4'])); + $this->assertFalse(isset($array['4.5'])); + $this->assertFalse(isset($array[4.5])); } /** @dataProvider provideTestArray */ @@ -179,6 +187,12 @@ 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 */ @@ -217,6 +231,7 @@ public function testAppendWithGap(LazyBSONArray $array): void // 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]); } @@ -231,12 +246,29 @@ public function testOffsetUnset(LazyBSONArray $array): void unset($array[0]); $this->assertFalse(isset($array[0])); - // Set new value to ensure unset also clears values not read from BSON + // 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 { @@ -286,27 +318,27 @@ public function testCount(): void $this->assertCount(3, $array); } - public function testSerialization(): void + /** @dataProvider provideTestArray */ + public function testSerialization(LazyBSONArray $array): void { - $array = new LazyBSONArray(PackedArray::fromPHP(['foo', 'bar', 'baz'])); - $array[0] = 'foobar'; + $array[2] = [0]; $array[3] = 'yay!'; unset($array[1]); $serialized = serialize($array); $unserialized = unserialize($serialized); - $this->assertEquals(['foobar', 'baz', 'yay!'], iterator_to_array($unserialized)); + $this->assertEquals(['bar', [0], 'yay!'], iterator_to_array($unserialized)); } - public function testJsonSerialize(): void + /** @dataProvider provideTestArray */ + public function testJsonSerialize(LazyBSONArray $array): void { - $array = new LazyBSONArray(PackedArray::fromPHP(['foo', 'bar', 'baz'])); - $array[0] = 'foobar'; + $array[2] = [0]; $array[3] = 'yay!'; unset($array[1]); - $this->assertJsonStringEqualsJsonString('["foobar","baz","yay!"]', json_encode($array, JSON_THROW_ON_ERROR)); + $this->assertJsonStringEqualsJsonString('["bar",[0],"yay!"]', json_encode($array, JSON_THROW_ON_ERROR)); } public function testIteratorToArrayWithReverseKeys(): void diff --git a/tests/Model/LazyBSONDocumentTest.php b/tests/Model/LazyBSONDocumentTest.php index 48692db3f..ebc7f3ad2 100644 --- a/tests/Model/LazyBSONDocumentTest.php +++ b/tests/Model/LazyBSONDocumentTest.php @@ -82,7 +82,7 @@ public function testConstructWithWrongType(): void } /** @dataProvider provideTestDocumentWithNativeArrays */ - public function testConstructWithArrayUsesLiteralValues($document): void + public function testConstructWithArrayUsesLiteralValues(LazyBSONDocument $document): void { $this->assertInstanceOf(stdClass::class, $document->document); $this->assertIsArray($document->hash); @@ -238,7 +238,7 @@ public function testPropertyUnset(LazyBSONDocument $document): void unset($document->foo); $this->assertFalse(isset($document->foo)); - // Set new value to ensure unset also clears values not read from BSON + // 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)); @@ -255,7 +255,7 @@ public function testOffsetUnset(LazyBSONDocument $document): void unset($document['foo']); $this->assertFalse(isset($document['foo'])); - // Set new value to ensure unset also clears values not read from BSON + // 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'])); @@ -318,26 +318,26 @@ public function testCount(): void $this->assertCount(2, $document); } - public function testSerialization(): void + /** @dataProvider provideTestDocument */ + public function testSerialization(LazyBSONDocument $document): void { - $document = new LazyBSONDocument(Document::fromPHP(['foo' => 'bar', 'bar' => 'baz'])); - $document['foo'] = 'foobar'; - $document['baz'] = 'yay!'; - unset($document['bar']); + $document['array'] = [0]; + $document['new'] = 'yay!'; + unset($document['document']); $serialized = serialize($document); $unserialized = unserialize($serialized); - $this->assertEquals(['foo' => 'foobar', 'baz' => 'yay!'], iterator_to_array($unserialized)); + $this->assertEquals(['foo' => 'bar', 'array' => [0], 'new' => 'yay!'], iterator_to_array($unserialized)); } - public function testJsonSerialize(): void + /** @dataProvider provideTestDocument */ + public function testJsonSerialize(LazyBSONDocument $document): void { - $document = new LazyBSONDocument(Document::fromPHP(['foo' => 'bar', 'bar' => 'baz'])); - $document['foo'] = 'foobar'; - $document['baz'] = 'yay!'; - unset($document['bar']); + $document['array'] = [0]; + $document['new'] = 'yay!'; + unset($document['document']); - $this->assertJsonStringEqualsJsonString('{"foo":"foobar","baz":"yay!"}', json_encode($document, JSON_THROW_ON_ERROR)); + $this->assertJsonStringEqualsJsonString('{"foo":"bar","array":[0],"new":"yay!"}', json_encode($document, JSON_THROW_ON_ERROR)); } }