Completed
Push — master ( a8fe50...bce26f )
by Maciej
13s
created

ODM/MongoDB/Persisters/DocumentPersister.php (4 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ODM\MongoDB\Persisters;
6
7
use Doctrine\Common\Persistence\Mapping\MappingException;
8
use Doctrine\ODM\MongoDB\DocumentManager;
9
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
10
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
11
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
12
use Doctrine\ODM\MongoDB\Iterator\Iterator;
13
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
14
use Doctrine\ODM\MongoDB\LockException;
15
use Doctrine\ODM\MongoDB\LockMode;
16
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
17
use Doctrine\ODM\MongoDB\MongoDBException;
18
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
19
use Doctrine\ODM\MongoDB\Proxy\Proxy;
20
use Doctrine\ODM\MongoDB\Query\CriteriaMerger;
21
use Doctrine\ODM\MongoDB\Query\Query;
22
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
23
use Doctrine\ODM\MongoDB\Types\Type;
24
use Doctrine\ODM\MongoDB\UnitOfWork;
25
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
26
use MongoDB\BSON\ObjectId;
27
use MongoDB\Collection;
28
use MongoDB\Driver\Cursor;
29
use MongoDB\Driver\Exception\Exception as DriverException;
30
use MongoDB\Driver\Exception\WriteException;
31
use MongoDB\GridFS\Bucket;
32
use function array_combine;
33
use function array_fill;
34
use function array_intersect_key;
35
use function array_keys;
36
use function array_map;
37
use function array_merge;
38
use function array_search;
39
use function array_slice;
40
use function array_values;
41
use function count;
42
use function explode;
43
use function get_class;
44
use function get_object_vars;
45
use function implode;
46
use function in_array;
47
use function is_array;
48
use function is_object;
49
use function is_scalar;
50
use function max;
51
use function spl_object_hash;
52
use function sprintf;
53
use function strpos;
54
use function strtolower;
55
56
/**
57
 * The DocumentPersister is responsible for persisting documents.
58
 *
59
 */
60
class DocumentPersister
61
{
62
    /** @var PersistenceBuilder */
63
    private $pb;
64
65
    /** @var DocumentManager */
66
    private $dm;
67
68
    /** @var UnitOfWork */
69
    private $uow;
70
71
    /** @var ClassMetadata */
72
    private $class;
73
74
    /** @var Collection */
75
    private $collection;
76
77
    /** @var Bucket|null */
78
    private $bucket;
79
80
    /**
81
     * Array of queued inserts for the persister to insert.
82
     *
83
     * @var array
84
     */
85
    private $queuedInserts = [];
86
87
    /**
88
     * Array of queued inserts for the persister to insert.
89
     *
90
     * @var array
91
     */
92
    private $queuedUpserts = [];
93
94
    /** @var CriteriaMerger */
95
    private $cm;
96
97
    /** @var CollectionPersister */
98
    private $cp;
99
100
    /** @var HydratorFactory */
101
    private $hydratorFactory;
102
103 1093
    public function __construct(
104
        PersistenceBuilder $pb,
105
        DocumentManager $dm,
106
        UnitOfWork $uow,
107
        HydratorFactory $hydratorFactory,
108
        ClassMetadata $class,
109
        ?CriteriaMerger $cm = null
110
    ) {
111 1093
        $this->pb = $pb;
112 1093
        $this->dm = $dm;
113 1093
        $this->cm = $cm ?: new CriteriaMerger();
114 1093
        $this->uow = $uow;
115 1093
        $this->hydratorFactory = $hydratorFactory;
116 1093
        $this->class = $class;
117 1093
        $this->collection = $dm->getDocumentCollection($class->name);
118 1093
        $this->cp = $this->uow->getCollectionPersister();
119
120 1093
        if (! $class->isFile) {
121 1085
            return;
122
        }
123
124 10
        $this->bucket = $dm->getDocumentBucket($class->name);
125 10
    }
126
127
    public function getInserts(): array
128
    {
129
        return $this->queuedInserts;
130
    }
131
132
    public function isQueuedForInsert(object $document): bool
133
    {
134
        return isset($this->queuedInserts[spl_object_hash($document)]);
135
    }
136
137
    /**
138
     * Adds a document to the queued insertions.
139
     * The document remains queued until {@link executeInserts} is invoked.
140
     */
141 485
    public function addInsert(object $document): void
142
    {
143 485
        $this->queuedInserts[spl_object_hash($document)] = $document;
144 485
    }
145
146
    public function getUpserts(): array
147
    {
148
        return $this->queuedUpserts;
149
    }
150
151
    public function isQueuedForUpsert(object $document): bool
152
    {
153
        return isset($this->queuedUpserts[spl_object_hash($document)]);
154
    }
155
156
    /**
157
     * Adds a document to the queued upserts.
158
     * The document remains queued until {@link executeUpserts} is invoked.
159
     */
160 83
    public function addUpsert(object $document): void
161
    {
162 83
        $this->queuedUpserts[spl_object_hash($document)] = $document;
163 83
    }
164
165
    /**
166
     * Gets the ClassMetadata instance of the document class this persister is used for.
167
     */
168
    public function getClassMetadata(): ClassMetadata
169
    {
170
        return $this->class;
171
    }
172
173
    /**
174
     * Executes all queued document insertions.
175
     *
176
     * Queued documents without an ID will inserted in a batch and queued
177
     * documents with an ID will be upserted individually.
178
     *
179
     * If no inserts are queued, invoking this method is a NOOP.
180
     *
181
     * @throws DriverException
182
     */
183 485
    public function executeInserts(array $options = []): void
184
    {
185 485
        if (! $this->queuedInserts) {
186
            return;
187
        }
188
189 485
        $inserts = [];
190 485
        $options = $this->getWriteOptions($options);
191 485
        foreach ($this->queuedInserts as $oid => $document) {
192 485
            $data = $this->pb->prepareInsertData($document);
193
194
            // Set the initial version for each insert
195 484
            if ($this->class->isVersioned) {
196 20
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
197 20
                $nextVersion = null;
198 20
                if ($versionMapping['type'] === 'int') {
199 18
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
200 18
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
201 2
                } elseif ($versionMapping['type'] === 'date') {
202 2
                    $nextVersionDateTime = new \DateTime();
203 2
                    $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
204 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
205
                }
206 20
                $data[$versionMapping['name']] = $nextVersion;
207
            }
208
209 484
            $inserts[] = $data;
210
        }
211
212 484
        if ($inserts) {
213
            try {
214 484
                $this->collection->insertMany($inserts, $options);
215 6
            } catch (DriverException $e) {
216 6
                $this->queuedInserts = [];
217 6
                throw $e;
218
            }
219
        }
220
221
        /* All collections except for ones using addToSet have already been
222
         * saved. We have left these to be handled separately to avoid checking
223
         * collection for uniqueness on PHP side.
224
         */
225 484
        foreach ($this->queuedInserts as $document) {
226 484
            $this->handleCollections($document, $options);
227
        }
228
229 484
        $this->queuedInserts = [];
230 484
    }
231
232
    /**
233
     * Executes all queued document upserts.
234
     *
235
     * Queued documents with an ID are upserted individually.
236
     *
237
     * If no upserts are queued, invoking this method is a NOOP.
238
     */
239 83
    public function executeUpserts(array $options = []): void
240
    {
241 83
        if (! $this->queuedUpserts) {
242
            return;
243
        }
244
245 83
        $options = $this->getWriteOptions($options);
246 83
        foreach ($this->queuedUpserts as $oid => $document) {
247
            try {
248 83
                $this->executeUpsert($document, $options);
249 83
                $this->handleCollections($document, $options);
250 83
                unset($this->queuedUpserts[$oid]);
251
            } catch (WriteException $e) {
252
                unset($this->queuedUpserts[$oid]);
253 83
                throw $e;
254
            }
255
        }
256 83
    }
257
258
    /**
259
     * Executes a single upsert in {@link executeUpserts}
260
     */
261 83
    private function executeUpsert(object $document, array $options): void
262
    {
263 83
        $options['upsert'] = true;
264 83
        $criteria = $this->getQueryForDocument($document);
265
266 83
        $data = $this->pb->prepareUpsertData($document);
267
268
        // Set the initial version for each upsert
269 83
        if ($this->class->isVersioned) {
270 2
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
271 2
            $nextVersion = null;
272 2
            if ($versionMapping['type'] === 'int') {
273 1
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
274 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
275 1
            } elseif ($versionMapping['type'] === 'date') {
276 1
                $nextVersionDateTime = new \DateTime();
277 1
                $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
278 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
279
            }
280 2
            $data['$set'][$versionMapping['name']] = $nextVersion;
281
        }
282
283 83
        foreach (array_keys($criteria) as $field) {
284 83
            unset($data['$set'][$field]);
285 83
            unset($data['$inc'][$field]);
286 83
            unset($data['$setOnInsert'][$field]);
287
        }
288
289
        // Do not send empty update operators
290 83
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
291 83
            if (! empty($data[$operator])) {
292 68
                continue;
293
            }
294
295 83
            unset($data[$operator]);
296
        }
297
298
        /* If there are no modifiers remaining, we're upserting a document with
299
         * an identifier as its only field. Since a document with the identifier
300
         * may already exist, the desired behavior is "insert if not exists" and
301
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
302
         * the identifier to the same value in our criteria.
303
         *
304
         * This will fail for versions before MongoDB 2.6, which require an
305
         * empty $set modifier. The best we can do (without attempting to check
306
         * server versions in advance) is attempt the 2.6+ behavior and retry
307
         * after the relevant exception.
308
         *
309
         * See: https://jira.mongodb.org/browse/SERVER-12266
310
         */
311 83
        if (empty($data)) {
312 16
            $retry = true;
313 16
            $data = ['$set' => ['_id' => $criteria['_id']]];
314
        }
315
316
        try {
317 83
            $this->collection->updateOne($criteria, $data, $options);
318 83
            return;
319
        } catch (WriteException $e) {
320
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
321
                throw $e;
322
            }
323
        }
324
325
        $this->collection->updateOne($criteria, ['$set' => new \stdClass()], $options);
326
    }
327
328
    /**
329
     * Updates the already persisted document if it has any new changesets.
330
     *
331
     * @throws LockException
332
     */
333 197
    public function update(object $document, array $options = []): void
334
    {
335 197
        $update = $this->pb->prepareUpdateData($document);
336
337 197
        $query = $this->getQueryForDocument($document);
338
339 197
        foreach (array_keys($query) as $field) {
340 197
            unset($update['$set'][$field]);
341
        }
342
343 197
        if (empty($update['$set'])) {
344 90
            unset($update['$set']);
345
        }
346
347
        // Include versioning logic to set the new version value in the database
348
        // and to ensure the version has not changed since this document object instance
349
        // was fetched from the database
350 197
        $nextVersion = null;
351 197
        if ($this->class->isVersioned) {
352 13
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
353 13
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
354 13
            if ($versionMapping['type'] === 'int') {
355 10
                $nextVersion = $currentVersion + 1;
356 10
                $update['$inc'][$versionMapping['name']] = 1;
357 10
                $query[$versionMapping['name']] = $currentVersion;
358 3
            } elseif ($versionMapping['type'] === 'date') {
359 3
                $nextVersion = new \DateTime();
360 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
361 3
                $query[$versionMapping['name']] = Type::convertPHPToDatabaseValue($currentVersion);
362
            }
363
        }
364
365 197
        if (! empty($update)) {
366
            // Include locking logic so that if the document object in memory is currently
367
            // locked then it will remove it, otherwise it ensures the document is not locked.
368 130
            if ($this->class->isLockable) {
369 11
                $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document);
370 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
371 11
                if ($isLocked) {
372 2
                    $update['$unset'] = [$lockMapping['name'] => true];
373
                } else {
374 9
                    $query[$lockMapping['name']] = ['$exists' => false];
375
                }
376
            }
377
378 130
            $options = $this->getWriteOptions($options);
379
380 130
            $result = $this->collection->updateOne($query, $update, $options);
381
382 130
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
383 4
                throw LockException::lockFailed($document);
384 126
            } elseif ($this->class->isVersioned) {
385 9
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
386
            }
387
        }
388
389 193
        $this->handleCollections($document, $options);
390 193
    }
391
392
    /**
393
     * Removes document from mongo
394
     *
395
     * @throws LockException
396
     */
397 35
    public function delete(object $document, array $options = []): void
398
    {
399 35
        if ($this->bucket instanceof Bucket) {
400 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
401 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
402
403 1
            $this->bucket->delete($databaseIdentifier);
404
405 1
            return;
406
        }
407
408 34
        $query = $this->getQueryForDocument($document);
409
410 34
        if ($this->class->isLockable) {
411 2
            $query[$this->class->lockField] = ['$exists' => false];
412
        }
413
414 34
        $options = $this->getWriteOptions($options);
415
416 34
        $result = $this->collection->deleteOne($query, $options);
417
418 34
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
419 2
            throw LockException::lockFailed($document);
420
        }
421 32
    }
422
423
    /**
424
     * Refreshes a managed document.
425
     */
426 22
    public function refresh(object $document): void
427
    {
428 22
        $query = $this->getQueryForDocument($document);
429 22
        $data = $this->collection->findOne($query);
430 22
        $data = $this->hydratorFactory->hydrate($document, $data);
0 ignored issues
show
It seems like $data can also be of type null or object; however, Doctrine\ODM\MongoDB\Hyd...ratorFactory::hydrate() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
431 22
        $this->uow->setOriginalDocumentData($document, $data);
432 22
    }
433
434
    /**
435
     * Finds a document by a set of criteria.
436
     *
437
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
438
     * be used to match an _id value.
439
     *
440
     * @param mixed $criteria Query criteria
441
     * @throws LockException
442
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
443
     */
444 352
    public function load($criteria, ?object $document = null, array $hints = [], int $lockMode = 0, ?array $sort = null): ?object
445
    {
446
        // TODO: remove this
447 352
        if ($criteria === null || is_scalar($criteria) || $criteria instanceof ObjectId) {
448
            $criteria = ['_id' => $criteria];
449
        }
450
451 352
        $criteria = $this->prepareQueryOrNewObj($criteria);
452 352
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
453 352
        $criteria = $this->addFilterToPreparedQuery($criteria);
454
455 352
        $options = [];
456 352
        if ($sort !== null) {
457 92
            $options['sort'] = $this->prepareSort($sort);
458
        }
459 352
        $result = $this->collection->findOne($criteria, $options);
460
461 352
        if ($this->class->isLockable) {
462 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
463 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
464 1
                throw LockException::lockFailed($document);
465
            }
466
        }
467
468 351
        return $this->createDocument($result, $document, $hints);
0 ignored issues
show
It seems like $result defined by $this->collection->findOne($criteria, $options) on line 459 can also be of type array or null; however, Doctrine\ODM\MongoDB\Per...ister::createDocument() does only seem to accept object, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
469
    }
470
471
    /**
472
     * Finds documents by a set of criteria.
473
     */
474 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null): Iterator
475
    {
476 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
477 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
478 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
479
480 22
        $options = [];
481 22
        if ($sort !== null) {
482 11
            $options['sort'] = $this->prepareSort($sort);
483
        }
484
485 22
        if ($limit !== null) {
486 10
            $options['limit'] = $limit;
487
        }
488
489 22
        if ($skip !== null) {
490 1
            $options['skip'] = $skip;
491
        }
492
493 22
        $baseCursor = $this->collection->find($criteria, $options);
494 22
        $cursor = $this->wrapCursor($baseCursor);
495
496 22
        return $cursor;
497
    }
498
499
    /**
500
     * @throws MongoDBException
501
     */
502 273
    private function getShardKeyQuery(object $document): array
503
    {
504 273
        if (! $this->class->isSharded()) {
505 269
            return [];
506
        }
507
508 4
        $shardKey = $this->class->getShardKey();
509 4
        $keys = array_keys($shardKey['keys']);
510 4
        $data = $this->uow->getDocumentActualData($document);
511
512 4
        $shardKeyQueryPart = [];
513 4
        foreach ($keys as $key) {
514 4
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
515 4
            $this->guardMissingShardKey($document, $key, $data);
516
517 4
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
518 1
                $reference = $this->prepareReference(
519 1
                    $key,
520 1
                    $data[$mapping['fieldName']],
521 1
                    $mapping,
522 1
                    false
523
                );
524 1
                foreach ($reference as $keyValue) {
525 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
526
                }
527
            } else {
528 3
                $value = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
529 4
                $shardKeyQueryPart[$key] = $value;
530
            }
531
        }
532
533 4
        return $shardKeyQueryPart;
534
    }
535
536
    /**
537
     * Wraps the supplied base cursor in the corresponding ODM class.
538
     */
539 22
    private function wrapCursor(Cursor $baseCursor): Iterator
540
    {
541 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
542
    }
543
544
    /**
545
     * Checks whether the given managed document exists in the database.
546
     */
547 3
    public function exists(object $document): bool
548
    {
549 3
        $id = $this->class->getIdentifierObject($document);
550 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
551
    }
552
553
    /**
554
     * Locks document by storing the lock mode on the mapped lock field.
555
     */
556 5
    public function lock(object $document, int $lockMode): void
557
    {
558 5
        $id = $this->uow->getDocumentIdentifier($document);
559 5
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
560 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
561 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
562 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
563 5
    }
564
565
    /**
566
     * Releases any lock that exists on this document.
567
     *
568
     */
569 1
    public function unlock(object $document): void
570
    {
571 1
        $id = $this->uow->getDocumentIdentifier($document);
572 1
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
573 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
574 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
575 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
576 1
    }
577
578
    /**
579
     * Creates or fills a single document object from an query result.
580
     *
581
     * @param object $result   The query result.
582
     * @param object $document The document object to fill, if any.
583
     * @param array  $hints    Hints for document creation.
584
     * @return object The filled and managed document object or NULL, if the query result is empty.
585
     */
586 351
    private function createDocument($result, ?object $document = null, array $hints = []): ?object
587
    {
588 351
        if ($result === null) {
589 113
            return null;
590
        }
591
592 307
        if ($document !== null) {
593 28
            $hints[Query::HINT_REFRESH] = true;
594 28
            $id = $this->class->getPHPIdentifierValue($result['_id']);
595 28
            $this->uow->registerManaged($document, $id, $result);
0 ignored issues
show
$result is of type object, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
596
        }
597
598 307
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
0 ignored issues
show
$result is of type object, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
599
    }
600
601
    /**
602
     * Loads a PersistentCollection data. Used in the initialize() method.
603
     */
604 164
    public function loadCollection(PersistentCollectionInterface $collection): void
605
    {
606 164
        $mapping = $collection->getMapping();
607 164
        switch ($mapping['association']) {
608
            case ClassMetadata::EMBED_MANY:
609 109
                $this->loadEmbedManyCollection($collection);
610 109
                break;
611
612
            case ClassMetadata::REFERENCE_MANY:
613 77
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
614 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
615
                } else {
616 73
                    if ($mapping['isOwningSide']) {
617 61
                        $this->loadReferenceManyCollectionOwningSide($collection);
618
                    } else {
619 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
620
                    }
621
                }
622 77
                break;
623
        }
624 164
    }
625
626 109
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection): void
627
    {
628 109
        $embeddedDocuments = $collection->getMongoData();
629 109
        $mapping = $collection->getMapping();
630 109
        $owner = $collection->getOwner();
631 109
        if (! $embeddedDocuments) {
632 58
            return;
633
        }
634
635 82
        foreach ($embeddedDocuments as $key => $embeddedDocument) {
636 82
            $className = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
637 82
            $embeddedMetadata = $this->dm->getClassMetadata($className);
638 82
            $embeddedDocumentObject = $embeddedMetadata->newInstance();
639
640 82
            $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
641
642 82
            $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
643 82
            $id = $data[$embeddedMetadata->identifier] ?? null;
644
645 82
            if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
646 81
                $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
647
            }
648 82
            if (CollectionHelper::isHash($mapping['strategy'])) {
649 10
                $collection->set($key, $embeddedDocumentObject);
650
            } else {
651 82
                $collection->add($embeddedDocumentObject);
652
            }
653
        }
654 82
    }
655
656 61
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection): void
657
    {
658 61
        $hints = $collection->getHints();
659 61
        $mapping = $collection->getMapping();
660 61
        $groupedIds = [];
661
662 61
        $sorted = isset($mapping['sort']) && $mapping['sort'];
663
664 61
        foreach ($collection->getMongoData() as $key => $reference) {
665 55
            $className = $this->uow->getClassNameForAssociation($mapping, $reference);
666 55
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
667 55
            $id = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
668
669
            // create a reference to the class and id
670 55
            $reference = $this->dm->getReference($className, $id);
671
672
            // no custom sort so add the references right now in the order they are embedded
673 55
            if (! $sorted) {
674 54
                if (CollectionHelper::isHash($mapping['strategy'])) {
675 2
                    $collection->set($key, $reference);
676
                } else {
677 52
                    $collection->add($reference);
678
                }
679
            }
680
681
            // only query for the referenced object if it is not already initialized or the collection is sorted
682 55
            if (! (($reference instanceof Proxy && ! $reference->__isInitialized__)) && ! $sorted) {
683 22
                continue;
684
            }
685
686 40
            $groupedIds[$className][] = $identifier;
687
        }
688 61
        foreach ($groupedIds as $className => $ids) {
689 40
            $class = $this->dm->getClassMetadata($className);
690 40
            $mongoCollection = $this->dm->getDocumentCollection($className);
691 40
            $criteria = $this->cm->merge(
692 40
                ['_id' => ['$in' => array_values($ids)]],
693 40
                $this->dm->getFilterCollection()->getFilterCriteria($class),
694 40
                $mapping['criteria'] ?? []
695
            );
696 40
            $criteria = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
697
698 40
            $options = [];
699 40
            if (isset($mapping['sort'])) {
700 40
                $options['sort'] = $this->prepareSort($mapping['sort']);
701
            }
702 40
            if (isset($mapping['limit'])) {
703
                $options['limit'] = $mapping['limit'];
704
            }
705 40
            if (isset($mapping['skip'])) {
706
                $options['skip'] = $mapping['skip'];
707
            }
708 40
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
709
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
710
            }
711
712 40
            $cursor = $mongoCollection->find($criteria, $options);
713 40
            $documents = $cursor->toArray();
714 40
            foreach ($documents as $documentData) {
715 39
                $document = $this->uow->getById($documentData['_id'], $class);
716 39
                if ($document instanceof Proxy && ! $document->__isInitialized()) {
717 39
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
718 39
                    $this->uow->setOriginalDocumentData($document, $data);
719 39
                    $document->__isInitialized__ = true;
720
                }
721 39
                if (! $sorted) {
722 38
                    continue;
723
                }
724
725 40
                $collection->add($document);
726
            }
727
        }
728 61
    }
729
730 17
    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection): void
731
    {
732 17
        $query = $this->createReferenceManyInverseSideQuery($collection);
733 17
        $documents = $query->execute()->toArray();
734 17
        foreach ($documents as $key => $document) {
735 16
            $collection->add($document);
736
        }
737 17
    }
738
739 17
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection): Query
740
    {
741 17
        $hints = $collection->getHints();
742 17
        $mapping = $collection->getMapping();
743 17
        $owner = $collection->getOwner();
744 17
        $ownerClass = $this->dm->getClassMetadata(get_class($owner));
745 17
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
746 17
        $mappedByMapping = $targetClass->fieldMappings[$mapping['mappedBy']] ?? [];
747 17
        $mappedByFieldName = ClassMetadata::getReferenceFieldName($mappedByMapping['storeAs'] ?? ClassMetadata::REFERENCE_STORE_AS_DB_REF, $mapping['mappedBy']);
748
749 17
        $criteria = $this->cm->merge(
750 17
            [$mappedByFieldName => $ownerClass->getIdentifierObject($owner)],
751 17
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
752 17
            $mapping['criteria'] ?? []
753
        );
754 17
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
755 17
        $qb = $this->dm->createQueryBuilder($mapping['targetDocument'])
756 17
            ->setQueryArray($criteria);
757
758 17
        if (isset($mapping['sort'])) {
759 17
            $qb->sort($mapping['sort']);
760
        }
761 17
        if (isset($mapping['limit'])) {
762 2
            $qb->limit($mapping['limit']);
763
        }
764 17
        if (isset($mapping['skip'])) {
765
            $qb->skip($mapping['skip']);
766
        }
767
768 17
        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
769
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
770
        }
771
772 17
        foreach ($mapping['prime'] as $field) {
773 4
            $qb->field($field)->prime(true);
774
        }
775
776 17
        return $qb->getQuery();
777
    }
778
779 5
    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection): void
780
    {
781 5
        $cursor = $this->createReferenceManyWithRepositoryMethodCursor($collection);
782 5
        $mapping = $collection->getMapping();
783 5
        $documents = $cursor->toArray();
784 5
        foreach ($documents as $key => $obj) {
785 5
            if (CollectionHelper::isHash($mapping['strategy'])) {
786 1
                $collection->set($key, $obj);
787
            } else {
788 5
                $collection->add($obj);
789
            }
790
        }
791 5
    }
792
793 5
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection): Iterator
794
    {
795 5
        $mapping = $collection->getMapping();
796 5
        $repositoryMethod = $mapping['repositoryMethod'];
797 5
        $cursor = $this->dm->getRepository($mapping['targetDocument'])
798 5
            ->$repositoryMethod($collection->getOwner());
799
800 5
        if (! $cursor instanceof Iterator) {
801
            throw new \BadMethodCallException(sprintf('Expected repository method %s to return an iterable object', $repositoryMethod));
802
        }
803
804 5
        if (! empty($mapping['prime'])) {
805 1
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
806 1
            $primers = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));
807 1
            $class = $this->dm->getClassMetadata($mapping['targetDocument']);
808
809 1
            $cursor = new PrimingIterator($cursor, $class, $referencePrimer, $primers, $collection->getHints());
810
        }
811
812 5
        return $cursor;
813
    }
814
815
    /**
816
     * Prepare a projection array by converting keys, which are PHP property
817
     * names, to MongoDB field names.
818
     */
819 14
    public function prepareProjection(array $fields): array
820
    {
821 14
        $preparedFields = [];
822
823 14
        foreach ($fields as $key => $value) {
824 14
            $preparedFields[$this->prepareFieldName($key)] = $value;
825
        }
826
827 14
        return $preparedFields;
828
    }
829
830
    /**
831
     * @param int|string|null $sort
832
     *
833
     * @return int|string|null
834
     */
835 25
    private function getSortDirection($sort)
836
    {
837 25
        switch (strtolower((string) $sort)) {
838 25
            case 'desc':
839 15
                return -1;
840
841 22
            case 'asc':
842 13
                return 1;
843
        }
844
845 12
        return $sort;
846
    }
847
848
    /**
849
     * Prepare a sort specification array by converting keys to MongoDB field
850
     * names and changing direction strings to int.
851
     */
852 142
    public function prepareSort(array $fields): array
853
    {
854 142
        $sortFields = [];
855
856 142
        foreach ($fields as $key => $value) {
857 25
            $sortFields[$this->prepareFieldName($key)] = $this->getSortDirection($value);
858
        }
859
860 142
        return $sortFields;
861
    }
862
863
    /**
864
     * Prepare a mongodb field name and convert the PHP property names to MongoDB field names.
865
     */
866 436
    public function prepareFieldName(string $fieldName): string
867
    {
868 436
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);
869
870 436
        return $fieldNames[0][0];
871
    }
872
873
    /**
874
     * Adds discriminator criteria to an already-prepared query.
875
     *
876
     * This method should be used once for query criteria and not be used for
877
     * nested expressions. It should be called before
878
     * {@link DocumentPerister::addFilterToPreparedQuery()}.
879
     */
880 507
    public function addDiscriminatorToPreparedQuery(array $preparedQuery): array
881
    {
882
        /* If the class has a discriminator field, which is not already in the
883
         * criteria, inject it now. The field/values need no preparation.
884
         */
885 507
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
886 27
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
887 27
            if (count($discriminatorValues) === 1) {
888 19
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
889
            } else {
890 10
                $preparedQuery[$this->class->discriminatorField] = ['$in' => $discriminatorValues];
891
            }
892
        }
893
894 507
        return $preparedQuery;
895
    }
896
897
    /**
898
     * Adds filter criteria to an already-prepared query.
899
     *
900
     * This method should be used once for query criteria and not be used for
901
     * nested expressions. It should be called after
902
     * {@link DocumentPerister::addDiscriminatorToPreparedQuery()}.
903
     */
904 508
    public function addFilterToPreparedQuery(array $preparedQuery): array
905
    {
906
        /* If filter criteria exists for this class, prepare it and merge
907
         * over the existing query.
908
         *
909
         * @todo Consider recursive merging in case the filter criteria and
910
         * prepared query both contain top-level $and/$or operators.
911
         */
912 508
        $filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class);
913 508
        if ($filterCriteria) {
914 18
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
915
        }
916
917 508
        return $preparedQuery;
918
    }
919
920
    /**
921
     * Prepares the query criteria or new document object.
922
     *
923
     * PHP field names and types will be converted to those used by MongoDB.
924
     */
925 540
    public function prepareQueryOrNewObj(array $query, bool $isNewObj = false): array
926
    {
927 540
        $preparedQuery = [];
928
929 540
        foreach ($query as $key => $value) {
930
            // Recursively prepare logical query clauses
931 498
            if (in_array($key, ['$and', '$or', '$nor']) && is_array($value)) {
932 20
                foreach ($value as $k2 => $v2) {
933 20
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
934
                }
935 20
                continue;
936
            }
937
938 498
            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
939 40
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
940 40
                continue;
941
            }
942
943 498
            $preparedQueryElements = $this->prepareQueryElement((string) $key, $value, null, true, $isNewObj);
944 498
            foreach ($preparedQueryElements as list($preparedKey, $preparedValue)) {
945 498
                $preparedQuery[$preparedKey] = is_array($preparedValue)
946 134
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
947 498
                    : Type::convertPHPToDatabaseValue($preparedValue);
948
            }
949
        }
950
951 540
        return $preparedQuery;
952
    }
953
954
    /**
955
     * Prepares a query value and converts the PHP value to the database value
956
     * if it is an identifier.
957
     *
958
     * It also handles converting $fieldName to the database name if they are different.
959
     *
960
     * @param mixed $value
961
     */
962 894
    private function prepareQueryElement(string $fieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false): array
963
    {
964 894
        $class = $class ?? $this->class;
965
966
        // @todo Consider inlining calls to ClassMetadata methods
967
968
        // Process all non-identifier fields by translating field names
969 894
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
970 254
            $mapping = $class->fieldMappings[$fieldName];
971 254
            $fieldName = $mapping['name'];
972
973 254
            if (! $prepareValue) {
974 52
                return [[$fieldName, $value]];
975
            }
976
977
            // Prepare mapped, embedded objects
978 212
            if (! empty($mapping['embedded']) && is_object($value) &&
979 212
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
980 3
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
981
            }
982
983 210
            if (! empty($mapping['reference']) && is_object($value) && ! ($value instanceof ObjectId)) {
984
                try {
985 14
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
986 1
                } catch (MappingException $e) {
987
                    // do nothing in case passed object is not mapped document
988
                }
989
            }
990
991
            // No further preparation unless we're dealing with a simple reference
992
            // We can't have expressions in empty() with PHP < 5.5, so store it in a variable
993 197
            $arrayValue = (array) $value;
994 197
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty($arrayValue)) {
995 133
                return [[$fieldName, $value]];
996
            }
997
998
            // Additional preparation for one or more simple reference values
999 91
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1000
1001 91
            if (! is_array($value)) {
1002 87
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1003
            }
1004
1005
            // Objects without operators or with DBRef fields can be converted immediately
1006 6
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1007 3
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1008
            }
1009
1010 6
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1011
        }
1012
1013
        // Process identifier fields
1014 803
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1015 342
            $fieldName = '_id';
1016
1017 342
            if (! $prepareValue) {
1018 42
                return [[$fieldName, $value]];
1019
            }
1020
1021 303
            if (! is_array($value)) {
1022 277
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1023
            }
1024
1025
            // Objects without operators or with DBRef fields can be converted immediately
1026 62
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1027 6
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1028
            }
1029
1030 57
            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1031
        }
1032
1033
        // No processing for unmapped, non-identifier, non-dotted field names
1034 558
        if (strpos($fieldName, '.') === false) {
1035 416
            return [[$fieldName, $value]];
1036
        }
1037
1038
        /* Process "fieldName.objectProperty" queries (on arrays or objects).
1039
         *
1040
         * We can limit parsing here, since at most three segments are
1041
         * significant: "fieldName.objectProperty" with an optional index or key
1042
         * for collections stored as either BSON arrays or objects.
1043
         */
1044 154
        $e = explode('.', $fieldName, 4);
1045
1046
        // No further processing for unmapped fields
1047 154
        if (! isset($class->fieldMappings[$e[0]])) {
1048 6
            return [[$fieldName, $value]];
1049
        }
1050
1051 149
        $mapping = $class->fieldMappings[$e[0]];
1052 149
        $e[0] = $mapping['name'];
1053
1054
        // Hash and raw fields will not be prepared beyond the field name
1055 149
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1056 1
            $fieldName = implode('.', $e);
1057
1058 1
            return [[$fieldName, $value]];
1059
        }
1060
1061 148
        if ($mapping['type'] === 'many' && CollectionHelper::isHash($mapping['strategy'])
1062 148
                && isset($e[2])) {
1063 1
            $objectProperty = $e[2];
1064 1
            $objectPropertyPrefix = $e[1] . '.';
1065 1
            $nextObjectProperty = implode('.', array_slice($e, 3));
1066 147
        } elseif ($e[1] !== '$') {
1067 146
            $fieldName = $e[0] . '.' . $e[1];
1068 146
            $objectProperty = $e[1];
1069 146
            $objectPropertyPrefix = '';
1070 146
            $nextObjectProperty = implode('.', array_slice($e, 2));
1071 1
        } elseif (isset($e[2])) {
1072 1
            $fieldName = $e[0] . '.' . $e[1] . '.' . $e[2];
1073 1
            $objectProperty = $e[2];
1074 1
            $objectPropertyPrefix = $e[1] . '.';
1075 1
            $nextObjectProperty = implode('.', array_slice($e, 3));
1076
        } else {
1077 1
            $fieldName = $e[0] . '.' . $e[1];
1078
1079 1
            return [[$fieldName, $value]];
1080
        }
1081
1082
        // No further processing for fields without a targetDocument mapping
1083 148
        if (! isset($mapping['targetDocument'])) {
1084 3
            if ($nextObjectProperty) {
1085
                $fieldName .= '.' . $nextObjectProperty;
1086
            }
1087
1088 3
            return [[$fieldName, $value]];
1089
        }
1090
1091 145
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1092
1093
        // No further processing for unmapped targetDocument fields
1094 145
        if (! $targetClass->hasField($objectProperty)) {
1095 25
            if ($nextObjectProperty) {
1096
                $fieldName .= '.' . $nextObjectProperty;
1097
            }
1098
1099 25
            return [[$fieldName, $value]];
1100
        }
1101
1102 125
        $targetMapping = $targetClass->getFieldMapping($objectProperty);
1103 125
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1104
1105
        // Prepare DBRef identifiers or the mapped field's property path
1106 125
        $fieldName = ($objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID)
1107 105
            ? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1108 125
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1109
1110
        // Process targetDocument identifier fields
1111 125
        if ($objectPropertyIsId) {
1112 106
            if (! $prepareValue) {
1113 7
                return [[$fieldName, $value]];
1114
            }
1115
1116 99
            if (! is_array($value)) {
1117 85
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1118
            }
1119
1120
            // Objects without operators or with DBRef fields can be converted immediately
1121 16
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1122 6
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1123
            }
1124
1125 16
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1126
        }
1127
1128
        /* The property path may include a third field segment, excluding the
1129
         * collection item pointer. If present, this next object property must
1130
         * be processed recursively.
1131
         */
1132 19
        if ($nextObjectProperty) {
1133
            // Respect the targetDocument's class metadata when recursing
1134 16
            $nextTargetClass = isset($targetMapping['targetDocument'])
1135 10
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1136 16
                : null;
1137
1138 16
            if (empty($targetMapping['reference'])) {
1139 14
                $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1140
            } else {
1141
                // No recursive processing for references as most probably somebody is querying DBRef or alike
1142 4
                if ($nextObjectProperty[0] !== '$' && in_array($targetMapping['storeAs'], [ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB, ClassMetadata::REFERENCE_STORE_AS_DB_REF])) {
1143 1
                    $nextObjectProperty = '$' . $nextObjectProperty;
1144
                }
1145 4
                $fieldNames = [[$nextObjectProperty, $value]];
1146
            }
1147
1148
            return array_map(function ($preparedTuple) use ($fieldName) {
1149 16
                list($key, $value) = $preparedTuple;
1150
1151 16
                return [$fieldName . '.' . $key, $value];
1152 16
            }, $fieldNames);
1153
        }
1154
1155 5
        return [[$fieldName, $value]];
1156
    }
1157
1158
    /**
1159
     * Prepares a query expression.
1160
     *
1161
     * @param array|object $expression
1162
     */
1163 79
    private function prepareQueryExpression($expression, ClassMetadata $class): array
1164
    {
1165 79
        foreach ($expression as $k => $v) {
1166
            // Ignore query operators whose arguments need no type conversion
1167 79
            if (in_array($k, ['$exists', '$type', '$mod', '$size'])) {
1168 16
                continue;
1169
            }
1170
1171
            // Process query operators whose argument arrays need type conversion
1172 79
            if (in_array($k, ['$in', '$nin', '$all']) && is_array($v)) {
1173 77
                foreach ($v as $k2 => $v2) {
1174 77
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
1175
                }
1176 77
                continue;
1177
            }
1178
1179
            // Recursively process expressions within a $not operator
1180 18
            if ($k === '$not' && is_array($v)) {
1181 15
                $expression[$k] = $this->prepareQueryExpression($v, $class);
1182 15
                continue;
1183
            }
1184
1185 18
            $expression[$k] = $class->getDatabaseIdentifierValue($v);
1186
        }
1187
1188 79
        return $expression;
1189
    }
1190
1191
    /**
1192
     * Checks whether the value has DBRef fields.
1193
     *
1194
     * This method doesn't check if the the value is a complete DBRef object,
1195
     * although it should return true for a DBRef. Rather, we're checking that
1196
     * the value has one or more fields for a DBref. In practice, this could be
1197
     * $elemMatch criteria for matching a DBRef.
1198
     *
1199
     * @param mixed $value
1200
     */
1201 80
    private function hasDBRefFields($value): bool
1202
    {
1203 80
        if (! is_array($value) && ! is_object($value)) {
1204
            return false;
1205
        }
1206
1207 80
        if (is_object($value)) {
1208
            $value = get_object_vars($value);
1209
        }
1210
1211 80
        foreach ($value as $key => $_) {
1212 80
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
1213 80
                return true;
1214
            }
1215
        }
1216
1217 79
        return false;
1218
    }
1219
1220
    /**
1221
     * Checks whether the value has query operators.
1222
     *
1223
     * @param mixed $value
1224
     */
1225 84
    private function hasQueryOperators($value): bool
1226
    {
1227 84
        if (! is_array($value) && ! is_object($value)) {
1228
            return false;
1229
        }
1230
1231 84
        if (is_object($value)) {
1232
            $value = get_object_vars($value);
1233
        }
1234
1235 84
        foreach ($value as $key => $_) {
1236 84
            if (isset($key[0]) && $key[0] === '$') {
1237 84
                return true;
1238
            }
1239
        }
1240
1241 11
        return false;
1242
    }
1243
1244
    /**
1245
     * Gets the array of discriminator values for the given ClassMetadata
1246
     */
1247 27
    private function getClassDiscriminatorValues(ClassMetadata $metadata): array
1248
    {
1249 27
        $discriminatorValues = [$metadata->discriminatorValue];
1250 27
        foreach ($metadata->subClasses as $className) {
1251 8
            $key = array_search($className, $metadata->discriminatorMap);
1252 8
            if (! $key) {
1253
                continue;
1254
            }
1255
1256 8
            $discriminatorValues[] = $key;
1257
        }
1258
1259
        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
1260 27
        if ($metadata->defaultDiscriminatorValue && in_array($metadata->defaultDiscriminatorValue, $discriminatorValues)) {
1261 2
            $discriminatorValues[] = null;
1262
        }
1263
1264 27
        return $discriminatorValues;
1265
    }
1266
1267 555
    private function handleCollections(object $document, array $options): void
1268
    {
1269
        // Collection deletions (deletions of complete collections)
1270 555
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1271 104
            if (! $this->uow->isCollectionScheduledForDeletion($coll)) {
1272 94
                continue;
1273
            }
1274
1275 30
            $this->cp->delete($coll, $options);
1276
        }
1277
        // Collection updates (deleteRows, updateRows, insertRows)
1278 555
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1279 104
            if (! $this->uow->isCollectionScheduledForUpdate($coll)) {
1280 28
                continue;
1281
            }
1282
1283 97
            $this->cp->update($coll, $options);
1284
        }
1285
        // Take new snapshots from visited collections
1286 555
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
1287 229
            $coll->takeSnapshot();
1288
        }
1289 555
    }
1290
1291
    /**
1292
     * If the document is new, ignore shard key field value, otherwise throw an exception.
1293
     * Also, shard key field should be present in actual document data.
1294
     *
1295
     * @throws MongoDBException
1296
     */
1297 4
    private function guardMissingShardKey(object $document, string $shardKeyField, array $actualDocumentData): void
1298
    {
1299 4
        $dcs = $this->uow->getDocumentChangeSet($document);
1300 4
        $isUpdate = $this->uow->isScheduledForUpdate($document);
1301
1302 4
        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
1303 4
        $fieldName = $fieldMapping['fieldName'];
1304
1305 4
        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] !== $dcs[$fieldName][1]) {
1306
            throw MongoDBException::shardKeyFieldCannotBeChanged($shardKeyField, $this->class->getName());
1307
        }
1308
1309 4
        if (! isset($actualDocumentData[$fieldName])) {
1310
            throw MongoDBException::shardKeyFieldMissing($shardKeyField, $this->class->getName());
1311
        }
1312 4
    }
1313
1314
    /**
1315
     * Get shard key aware query for single document.
1316
     */
1317 269
    private function getQueryForDocument(object $document): array
1318
    {
1319 269
        $id = $this->uow->getDocumentIdentifier($document);
1320 269
        $id = $this->class->getDatabaseIdentifierValue($id);
1321
1322 269
        $shardKeyQueryPart = $this->getShardKeyQuery($document);
1323 269
        $query = array_merge(['_id' => $id], $shardKeyQueryPart);
1324
1325 269
        return $query;
1326
    }
1327
1328 556
    private function getWriteOptions(array $options = []): array
1329
    {
1330 556
        $defaultOptions = $this->dm->getConfiguration()->getDefaultCommitOptions();
1331 556
        $documentOptions = [];
1332 556
        if ($this->class->hasWriteConcern()) {
1333 9
            $documentOptions['w'] = $this->class->getWriteConcern();
1334
        }
1335
1336 556
        return array_merge($defaultOptions, $documentOptions, $options);
1337
    }
1338
1339 15
    private function prepareReference(string $fieldName, $value, array $mapping, bool $inNewObj): array
1340
    {
1341 15
        $reference = $this->dm->createReference($value, $mapping);
1342 14
        if ($inNewObj || $mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_ID) {
1343 8
            return [[$fieldName, $reference]];
1344
        }
1345
1346 6
        switch ($mapping['storeAs']) {
1347
            case ClassMetadata::REFERENCE_STORE_AS_REF:
1348
                $keys = ['id' => true];
1349
                break;
1350
1351
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF:
1352
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB:
1353 6
                $keys = ['$ref' => true, '$id' => true, '$db' => true];
1354
1355 6
                if ($mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_DB_REF) {
1356 5
                    unset($keys['$db']);
1357
                }
1358
1359 6
                if (isset($mapping['targetDocument'])) {
1360 4
                    unset($keys['$ref'], $keys['$db']);
1361
                }
1362 6
                break;
1363
1364
            default:
1365
                throw new \InvalidArgumentException(sprintf('Reference type %s is invalid.', $mapping['storeAs']));
1366
        }
1367
1368 6
        if ($mapping['type'] === 'many') {
1369 2
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
1370
        }
1371
1372 4
        return array_map(
1373
            function ($key) use ($reference, $fieldName) {
1374 4
                return [$fieldName . '.' . $key, $reference[$key]];
1375 4
            },
1376 4
            array_keys($keys)
1377
        );
1378
    }
1379
}
1380