PHPLIB-418: Add the ability to specify a pipeline to an update command

parent 67ffd336
...@@ -24,7 +24,9 @@ type: array|object ...@@ -24,7 +24,9 @@ type: array|object
description: | description: |
Specifies the field and value combinations to update and any relevant update Specifies the field and value combinations to update and any relevant update
operators. ``$update`` uses MongoDB's :method:`update operators operators. ``$update`` uses MongoDB's :method:`update operators
</reference/operator/update>`. </reference/operator/update>`. Starting with MongoDB 4.2, an `aggregation
pipeline <https://docs.mongodb.com/master/reference/command/update/#update-with-an-aggregation-pipeline>`_
can be passed as this parameter.
interface: phpmethod interface: phpmethod
operation: ~ operation: ~
optional: false optional: false
......
...@@ -31,6 +31,7 @@ use function is_bool; ...@@ -31,6 +31,7 @@ use function is_bool;
use function is_integer; use function is_integer;
use function is_object; use function is_object;
use function MongoDB\create_field_path_type_map; use function MongoDB\create_field_path_type_map;
use function MongoDB\is_pipeline;
use function MongoDB\server_supports_feature; use function MongoDB\server_supports_feature;
/** /**
...@@ -255,12 +256,18 @@ class FindAndModify implements Executable, Explainable ...@@ -255,12 +256,18 @@ class FindAndModify implements Executable, Explainable
$cmd['upsert'] = $this->options['upsert']; $cmd['upsert'] = $this->options['upsert'];
} }
foreach (['collation', 'fields', 'query', 'sort', 'update'] as $option) { foreach (['collation', 'fields', 'query', 'sort'] as $option) {
if (isset($this->options[$option])) { if (isset($this->options[$option])) {
$cmd[$option] = (object) $this->options[$option]; $cmd[$option] = (object) $this->options[$option];
} }
} }
if (isset($this->options['update'])) {
$cmd['update'] = is_pipeline($this->options['update'])
? $this->options['update']
: (object) $this->options['update'];
}
if (isset($this->options['arrayFilters'])) { if (isset($this->options['arrayFilters'])) {
$cmd['arrayFilters'] = $this->options['arrayFilters']; $cmd['arrayFilters'] = $this->options['arrayFilters'];
} }
......
...@@ -25,6 +25,7 @@ use function is_array; ...@@ -25,6 +25,7 @@ use function is_array;
use function is_integer; use function is_integer;
use function is_object; use function is_object;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/** /**
* Operation for updating a document with the findAndModify command. * Operation for updating a document with the findAndModify command.
...@@ -105,8 +106,8 @@ class FindOneAndUpdate implements Executable, Explainable ...@@ -105,8 +106,8 @@ class FindOneAndUpdate implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object'); throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
} }
if (! is_first_key_operator($update)) { if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator'); throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
} }
$options += [ $options += [
......
...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult; ...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array; use function is_array;
use function is_object; use function is_object;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/** /**
* Operation for replacing a single document with the update command. * Operation for replacing a single document with the update command.
...@@ -79,6 +80,10 @@ class ReplaceOne implements Executable ...@@ -79,6 +80,10 @@ class ReplaceOne implements Executable
throw new InvalidArgumentException('First key in $replacement argument is an update operator'); throw new InvalidArgumentException('First key in $replacement argument is an update operator');
} }
if (is_pipeline($replacement)) {
throw new InvalidArgumentException('$replacement argument is a pipeline');
}
$this->update = new Update( $this->update = new Update(
$databaseName, $databaseName,
$collectionName, $collectionName,
......
...@@ -29,6 +29,7 @@ use function is_array; ...@@ -29,6 +29,7 @@ use function is_array;
use function is_bool; use function is_bool;
use function is_object; use function is_object;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
use function MongoDB\server_supports_feature; use function MongoDB\server_supports_feature;
/** /**
...@@ -126,7 +127,7 @@ class Update implements Executable, Explainable ...@@ -126,7 +127,7 @@ class Update implements Executable, Explainable
throw InvalidArgumentException::invalidType('"multi" option', $options['multi'], 'boolean'); throw InvalidArgumentException::invalidType('"multi" option', $options['multi'], 'boolean');
} }
if ($options['multi'] && ! is_first_key_operator($update)) { if ($options['multi'] && ! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('"multi" option cannot be true if $update is a replacement document'); throw new InvalidArgumentException('"multi" option cannot be true if $update is a replacement document');
} }
......
...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult; ...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array; use function is_array;
use function is_object; use function is_object;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/** /**
* Operation for updating multiple documents with the update command. * Operation for updating multiple documents with the update command.
...@@ -81,8 +82,8 @@ class UpdateMany implements Executable, Explainable ...@@ -81,8 +82,8 @@ class UpdateMany implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object'); throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
} }
if (! is_first_key_operator($update)) { if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator'); throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
} }
$this->update = new Update( $this->update = new Update(
......
...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult; ...@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array; use function is_array;
use function is_object; use function is_object;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/** /**
* Operation for updating a single document with the update command. * Operation for updating a single document with the update command.
...@@ -81,8 +82,8 @@ class UpdateOne implements Executable, Explainable ...@@ -81,8 +82,8 @@ class UpdateOne implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object'); throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
} }
if (! is_first_key_operator($update)) { if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator'); throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
} }
$this->update = new Update( $this->update = new Update(
......
...@@ -119,6 +119,47 @@ function is_first_key_operator($document) ...@@ -119,6 +119,47 @@ function is_first_key_operator($document)
return isset($firstKey[0]) && $firstKey[0] === '$'; return isset($firstKey[0]) && $firstKey[0] === '$';
} }
/**
* Returns whether an update specification is a valid aggregation pipeline.
*
* @internal
* @param mixed $pipeline
* @return boolean
*/
function is_pipeline($pipeline)
{
if (! is_array($pipeline)) {
return false;
}
if ($pipeline === []) {
return false;
}
$expectedKey = 0;
foreach ($pipeline as $key => $stage) {
if (! is_array($stage) && ! is_object($stage)) {
return false;
}
if ($expectedKey !== $key) {
return false;
}
$expectedKey++;
$stage = (array) $stage;
reset($stage);
$key = key($stage);
if (! isset($key[0]) || $key[0] !== '$') {
return false;
}
}
return true;
}
/** /**
* Returns whether we are currently in a transaction. * Returns whether we are currently in a transaction.
* *
......
...@@ -10,6 +10,7 @@ use function MongoDB\create_field_path_type_map; ...@@ -10,6 +10,7 @@ use function MongoDB\create_field_path_type_map;
use function MongoDB\generate_index_name; use function MongoDB\generate_index_name;
use function MongoDB\is_first_key_operator; use function MongoDB\is_first_key_operator;
use function MongoDB\is_mapreduce_output_inline; use function MongoDB\is_mapreduce_output_inline;
use function MongoDB\is_pipeline;
/** /**
* Unit tests for utility functions. * Unit tests for utility functions.
...@@ -224,4 +225,33 @@ class FunctionsTest extends TestCase ...@@ -224,4 +225,33 @@ class FunctionsTest extends TestCase
], ],
]; ];
} }
/**
* @dataProvider providePipelines
*/
public function testIsPipeline($expected, $pipeline)
{
$this->assertSame($expected, is_pipeline($pipeline));
}
public function providePipelines()
{
return [
'Not an array' => [false, (object) []],
'Empty array' => [false, []],
'Non-sequential indexes in array' => [false, [1 => ['$group' => []]]],
'Update document instead of pipeline' => [false, ['$set' => ['foo' => 'bar']]],
'Invalid pipeline stage' => [false, [['group' => []]]],
'Update with DbRef' => [false, ['x' => ['$ref' => 'foo', '$id' => 'bar']]],
'Valid pipeline' => [
true,
[
['$match' => ['foo' => 'bar']],
['$group' => ['_id' => 1]],
],
],
'False positive with DbRef in numeric field' => [true, ['0' => ['$ref' => 'foo', '$id' => 'bar']]],
'DbRef in numeric field as object' => [false, (object) ['0' => ['$ref' => 'foo', '$id' => 'bar']]],
];
}
} }
...@@ -25,10 +25,10 @@ class FindOneAndUpdateTest extends TestCase ...@@ -25,10 +25,10 @@ class FindOneAndUpdateTest extends TestCase
new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], $update); new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], $update);
} }
public function testConstructorUpdateArgumentRequiresOperators() public function testConstructorUpdateArgumentRequiresOperatorsOrPipeline()
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('First key in $update argument is not an update operator'); $this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], []); new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], []);
} }
......
...@@ -41,7 +41,7 @@ class UpdateManyTest extends TestCase ...@@ -41,7 +41,7 @@ class UpdateManyTest extends TestCase
public function testConstructorUpdateArgumentRequiresOperators($replacement) public function testConstructorUpdateArgumentRequiresOperators($replacement)
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('First key in $update argument is not an update operator'); $this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
new UpdateMany($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement); new UpdateMany($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement);
} }
......
...@@ -41,7 +41,7 @@ class UpdateOneTest extends TestCase ...@@ -41,7 +41,7 @@ class UpdateOneTest extends TestCase
public function testConstructorUpdateArgumentRequiresOperators($replacement) public function testConstructorUpdateArgumentRequiresOperators($replacement)
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('First key in $update argument is not an update operator'); $this->expectExceptionMessage('Expected an update document with operator as first key or a pipeline');
new UpdateOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement); new UpdateOne($this->getDatabaseName(), $this->getCollectionName(), ['x' => 1], $replacement);
} }
......
...@@ -14,15 +14,6 @@ use function glob; ...@@ -14,15 +14,6 @@ use function glob;
*/ */
class CrudSpecTest extends FunctionalTestCase class CrudSpecTest extends FunctionalTestCase
{ {
/* These should all pass before the driver can be considered compatible with
* MongoDB 4.2. */
private static $incompleteTests = [
'bulkWrite-arrayFilters: BulkWrite with arrayFilters' => 'Fails due to command assertions',
'updateWithPipelines: UpdateOne using pipelines' => 'PHPLIB-418',
'updateWithPipelines: UpdateMany using pipelines' => 'PHPLIB-418',
'updateWithPipelines: FindOneAndUpdate using pipelines' => 'PHPLIB-418',
];
/** /**
* Assert that the expected and actual command documents match. * Assert that the expected and actual command documents match.
* *
...@@ -48,10 +39,6 @@ class CrudSpecTest extends FunctionalTestCase ...@@ -48,10 +39,6 @@ class CrudSpecTest extends FunctionalTestCase
*/ */
public function testCrud(stdClass $test, array $runOn = null, array $data, $databaseName = null, $collectionName = null) public function testCrud(stdClass $test, array $runOn = null, array $data, $databaseName = null, $collectionName = null)
{ {
if (isset(self::$incompleteTests[$this->dataDescription()])) {
$this->markTestIncomplete(self::$incompleteTests[$this->dataDescription()]);
}
if (isset($runOn)) { if (isset($runOn)) {
$this->checkServerRequirements($runOn); $this->checkServerRequirements($runOn);
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment