Skip to content

Commit 14c22fd

Browse files
authored
PHPLIB-1129: Improve pipeline checks for replace operations (#1098)
* Improve pipeline checks for replace operations Consolidates data providers in base TestCase and FunctionalTestCase classes. Change is_pipeline() to conditionally consider empty arrays as pipelines. This was needed for improvements to replace/update parameter validation, and will prove useful for consolidated pipeline validation in PHPLIB-881. Prohibit update documents and pipelines (both empty and non-empty) for replacement operations. Prohibit replacement documents and empty pipeines for update operations. Empty pipelines must be prohibited because libmongoc does not support them for update commands (they're indistinguishable from empty replacement documents). For consistency, we also prohibit them for FindOneAndUpdate even though PHPLIB could work around that. Prohibit update pipelines for replace operations (both empty and non-empty). Previously ReplaceOne only prohibited non-empty pipelines and BulkWrite replaceOne and FindOneAndReplace has no validation. A notable exception is empty arrays, which are treated as replacement documents for BC. Add tests for valid update/replacement arguments for BulkWrite replaceOne, updateMany, and updateOne operations. Also adds more comprehensive tests for valid $update arguments for UpdateMany and UpdateOne operations (update documents and non-empty pipelines). * Demonstrate edge case where replacement doc encodes as pipeline array * Test FindAndModify with replacement doc resembling a pipeline Note that FindAndModify's behavior differs from Update since it is unaffected by libmongoc's pipeline detection for update commands. FindAndModify behaves correctly. Add a comment explaining how the conditional object cast in FindAndModify::createCommandDocument() affects BSON encoding for the update option.
1 parent 2d29868 commit 14c22fd

27 files changed

+330
-400
lines changed

psalm-baseline.xml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@
3636
</UnsafeInstantiation>
3737
</file>
3838
<file src="src/Exception/InvalidArgumentException.php">
39-
<UnsafeInstantiation occurrences="1">
39+
<UnsafeInstantiation occurrences="2">
4040
<code>new static(sprintf('Expected %s to have type "%s" but found "%s"', $name, $expectedType, get_debug_type($value)))</code>
41+
<code>new static('Expected update operator(s) or non-empty pipeline for ' . $name)</code>
4142
</UnsafeInstantiation>
4243
</file>
4344
<file src="src/Exception/ResumeTokenException.php">
@@ -308,7 +309,7 @@
308309
<DocblockTypeContradiction occurrences="1">
309310
<code>is_array($operation)</code>
310311
</DocblockTypeContradiction>
311-
<MixedArgument occurrences="12">
312+
<MixedArgument occurrences="13">
312313
<code>$args</code>
313314
<code>$args</code>
314315
<code>$args</code>
@@ -320,9 +321,10 @@
320321
<code>$args[1]</code>
321322
<code>$args[1]</code>
322323
<code>$args[1]</code>
324+
<code>$args[1]</code>
323325
<code>$args[2]</code>
324326
</MixedArgument>
325-
<MixedArrayAccess occurrences="25">
327+
<MixedArrayAccess occurrences="28">
326328
<code>$args[0]</code>
327329
<code>$args[0]</code>
328330
<code>$args[0]</code>
@@ -337,6 +339,9 @@
337339
<code>$args[1]</code>
338340
<code>$args[1]</code>
339341
<code>$args[1]</code>
342+
<code>$args[1]</code>
343+
<code>$args[1]</code>
344+
<code>$args[1]</code>
340345
<code>$args[2]</code>
341346
<code>$args[2]</code>
342347
<code>$args[2]</code>
@@ -349,7 +354,8 @@
349354
<code>$args[2]['upsert']</code>
350355
<code>$args[2]['upsert']</code>
351356
</MixedArrayAccess>
352-
<MixedArrayAssignment occurrences="11">
357+
<MixedArrayAssignment occurrences="12">
358+
<code>$args[1]</code>
353359
<code>$args[1]</code>
354360
<code>$args[1]</code>
355361
<code>$args[2]</code>

src/Operation/BulkWrite.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,19 @@ public function __construct(string $databaseName, string $collectionName, array
191191
throw InvalidArgumentException::invalidType(sprintf('$operations[%d]["%s"][1]', $i, $type), $args[1], 'array or object');
192192
}
193193

194+
// Treat empty arrays as replacement documents for BC
195+
if ($args[1] === []) {
196+
$args[1] = (object) $args[1];
197+
}
198+
194199
if (is_first_key_operator($args[1])) {
195200
throw new InvalidArgumentException(sprintf('First key in $operations[%d]["%s"][1] is an update operator', $i, $type));
196201
}
197202

203+
if (is_pipeline($args[1], true /* allowEmpty */)) {
204+
throw new InvalidArgumentException(sprintf('$operations[%d]["%s"][1] is an update pipeline', $i, $type));
205+
}
206+
198207
if (! isset($args[2])) {
199208
$args[2] = [];
200209
}
@@ -229,7 +238,7 @@ public function __construct(string $databaseName, string $collectionName, array
229238
}
230239

231240
if (! is_first_key_operator($args[1]) && ! is_pipeline($args[1])) {
232-
throw new InvalidArgumentException(sprintf('First key in $operations[%d]["%s"][1] is neither an update operator nor a pipeline', $i, $type));
241+
throw new InvalidArgumentException(sprintf('Expected update operator(s) or non-empty pipeline for $operations[%d]["%s"][1]', $i, $type));
233242
}
234243

235244
if (! isset($args[2])) {

src/Operation/FindAndModify.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,14 @@ private function createCommandDocument(): array
295295
if (isset($this->options['update'])) {
296296
/** @psalm-var array|object */
297297
$update = $this->options['update'];
298+
/* A non-empty pipeline will encode as a BSON array, so leave it
299+
* as-is. Cast anything else to an object since a BSON document is
300+
* likely expected. This includes empty arrays, which historically
301+
* can be used to represent empty replacement documents.
302+
*
303+
* This also allows an empty pipeline expressed as a PackedArray or
304+
* Serializable to still encode as a BSON array, since the object
305+
* cast will have no effect. */
298306
$cmd['update'] = is_pipeline($update) ? $update : (object) $update;
299307
}
300308

src/Operation/FindOneAndReplace.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use function is_integer;
2828
use function is_object;
2929
use function MongoDB\is_first_key_operator;
30+
use function MongoDB\is_pipeline;
3031

3132
/**
3233
* Operation for replacing a document with the findAndModify command.
@@ -109,8 +110,17 @@ public function __construct(string $databaseName, string $collectionName, $filte
109110
throw InvalidArgumentException::invalidType('$replacement', $replacement, 'array or object');
110111
}
111112

113+
// Treat empty arrays as replacement documents for BC
114+
if ($replacement === []) {
115+
$replacement = (object) $replacement;
116+
}
117+
112118
if (is_first_key_operator($replacement)) {
113-
throw new InvalidArgumentException('First key in $replacement argument is an update operator');
119+
throw new InvalidArgumentException('First key in $replacement is an update operator');
120+
}
121+
122+
if (is_pipeline($replacement, true /* allowEmpty */)) {
123+
throw new InvalidArgumentException('$replacement is an update pipeline');
114124
}
115125

116126
if (isset($options['projection']) && ! is_array($options['projection']) && ! is_object($options['projection'])) {

src/Operation/FindOneAndUpdate.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ public function __construct(string $databaseName, string $collectionName, $filte
114114
}
115115

116116
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
117-
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
117+
throw new InvalidArgumentException('Expected update operator(s) or non-empty pipeline for $update');
118118
}
119119

120120
if (isset($options['projection']) && ! is_array($options['projection']) && ! is_object($options['projection'])) {

src/Operation/ReplaceOne.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,17 @@ public function __construct(string $databaseName, string $collectionName, $filte
8585
throw InvalidArgumentException::invalidType('$replacement', $replacement, 'array or object');
8686
}
8787

88+
// Treat empty arrays as replacement documents for BC
89+
if ($replacement === []) {
90+
$replacement = (object) $replacement;
91+
}
92+
8893
if (is_first_key_operator($replacement)) {
89-
throw new InvalidArgumentException('First key in $replacement argument is an update operator');
94+
throw new InvalidArgumentException('First key in $replacement is an update operator');
9095
}
9196

92-
if (is_pipeline($replacement)) {
93-
throw new InvalidArgumentException('$replacement argument is a pipeline');
97+
if (is_pipeline($replacement, true /* allowEmpty */)) {
98+
throw new InvalidArgumentException('$replacement is an update pipeline');
9499
}
95100

96101
$this->update = new Update(

src/Operation/Update.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public function __construct(string $databaseName, string $collectionName, $filte
148148
}
149149

150150
if ($options['multi'] && ! is_first_key_operator($update) && ! is_pipeline($update)) {
151-
throw new InvalidArgumentException('"multi" option cannot be true if $update is a replacement document');
151+
throw new InvalidArgumentException('"multi" option cannot be true unless $update has update operator(s) or non-empty pipeline');
152152
}
153153

154154
if (isset($options['session']) && ! $options['session'] instanceof Session) {

src/Operation/UpdateMany.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public function __construct(string $databaseName, string $collectionName, $filte
8989
}
9090

9191
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
92-
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
92+
throw new InvalidArgumentException('Expected update operator(s) or non-empty pipeline for $update');
9393
}
9494

9595
$this->update = new Update(

src/Operation/UpdateOne.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ public function __construct(string $databaseName, string $collectionName, $filte
8989
}
9090

9191
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
92-
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
92+
throw new InvalidArgumentException('Expected update operator(s) or non-empty pipeline for $update');
9393
}
9494

9595
$this->update = new Update(

src/functions.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,22 @@ function is_first_key_operator($document): bool
213213
}
214214

215215
/**
216-
* Returns whether an update specification is a valid aggregation pipeline.
216+
* Returns whether the argument is a valid aggregation or update pipeline.
217+
*
218+
* This is primarily used for validating arguments for update and replace
219+
* operations, but can also be used for validating an aggregation pipeline.
220+
*
221+
* The $allowEmpty parameter can be used to control whether an empty array
222+
* should be considered a valid pipeline. Empty arrays are generally valid for
223+
* an aggregation pipeline, but the things are more complicated for update
224+
* pipelines.
225+
*
226+
* Update operations must prohibit empty pipelines, since libmongoc may encode
227+
* an empty pipeline array as an empty replacement document when writing an
228+
* update command (arrays and documents have the same bson_t representation).
229+
* For consistency, findOneAndUpdate should also prohibit empty pipelines.
230+
* Replace operations (e.g. replaceOne, findOneAndReplace) should reject empty
231+
* and non-empty pipelines alike, since neither is a replacement document.
217232
*
218233
* Note: this method may propagate an InvalidArgumentException from
219234
* document_or_array() if a Serializable object within the pipeline array
@@ -223,11 +238,11 @@ function is_first_key_operator($document): bool
223238
* @param array|object $pipeline
224239
* @throws InvalidArgumentException
225240
*/
226-
function is_pipeline($pipeline): bool
241+
function is_pipeline($pipeline, bool $allowEmpty = false): bool
227242
{
228243
if ($pipeline instanceof PackedArray) {
229244
/* Nested documents and arrays are intentionally left as BSON. We avoid
230-
* iterator_to_array() since Document iteration returns all values as
245+
* iterator_to_array() since PackedArray iteration returns all values as
231246
* MongoDB\BSON\Value instances. */
232247
/** @psalm-var array */
233248
$pipeline = $pipeline->toPHP([
@@ -244,7 +259,7 @@ function is_pipeline($pipeline): bool
244259
}
245260

246261
if ($pipeline === []) {
247-
return false;
262+
return $allowEmpty;
248263
}
249264

250265
$expectedKey = 0;

tests/Operation/BulkWriteFunctionalTest.php

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@
44

55
use MongoDB\BSON\Document;
66
use MongoDB\BSON\ObjectId;
7-
use MongoDB\BSON\PackedArray;
87
use MongoDB\BulkWriteResult;
98
use MongoDB\Collection;
109
use MongoDB\Driver\BulkWrite as Bulk;
1110
use MongoDB\Driver\WriteConcern;
1211
use MongoDB\Exception\BadMethodCallException;
13-
use MongoDB\Model\BSONArray;
1412
use MongoDB\Model\BSONDocument;
1513
use MongoDB\Operation\BulkWrite;
1614
use MongoDB\Tests\CommandObserver;
@@ -177,18 +175,6 @@ function (array $event) use ($expectedFilter): void {
177175
);
178176
}
179177

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-
192178
/** @dataProvider provideReplacementDocuments */
193179
public function testReplacementDocuments($replacement, stdClass $expectedReplacement): void
194180
{
@@ -208,18 +194,6 @@ function (array $event) use ($expectedReplacement): void {
208194
);
209195
}
210196

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-
223197
/**
224198
* @dataProvider provideUpdateDocuments
225199
* @dataProvider provideUpdatePipelines
@@ -250,29 +224,6 @@ function (array $event) use ($expectedUpdate): void {
250224
);
251225
}
252226

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-
276227
public function testDeletes(): void
277228
{
278229
$this->createFixtures(4);

0 commit comments

Comments
 (0)