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

parent 67ffd336
......@@ -24,7 +24,9 @@ type: array|object
description: |
Specifies the field and value combinations to update and any relevant update
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
operation: ~
optional: false
......
......@@ -31,6 +31,7 @@ use function is_bool;
use function is_integer;
use function is_object;
use function MongoDB\create_field_path_type_map;
use function MongoDB\is_pipeline;
use function MongoDB\server_supports_feature;
/**
......@@ -255,12 +256,18 @@ class FindAndModify implements Executable, Explainable
$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])) {
$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'])) {
$cmd['arrayFilters'] = $this->options['arrayFilters'];
}
......
......@@ -25,6 +25,7 @@ use function is_array;
use function is_integer;
use function is_object;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/**
* Operation for updating a document with the findAndModify command.
......@@ -105,8 +106,8 @@ class FindOneAndUpdate implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
}
if (! is_first_key_operator($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator');
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
}
$options += [
......
......@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array;
use function is_object;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/**
* Operation for replacing a single document with the update command.
......@@ -79,6 +80,10 @@ class ReplaceOne implements Executable
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(
$databaseName,
$collectionName,
......
......@@ -29,6 +29,7 @@ use function is_array;
use function is_bool;
use function is_object;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
use function MongoDB\server_supports_feature;
/**
......@@ -126,7 +127,7 @@ class Update implements Executable, Explainable
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');
}
......
......@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array;
use function is_object;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/**
* Operation for updating multiple documents with the update command.
......@@ -81,8 +82,8 @@ class UpdateMany implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
}
if (! is_first_key_operator($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator');
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
}
$this->update = new Update(
......
......@@ -25,6 +25,7 @@ use MongoDB\UpdateResult;
use function is_array;
use function is_object;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_pipeline;
/**
* Operation for updating a single document with the update command.
......@@ -81,8 +82,8 @@ class UpdateOne implements Executable, Explainable
throw InvalidArgumentException::invalidType('$update', $update, 'array or object');
}
if (! is_first_key_operator($update)) {
throw new InvalidArgumentException('First key in $update argument is not an update operator');
if (! is_first_key_operator($update) && ! is_pipeline($update)) {
throw new InvalidArgumentException('Expected an update document with operator as first key or a pipeline');
}
$this->update = new Update(
......
......@@ -119,6 +119,47 @@ function is_first_key_operator($document)
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.
*
......
......@@ -10,6 +10,7 @@ use function MongoDB\create_field_path_type_map;
use function MongoDB\generate_index_name;
use function MongoDB\is_first_key_operator;
use function MongoDB\is_mapreduce_output_inline;
use function MongoDB\is_pipeline;
/**
* Unit tests for utility functions.
......@@ -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
new FindOneAndUpdate($this->getDatabaseName(), $this->getCollectionName(), [], $update);
}
public function testConstructorUpdateArgumentRequiresOperators()
public function testConstructorUpdateArgumentRequiresOperatorsOrPipeline()
{
$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(), [], []);
}
......
......@@ -41,7 +41,7 @@ class UpdateManyTest extends TestCase
public function testConstructorUpdateArgumentRequiresOperators($replacement)
{
$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);
}
......
......@@ -41,7 +41,7 @@ class UpdateOneTest extends TestCase
public function testConstructorUpdateArgumentRequiresOperators($replacement)
{
$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);
}
......
......@@ -14,15 +14,6 @@ use function glob;
*/
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.
*
......@@ -48,10 +39,6 @@ class CrudSpecTest extends FunctionalTestCase
*/
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)) {
$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