diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 69470d320..aa05ccae1 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -217,28 +217,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/psalm.xml.dist b/psalm.xml.dist
index 28efbec86..07ea13744 100644
--- a/psalm.xml.dist
+++ b/psalm.xml.dist
@@ -39,6 +39,9 @@
+
+
+
diff --git a/src/Client.php b/src/Client.php
index 938da2f1a..481cc0583 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -17,13 +17,8 @@
namespace MongoDB;
-use Composer\InstalledVersions;
use Iterator;
-use MongoDB\BSON\Document;
-use MongoDB\BSON\PackedArray;
-use MongoDB\Builder\BuilderEncoder;
use MongoDB\Builder\Pipeline;
-use MongoDB\Codec\Encoder;
use MongoDB\Driver\BulkWriteCommand;
use MongoDB\Driver\BulkWriteCommandResult;
use MongoDB\Driver\ClientEncryption;
@@ -38,9 +33,9 @@
use MongoDB\Exception\InvalidArgumentException;
use MongoDB\Exception\UnexpectedValueException;
use MongoDB\Exception\UnsupportedException;
-use MongoDB\Model\BSONArray;
-use MongoDB\Model\BSONDocument;
+use MongoDB\Model\AutoEncryptionOptions;
use MongoDB\Model\DatabaseInfo;
+use MongoDB\Model\DriverOptions;
use MongoDB\Operation\ClientBulkWriteCommand;
use MongoDB\Operation\DropDatabase;
use MongoDB\Operation\ListDatabaseNames;
@@ -48,26 +43,14 @@
use MongoDB\Operation\Watch;
use stdClass;
use Stringable;
-use Throwable;
use function array_diff_key;
-use function is_array;
-use function is_string;
+/** @psalm-import-type stage from Builder\Pipeline */
class Client implements Stringable
{
public const DEFAULT_URI = 'mongodb://127.0.0.1/';
- private const DEFAULT_TYPE_MAP = [
- 'array' => BSONArray::class,
- 'document' => BSONDocument::class,
- 'root' => BSONDocument::class,
- ];
-
- private const HANDSHAKE_SEPARATOR = '/';
-
- private static ?string $version = null;
-
private Manager $manager;
private ReadConcern $readConcern;
@@ -76,14 +59,9 @@ class Client implements Stringable
private string $uri;
- private array $typeMap;
-
- /** @psalm-var Encoder */
- private readonly Encoder $builderEncoder;
-
private WriteConcern $writeConcern;
- private bool $autoEncryptionEnabled;
+ private DriverOptions $driverOptions;
/**
* Constructs a new Client instance.
@@ -113,34 +91,14 @@ class Client implements Stringable
*/
public function __construct(?string $uri = null, array $uriOptions = [], array $driverOptions = [])
{
- $driverOptions += ['typeMap' => self::DEFAULT_TYPE_MAP];
-
- if (! is_array($driverOptions['typeMap'])) {
- throw InvalidArgumentException::invalidType('"typeMap" driver option', $driverOptions['typeMap'], 'array');
- }
-
- if (isset($driverOptions['autoEncryption']) && is_array($driverOptions['autoEncryption'])) {
- $driverOptions['autoEncryption'] = $this->prepareEncryptionOptions($driverOptions['autoEncryption']);
- }
-
- if (isset($driverOptions['builderEncoder']) && ! $driverOptions['builderEncoder'] instanceof Encoder) {
- throw InvalidArgumentException::invalidType('"builderEncoder" option', $driverOptions['builderEncoder'], Encoder::class);
- }
-
- $driverOptions['driver'] = $this->mergeDriverInfo($driverOptions['driver'] ?? []);
+ $this->driverOptions = DriverOptions::fromArray($driverOptions);
$this->uri = $uri ?? self::DEFAULT_URI;
- $this->builderEncoder = $driverOptions['builderEncoder'] ?? new BuilderEncoder();
- $this->typeMap = $driverOptions['typeMap'];
-
- /* Database and Collection objects may need to know whether auto
- * encryption is enabled for dropping collections. Track this via an
- * internal option until PHPC-2615 is implemented. */
- $this->autoEncryptionEnabled = isset($driverOptions['autoEncryption']['keyVaultNamespace']);
- $driverOptions = array_diff_key($driverOptions, ['builderEncoder' => 1, 'typeMap' => 1]);
+ $driverOptions = array_diff_key($this->driverOptions->toArray(), ['builderEncoder' => 1, 'typeMap' => 1]);
$this->manager = new Manager($uri, $uriOptions, $driverOptions);
+
$this->readConcern = $this->manager->getReadConcern();
$this->readPreference = $this->manager->getReadPreference();
$this->writeConcern = $this->manager->getWriteConcern();
@@ -156,8 +114,8 @@ public function __debugInfo(): array
return [
'manager' => $this->manager,
'uri' => $this->uri,
- 'typeMap' => $this->typeMap,
- 'builderEncoder' => $this->builderEncoder,
+ 'typeMap' => $this->driverOptions->typeMap,
+ 'builderEncoder' => $this->driverOptions->builderEncoder,
'writeConcern' => $this->writeConcern,
];
}
@@ -225,13 +183,13 @@ public function bulkWrite(BulkWriteCommand|ClientBulkWrite $bulk, array $options
/**
* Returns a ClientEncryption instance for explicit encryption and decryption
*
- * @param array $options Encryption options
+ * @param array{kmsProviders?: stdClass|array, keyVaultClient?: Client|Manager} $options
*/
public function createClientEncryption(array $options): ClientEncryption
{
- $options = $this->prepareEncryptionOptions($options);
+ $options = AutoEncryptionOptions::fromArray($options);
- return $this->manager->createClientEncryption($options);
+ return $this->manager->createClientEncryption($options->toArray());
}
/**
@@ -268,7 +226,11 @@ public function dropDatabase(string $databaseName, array $options = []): void
*/
public function getCollection(string $databaseName, string $collectionName, array $options = []): Collection
{
- $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder, 'autoEncryptionEnabled' => $this->autoEncryptionEnabled];
+ $options += [
+ 'typeMap' => $this->driverOptions->typeMap,
+ 'builderEncoder' => $this->driverOptions->builderEncoder,
+ 'autoEncryptionEnabled' => $this->driverOptions->isAutoEncryptionEnabled(),
+ ];
return new Collection($this->manager, $databaseName, $collectionName, $options);
}
@@ -283,7 +245,11 @@ public function getCollection(string $databaseName, string $collectionName, arra
*/
public function getDatabase(string $databaseName, array $options = []): Database
{
- $options += ['typeMap' => $this->typeMap, 'builderEncoder' => $this->builderEncoder, 'autoEncryptionEnabled' => $this->autoEncryptionEnabled];
+ $options += [
+ 'typeMap' => $this->driverOptions->typeMap,
+ 'builderEncoder' => $this->driverOptions->builderEncoder,
+ 'autoEncryptionEnabled' => $this->driverOptions->isAutoEncryptionEnabled(),
+ ];
return new Database($this->manager, $databaseName, $options);
}
@@ -319,7 +285,7 @@ public function getReadPreference(): ReadPreference
*/
public function getTypeMap(): array
{
- return $this->typeMap;
+ return $this->driverOptions->typeMap;
}
/**
@@ -418,8 +384,8 @@ public function startSession(array $options = []): Session
* Create a change stream for watching changes to the cluster.
*
* @see Watch::__construct() for supported options
- * @param array $pipeline Aggregation pipeline
- * @param array $options Command options
+ * @psalm-param list $pipeline Aggregation pipeline
+ * @param array $options Command options
* @throws InvalidArgumentException for parameter/option parsing errors
*/
public function watch(array $pipeline = [], array $options = []): ChangeStream
@@ -428,7 +394,8 @@ public function watch(array $pipeline = [], array $options = []): ChangeStream
$pipeline = new Pipeline(...$pipeline);
}
- $pipeline = $this->builderEncoder->encodeIfSupported($pipeline);
+ /** @var array $pipeline */
+ $pipeline = $this->driverOptions->builderEncoder->encodeIfSupported($pipeline);
if (! isset($options['readPreference']) && ! is_in_transaction($options)) {
$options['readPreference'] = $this->readPreference;
@@ -441,76 +408,11 @@ public function watch(array $pipeline = [], array $options = []): ChangeStream
}
if (! isset($options['typeMap'])) {
- $options['typeMap'] = $this->typeMap;
+ $options['typeMap'] = $this->driverOptions->typeMap;
}
$operation = new Watch($this->manager, null, null, $pipeline, $options);
return $operation->execute($server);
}
-
- private static function getVersion(): string
- {
- if (self::$version === null) {
- try {
- self::$version = InstalledVersions::getPrettyVersion('mongodb/mongodb') ?? 'unknown';
- } catch (Throwable) {
- self::$version = 'error';
- }
- }
-
- return self::$version;
- }
-
- private function mergeDriverInfo(array $driver): array
- {
- $mergedDriver = [
- 'name' => 'PHPLIB',
- 'version' => self::getVersion(),
- ];
-
- if (isset($driver['name'])) {
- if (! is_string($driver['name'])) {
- throw InvalidArgumentException::invalidType('"name" handshake option', $driver['name'], 'string');
- }
-
- $mergedDriver['name'] .= self::HANDSHAKE_SEPARATOR . $driver['name'];
- }
-
- if (isset($driver['version'])) {
- if (! is_string($driver['version'])) {
- throw InvalidArgumentException::invalidType('"version" handshake option', $driver['version'], 'string');
- }
-
- $mergedDriver['version'] .= self::HANDSHAKE_SEPARATOR . $driver['version'];
- }
-
- if (isset($driver['platform'])) {
- $mergedDriver['platform'] = $driver['platform'];
- }
-
- return $mergedDriver;
- }
-
- private function prepareEncryptionOptions(array $options): array
- {
- if (isset($options['keyVaultClient'])) {
- if ($options['keyVaultClient'] instanceof self) {
- $options['keyVaultClient'] = $options['keyVaultClient']->manager;
- } elseif (! $options['keyVaultClient'] instanceof Manager) {
- throw InvalidArgumentException::invalidType('"keyVaultClient" option', $options['keyVaultClient'], [self::class, Manager::class]);
- }
- }
-
- // The server requires an empty document for automatic credentials.
- if (isset($options['kmsProviders']) && is_array($options['kmsProviders'])) {
- foreach ($options['kmsProviders'] as $name => $provider) {
- if ($provider === []) {
- $options['kmsProviders'][$name] = new stdClass();
- }
- }
- }
-
- return $options;
- }
}
diff --git a/src/Model/AutoEncryptionOptions.php b/src/Model/AutoEncryptionOptions.php
new file mode 100644
index 000000000..c67e27050
--- /dev/null
+++ b/src/Model/AutoEncryptionOptions.php
@@ -0,0 +1,67 @@
+, keyVaultClient?: Client|Manager} $options */
+ public static function fromArray(array $options): self
+ {
+ // The server requires an empty document for automatic credentials.
+ if (isset($options[self::KEY_KMS_PROVIDERS]) && is_array($options[self::KEY_KMS_PROVIDERS])) {
+ foreach ($options[self::KEY_KMS_PROVIDERS] as $name => $provider) {
+ if ($provider === []) {
+ $options[self::KEY_KMS_PROVIDERS][$name] = new stdClass();
+ }
+ }
+ }
+
+ $keyVaultClient = $options[self::KEY_KEY_VAULT_CLIENT] ?? null;
+
+ if ($keyVaultClient !== null && ! $keyVaultClient instanceof Client && ! $keyVaultClient instanceof Manager) {
+ throw InvalidArgumentException::invalidType(
+ sprintf('"%s" option', self::KEY_KEY_VAULT_CLIENT),
+ $keyVaultClient,
+ [Client::class, Manager::class],
+ );
+ }
+
+ return new self(
+ keyVaultClient: $keyVaultClient instanceof Client ? $keyVaultClient->getManager() : $keyVaultClient,
+ kmsProviders: $options[self::KEY_KMS_PROVIDERS] ?? null,
+ miscOptions: array_diff_key($options, [self::KEY_KEY_VAULT_CLIENT => 1, self::KEY_KMS_PROVIDERS => 1]),
+ );
+ }
+
+ public function toArray(): array
+ {
+ return array_filter(
+ [
+ self::KEY_KEY_VAULT_CLIENT => $this->keyVaultClient,
+ self::KEY_KMS_PROVIDERS => $this->kmsProviders,
+ ] + $this->miscOptions,
+ static fn ($option) => $option !== null,
+ );
+ }
+}
diff --git a/src/Model/DriverOptions.php b/src/Model/DriverOptions.php
new file mode 100644
index 000000000..3d3b25fda
--- /dev/null
+++ b/src/Model/DriverOptions.php
@@ -0,0 +1,170 @@
+ BSONArray::class,
+ 'document' => BSONDocument::class,
+ 'root' => BSONDocument::class,
+ ];
+
+ private const HANDSHAKE_SEPARATOR = '/';
+
+ private static ?string $version = null;
+
+ public array $driver;
+
+ /**
+ * @param array|null $autoEncryption
+ * @param array{name?: string, version?: string, platform?: string} $driver
+ */
+ private function __construct(
+ public readonly array $typeMap,
+ public readonly Encoder $builderEncoder,
+ public readonly ?array $autoEncryption,
+ private readonly array $miscOptions,
+ array $driver,
+ ) {
+ $this->driver = $this->mergeDriverInfo($driver);
+ }
+
+ public static function fromArray(array $options): self
+ {
+ $options += [self::KEY_TYPE_MAP => self::DEFAULT_TYPE_MAP];
+
+ if (! is_array($options[self::KEY_TYPE_MAP])) {
+ throw InvalidArgumentException::invalidType(
+ sprintf('"%s" driver option', self::KEY_TYPE_MAP),
+ $options[self::KEY_TYPE_MAP],
+ 'array',
+ );
+ }
+
+ if (isset($options[self::KEY_BUILDER_ENCODER]) && ! $options[self::KEY_BUILDER_ENCODER] instanceof Encoder) {
+ throw InvalidArgumentException::invalidType(
+ sprintf('"%s" option', self::KEY_BUILDER_ENCODER),
+ $options[self::KEY_BUILDER_ENCODER],
+ Encoder::class,
+ );
+ }
+
+ /** @var array{kmsProviders?: stdClass|array, keyVaultClient?: Client|Manager} $autoEncryptionOptions */
+ $autoEncryptionOptions = $options[self::KEY_AUTO_ENCRYPTION] ?? [];
+ $autoEncryption = ! empty($autoEncryptionOptions)
+ ? AutoEncryptionOptions::fromArray($autoEncryptionOptions)->toArray()
+ : null;
+
+ /** @var array{name?: string, version?: string, platform?: string} $driver $driver */
+ $driver = $options[self::KEY_DRIVER] ?? [];
+
+ return new self(
+ typeMap: $options[self::KEY_TYPE_MAP],
+ builderEncoder: $options[self::KEY_BUILDER_ENCODER] ?? new BuilderEncoder(),
+ autoEncryption: $autoEncryption,
+ miscOptions: array_diff_key($options, [
+ self::KEY_TYPE_MAP => 1,
+ self::KEY_BUILDER_ENCODER => 1,
+ self::KEY_AUTO_ENCRYPTION => 1,
+ self::KEY_DRIVER => 1,
+ ]),
+ driver: $driver,
+ );
+ }
+
+ public function isAutoEncryptionEnabled(): bool
+ {
+ return isset($this->autoEncryption['keyVaultNamespace']);
+ }
+
+ public function toArray(): array
+ {
+ return array_filter(
+ [
+ 'typeMap' => $this->typeMap,
+ 'builderEncoder' => $this->builderEncoder,
+ 'autoEncryption' => $this->autoEncryption,
+ 'driver' => $this->driver,
+ ] + $this->miscOptions,
+ static fn ($option) => $option !== null,
+ );
+ }
+
+ private static function getVersion(): string
+ {
+ if (self::$version === null) {
+ try {
+ self::$version = InstalledVersions::getPrettyVersion('mongodb/mongodb') ?? 'unknown';
+ } catch (Throwable) {
+ self::$version = 'error';
+ }
+ }
+
+ return self::$version;
+ }
+
+ /** @param array{name?: string, version?: string, platform?: string} $driver */
+ private function mergeDriverInfo(array $driver): array
+ {
+ if (isset($driver['name'])) {
+ if (! is_string($driver['name'])) {
+ throw InvalidArgumentException::invalidType(
+ '"name" handshake option',
+ $driver['name'],
+ 'string',
+ );
+ }
+ }
+
+ if (isset($driver['version'])) {
+ if (! is_string($driver['version'])) {
+ throw InvalidArgumentException::invalidType('"version" handshake option', $driver['version'], 'string');
+ }
+ }
+
+ $mergedDriver = [
+ 'name' => 'PHPLIB',
+ 'version' => self::getVersion(),
+ ];
+
+ if (isset($driver['name'])) {
+ $mergedDriver['name'] .= self::HANDSHAKE_SEPARATOR . $driver['name'];
+ }
+
+ if (isset($driver['version'])) {
+ $mergedDriver['version'] .= self::HANDSHAKE_SEPARATOR . $driver['version'];
+ }
+
+ if ($this->isAutoEncryptionEnabled()) {
+ $driver['platform'] = trim(sprintf('iue %s', $driver['platform'] ?? ''));
+ }
+
+ if (isset($driver['platform'])) {
+ $mergedDriver['platform'] = $driver['platform'];
+ }
+
+ return $mergedDriver;
+ }
+}
diff --git a/tests/Model/AutoEncryptionOptionsTest.php b/tests/Model/AutoEncryptionOptionsTest.php
new file mode 100644
index 000000000..74f76f561
--- /dev/null
+++ b/tests/Model/AutoEncryptionOptionsTest.php
@@ -0,0 +1,76 @@
+assertEquals($expected, $actual->toArray());
+ }
+
+ public function testFromArrayFailsForInvalidOptions(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+
+ AutoEncryptionOptions::fromArray([
+ 'keyVaultClient' => new stdClass(),
+ ]);
+ }
+
+ public static function fromArrayProvider(): Generator
+ {
+ $client = new Client();
+
+ yield 'with manager passed for `keyVaultClient`' => [
+ [
+ 'keyVaultClient' => $client->getManager(),
+ 'kmsProviders' => new stdClass(),
+ ],
+ [
+ 'keyVaultClient' => $client->getManager(),
+ 'kmsProviders' => new stdClass(),
+ ],
+ ];
+
+ yield 'with client passed for `keyVaultClient`' => [
+ ['keyVaultClient' => $client],
+ [
+ 'keyVaultClient' => $client->getManager(),
+ ],
+ ];
+
+ yield 'with extra options' => [
+ [
+ 'kmsProviders' => [
+ 'foo' => [],
+ 'aws' => ['foo' => 'bar'],
+ ],
+ 'tlsProviders' => [
+ ['foo' => 'bar'],
+ ],
+ 'disableClientPersistence' => false,
+ ],
+ [
+ 'kmsProviders' => [
+ 'foo' => new stdClass(),
+ 'aws' => ['foo' => 'bar'],
+ ],
+ 'tlsProviders' => [
+ ['foo' => 'bar'],
+ ],
+ 'disableClientPersistence' => false,
+ ],
+ ];
+ }
+}
diff --git a/tests/Model/DriverOptionsTest.php b/tests/Model/DriverOptionsTest.php
new file mode 100644
index 000000000..2438bda37
--- /dev/null
+++ b/tests/Model/DriverOptionsTest.php
@@ -0,0 +1,174 @@
+toArray();
+ // This changes per runtime, so is tested with regex separately in `testDriverInfo`
+ unset($actualArray['driver']['version']);
+
+ $this->assertEquals($expected, $actualArray);
+ }
+
+ #[DataProvider('provideInvalidOptions')]
+ public function testFromArrayFailsForInvalidOptions(array $options): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ DriverOptions::fromArray($options);
+ }
+
+ public function testIsAutoEncryptionEnabled(): void
+ {
+ $enabled = DriverOptions::fromArray([
+ 'kmsProviders' => [
+ 'foo' => new StdClass(),
+ 'aws' => ['foo' => 'bar'],
+ ],
+ 'autoEncryption' => ['keyVaultNamespace' => 'foo'],
+ ]);
+
+ $this->assertTrue($enabled->isAutoEncryptionEnabled());
+
+ $notEnabled = DriverOptions::fromArray([
+ 'autoEncryption' => [
+ 'kmsProviders' => [
+ 'foo' => new StdClass(),
+ 'aws' => ['foo' => 'bar'],
+ ],
+ ],
+ ]);
+
+ $this->assertFalse($notEnabled->isAutoEncryptionEnabled());
+ }
+
+ #[DataProvider('provideDriverInfo')]
+ public function testDriverInfo(array $options, string $name, string $versionRegex, ?string $platform): void
+ {
+ $options = DriverOptions::fromArray(['driver' => $options]);
+
+ $this->assertEquals($name, $options->driver['name']);
+ $this->assertMatchesRegularExpression($versionRegex, $options->driver['version']);
+ $this->assertEquals($platform, $options->driver['platform']);
+ }
+
+ public static function provideOptions(): Generator
+ {
+ yield 'defaults' => [
+ [],
+ [
+ 'typeMap' => [
+ 'array' => BSONArray::class,
+ 'document' => BSONDocument::class,
+ 'root' => BSONDocument::class,
+ ],
+ 'builderEncoder' => new BuilderEncoder(),
+ 'driver' => ['name' => 'PHPLIB'],
+ ],
+ ];
+
+ yield 'encryption enabled' => [
+ [
+ 'autoEncryption' => ['keyVaultNamespace' => 'foo'],
+ ],
+ [
+ 'typeMap' => [
+ 'array' => BSONArray::class,
+ 'document' => BSONDocument::class,
+ 'root' => BSONDocument::class,
+ ],
+ 'autoEncryption' => ['keyVaultNamespace' => 'foo'],
+ 'builderEncoder' => new BuilderEncoder(),
+ 'driver' => [
+ 'name' => 'PHPLIB',
+ 'platform' => 'iue',
+ ],
+ ],
+ ];
+
+ yield 'encryption enabled with platform' => [
+ [
+ 'autoEncryption' => ['keyVaultNamespace' => 'foo'],
+ 'driver' => ['platform' => 'bar'],
+ ],
+ [
+ 'typeMap' => [
+ 'array' => BSONArray::class,
+ 'document' => BSONDocument::class,
+ 'root' => BSONDocument::class,
+ ],
+ 'autoEncryption' => ['keyVaultNamespace' => 'foo'],
+ 'builderEncoder' => new BuilderEncoder(),
+ 'driver' => [
+ 'name' => 'PHPLIB',
+ 'platform' => 'iue bar',
+ ],
+ ],
+ ];
+
+ yield 'extra options' => [
+ [
+ 'typeMap' => [],
+ 'builderEncoder' => new BuilderEncoder(),
+ 'autoEncryption' => [],
+ 'some' => 'option',
+ 'some_other' => ['option' => 'too'],
+ 'driver' => ['platform' => 'foo'],
+ ],
+ [
+ 'typeMap' => [],
+ 'builderEncoder' => new BuilderEncoder(),
+ 'driver' => [
+ 'name' => 'PHPLIB',
+ 'platform' => 'foo',
+ ],
+ 'some' => 'option',
+ 'some_other' => ['option' => 'too'],
+ ],
+ ];
+ }
+
+ public static function provideInvalidOptions(): Generator
+ {
+ yield 'invalid type for type map' => [
+ ['typeMap' => null],
+ ];
+
+ yield 'invalid type for builder encoder' => [
+ [
+ 'builderEncoder' => new StdClass(),
+ ],
+ ];
+ }
+
+ public static function provideDriverInfo(): Generator
+ {
+ yield 'all' => [
+ [
+ 'name' => 'foo',
+ 'version' => 'bar',
+ 'platform' => 'baz',
+ ],
+ 'PHPLIB/foo',
+ '/^.+\/bar$/',
+ 'baz',
+ ];
+
+ yield 'default' => [[], 'PHPLIB', '/.+/', null];
+ }
+}