Completed
Push — master ( 26ecbc...8c0c5d )
by Maciej
14s
created

ODM/MongoDB/Persisters/DocumentPersister.php (1 issue)

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 BadMethodCallException;
8
use DateTime;
9
use Doctrine\Common\Persistence\Mapping\MappingException;
10
use Doctrine\ODM\MongoDB\DocumentManager;
11
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
12
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
13
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
14
use Doctrine\ODM\MongoDB\Iterator\Iterator;
15
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
16
use Doctrine\ODM\MongoDB\LockException;
17
use Doctrine\ODM\MongoDB\LockMode;
18
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
19
use Doctrine\ODM\MongoDB\MongoDBException;
20
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionException;
21
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
22
use Doctrine\ODM\MongoDB\Query\CriteriaMerger;
23
use Doctrine\ODM\MongoDB\Query\Query;
24
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
25
use Doctrine\ODM\MongoDB\Types\Type;
26
use Doctrine\ODM\MongoDB\UnitOfWork;
27
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
28
use InvalidArgumentException;
29
use MongoDB\BSON\ObjectId;
30
use MongoDB\Collection;
31
use MongoDB\Driver\Cursor;
32
use MongoDB\Driver\Exception\Exception as DriverException;
33
use MongoDB\Driver\Exception\WriteException;
34
use MongoDB\GridFS\Bucket;
35
use ProxyManager\Proxy\GhostObjectInterface;
36
use stdClass;
37
use function array_combine;
38
use function array_fill;
39
use function array_intersect_key;
40
use function array_keys;
41
use function array_map;
42
use function array_merge;
43
use function array_search;
44
use function array_slice;
45
use function array_values;
46
use function assert;
47
use function count;
48
use function explode;
49
use function get_class;
50
use function get_object_vars;
51
use function implode;
52
use function in_array;
53
use function is_array;
54
use function is_object;
55
use function is_scalar;
56
use function is_string;
57
use function max;
58
use function spl_object_hash;
59
use function sprintf;
60
use function strpos;
61
use function strtolower;
62
63
/**
64
 * The DocumentPersister is responsible for persisting documents.
65
 */
66
class DocumentPersister
67
{
68
    /** @var PersistenceBuilder */
69
    private $pb;
70
71
    /** @var DocumentManager */
72
    private $dm;
73
74
    /** @var UnitOfWork */
75
    private $uow;
76
77
    /** @var ClassMetadata */
78
    private $class;
79
80
    /** @var Collection */
81
    private $collection;
82
83
    /** @var Bucket|null */
84
    private $bucket;
85
86
    /**
87
     * Array of queued inserts for the persister to insert.
88
     *
89
     * @var array
90
     */
91
    private $queuedInserts = [];
92
93
    /**
94
     * Array of queued inserts for the persister to insert.
95
     *
96
     * @var array
97
     */
98
    private $queuedUpserts = [];
99
100
    /** @var CriteriaMerger */
101
    private $cm;
102
103
    /** @var CollectionPersister */
104
    private $cp;
105
106
    /** @var HydratorFactory */
107
    private $hydratorFactory;
108
109 1128
    public function __construct(
110
        PersistenceBuilder $pb,
111
        DocumentManager $dm,
112
        UnitOfWork $uow,
113
        HydratorFactory $hydratorFactory,
114
        ClassMetadata $class,
115
        ?CriteriaMerger $cm = null
116
    ) {
117 1128
        $this->pb              = $pb;
118 1128
        $this->dm              = $dm;
119 1128
        $this->cm              = $cm ?: new CriteriaMerger();
120 1128
        $this->uow             = $uow;
121 1128
        $this->hydratorFactory = $hydratorFactory;
122 1128
        $this->class           = $class;
123 1128
        $this->collection      = $dm->getDocumentCollection($class->name);
124 1128
        $this->cp              = $this->uow->getCollectionPersister();
125
126 1128
        if (! $class->isFile) {
127 1120
            return;
128
        }
129
130 10
        $this->bucket = $dm->getDocumentBucket($class->name);
131 10
    }
132
133
    public function getInserts() : array
134
    {
135
        return $this->queuedInserts;
136
    }
137
138
    public function isQueuedForInsert(object $document) : bool
139
    {
140
        return isset($this->queuedInserts[spl_object_hash($document)]);
141
    }
142
143
    /**
144
     * Adds a document to the queued insertions.
145
     * The document remains queued until {@link executeInserts} is invoked.
146
     */
147 519
    public function addInsert(object $document) : void
148
    {
149 519
        $this->queuedInserts[spl_object_hash($document)] = $document;
150 519
    }
151
152
    public function getUpserts() : array
153
    {
154
        return $this->queuedUpserts;
155
    }
156
157
    public function isQueuedForUpsert(object $document) : bool
158
    {
159
        return isset($this->queuedUpserts[spl_object_hash($document)]);
160
    }
161
162
    /**
163
     * Adds a document to the queued upserts.
164
     * The document remains queued until {@link executeUpserts} is invoked.
165
     */
166 85
    public function addUpsert(object $document) : void
167
    {
168 85
        $this->queuedUpserts[spl_object_hash($document)] = $document;
169 85
    }
170
171
    /**
172
     * Gets the ClassMetadata instance of the document class this persister is
173
     * used for.
174
     */
175
    public function getClassMetadata() : ClassMetadata
176
    {
177
        return $this->class;
178
    }
179
180
    /**
181
     * Executes all queued document insertions.
182
     *
183
     * Queued documents without an ID will inserted in a batch and queued
184
     * documents with an ID will be upserted individually.
185
     *
186
     * If no inserts are queued, invoking this method is a NOOP.
187
     *
188
     * @throws DriverException
189
     */
190 519
    public function executeInserts(array $options = []) : void
191
    {
192 519
        if (! $this->queuedInserts) {
193
            return;
194
        }
195
196 519
        $inserts = [];
197 519
        $options = $this->getWriteOptions($options);
198 519
        foreach ($this->queuedInserts as $oid => $document) {
199 519
            $data = $this->pb->prepareInsertData($document);
200
201
            // Set the initial version for each insert
202 508
            if ($this->class->isVersioned) {
203 40
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
204 40
                $nextVersion    = null;
205 40
                if ($versionMapping['type'] === 'int') {
206 38
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
207 38
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
208 2
                } elseif ($versionMapping['type'] === 'date') {
209 2
                    $nextVersionDateTime = new DateTime();
210 2
                    $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
211 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
212
                }
213 40
                $data[$versionMapping['name']] = $nextVersion;
214
            }
215
216 508
            $inserts[] = $data;
217
        }
218
219 508
        if ($inserts) {
220
            try {
221 508
                $this->collection->insertMany($inserts, $options);
222 6
            } catch (DriverException $e) {
223 6
                $this->queuedInserts = [];
224 6
                throw $e;
225
            }
226
        }
227
228
        /* All collections except for ones using addToSet have already been
229
         * saved. We have left these to be handled separately to avoid checking
230
         * collection for uniqueness on PHP side.
231
         */
232 508
        foreach ($this->queuedInserts as $document) {
233 508
            $this->handleCollections($document, $options);
234
        }
235
236 508
        $this->queuedInserts = [];
237 508
    }
238
239
    /**
240
     * Executes all queued document upserts.
241
     *
242
     * Queued documents with an ID are upserted individually.
243
     *
244
     * If no upserts are queued, invoking this method is a NOOP.
245
     */
246 85
    public function executeUpserts(array $options = []) : void
247
    {
248 85
        if (! $this->queuedUpserts) {
249
            return;
250
        }
251
252 85
        $options = $this->getWriteOptions($options);
253 85
        foreach ($this->queuedUpserts as $oid => $document) {
254
            try {
255 85
                $this->executeUpsert($document, $options);
256 85
                $this->handleCollections($document, $options);
257 85
                unset($this->queuedUpserts[$oid]);
258
            } catch (WriteException $e) {
259
                unset($this->queuedUpserts[$oid]);
260 85
                throw $e;
261
            }
262
        }
263 85
    }
264
265
    /**
266
     * Executes a single upsert in {@link executeUpserts}
267
     */
268 85
    private function executeUpsert(object $document, array $options) : void
269
    {
270 85
        $options['upsert'] = true;
271 85
        $criteria          = $this->getQueryForDocument($document);
272
273 85
        $data = $this->pb->prepareUpsertData($document);
274
275
        // Set the initial version for each upsert
276 85
        if ($this->class->isVersioned) {
277 3
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
278 3
            $nextVersion    = null;
279 3
            if ($versionMapping['type'] === 'int') {
280 2
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
281 2
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
282 1
            } elseif ($versionMapping['type'] === 'date') {
283 1
                $nextVersionDateTime = new DateTime();
284 1
                $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
285 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
286
            }
287 3
            $data['$set'][$versionMapping['name']] = $nextVersion;
288
        }
289
290 85
        foreach (array_keys($criteria) as $field) {
291 85
            unset($data['$set'][$field]);
292 85
            unset($data['$inc'][$field]);
293 85
            unset($data['$setOnInsert'][$field]);
294
        }
295
296
        // Do not send empty update operators
297 85
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
298 85
            if (! empty($data[$operator])) {
299 70
                continue;
300
            }
301
302 85
            unset($data[$operator]);
303
        }
304
305
        /* If there are no modifiers remaining, we're upserting a document with
306
         * an identifier as its only field. Since a document with the identifier
307
         * may already exist, the desired behavior is "insert if not exists" and
308
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
309
         * the identifier to the same value in our criteria.
310
         *
311
         * This will fail for versions before MongoDB 2.6, which require an
312
         * empty $set modifier. The best we can do (without attempting to check
313
         * server versions in advance) is attempt the 2.6+ behavior and retry
314
         * after the relevant exception.
315
         *
316
         * See: https://jira.mongodb.org/browse/SERVER-12266
317
         */
318 85
        if (empty($data)) {
319 16
            $retry = true;
320 16
            $data  = ['$set' => ['_id' => $criteria['_id']]];
321
        }
322
323
        try {
324 85
            $this->collection->updateOne($criteria, $data, $options);
325 85
            return;
326
        } catch (WriteException $e) {
327
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
328
                throw $e;
329
            }
330
        }
331
332
        $this->collection->updateOne($criteria, ['$set' => new stdClass()], $options);
333
    }
334
335
    /**
336
     * Updates the already persisted document if it has any new changesets.
337
     *
338
     * @throws LockException
339
     */
340 227
    public function update(object $document, array $options = []) : void
341
    {
342 227
        $update = $this->pb->prepareUpdateData($document);
343
344 227
        $query = $this->getQueryForDocument($document);
345
346 225
        foreach (array_keys($query) as $field) {
347 225
            unset($update['$set'][$field]);
348
        }
349
350 225
        if (empty($update['$set'])) {
351 100
            unset($update['$set']);
352
        }
353
354
        // Include versioning logic to set the new version value in the database
355
        // and to ensure the version has not changed since this document object instance
356
        // was fetched from the database
357 225
        $nextVersion = null;
358 225
        if ($this->class->isVersioned) {
359 33
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
360 33
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
361 33
            if ($versionMapping['type'] === 'int') {
362 30
                $nextVersion                             = $currentVersion + 1;
363 30
                $update['$inc'][$versionMapping['name']] = 1;
364 30
                $query[$versionMapping['name']]          = $currentVersion;
365 3
            } elseif ($versionMapping['type'] === 'date') {
366 3
                $nextVersion                             = new DateTime();
367 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
368 3
                $query[$versionMapping['name']]          = Type::convertPHPToDatabaseValue($currentVersion);
369
            }
370
        }
371
372 225
        if (! empty($update)) {
373
            // Include locking logic so that if the document object in memory is currently
374
            // locked then it will remove it, otherwise it ensures the document is not locked.
375 151
            if ($this->class->isLockable) {
376 11
                $isLocked    = $this->class->reflFields[$this->class->lockField]->getValue($document);
377 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
378 11
                if ($isLocked) {
379 2
                    $update['$unset'] = [$lockMapping['name'] => true];
380
                } else {
381 9
                    $query[$lockMapping['name']] = ['$exists' => false];
382
                }
383
            }
384
385 151
            $options = $this->getWriteOptions($options);
386
387 151
            $result = $this->collection->updateOne($query, $update, $options);
388
389 151
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
390 6
                throw LockException::lockFailed($document);
391 146
            } elseif ($this->class->isVersioned) {
392 28
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
393
            }
394
        }
395
396 220
        $this->handleCollections($document, $options);
397 220
    }
398
399
    /**
400
     * Removes document from mongo
401
     *
402
     * @throws LockException
403
     */
404 36
    public function delete(object $document, array $options = []) : void
405
    {
406 36
        if ($this->bucket instanceof Bucket) {
407 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
408 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
409
410 1
            $this->bucket->delete($databaseIdentifier);
411
412 1
            return;
413
        }
414
415 35
        $query = $this->getQueryForDocument($document);
416
417 35
        if ($this->class->isLockable) {
418 2
            $query[$this->class->lockField] = ['$exists' => false];
419
        }
420
421 35
        $options = $this->getWriteOptions($options);
422
423 35
        $result = $this->collection->deleteOne($query, $options);
424
425 35
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
426 2
            throw LockException::lockFailed($document);
427
        }
428 33
    }
429
430
    /**
431
     * Refreshes a managed document.
432
     */
433 23
    public function refresh(object $document) : void
434
    {
435 23
        $query = $this->getQueryForDocument($document);
436 23
        $data  = $this->collection->findOne($query);
437 23
        if ($data === null) {
438
            throw MongoDBException::cannotRefreshDocument();
439
        }
440 23
        $data = $this->hydratorFactory->hydrate($document, (array) $data);
441 23
        $this->uow->setOriginalDocumentData($document, $data);
442 23
    }
443
444
    /**
445
     * Finds a document by a set of criteria.
446
     *
447
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
448
     * be used to match an _id value.
449
     *
450
     * @param mixed $criteria Query criteria
451
     *
452
     * @throws LockException
453
     *
454
     * @todo Check identity map? loadById method? Try to guess whether
455
     *     $criteria is the id?
456
     */
457 365
    public function load($criteria, ?object $document = null, array $hints = [], int $lockMode = 0, ?array $sort = null) : ?object
458
    {
459
        // TODO: remove this
460 365
        if ($criteria === null || is_scalar($criteria) || $criteria instanceof ObjectId) {
461
            $criteria = ['_id' => $criteria];
462
        }
463
464 365
        $criteria = $this->prepareQueryOrNewObj($criteria);
465 365
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
466 365
        $criteria = $this->addFilterToPreparedQuery($criteria);
467
468 365
        $options = [];
469 365
        if ($sort !== null) {
470 95
            $options['sort'] = $this->prepareSort($sort);
471
        }
472 365
        $result = $this->collection->findOne($criteria, $options);
473 365
        $result = $result !== null ? (array) $result : null;
474
475 365
        if ($this->class->isLockable) {
476 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
477 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
478 1
                throw LockException::lockFailed($document);
479
            }
480
        }
481
482 364
        if ($result === null) {
483 115
            return null;
484
        }
485
486 320
        return $this->createDocument($result, $document, $hints);
487
    }
488
489
    /**
490
     * Finds documents by a set of criteria.
491
     */
492 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null) : Iterator
493
    {
494 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
495 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
496 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
497
498 22
        $options = [];
499 22
        if ($sort !== null) {
500 11
            $options['sort'] = $this->prepareSort($sort);
501
        }
502
503 22
        if ($limit !== null) {
504 10
            $options['limit'] = $limit;
505
        }
506
507 22
        if ($skip !== null) {
508 1
            $options['skip'] = $skip;
509
        }
510
511 22
        $baseCursor = $this->collection->find($criteria, $options);
512 22
        return $this->wrapCursor($baseCursor);
513
    }
514
515
    /**
516
     * @throws MongoDBException
517
     */
518 307
    private function getShardKeyQuery(object $document) : array
519
    {
520 307
        if (! $this->class->isSharded()) {
521 297
            return [];
522
        }
523
524 10
        $shardKey = $this->class->getShardKey();
525 10
        $keys     = array_keys($shardKey['keys']);
526 10
        $data     = $this->uow->getDocumentActualData($document);
527
528 10
        $shardKeyQueryPart = [];
529 10
        foreach ($keys as $key) {
530 10
            assert(is_string($key));
531 10
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
532 10
            $this->guardMissingShardKey($document, $key, $data);
533
534 8
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
535 1
                $reference = $this->prepareReference(
536 1
                    $key,
537 1
                    $data[$mapping['fieldName']],
538 1
                    $mapping,
539 1
                    false
540
                );
541 1
                foreach ($reference as $keyValue) {
542 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
543
                }
544
            } else {
545 7
                $value                   = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
546 8
                $shardKeyQueryPart[$key] = $value;
547
            }
548
        }
549
550 8
        return $shardKeyQueryPart;
551
    }
552
553
    /**
554
     * Wraps the supplied base cursor in the corresponding ODM class.
555
     */
556 22
    private function wrapCursor(Cursor $baseCursor) : Iterator
557
    {
558 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
559
    }
560
561
    /**
562
     * Checks whether the given managed document exists in the database.
563
     */
564 3
    public function exists(object $document) : bool
565
    {
566 3
        $id = $this->class->getIdentifierObject($document);
567 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
568
    }
569
570
    /**
571
     * Locks document by storing the lock mode on the mapped lock field.
572
     */
573 5
    public function lock(object $document, int $lockMode) : void
574
    {
575 5
        $id          = $this->uow->getDocumentIdentifier($document);
576 5
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
577 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
578 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
579 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
580 5
    }
581
582
    /**
583
     * Releases any lock that exists on this document.
584
     */
585 1
    public function unlock(object $document) : void
586
    {
587 1
        $id          = $this->uow->getDocumentIdentifier($document);
588 1
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
589 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
590 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
591 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
592 1
    }
593
594
    /**
595
     * Creates or fills a single document object from an query result.
596
     *
597
     * @param array  $result   The query result.
598
     * @param object $document The document object to fill, if any.
599
     * @param array  $hints    Hints for document creation.
600
     *
601
     * @return object|null The filled and managed document object or NULL, if the query result is empty.
602
     */
603 320
    private function createDocument(array $result, ?object $document = null, array $hints = []) : ?object
604
    {
605 320
        if ($document !== null) {
606 26
            $hints[Query::HINT_REFRESH] = true;
607 26
            $id                         = $this->class->getPHPIdentifierValue($result['_id']);
608 26
            $this->uow->registerManaged($document, $id, $result);
609
        }
610
611 320
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
612
    }
613
614
    /**
615
     * Loads a PersistentCollection data. Used in the initialize() method.
616
     */
617 178
    public function loadCollection(PersistentCollectionInterface $collection) : void
618
    {
619 178
        $mapping = $collection->getMapping();
620 178
        switch ($mapping['association']) {
621
            case ClassMetadata::EMBED_MANY:
622 126
                $this->loadEmbedManyCollection($collection);
623 126
                break;
624
625
            case ClassMetadata::REFERENCE_MANY:
626 75
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
627 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
628
                } else {
629 71
                    if ($mapping['isOwningSide']) {
630 59
                        $this->loadReferenceManyCollectionOwningSide($collection);
631
                    } else {
632 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
633
                    }
634
                }
635 75
                break;
636
        }
637 178
    }
638
639 126
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection) : void
640
    {
641 126
        $embeddedDocuments = $collection->getMongoData();
642 126
        $mapping           = $collection->getMapping();
643 126
        $owner             = $collection->getOwner();
644 126
        if (! $embeddedDocuments) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $embeddedDocuments of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

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