Skip to content

Commit c11783a

Browse files
alcaeusGromNaN
andauthored
Support update pipelines in query builder (#2881)
* Support pipeline updates in Query Builder * Add pipeline updates to query builder documentation * Fix phpstan errors * Fix changed exception message * Update tests/Tests/Query/PipelineUpdateTest.php Co-authored-by: Jérôme Tamarelle <[email protected]> --------- Co-authored-by: Jérôme Tamarelle <[email protected]>
1 parent 257a0a2 commit c11783a

File tree

5 files changed

+320
-19
lines changed

5 files changed

+320
-19
lines changed

docs/en/reference/query-builder-api.rst

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,8 +641,7 @@ change document field values atomically. Additionally if you are modifying a fie
641641
that is a reference you can pass managed document to the Builder and let ODM build
642642
``DBRef`` object for you.
643643

644-
You have several modifier operations
645-
available to you that make it easy to update documents in Mongo:
644+
The following atomic update operators are available through the builder API:
646645

647646
* ``set($name, $value, $atomic = true)``
648647
* ``setNewObj($newObj)``
@@ -655,6 +654,49 @@ available to you that make it easy to update documents in Mongo:
655654
* ``pull($field, $value)``
656655
* ``pullAll($field, array $valueArray)``
657656

657+
You can also run `updates with Aggregation Pipeline <https://www.mongodb.com/docs/manual/tutorial/update-documents-with-aggregation-pipeline/>`_
658+
by using the ``pipeline()`` method. You can pass an aggregation builder instance, a ``Pipeline`` instance from the
659+
MongoDB PHP library, or an array of pipeline stages:
660+
661+
.. code-block:: php
662+
663+
<?php
664+
665+
// The three following are equivalent ways to define the same update pipeline stage
666+
667+
$pipeline = $dm->createAggregationBuilder(User::class)
668+
->set()
669+
->field('totalScore')
670+
->add('$score1', '$score2'),
671+
);
672+
673+
$pipeline = new Pipeline(
674+
Stage::set(
675+
totalScore: Expression::add(
676+
Expression::fieldPath('score1'),
677+
Expression::fieldPath('score2'),
678+
),
679+
)
680+
);
681+
682+
$pipeline = [
683+
['$set' => [
684+
'totalScore' => ['$add' => ['$score1', '$score2']],
685+
]],
686+
]
687+
688+
$dm->createQueryBuilder(User::class)
689+
->updateOne()
690+
->field('username')->equals('jwage')
691+
->pipeline($pipeline)
692+
->getQuery()
693+
->execute();
694+
695+
.. note::
696+
697+
Pipeline updates are only available for ``updateOne``, ``updateMany``, and ``findAndUpdate`` operations.
698+
699+
658700
Updating multiple documents
659701
---------------------------
660702

phpstan-baseline.neon

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ parameters:
787787
path: src/Query/Query.php
788788

789789
-
790-
message: '#^Strict comparison using \!\=\= between array\<string, mixed\>\|bool\|int\|MongoDB\\Driver\\ReadPreference\|string and null will always evaluate to true\.$#'
790+
message: '#^Strict comparison using \!\=\= between array\<int\<0, max\>\|string, mixed\>\|bool\|int\|MongoDB\\Builder\\Pipeline\|MongoDB\\Driver\\ReadPreference\|string and null will always evaluate to true\.$#'
791791
identifier: notIdentical.alwaysTrue
792792
count: 1
793793
path: src/Query/Query.php
@@ -1098,14 +1098,20 @@ parameters:
10981098
count: 1
10991099
path: tests/Tests/Query/BuilderTest.php
11001100

1101+
-
1102+
message: '#^Unreachable statement \- code above always terminates\.$#'
1103+
identifier: deadCode.unreachable
1104+
count: 1
1105+
path: tests/Tests/Query/PipelineUpdateTest.php
1106+
11011107
-
11021108
message: '#^Method Doctrine\\ODM\\MongoDB\\Tests\\QueryTest\:\:createCursorMock\(\) return type has no value type specified in iterable type Traversable\.$#'
11031109
identifier: missingType.iterableValue
11041110
count: 1
11051111
path: tests/Tests/QueryTest.php
11061112

11071113
-
1108-
message: '#^Parameter \#4 \$query of class Doctrine\\ODM\\MongoDB\\Query\\Query constructor expects array\{distinct\?\: string, hint\?\: array\<string, \-1\|1\>\|string, limit\?\: int, maxTimeMS\?\: int, multiple\?\: bool, new\?\: bool, newObj\?\: array\<string, mixed\>, query\?\: array\<string, mixed\>, \.\.\.\}, array\{type\: \-1\} given\.$#'
1114+
message: '#^Parameter \#4 \$query of class Doctrine\\ODM\\MongoDB\\Query\\Query constructor expects array\{distinct\?\: string, hint\?\: array\<string, \-1\|1\>\|string, limit\?\: int, maxTimeMS\?\: int, multiple\?\: bool, new\?\: bool, newObj\?\: array\<string, mixed\>, pipeline\?\: list\<array\<string, mixed\>\>\|MongoDB\\Builder\\Pipeline, \.\.\.\}, array\{type\: \-1\} given\.$#'
11091115
identifier: argument.type
11101116
count: 1
11111117
path: tests/Tests/QueryTest.php

src/Query/Builder.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Doctrine\ODM\MongoDB\Query;
66

77
use BadMethodCallException;
8+
use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder;
89
use Doctrine\ODM\MongoDB\Aggregation\Stage\Sort;
910
use Doctrine\ODM\MongoDB\DocumentManager;
1011
use Doctrine\ODM\MongoDB\Iterator\IterableResult;
@@ -14,6 +15,7 @@
1415
use InvalidArgumentException;
1516
use MongoDB\BSON\Binary;
1617
use MongoDB\BSON\Javascript;
18+
use MongoDB\Builder\Pipeline;
1719
use MongoDB\Collection;
1820
use MongoDB\Driver\ReadPreference;
1921

@@ -1064,6 +1066,25 @@ public function notIn(array $values): self
10641066
return $this;
10651067
}
10661068

1069+
/**
1070+
* Specifies a pipeline to be used for updates. The pipeline can be an aggregation builder, MongoDB pipeline
1071+
* instance, or an array of pipeline stages.
1072+
*
1073+
* @param AggregationBuilder|Pipeline|list<array<string, mixed>> $pipeline
1074+
*/
1075+
public function pipeline(AggregationBuilder|array|Pipeline $pipeline): self
1076+
{
1077+
if ($this->query['type'] !== Query::TYPE_UPDATE && $this->query['type'] !== Query::TYPE_FIND_AND_UPDATE) {
1078+
throw new BadMethodCallException('The pipeline() method can only be used with update or findAndUpdate queries.');
1079+
}
1080+
1081+
$this->query['pipeline'] = $pipeline instanceof AggregationBuilder
1082+
? $pipeline->getPipeline()
1083+
: $pipeline;
1084+
1085+
return $this;
1086+
}
1087+
10671088
/**
10681089
* Remove the first element from the current array field.
10691090
*

src/Query/Query.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Doctrine\ODM\MongoDB\MongoDBException;
1818
use Doctrine\ODM\MongoDB\UnitOfWork;
1919
use InvalidArgumentException;
20+
use MongoDB\Builder\Pipeline;
2021
use MongoDB\Collection;
2122
use MongoDB\DeleteResult;
2223
use MongoDB\Driver\ReadPreference;
@@ -51,6 +52,7 @@
5152
* multiple?: bool,
5253
* new?: bool,
5354
* newObj?: array<string, mixed>,
55+
* pipeline?: Pipeline|list<array<string, mixed>>,
5456
* query?: array<string, mixed>,
5557
* readPreference?: ReadPreference,
5658
* select?: array<string, 0|1|array<string, mixed>>,
@@ -459,11 +461,17 @@ private function runQuery()
459461
$queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
460462
$queryOptions['returnDocument'] = $this->query['new'] ?? false ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE;
461463

462-
$operation = $this->isFirstKeyUpdateOperator() ? 'findOneAndUpdate' : 'findOneAndReplace';
464+
if (isset($this->query['pipeline'])) {
465+
$operation = 'findOneAndUpdate';
466+
$update = $this->query['pipeline'];
467+
} else {
468+
$operation = $this->isFirstKeyUpdateOperator() ? 'findOneAndUpdate' : 'findOneAndReplace';
469+
$update = $this->query['newObj'];
470+
}
463471

464472
return $this->collection->{$operation}(
465473
$this->query['query'],
466-
$this->query['newObj'],
474+
$update,
467475
array_merge($options, $queryOptions)
468476
);
469477

@@ -480,29 +488,23 @@ private function runQuery()
480488
return $this->collection->insertOne($this->query['newObj'], $options);
481489

482490
case self::TYPE_UPDATE:
483-
$multiple = $this->query['multiple'] ?? false;
491+
$multiple = $this->query['multiple'] ?? false;
492+
$operation = $multiple ? 'updateMany' : 'updateOne';
493+
$update = $this->query['newObj'];
484494

485-
if ($this->isFirstKeyUpdateOperator()) {
486-
$operation = 'updateOne';
487-
} else {
495+
if (isset($this->query['pipeline'])) {
496+
$update = $this->query['pipeline'];
497+
} elseif (! $this->isFirstKeyUpdateOperator()) {
488498
if ($multiple) {
489499
throw new InvalidArgumentException('Combining the "multiple" option without using an update operator as first operation in a query is not supported.');
490500
}
491501

492502
$operation = 'replaceOne';
493503
}
494504

495-
if ($multiple) {
496-
return $this->collection->updateMany(
497-
$this->query['query'],
498-
$this->query['newObj'],
499-
array_merge($options, $this->getQueryOptions('upsert')),
500-
);
501-
}
502-
503505
return $this->collection->{$operation}(
504506
$this->query['query'],
505-
$this->query['newObj'],
507+
$update,
506508
array_merge($options, $this->getQueryOptions('upsert'))
507509
);
508510

0 commit comments

Comments
 (0)