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;