Skip to content

Commit 2628c09

Browse files
committed
PHPLIB-418: Add the ability to specify a pipeline to an update command
1 parent 67ffd33 commit 2628c09

13 files changed

+102
-26
lines changed

docs/includes/apiargs-MongoDBCollection-common-param.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ type: array|object
2424
description: |
2525
Specifies the field and value combinations to update and any relevant update
2626
operators. ``$update`` uses MongoDB's :method:`update operators
27-
</reference/operator/update>`.
27+
</reference/operator/update>`. Starting with MongoDB 4.2, an `aggregation
28+
pipeline <https://docs.mongodb.com/master/reference/command/update/#update-with-an-aggregation-pipeline>`_
29+
can be passed as this parameter.
2830
interface: phpmethod
2931
operation: ~
3032
optional: false

src/Operation/FindAndModify.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use function is_integer;
3232
use function is_object;
3333
use function MongoDB\create_field_path_type_map;
34+
use function MongoDB\is_pipeline;
3435
use function MongoDB\server_supports_feature;
3536

3637
/**
@@ -255,12 +256,18 @@ private function createCommandDocument(Server $server)
255256
$cmd['upsert'] = $this->options['upsert'];
256257
}
257258

258-
foreach (['collation', 'fields', 'query', 'sort', 'update'] as $option) {
259+
foreach (['collation', 'fields', 'query', 'sort'] as $option) {
259260
if (isset($this->options[$option])) {
260261
$cmd[$option] = (object) $this->options[$option];
261262
}
262263
}
263264

265+
if (isset($this->options['update'])) {
266+
$cmd['update'] = is_pipeline($this->options['update'])
267+
? $this->options['update']
268+
: (object) $this->options['update'];
269+
}
270+
264271
if (isset($this->options['arrayFilters'])) {
265272
$cmd['arrayFilters'] = $this->options['arrayFilters'];
266273
}

src/Operation/FindOneAndUpdate.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use function is_integer;
2626
use function is_object;
2727
use function MongoDB\is_first_key_operator;
28+
use function MongoDB\is_pipeline;
2829

2930
/**
3031
* Operation for updating a document with the findAndModify command.
@@ -105,8 +106,8 @@ public function __construct($databaseName, $collectionName, $filter, $update, ar
105106
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
106107
}
107108

108-
if (! is_first_key_operator($update)) {
109-
throw new InvalidArgumentException('First key in $update argument is not an update operator');
109+
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
110+
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
110111
}
111112

112113
$options += [

src/Operation/ReplaceOne.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use function is_array;
2626
use function is_object;
2727
use function MongoDB\is_first_key_operator;
28+
use function MongoDB\is_pipeline;
2829

2930
/**
3031
* Operation for replacing a single document with the update command.
@@ -79,6 +80,10 @@ public function __construct($databaseName, $collectionName, $filter, $replacemen
7980
throw new InvalidArgumentException('First key in $replacement argument is an update operator');
8081
}
8182

83+
if (is_pipeline($replacement)) {
84+
throw new InvalidArgumentException('$replacement argument is a pipeline');
85+
}
86+
8287
$this->update = new Update(
8388
$databaseName,
8489
$collectionName,

src/Operation/Update.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use function is_bool;
3030
use function is_object;
3131
use function MongoDB\is_first_key_operator;
32+
use function MongoDB\is_pipeline;
3233
use function MongoDB\server_supports_feature;
3334

3435
/**
@@ -126,7 +127,7 @@ public function __construct($databaseName, $collectionName, $filter, $update, ar
126127
throw InvalidArgumentException::invalidType('"multi" option', $options['multi'], 'boolean');
127128
}
128129

129-
if ($options['multi'] && ! is_first_key_operator($update)) {
130+
if ($options['multi'] && ! is_first_key_operator($update) && ! is_pipeline($update)) {
130131
throw new InvalidArgumentException('"multi" option cannot be true if $update is a replacement document');
131132
}
132133

src/Operation/UpdateMany.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use function is_array;
2626
use function is_object;
2727
use function MongoDB\is_first_key_operator;
28+
use function MongoDB\is_pipeline;
2829

2930
/**
3031
* Operation for updating multiple documents with the update command.
@@ -81,8 +82,8 @@ public function __construct($databaseName, $collectionName, $filter, $update, ar
8182
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
8283
}
8384

84-
if (! is_first_key_operator($update)) {
85-
throw new InvalidArgumentException('First key in $update argument is not an update operator');
85+
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
86+
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
8687
}
8788

8889
$this->update = new Update(

src/Operation/UpdateOne.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use function is_array;
2626
use function is_object;
2727
use function MongoDB\is_first_key_operator;
28+
use function MongoDB\is_pipeline;
2829

2930
/**
3031
* Operation for updating a single document with the update command.
@@ -81,8 +82,8 @@ public function __construct($databaseName, $collectionName, $filter, $update, ar
8182
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
8283
}
8384

84-
if (! is_first_key_operator($update)) {
85-
throw new InvalidArgumentException('First key in $update argument is not an update operator');
85+
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
86+
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
8687
}
8788

8889
$this->update = new Update(

src/functions.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,47 @@ function is_first_key_operator($document)
119119
return isset($firstKey[0]) && $firstKey[0] === '$';
120120
}
121121

122+
/**
123+
* Returns whether an update specification is a valid aggregation pipeline.
124+
*
125+
* @internal
126+
* @param mixed $pipeline
127+
* @return boolean
128+
*/
129+
function is_pipeline($pipeline)
130+
{
131+
if (! is_array($pipeline)) {
132+
return false;
133+
}
134+
135+
if ($pipeline === []) {
136+
return false;
137+
}
138+
139+
$expectedKey = 0;
140+
141+
foreach ($pipeline as $key => $stage) {
142+
if (! is_array($stage) && ! is_object($stage)) {
143+
return false;
144+
}
145+
146+
if ($expectedKey !== $key) {
147+
return false;
148+
}
149+
150+
$expectedKey++;
151+
$stage = (array) $stage;
152+
reset($stage);
153+
$key = key($stage);
154+
155+
if (! isset($key[0]) || $key[0] !== '$') {
156+
return false;
157+
}
158+
}
159+
160+
return true;
161+
}
162+
122163
/**
123164
* Returns whether we are currently in a transaction.
124165
*

tests/FunctionsTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use function MongoDB\generate_index_name;
1111
use function MongoDB\is_first_key_operator;
1212
use function MongoDB\is_mapreduce_output_inline;
13+
use function MongoDB\is_pipeline;
1314

1415
/**
1516
* Unit tests for utility functions.
@@ -224,4 +225,33 @@ public function provideTypeMapValues()
224225
],
225226
];
226227
}
228+
229+
/**
230+
* @dataProvider providePipelines
231+
*/
232+
public function testIsPipeline($expected, $pipeline)
233+
{
234+
$this->assertSame($expected, is_pipeline($pipeline));
235+
}
236+
237+
public function providePipelines()
238+
{
239+
return [
240+
'Not an array' => [false, (object) []],
241+
'Empty array' => [false, []],
242+
'Non-sequential indexes in array' => [false, [1 => ['$group' => []]]],
243+
'Update document instead of pipeline' => [false, ['$set' => ['foo' => 'bar']]],
244+
'Invalid pipeline stage' => [false, [['group' => []]]],
245+
'Update with DbRef' => [false, ['x' => ['$ref' => 'foo', '$id' => 'bar']]],
246+
'Valid pipeline' => [
247+
true,
248+
[
249+
['$match' => ['foo' => 'bar']],
250+
['$group' => ['_id' => 1]],
251+
],
252+
],
253+
'False positive with DbRef in numeric field' => [true, ['0' => ['$ref' => 'foo', '$id' => 'bar']]],
254+
'DbRef in numeric field as object' => [false, (object) ['0' => ['$ref' => 'foo', '$id' => 'bar']]],
255+
];
256+
}
227257
}

tests/Operation/FindOneAndUpdateTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ public function testConstructorUpdateArgumentTypeCheck($update)
2525
new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], $update);
2626
}
2727

28-
public function testConstructorUpdateArgumentRequiresOperators()
28+
public function testConstructorUpdateArgumentRequiresOperatorsOrPipeline()
2929
{
3030
$this->expectException(InvalidArgumentException::class);
31-
$this->expectExceptionMessage('First key in $update argument is not an update operator');
31+
$this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
3232
new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], []);
3333
}
3434

tests/Operation/UpdateManyTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function testConstructorUpdateArgument($update)
4141
public function testConstructorUpdateArgumentRequiresOperators($replacement)
4242
{
4343
$this->expectException(InvalidArgumentException::class);
44-
$this->expectExceptionMessage('First key in $update argument is not an update operator');
44+
$this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
4545
new UpdateMany($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement);
4646
}
4747

tests/Operation/UpdateOneTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public function testConstructorUpdateArgument($update)
4141
public function testConstructorUpdateArgumentRequiresOperators($replacement)
4242
{
4343
$this->expectException(InvalidArgumentException::class);
44-
$this->expectExceptionMessage('First key in $update argument is not an update operator');
44+
$this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
4545
new UpdateOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement);
4646
}
4747

tests/SpecTests/CrudSpecTest.php

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,6 @@
1414
*/
1515
class CrudSpecTest extends FunctionalTestCase
1616
{
17-
/* These should all pass before the driver can be considered compatible with
18-
* MongoDB 4.2. */
19-
private static $incompleteTests = [
20-
'bulkWrite-arrayFilters: BulkWrite with arrayFilters' => 'Fails due to command assertions',
21-
'updateWithPipelines: UpdateOne using pipelines' => 'PHPLIB-418',
22-
'updateWithPipelines: UpdateMany using pipelines' => 'PHPLIB-418',
23-
'updateWithPipelines: FindOneAndUpdate using pipelines' => 'PHPLIB-418',
24-
];
25-
2617
/**
2718
* Assert that the expected and actual command documents match.
2819
*
@@ -48,10 +39,6 @@ public static function assertCommandMatches(stdClass $expected, stdClass $actual
4839
*/
4940
public function testCrud(stdClass $test, array $runOn = null, array $data, $databaseName = null, $collectionName = null)
5041
{
51-
if (isset(self::$incompleteTests[$this->dataDescription()])) {
52-
$this->markTestIncomplete(self::$incompleteTests[$this->dataDescription()]);
53-
}
54-
5542
if (isset($runOn)) {
5643
$this->checkServerRequirements($runOn);
5744
}

0 commit comments

Comments
 (0)