Skip to content

PHPLIB-1122: Additional support for BSON objects #1096

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@
<code>! is_array($encryptedFields['fields'])</code>
<code>! is_array($field) &amp;&amp; ! is_object($field)</code>
</DocblockTypeContradiction>
<MixedArgument occurrences="1">
<code>$this-&gt;options['encryptedFields']</code>
</MixedArgument>
</file>
<file src="src/Operation/CreateIndexes.php">
<DocblockTypeContradiction occurrences="1">
Expand Down
33 changes: 26 additions & 7 deletions src/Operation/CreateEncryptedCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
namespace MongoDB\Operation;

use MongoDB\BSON\Binary;
use MongoDB\BSON\PackedArray;
use MongoDB\BSON\Serializable;
use MongoDB\Driver\ClientEncryption;
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
use MongoDB\Driver\Server;
Expand All @@ -27,6 +29,7 @@
use function array_key_exists;
use function is_array;
use function is_object;
use function MongoDB\document_to_array;
use function MongoDB\server_supports_feature;

/**
Expand Down Expand Up @@ -87,7 +90,7 @@ public function __construct(string $databaseName, string $collectionName, array
$this->createCollection = new CreateCollection($databaseName, $collectionName, $options);

/** @psalm-var array{ecocCollection?: ?string, escCollection?: ?string} */
$encryptedFields = (array) $options['encryptedFields'];
$encryptedFields = document_to_array($options['encryptedFields']);
$enxcolOptions = ['clusteredIndex' => ['key' => ['_id' => 1], 'unique' => true]];

$this->createMetadataCollections = [
Expand Down Expand Up @@ -118,12 +121,28 @@ public function __construct(string $databaseName, string $collectionName, array
*/
public function createDataKeys(ClientEncryption $clientEncryption, string $kmsProvider, ?array $masterKey, ?array &$encryptedFields = null): void
{
/** @psalm-var array{fields: list<array{keyId: ?Binary}|object{keyId: ?Binary}>} */
$encryptedFields = (array) $this->options['encryptedFields'];
/** @psalm-var array{fields: list<array{keyId: ?Binary}|object{keyId: ?Binary}>|Serializable|PackedArray} */
$encryptedFields = document_to_array($this->options['encryptedFields']);

/* NOP if there are no fields to examine. If the type is invalid, defer
* to the server to raise an error in execute(). */
if (! isset($encryptedFields['fields']) || ! is_array($encryptedFields['fields'])) {
// NOP if there are no fields to examine
if (! isset($encryptedFields['fields'])) {
return;
}

// Allow PackedArray or Serializable object for the fields array
if ($encryptedFields['fields'] instanceof PackedArray) {
/** @psalm-var array */
$encryptedFields['fields'] = $encryptedFields['fields']->toPHP([
'array' => 'array',
'document' => 'object',
'root' => 'array',
]);
} elseif ($encryptedFields['fields'] instanceof Serializable) {
$encryptedFields['fields'] = $encryptedFields['fields']->bsonSerialize();
}

// Skip invalid types and defer to the server to raise an error
if (! is_array($encryptedFields['fields'])) {
return;
}

Expand All @@ -138,7 +157,7 @@ public function createDataKeys(ClientEncryption $clientEncryption, string $kmsPr
continue;
}

$field = (array) $field;
$field = document_to_array($field);

if (array_key_exists('keyId', $field) && $field['keyId'] === null) {
$field['keyId'] = $clientEncryption->createDataKey(...$createDataKeyArgs);
Expand Down
3 changes: 2 additions & 1 deletion src/Operation/DropEncryptedCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

use function is_array;
use function is_object;
use function MongoDB\document_to_array;

/**
* Drop an encrypted collection.
Expand Down Expand Up @@ -72,7 +73,7 @@ public function __construct(string $databaseName, string $collectionName, array
}

/** @psalm-var array{ecocCollection?: ?string, escCollection?: ?string} */
$encryptedFields = (array) $options['encryptedFields'];
$encryptedFields = document_to_array($options['encryptedFields']);

$this->dropMetadataCollections = [
new DropCollection($databaseName, $encryptedFields['escCollection'] ?? 'enxcol_.' . $collectionName . '.esc'),
Expand Down
3 changes: 2 additions & 1 deletion src/Operation/MapReduce.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
use function is_object;
use function is_string;
use function MongoDB\create_field_path_type_map;
use function MongoDB\document_to_array;
use function MongoDB\is_mapreduce_output_inline;
use function trigger_error;

Expand Down Expand Up @@ -315,7 +316,7 @@ private function checkOutDeprecations($out): void
return;
}

$out = (array) $out;
$out = document_to_array($out);

if (isset($out['nonAtomic']) && ! $out['nonAtomic']) {
@trigger_error('Specifying false for "out.nonAtomic" is deprecated.', E_USER_DEPRECATED);
Expand Down
203 changes: 203 additions & 0 deletions tests/Operation/BulkWriteFunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@

namespace MongoDB\Tests\Operation;

use MongoDB\BSON\Document;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\PackedArray;
use MongoDB\BulkWriteResult;
use MongoDB\Collection;
use MongoDB\Driver\BulkWrite as Bulk;
use MongoDB\Driver\WriteConcern;
use MongoDB\Exception\BadMethodCallException;
use MongoDB\Model\BSONArray;
use MongoDB\Model\BSONDocument;
use MongoDB\Operation\BulkWrite;
use MongoDB\Tests\CommandObserver;
use stdClass;

use function is_array;
use function version_compare;

class BulkWriteFunctionalTest extends FunctionalTestCase
Expand Down Expand Up @@ -57,6 +62,60 @@ public function testInserts(): void
$this->assertSameDocuments($expected, $this->collection->find());
}

/**
* @dataProvider provideDocumentsWithIds
* @dataProvider provideDocumentsWithoutIds
*/
public function testInsertDocumentEncoding($document, stdClass $expectedDocument): void
{
(new CommandObserver())->observe(
function () use ($document, $expectedDocument): void {
$operation = new BulkWrite(
$this->getDatabaseName(),
$this->getCollectionName(),
[['insertOne' => [$document]]]
);

$result = $operation->execute($this->getPrimaryServer());

// Replace _id placeholder if necessary
if ($expectedDocument->_id === null) {
$expectedDocument->_id = $result->getInsertedIds()[0];
}
},
function (array $event) use ($expectedDocument): void {
$this->assertEquals($expectedDocument, $event['started']->getCommand()->documents[0] ?? null);
}
);
}

public function provideDocumentsWithIds(): array
{
$expectedDocument = (object) ['_id' => 1];

return [
'with_id:array' => [['_id' => 1], $expectedDocument],
'with_id:object' => [(object) ['_id' => 1], $expectedDocument],
'with_id:Serializable' => [new BSONDocument(['_id' => 1]), $expectedDocument],
'with_id:Document' => [Document::fromPHP(['_id' => 1]), $expectedDocument],
];
}

public function provideDocumentsWithoutIds(): array
{
/* Note: _id placeholders must be replaced with generated ObjectIds. We
* also clone the value for each data set since tests may need to modify
* the object. */
$expectedDocument = (object) ['_id' => null, 'x' => 1];

return [
'without_id:array' => [['x' => 1], clone $expectedDocument],
'without_id:object' => [(object) ['x' => 1], clone $expectedDocument],
'without_id:Serializable' => [new BSONDocument(['x' => 1]), clone $expectedDocument],
'without_id:Document' => [Document::fromPHP(['x' => 1]), clone $expectedDocument],
];
}

public function testUpdates(): void
{
$this->createFixtures(4);
Expand Down Expand Up @@ -93,6 +152,127 @@ public function testUpdates(): void
$this->assertSameDocuments($expected, $this->collection->find());
}

/** @dataProvider provideFilterDocuments */
public function testUpdateFilterDocuments($filter, stdClass $expectedFilter): void
{
(new CommandObserver())->observe(
function () use ($filter): void {
$operation = new BulkWrite(
$this->getDatabaseName(),
$this->getCollectionName(),
[
['replaceOne' => [$filter, ['x' => 1]]],
['updateOne' => [$filter, ['$set' => ['x' => 1]]]],
['updateMany' => [$filter, ['$set' => ['x' => 1]]]],
]
);

$operation->execute($this->getPrimaryServer());
},
function (array $event) use ($expectedFilter): void {
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[0]->q ?? null);
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[1]->q ?? null);
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[2]->q ?? null);
}
);
}

public function provideFilterDocuments(): array
{
$expectedQuery = (object) ['x' => 1];

return [
'array' => [['x' => 1], $expectedQuery],
'object' => [(object) ['x' => 1], $expectedQuery],
'Serializable' => [new BSONDocument(['x' => 1]), $expectedQuery],
'Document' => [Document::fromPHP(['x' => 1]), $expectedQuery],
];
}

/** @dataProvider provideReplacementDocuments */
public function testReplacementDocuments($replacement, stdClass $expectedReplacement): void
{
(new CommandObserver())->observe(
function () use ($replacement): void {
$operation = new BulkWrite(
$this->getDatabaseName(),
$this->getCollectionName(),
[['replaceOne' => [['x' => 1], $replacement]]]
);

$operation->execute($this->getPrimaryServer());
},
function (array $event) use ($expectedReplacement): void {
$this->assertEquals($expectedReplacement, $event['started']->getCommand()->updates[0]->u ?? null);
}
);
}

public function provideReplacementDocuments(): array
{
$expected = (object) ['x' => 1];

return [
'replacement:array' => [['x' => 1], $expected],
'replacement:object' => [(object) ['x' => 1], $expected],
'replacement:Serializable' => [new BSONDocument(['x' => 1]), $expected],
'replacement:Document' => [Document::fromPHP(['x' => 1]), $expected],
];
}

/**
* @dataProvider provideUpdateDocuments
* @dataProvider provideUpdatePipelines
*/
public function testUpdateDocuments($update, $expectedUpdate): void
{
if (is_array($expectedUpdate) && version_compare($this->getServerVersion(), '4.2.0', '<')) {
$this->markTestSkipped('Pipeline-style updates are not supported');
}

(new CommandObserver())->observe(
function () use ($update): void {
$operation = new BulkWrite(
$this->getDatabaseName(),
$this->getCollectionName(),
[
['updateOne' => [['x' => 1], $update]],
['updateMany' => [['x' => 1], $update]],
]
);

$operation->execute($this->getPrimaryServer());
},
function (array $event) use ($expectedUpdate): void {
$this->assertEquals($expectedUpdate, $event['started']->getCommand()->updates[0]->u ?? null);
$this->assertEquals($expectedUpdate, $event['started']->getCommand()->updates[1]->u ?? null);
}
);
}

public function provideUpdateDocuments(): array
{
$expected = (object) ['$set' => (object) ['x' => 1]];

return [
'update:array' => [['$set' => ['x' => 1]], $expected],
'update:object' => [(object) ['$set' => ['x' => 1]], $expected],
'update:Serializable' => [new BSONDocument(['$set' => ['x' => 1]]), $expected],
'update:Document' => [Document::fromPHP(['$set' => ['x' => 1]]), $expected],
];
}

public function provideUpdatePipelines(): array
{
$expected = [(object) ['$set' => (object) ['x' => 1]]];

return [
'pipeline:array' => [[['$set' => ['x' => 1]]], $expected],
'pipeline:Serializable' => [new BSONArray([['$set' => ['x' => 1]]]), $expected],
'pipeline:PackedArray' => [PackedArray::fromPHP([['$set' => ['x' => 1]]]), $expected],
];
}

public function testDeletes(): void
{
$this->createFixtures(4);
Expand All @@ -115,6 +295,29 @@ public function testDeletes(): void
$this->assertSameDocuments($expected, $this->collection->find());
}

/** @dataProvider provideFilterDocuments */
public function testDeleteFilterDocuments($filter, stdClass $expectedQuery): void
{
(new CommandObserver())->observe(
function () use ($filter): void {
$operation = new BulkWrite(
$this->getDatabaseName(),
$this->getCollectionName(),
[
['deleteOne' => [$filter]],
['deleteMany' => [$filter]],
]
);

$operation->execute($this->getPrimaryServer());
},
function (array $event) use ($expectedQuery): void {
$this->assertEquals($expectedQuery, $event['started']->getCommand()->deletes[0]->q ?? null);
$this->assertEquals($expectedQuery, $event['started']->getCommand()->deletes[1]->q ?? null);
}
);
}

public function testMixedOrderedOperations(): void
{
$this->createFixtures(3);
Expand Down
Loading