Skip to content

Commit 2d29868

Browse files
authored
PHPLIB-1122: Additional support for BSON objects (#1096)
* Support Document for MapReduce $out parameter Also adds tests for $out deprecations (re: PHPLIB-480) using various document types. * Support BSON objects for encryptedFields option CreateEncryptedCollection::createDataKeys() required more intensive changes to support BSON objects at various levels. The handling for PackedArray and array-yielding Serializable objects is modeled after document_to_array(). Tests were intentionally omitted for the state collection names, since custom values are unsupported and the options aren't documented for public use. Adding functional tests to assert options in outgoing 'create' commands seems like overkill. * Test BSON objects with BulkWrite operations
1 parent 9a8f40a commit 2d29868

9 files changed

+393
-29
lines changed

psalm-baseline.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,9 @@
422422
<code>! is_array($encryptedFields['fields'])</code>
423423
<code>! is_array($field) &amp;&amp; ! is_object($field)</code>
424424
</DocblockTypeContradiction>
425+
<MixedArgument occurrences="1">
426+
<code>$this-&gt;options['encryptedFields']</code>
427+
</MixedArgument>
425428
</file>
426429
<file src="src/Operation/CreateIndexes.php">
427430
<DocblockTypeContradiction occurrences="1">

src/Operation/CreateEncryptedCollection.php

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
namespace MongoDB\Operation;
1919

2020
use MongoDB\BSON\Binary;
21+
use MongoDB\BSON\PackedArray;
22+
use MongoDB\BSON\Serializable;
2123
use MongoDB\Driver\ClientEncryption;
2224
use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException;
2325
use MongoDB\Driver\Server;
@@ -27,6 +29,7 @@
2729
use function array_key_exists;
2830
use function is_array;
2931
use function is_object;
32+
use function MongoDB\document_to_array;
3033
use function MongoDB\server_supports_feature;
3134

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

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

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

124-
/* NOP if there are no fields to examine. If the type is invalid, defer
125-
* to the server to raise an error in execute(). */
126-
if (! isset($encryptedFields['fields']) || ! is_array($encryptedFields['fields'])) {
127+
// NOP if there are no fields to examine
128+
if (! isset($encryptedFields['fields'])) {
129+
return;
130+
}
131+
132+
// Allow PackedArray or Serializable object for the fields array
133+
if ($encryptedFields['fields'] instanceof PackedArray) {
134+
/** @psalm-var array */
135+
$encryptedFields['fields'] = $encryptedFields['fields']->toPHP([
136+
'array' => 'array',
137+
'document' => 'object',
138+
'root' => 'array',
139+
]);
140+
} elseif ($encryptedFields['fields'] instanceof Serializable) {
141+
$encryptedFields['fields'] = $encryptedFields['fields']->bsonSerialize();
142+
}
143+
144+
// Skip invalid types and defer to the server to raise an error
145+
if (! is_array($encryptedFields['fields'])) {
127146
return;
128147
}
129148

@@ -138,7 +157,7 @@ public function createDataKeys(ClientEncryption $clientEncryption, string $kmsPr
138157
continue;
139158
}
140159

141-
$field = (array) $field;
160+
$field = document_to_array($field);
142161

143162
if (array_key_exists('keyId', $field) && $field['keyId'] === null) {
144163
$field['keyId'] = $clientEncryption->createDataKey(...$createDataKeyArgs);

src/Operation/DropEncryptedCollection.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
use function is_array;
2525
use function is_object;
26+
use function MongoDB\document_to_array;
2627

2728
/**
2829
* Drop an encrypted collection.
@@ -72,7 +73,7 @@ public function __construct(string $databaseName, string $collectionName, array
7273
}
7374

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

7778
$this->dropMetadataCollections = [
7879
new DropCollection($databaseName, $encryptedFields['escCollection'] ?? 'enxcol_.' . $collectionName . '.esc'),

src/Operation/MapReduce.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use function is_object;
4141
use function is_string;
4242
use function MongoDB\create_field_path_type_map;
43+
use function MongoDB\document_to_array;
4344
use function MongoDB\is_mapreduce_output_inline;
4445
use function trigger_error;
4546

@@ -315,7 +316,7 @@ private function checkOutDeprecations($out): void
315316
return;
316317
}
317318

318-
$out = (array) $out;
319+
$out = document_to_array($out);
319320

320321
if (isset($out['nonAtomic']) && ! $out['nonAtomic']) {
321322
@trigger_error('Specifying false for "out.nonAtomic" is deprecated.', E_USER_DEPRECATED);

tests/Operation/BulkWriteFunctionalTest.php

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22

33
namespace MongoDB\Tests\Operation;
44

5+
use MongoDB\BSON\Document;
56
use MongoDB\BSON\ObjectId;
7+
use MongoDB\BSON\PackedArray;
68
use MongoDB\BulkWriteResult;
79
use MongoDB\Collection;
810
use MongoDB\Driver\BulkWrite as Bulk;
911
use MongoDB\Driver\WriteConcern;
1012
use MongoDB\Exception\BadMethodCallException;
13+
use MongoDB\Model\BSONArray;
1114
use MongoDB\Model\BSONDocument;
1215
use MongoDB\Operation\BulkWrite;
1316
use MongoDB\Tests\CommandObserver;
17+
use stdClass;
1418

19+
use function is_array;
1520
use function version_compare;
1621

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

65+
/**
66+
* @dataProvider provideDocumentsWithIds
67+
* @dataProvider provideDocumentsWithoutIds
68+
*/
69+
public function testInsertDocumentEncoding($document, stdClass $expectedDocument): void
70+
{
71+
(new CommandObserver())->observe(
72+
function () use ($document, $expectedDocument): void {
73+
$operation = new BulkWrite(
74+
$this->getDatabaseName(),
75+
$this->getCollectionName(),
76+
[['insertOne' => [$document]]]
77+
);
78+
79+
$result = $operation->execute($this->getPrimaryServer());
80+
81+
// Replace _id placeholder if necessary
82+
if ($expectedDocument->_id === null) {
83+
$expectedDocument->_id = $result->getInsertedIds()[0];
84+
}
85+
},
86+
function (array $event) use ($expectedDocument): void {
87+
$this->assertEquals($expectedDocument, $event['started']->getCommand()->documents[0] ?? null);
88+
}
89+
);
90+
}
91+
92+
public function provideDocumentsWithIds(): array
93+
{
94+
$expectedDocument = (object) ['_id' => 1];
95+
96+
return [
97+
'with_id:array' => [['_id' => 1], $expectedDocument],
98+
'with_id:object' => [(object) ['_id' => 1], $expectedDocument],
99+
'with_id:Serializable' => [new BSONDocument(['_id' => 1]), $expectedDocument],
100+
'with_id:Document' => [Document::fromPHP(['_id' => 1]), $expectedDocument],
101+
];
102+
}
103+
104+
public function provideDocumentsWithoutIds(): array
105+
{
106+
/* Note: _id placeholders must be replaced with generated ObjectIds. We
107+
* also clone the value for each data set since tests may need to modify
108+
* the object. */
109+
$expectedDocument = (object) ['_id' => null, 'x' => 1];
110+
111+
return [
112+
'without_id:array' => [['x' => 1], clone $expectedDocument],
113+
'without_id:object' => [(object) ['x' => 1], clone $expectedDocument],
114+
'without_id:Serializable' => [new BSONDocument(['x' => 1]), clone $expectedDocument],
115+
'without_id:Document' => [Document::fromPHP(['x' => 1]), clone $expectedDocument],
116+
];
117+
}
118+
60119
public function testUpdates(): void
61120
{
62121
$this->createFixtures(4);
@@ -93,6 +152,127 @@ public function testUpdates(): void
93152
$this->assertSameDocuments($expected, $this->collection->find());
94153
}
95154

155+
/** @dataProvider provideFilterDocuments */
156+
public function testUpdateFilterDocuments($filter, stdClass $expectedFilter): void
157+
{
158+
(new CommandObserver())->observe(
159+
function () use ($filter): void {
160+
$operation = new BulkWrite(
161+
$this->getDatabaseName(),
162+
$this->getCollectionName(),
163+
[
164+
['replaceOne' => [$filter, ['x' => 1]]],
165+
['updateOne' => [$filter, ['$set' => ['x' => 1]]]],
166+
['updateMany' => [$filter, ['$set' => ['x' => 1]]]],
167+
]
168+
);
169+
170+
$operation->execute($this->getPrimaryServer());
171+
},
172+
function (array $event) use ($expectedFilter): void {
173+
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[0]->q ?? null);
174+
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[1]->q ?? null);
175+
$this->assertEquals($expectedFilter, $event['started']->getCommand()->updates[2]->q ?? null);
176+
}
177+
);
178+
}
179+
180+
public function provideFilterDocuments(): array
181+
{
182+
$expectedQuery = (object) ['x' => 1];
183+
184+
return [
185+
'array' => [['x' => 1], $expectedQuery],
186+
'object' => [(object) ['x' => 1], $expectedQuery],
187+
'Serializable' => [new BSONDocument(['x' => 1]), $expectedQuery],
188+
'Document' => [Document::fromPHP(['x' => 1]), $expectedQuery],
189+
];
190+
}
191+
192+
/** @dataProvider provideReplacementDocuments */
193+
public function testReplacementDocuments($replacement, stdClass $expectedReplacement): void
194+
{
195+
(new CommandObserver())->observe(
196+
function () use ($replacement): void {
197+
$operation = new BulkWrite(
198+
$this->getDatabaseName(),
199+
$this->getCollectionName(),
200+
[['replaceOne' => [['x' => 1], $replacement]]]
201+
);
202+
203+
$operation->execute($this->getPrimaryServer());
204+
},
205+
function (array $event) use ($expectedReplacement): void {
206+
$this->assertEquals($expectedReplacement, $event['started']->getCommand()->updates[0]->u ?? null);
207+
}
208+
);
209+
}
210+
211+
public function provideReplacementDocuments(): array
212+
{
213+
$expected = (object) ['x' => 1];
214+
215+
return [
216+
'replacement:array' => [['x' => 1], $expected],
217+
'replacement:object' => [(object) ['x' => 1], $expected],
218+
'replacement:Serializable' => [new BSONDocument(['x' => 1]), $expected],
219+
'replacement:Document' => [Document::fromPHP(['x' => 1]), $expected],
220+
];
221+
}
222+
223+
/**
224+
* @dataProvider provideUpdateDocuments
225+
* @dataProvider provideUpdatePipelines
226+
*/
227+
public function testUpdateDocuments($update, $expectedUpdate): void
228+
{
229+
if (is_array($expectedUpdate) && version_compare($this->getServerVersion(), '4.2.0', '<')) {
230+
$this->markTestSkipped('Pipeline-style updates are not supported');
231+
}
232+
233+
(new CommandObserver())->observe(
234+
function () use ($update): void {
235+
$operation = new BulkWrite(
236+
$this->getDatabaseName(),
237+
$this->getCollectionName(),
238+
[
239+
['updateOne' => [['x' => 1], $update]],
240+
['updateMany' => [['x' => 1], $update]],
241+
]
242+
);
243+
244+
$operation->execute($this->getPrimaryServer());
245+
},
246+
function (array $event) use ($expectedUpdate): void {
247+
$this->assertEquals($expectedUpdate, $event['started']->getCommand()->updates[0]->u ?? null);
248+
$this->assertEquals($expectedUpdate, $event['started']->getCommand()->updates[1]->u ?? null);
249+
}
250+
);
251+
}
252+
253+
public function provideUpdateDocuments(): array
254+
{
255+
$expected = (object) ['$set' => (object) ['x' => 1]];
256+
257+
return [
258+
'update:array' => [['$set' => ['x' => 1]], $expected],
259+
'update:object' => [(object) ['$set' => ['x' => 1]], $expected],
260+
'update:Serializable' => [new BSONDocument(['$set' => ['x' => 1]]), $expected],
261+
'update:Document' => [Document::fromPHP(['$set' => ['x' => 1]]), $expected],
262+
];
263+
}
264+
265+
public function provideUpdatePipelines(): array
266+
{
267+
$expected = [(object) ['$set' => (object) ['x' => 1]]];
268+
269+
return [
270+
'pipeline:array' => [[['$set' => ['x' => 1]]], $expected],
271+
'pipeline:Serializable' => [new BSONArray([['$set' => ['x' => 1]]]), $expected],
272+
'pipeline:PackedArray' => [PackedArray::fromPHP([['$set' => ['x' => 1]]]), $expected],
273+
];
274+
}
275+
96276
public function testDeletes(): void
97277
{
98278
$this->createFixtures(4);
@@ -115,6 +295,29 @@ public function testDeletes(): void
115295
$this->assertSameDocuments($expected, $this->collection->find());
116296
}
117297

298+
/** @dataProvider provideFilterDocuments */
299+
public function testDeleteFilterDocuments($filter, stdClass $expectedQuery): void
300+
{
301+
(new CommandObserver())->observe(
302+
function () use ($filter): void {
303+
$operation = new BulkWrite(
304+
$this->getDatabaseName(),
305+
$this->getCollectionName(),
306+
[
307+
['deleteOne' => [$filter]],
308+
['deleteMany' => [$filter]],
309+
]
310+
);
311+
312+
$operation->execute($this->getPrimaryServer());
313+
},
314+
function (array $event) use ($expectedQuery): void {
315+
$this->assertEquals($expectedQuery, $event['started']->getCommand()->deletes[0]->q ?? null);
316+
$this->assertEquals($expectedQuery, $event['started']->getCommand()->deletes[1]->q ?? null);
317+
}
318+
);
319+
}
320+
118321
public function testMixedOrderedOperations(): void
119322
{
120323
$this->createFixtures(3);

0 commit comments

Comments
 (0)