diff --git a/.gitattributes b/.gitattributes
index eb9a2b8f3..a7f276e7d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,6 +4,7 @@ tests export-ignore
docs export-ignore
examples export-ignore
mongo-orchestration export-ignore
+stubs export-ignore
tools export-ignore
Makefile export-ignore
phpcs.xml.dist export-ignore
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 1e29faf7f..27dc4743b 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -75,6 +75,16 @@
$mergedDriver['platform']
+
+
+ ($value is BSONType ? NativeType : $value)
+
+
+
+
+ ($value is NativeType ? BSONType : $value)
+
+
$cmd[$option]
diff --git a/psalm.xml.dist b/psalm.xml.dist
index 0acafb77f..7ca91ba36 100644
--- a/psalm.xml.dist
+++ b/psalm.xml.dist
@@ -15,4 +15,9 @@
+
+
+
+
+
diff --git a/src/Codec/Codec.php b/src/Codec/Codec.php
new file mode 100644
index 000000000..26fddf2cb
--- /dev/null
+++ b/src/Codec/Codec.php
@@ -0,0 +1,31 @@
+
+ * @template-extends Encoder
+ */
+interface Codec extends Decoder, Encoder
+{
+}
diff --git a/src/Codec/CodecLibrary.php b/src/Codec/CodecLibrary.php
new file mode 100644
index 000000000..82acb2187
--- /dev/null
+++ b/src/Codec/CodecLibrary.php
@@ -0,0 +1,146 @@
+ */
+ private $decoders = [];
+
+ /** @var array */
+ private $encoders = [];
+
+ /** @param Decoder|Encoder $items */
+ public function __construct(...$items)
+ {
+ foreach ($items as $item) {
+ if (! $item instanceof Decoder && ! $item instanceof Encoder) {
+ throw InvalidArgumentException::invalidType('$items', $item, [Decoder::class, Encoder::class]);
+ }
+
+ if ($item instanceof Codec) {
+ // Use attachCodec to avoid multiple calls to attachLibrary
+ $this->attachCodec($item);
+
+ continue;
+ }
+
+ if ($item instanceof Decoder) {
+ $this->attachDecoder($item);
+ }
+
+ if ($item instanceof Encoder) {
+ $this->attachEncoder($item);
+ }
+ }
+ }
+
+ /** @return static */
+ final public function attachCodec(Codec $codec): self
+ {
+ $this->decoders[] = $codec;
+ $this->encoders[] = $codec;
+ if ($codec instanceof KnowsCodecLibrary) {
+ $codec->attachCodecLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @return static */
+ final public function attachDecoder(Decoder $decoder): self
+ {
+ $this->decoders[] = $decoder;
+ if ($decoder instanceof KnowsCodecLibrary) {
+ $decoder->attachCodecLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @return static */
+ final public function attachEncoder(Encoder $encoder): self
+ {
+ $this->encoders[] = $encoder;
+ if ($encoder instanceof KnowsCodecLibrary) {
+ $encoder->attachCodecLibrary($this);
+ }
+
+ return $this;
+ }
+
+ /** @param mixed $value */
+ final public function canDecode($value): bool
+ {
+ foreach ($this->decoders as $decoder) {
+ if ($decoder->canDecode($value)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /** @param mixed $value */
+ final public function canEncode($value): bool
+ {
+ foreach ($this->encoders as $encoder) {
+ if ($encoder->canEncode($value)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ final public function decode($value)
+ {
+ foreach ($this->decoders as $decoder) {
+ if ($decoder->canDecode($value)) {
+ return $decoder->decode($value);
+ }
+ }
+
+ throw UnsupportedValueException::invalidDecodableValue($value);
+ }
+
+ /**
+ * @param mixed $value
+ * @return mixed
+ */
+ final public function encode($value)
+ {
+ foreach ($this->encoders as $encoder) {
+ if ($encoder->canEncode($value)) {
+ return $encoder->encode($value);
+ }
+ }
+
+ throw UnsupportedValueException::invalidEncodableValue($value);
+ }
+}
diff --git a/src/Codec/DecodeIfSupported.php b/src/Codec/DecodeIfSupported.php
new file mode 100644
index 000000000..56dcfb9ec
--- /dev/null
+++ b/src/Codec/DecodeIfSupported.php
@@ -0,0 +1,52 @@
+canDecode($value) ? $this->decode($value) : $value;
+ }
+}
diff --git a/src/Codec/Decoder.php b/src/Codec/Decoder.php
new file mode 100644
index 000000000..904e097fe
--- /dev/null
+++ b/src/Codec/Decoder.php
@@ -0,0 +1,59 @@
+
+ */
+interface DocumentCodec extends Codec
+{
+ /**
+ * @param mixed $value
+ * @psalm-param Document $value
+ * @psalm-return ObjectType
+ * @throws UnsupportedValueException if the decoder does not support the value
+ */
+ public function decode($value): object;
+
+ /**
+ * @param mixed $value
+ * @psalm-param ObjectType $value
+ * @throws UnsupportedValueException if the encoder does not support the value
+ */
+ public function encode($value): Document;
+}
diff --git a/src/Codec/EncodeIfSupported.php b/src/Codec/EncodeIfSupported.php
new file mode 100644
index 000000000..c4aebac6b
--- /dev/null
+++ b/src/Codec/EncodeIfSupported.php
@@ -0,0 +1,52 @@
+canEncode($value) ? $this->encode($value) : $value;
+ }
+}
diff --git a/src/Codec/Encoder.php b/src/Codec/Encoder.php
new file mode 100644
index 000000000..dba58d9d5
--- /dev/null
+++ b/src/Codec/Encoder.php
@@ -0,0 +1,59 @@
+value;
+ }
+
+ /** @param mixed $value */
+ public static function invalidDecodableValue($value): self
+ {
+ return new self(sprintf('Could not decode value of type "%s".', get_debug_type($value)), $value);
+ }
+
+ /** @param mixed $value */
+ public static function invalidEncodableValue($value): self
+ {
+ return new self(sprintf('Could not encode value of type "%s".', get_debug_type($value)), $value);
+ }
+
+ /** @param mixed $value */
+ private function __construct(string $message, $value)
+ {
+ parent::__construct($message);
+
+ $this->value = $value;
+ }
+}
diff --git a/stubs/BSON/Document.stub.php b/stubs/BSON/Document.stub.php
new file mode 100644
index 000000000..b1b3c60fb
--- /dev/null
+++ b/stubs/BSON/Document.stub.php
@@ -0,0 +1,49 @@
+
+ */
+final class Document implements \IteratorAggregate, \Serializable
+{
+ private function __construct() {}
+
+ final static public function fromBSON(string $bson): Document {}
+
+ final static public function fromJSON(string $json): Document {}
+
+ /** @param array|object $value */
+ final static public function fromPHP($value): Document {}
+
+ /** @return TValue */
+ final public function get(string $key) {}
+
+ /** @return Iterator */
+ final public function getIterator(): Iterator {}
+
+ final public function has(string $key): bool {}
+
+ /** @return array|object */
+ final public function toPHP(?array $typeMap = null) {}
+
+ final public function toCanonicalExtendedJSON(): string {}
+
+ final public function toRelaxedExtendedJSON(): string {}
+
+ final public function __toString(): string {}
+
+ final public static function __set_state(array $properties): Document {}
+
+ final public function serialize(): string {}
+
+ /** @param string $serialized */
+ final public function unserialize($serialized): void {}
+
+ final public function __unserialize(array $data): void {}
+
+ final public function __serialize(): array {}
+}
diff --git a/stubs/BSON/Iterator.stub.php b/stubs/BSON/Iterator.stub.php
new file mode 100644
index 000000000..cc8f699e4
--- /dev/null
+++ b/stubs/BSON/Iterator.stub.php
@@ -0,0 +1,29 @@
+
+ */
+final class Iterator implements \Iterator
+{
+ final private function __construct() {}
+
+ /** @return TValue */
+ final public function current() {}
+
+ /** @return TKey */
+ final public function key() {}
+
+ final public function next(): void {}
+
+ final public function rewind(): void {}
+
+ final public function valid(): bool {}
+
+ final public function __wakeup(): void {}
+}
diff --git a/stubs/BSON/PackedArray.stub.php b/stubs/BSON/PackedArray.stub.php
new file mode 100644
index 000000000..231a55019
--- /dev/null
+++ b/stubs/BSON/PackedArray.stub.php
@@ -0,0 +1,40 @@
+
+ */
+final class PackedArray implements \IteratorAggregate, \Serializable
+{
+ private function __construct() {}
+
+ final static public function fromPHP(array $value): PackedArray {}
+
+ /** @return TValue */
+ final public function get(int $index) {}
+
+ /** @return Iterator */
+ final public function getIterator(): Iterator {}
+
+ final public function has(int $index): bool {}
+
+ /** @return array|object */
+ final public function toPHP(?array $typeMap = null) {}
+
+ final public function __toString(): string {}
+
+ final public static function __set_state(array $properties): PackedArray {}
+
+ final public function serialize(): string {}
+
+ /** @param string $serialized */
+ final public function unserialize($serialized): void {}
+
+ final public function __unserialize(array $data): void {}
+
+ final public function __serialize(): array {}
+}
diff --git a/tests/Codec/CodecLibraryTest.php b/tests/Codec/CodecLibraryTest.php
new file mode 100644
index 000000000..489e1d1d8
--- /dev/null
+++ b/tests/Codec/CodecLibraryTest.php
@@ -0,0 +1,167 @@
+getCodecLibrary();
+
+ $this->assertTrue($codec->canDecode('encoded'));
+ $this->assertFalse($codec->canDecode('decoded'));
+
+ $this->assertSame('decoded', $codec->decode('encoded'));
+ }
+
+ public function testDecodeIfSupported(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertSame('decoded', $codec->decodeIfSupported('encoded'));
+ $this->assertSame('decoded', $codec->decodeIfSupported('decoded'));
+ }
+
+ public function testDecodeNull(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertFalse($codec->canDecode(null));
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue(null));
+ $codec->decode(null);
+ }
+
+ public function testDecodeUnsupportedValue(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidDecodableValue('foo'));
+ $this->getCodecLibrary()->decode('foo');
+ }
+
+ public function testEncode(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertTrue($codec->canEncode('decoded'));
+ $this->assertFalse($codec->canEncode('encoded'));
+
+ $this->assertSame('encoded', $codec->encode('decoded'));
+ }
+
+ public function testEncodeIfSupported(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertSame('encoded', $codec->encodeIfSupported('decoded'));
+ $this->assertSame('encoded', $codec->encodeIfSupported('encoded'));
+ }
+
+ public function testEncodeNull(): void
+ {
+ $codec = $this->getCodecLibrary();
+
+ $this->assertFalse($codec->canEncode(null));
+
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue(null));
+ $codec->encode(null);
+ }
+
+ public function testEncodeUnsupportedValue(): void
+ {
+ $this->expectExceptionObject(UnsupportedValueException::invalidEncodableValue('foo'));
+ $this->getCodecLibrary()->encode('foo');
+ }
+
+ public function testLibraryAttachesToCodecs(): void
+ {
+ // TODO PHPUnit >= 10: use createMockForIntersectionOfInterfaces instead
+ $codec = $this->getTestCodec();
+ $library = $this->getCodecLibrary();
+
+ $library->attachCodec($codec);
+ $this->assertSame($library, $codec->library);
+ }
+
+ public function testLibraryAttachesToCodecsWhenCreating(): void
+ {
+ $codec = $this->getTestCodec();
+ $library = new CodecLibrary($codec);
+
+ $this->assertSame($library, $codec->library);
+ }
+
+ private function 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 getTestCodec(): Codec
+ {
+ return new class implements Codec, KnowsCodecLibrary {
+ use DecodeIfSupported;
+ use EncodeIfSupported;
+
+ public $library;
+
+ public function attachCodecLibrary(CodecLibrary $library): void
+ {
+ $this->library = $library;
+ }
+
+ public function canDecode($value): bool
+ {
+ return false;
+ }
+
+ public function canEncode($value): bool
+ {
+ return false;
+ }
+
+ public function decode($value)
+ {
+ return null;
+ }
+
+ public function encode($value)
+ {
+ return null;
+ }
+ };
+ }
+}
diff --git a/tests/PedantryTest.php b/tests/PedantryTest.php
index 2885d82a7..a46477d94 100644
--- a/tests/PedantryTest.php
+++ b/tests/PedantryTest.php
@@ -33,7 +33,8 @@ public function testMethodsAreOrderedAlphabeticallyByVisibility($className): voi
$methods = array_filter(
$methods,
function (ReflectionMethod $method) use ($class) {
- return $method->getDeclaringClass() == $class;
+ return $method->getDeclaringClass() == $class // Exclude inherited methods
+ && $method->getFileName() === $class->getFileName(); // Exclude methods inherited from traits
}
);
@@ -86,7 +87,8 @@ public function provideProjectClassNames()
continue;
}
- $classNames[][] = 'MongoDB\\' . str_replace(DIRECTORY_SEPARATOR, '\\', substr($file->getRealPath(), strlen($srcDir) + 1, -4));
+ $className = 'MongoDB\\' . str_replace(DIRECTORY_SEPARATOR, '\\', substr($file->getRealPath(), strlen($srcDir) + 1, -4));
+ $classNames[$className][] = $className;
}
return $classNames;