Completed
Pull Request — master (#1749)
by Gabriel
11:34
created

DocumentPersister::getClassDiscriminatorValues()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 17
ccs 9
cts 9
cp 1
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 9
nc 6
nop 1
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ODM\MongoDB\Persisters;
6
7
use Doctrine\Common\EventManager;
8
use Doctrine\Common\Persistence\Mapping\MappingException;
9
use Doctrine\ODM\MongoDB\DocumentManager;
10
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
11
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
12
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
13
use Doctrine\ODM\MongoDB\Iterator\Iterator;
14
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
15
use Doctrine\ODM\MongoDB\LockException;
16
use Doctrine\ODM\MongoDB\LockMode;
17
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
18
use Doctrine\ODM\MongoDB\MongoDBException;
19
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
20
use Doctrine\ODM\MongoDB\Proxy\Proxy;
21
use Doctrine\ODM\MongoDB\Query\CriteriaMerger;
22
use Doctrine\ODM\MongoDB\Query\Query;
23
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
24
use Doctrine\ODM\MongoDB\Types\Type;
25
use Doctrine\ODM\MongoDB\UnitOfWork;
26
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
27
use MongoDB\BSON\ObjectId;
28
use MongoDB\Collection;
29
use MongoDB\Driver\Cursor;
30
use MongoDB\Driver\Exception\Exception as DriverException;
31
use MongoDB\Driver\Exception\WriteException;
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 strpos;
53
use function strtolower;
54
55
/**
56
 * The DocumentPersister is responsible for persisting documents.
57
 *
58
 */
59
class DocumentPersister
60
{
61
    /**
62
     * The PersistenceBuilder instance.
63
     *
64
     * @var PersistenceBuilder
65
     */
66
    private $pb;
67
68
    /**
69
     * The DocumentManager instance.
70
     *
71
     * @var DocumentManager
72
     */
73
    private $dm;
74
75
    /**
76
     * The EventManager instance
77
     *
78
     * @var EventManager
79
     */
80
    private $evm;
81
82
    /**
83
     * The UnitOfWork instance.
84
     *
85
     * @var UnitOfWork
86
     */
87
    private $uow;
88
89
    /**
90
     * The ClassMetadata instance for the document type being persisted.
91
     *
92
     * @var ClassMetadata
93
     */
94
    private $class;
95
96
    /**
97
     * The MongoCollection instance for this document.
98
     *
99
     * @var Collection
100
     */
101
    private $collection;
102
103
    /**
104
     * Array of queued inserts for the persister to insert.
105
     *
106
     * @var array
107
     */
108
    private $queuedInserts = [];
109
110
    /**
111
     * Array of queued inserts for the persister to insert.
112
     *
113
     * @var array
114
     */
115
    private $queuedUpserts = [];
116
117
    /**
118
     * The CriteriaMerger instance.
119
     *
120
     * @var CriteriaMerger
121
     */
122
    private $cm;
123
124
    /**
125
     * The CollectionPersister instance.
126
     *
127
     * @var CollectionPersister
128
     */
129
    private $cp;
130
131
    /**
132
     * The HydratorFactory instance.
133
     *
134
     * @var HydratorFactory
135
     */
136
    private $hydratorFactory;
137
138
    /**
139
     * Initializes this instance.
140
     *
141
     */
142 1073
    public function __construct(
143
        PersistenceBuilder $pb,
144
        DocumentManager $dm,
145
        EventManager $evm,
146
        UnitOfWork $uow,
147
        HydratorFactory $hydratorFactory,
148
        ClassMetadata $class,
149
        ?CriteriaMerger $cm = null
150
    ) {
151 1073
        $this->pb = $pb;
152 1073
        $this->dm = $dm;
153 1073
        $this->evm = $evm;
154 1073
        $this->cm = $cm ?: new CriteriaMerger();
155 1073
        $this->uow = $uow;
156 1073
        $this->hydratorFactory = $hydratorFactory;
157 1073
        $this->class = $class;
158 1073
        $this->collection = $dm->getDocumentCollection($class->name);
159 1073
        $this->cp = $this->uow->getCollectionPersister();
160 1073
    }
161
162
    /**
163
     * @return array
164
     */
165
    public function getInserts()
166
    {
167
        return $this->queuedInserts;
168
    }
169
170
    /**
171
     * @param object $document
172
     * @return bool
173
     */
174
    public function isQueuedForInsert($document)
175
    {
176
        return isset($this->queuedInserts[spl_object_hash($document)]);
177
    }
178
179
    /**
180
     * Adds a document to the queued insertions.
181
     * The document remains queued until {@link executeInserts} is invoked.
182
     *
183
     * @param object $document The document to queue for insertion.
184
     */
185 476
    public function addInsert($document)
186
    {
187 476
        $this->queuedInserts[spl_object_hash($document)] = $document;
188 476
    }
189
190
    /**
191
     * @return array
192
     */
193
    public function getUpserts()
194
    {
195
        return $this->queuedUpserts;
196
    }
197
198
    /**
199
     * @param object $document
200
     * @return bool
201
     */
202
    public function isQueuedForUpsert($document)
203
    {
204
        return isset($this->queuedUpserts[spl_object_hash($document)]);
205
    }
206
207
    /**
208
     * Adds a document to the queued upserts.
209
     * The document remains queued until {@link executeUpserts} is invoked.
210
     *
211
     * @param object $document The document to queue for insertion.
212
     */
213 83
    public function addUpsert($document)
214
    {
215 83
        $this->queuedUpserts[spl_object_hash($document)] = $document;
216 83
    }
217
218
    /**
219
     * Gets the ClassMetadata instance of the document class this persister is used for.
220
     *
221
     * @return ClassMetadata
222
     */
223
    public function getClassMetadata()
224
    {
225
        return $this->class;
226
    }
227
228
    /**
229
     * Executes all queued document insertions.
230
     *
231
     * Queued documents without an ID will inserted in a batch and queued
232
     * documents with an ID will be upserted individually.
233
     *
234
     * If no inserts are queued, invoking this method is a NOOP.
235
     *
236
     * @param array $options Options for batchInsert() and update() driver methods
237
     */
238 476
    public function executeInserts(array $options = [])
239
    {
240 476
        if (! $this->queuedInserts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->queuedInserts 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...
241
            return;
242
        }
243
244 476
        $inserts = [];
245 476
        $options = $this->getWriteOptions($options);
246 476
        foreach ($this->queuedInserts as $oid => $document) {
247 476
            $data = $this->pb->prepareInsertData($document);
248
249
            // Set the initial version for each insert
250 475
            if ($this->class->isVersioned) {
251 20
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
252 20
                $nextVersion = null;
253 20
                if ($versionMapping['type'] === 'int') {
254 18
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
255 18
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
256 2
                } elseif ($versionMapping['type'] === 'date') {
257 2
                    $nextVersionDateTime = new \DateTime();
258 2
                    $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
259 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
260
                }
261 20
                $data[$versionMapping['name']] = $nextVersion;
262
            }
263
264 475
            $inserts[] = $data;
265
        }
266
267 475
        if ($inserts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $inserts 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...
268
            try {
269 475
                $this->collection->insertMany($inserts, $options);
270 6
            } catch (DriverException $e) {
0 ignored issues
show
Bug introduced by
The class MongoDB\Driver\Exception\Exception does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
271 6
                $this->queuedInserts = [];
272 6
                throw $e;
273
            }
274
        }
275
276
        /* All collections except for ones using addToSet have already been
277
         * saved. We have left these to be handled separately to avoid checking
278
         * collection for uniqueness on PHP side.
279
         */
280 475
        foreach ($this->queuedInserts as $document) {
281 475
            $this->handleCollections($document, $options);
282
        }
283
284 475
        $this->queuedInserts = [];
285 475
    }
286
287
    /**
288
     * Executes all queued document upserts.
289
     *
290
     * Queued documents with an ID are upserted individually.
291
     *
292
     * If no upserts are queued, invoking this method is a NOOP.
293
     *
294
     * @param array $options Options for batchInsert() and update() driver methods
295
     */
296 83
    public function executeUpserts(array $options = [])
297
    {
298 83
        if (! $this->queuedUpserts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->queuedUpserts 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...
299
            return;
300
        }
301
302 83
        $options = $this->getWriteOptions($options);
303 83
        foreach ($this->queuedUpserts as $oid => $document) {
304
            try {
305 83
                $this->executeUpsert($document, $options);
306 83
                $this->handleCollections($document, $options);
307 83
                unset($this->queuedUpserts[$oid]);
308
            } catch (WriteException $e) {
0 ignored issues
show
Bug introduced by
The class MongoDB\Driver\Exception\WriteException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
309
                unset($this->queuedUpserts[$oid]);
310 83
                throw $e;
311
            }
312
        }
313 83
    }
314
315
    /**
316
     * Executes a single upsert in {@link executeUpserts}
317
     *
318
     * @param object $document
319
     * @param array  $options
320
     */
321 83
    private function executeUpsert($document, array $options)
322
    {
323 83
        $options['upsert'] = true;
324 83
        $criteria = $this->getQueryForDocument($document);
325
326 83
        $data = $this->pb->prepareUpsertData($document);
327
328
        // Set the initial version for each upsert
329 83
        if ($this->class->isVersioned) {
330 2
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
331 2
            $nextVersion = null;
332 2
            if ($versionMapping['type'] === 'int') {
333 1
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
334 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
335 1
            } elseif ($versionMapping['type'] === 'date') {
336 1
                $nextVersionDateTime = new \DateTime();
337 1
                $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
338 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
339
            }
340 2
            $data['$set'][$versionMapping['name']] = $nextVersion;
341
        }
342
343 83
        foreach (array_keys($criteria) as $field) {
344 83
            unset($data['$set'][$field]);
345
        }
346
347
        // Do not send an empty $set modifier
348 83
        if (empty($data['$set'])) {
349 16
            unset($data['$set']);
350
        }
351
352
        /* If there are no modifiers remaining, we're upserting a document with
353
         * an identifier as its only field. Since a document with the identifier
354
         * may already exist, the desired behavior is "insert if not exists" and
355
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
356
         * the identifier to the same value in our criteria.
357
         *
358
         * This will fail for versions before MongoDB 2.6, which require an
359
         * empty $set modifier. The best we can do (without attempting to check
360
         * server versions in advance) is attempt the 2.6+ behavior and retry
361
         * after the relevant exception.
362
         *
363
         * See: https://jira.mongodb.org/browse/SERVER-12266
364
         */
365 83
        if (empty($data)) {
366 16
            $retry = true;
367 16
            $data = ['$set' => ['_id' => $criteria['_id']]];
368
        }
369
370
        try {
371 83
            $this->collection->updateOne($criteria, $data, $options);
372 83
            return;
373
        } catch (WriteException $e) {
0 ignored issues
show
Bug introduced by
The class MongoDB\Driver\Exception\WriteException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
374
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
375
                throw $e;
376
            }
377
        }
378
379
        $this->collection->updateOne($criteria, ['$set' => new \stdClass()], $options);
380
    }
381
382
    /**
383
     * Updates the already persisted document if it has any new changesets.
384
     *
385
     * @param object $document
386
     * @param array  $options  Array of options to be used with update()
387
     * @throws LockException
388
     */
389 195
    public function update($document, array $options = [])
390
    {
391 195
        $update = $this->pb->prepareUpdateData($document);
392
393 195
        $query = $this->getQueryForDocument($document);
394
395 195
        foreach (array_keys($query) as $field) {
396 195
            unset($update['$set'][$field]);
397
        }
398
399 195
        if (empty($update['$set'])) {
400 89
            unset($update['$set']);
401
        }
402
403
        // Include versioning logic to set the new version value in the database
404
        // and to ensure the version has not changed since this document object instance
405
        // was fetched from the database
406 195
        $nextVersion = null;
407 195
        if ($this->class->isVersioned) {
408 13
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
409 13
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
410 13
            if ($versionMapping['type'] === 'int') {
411 10
                $nextVersion = $currentVersion + 1;
412 10
                $update['$inc'][$versionMapping['name']] = 1;
413 10
                $query[$versionMapping['name']] = $currentVersion;
414 3
            } elseif ($versionMapping['type'] === 'date') {
415 3
                $nextVersion = new \DateTime();
416 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
417 3
                $query[$versionMapping['name']] = Type::convertPHPToDatabaseValue($currentVersion);
418
            }
419
        }
420
421 195
        if (! empty($update)) {
422
            // Include locking logic so that if the document object in memory is currently
423
            // locked then it will remove it, otherwise it ensures the document is not locked.
424 129
            if ($this->class->isLockable) {
425 11
                $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document);
426 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
427 11
                if ($isLocked) {
428 2
                    $update['$unset'] = [$lockMapping['name'] => true];
429
                } else {
430 9
                    $query[$lockMapping['name']] = ['$exists' => false];
431
                }
432
            }
433
434 129
            $options = $this->getWriteOptions($options);
435
436 129
            $result = $this->collection->updateOne($query, $update, $options);
437
438 129
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
439 4
                throw LockException::lockFailed($document);
440 125
            } elseif ($this->class->isVersioned) {
441 9
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
442
            }
443
        }
444
445 191
        $this->handleCollections($document, $options);
446 191
    }
447
448
    /**
449
     * Removes document from mongo
450
     *
451
     * @param mixed $document
452
     * @param array $options  Array of options to be used with remove()
453
     * @throws LockException
454
     */
455 32
    public function delete($document, array $options = [])
456
    {
457 32
        $query = $this->getQueryForDocument($document);
458
459 32
        if ($this->class->isLockable) {
460 2
            $query[$this->class->lockField] = ['$exists' => false];
461
        }
462
463 32
        $options = $this->getWriteOptions($options);
464
465 32
        $result = $this->collection->deleteOne($query, $options);
466
467 32
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
468 2
            throw LockException::lockFailed($document);
469
        }
470 30
    }
471
472
    /**
473
     * Refreshes a managed document.
474
     *
475
     * @param object $document The document to refresh.
476
     */
477 20
    public function refresh($document)
478
    {
479 20
        $query = $this->getQueryForDocument($document);
480 20
        $data = $this->collection->findOne($query);
481 20
        $data = $this->hydratorFactory->hydrate($document, $data);
482 20
        $this->uow->setOriginalDocumentData($document, $data);
483 20
    }
484
485
    /**
486
     * Finds a document by a set of criteria.
487
     *
488
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
489
     * be used to match an _id value.
490
     *
491
     * @param mixed  $criteria Query criteria
492
     * @param object $document Document to load the data into. If not specified, a new document is created.
493
     * @param array  $hints    Hints for document creation
494
     * @param int    $lockMode
495
     * @param array  $sort     Sort array for Cursor::sort()
496
     * @throws LockException
497
     * @return object|null The loaded and managed document instance or null if no document was found
498
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
499
     */
500 342
    public function load($criteria, $document = null, array $hints = [], $lockMode = 0, ?array $sort = null)
0 ignored issues
show
Unused Code introduced by
The parameter $lockMode is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
501
    {
502
        // TODO: remove this
503 342
        if ($criteria === null || is_scalar($criteria) || $criteria instanceof ObjectId) {
0 ignored issues
show
Bug introduced by
The class MongoDB\BSON\ObjectId does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
504
            $criteria = ['_id' => $criteria];
505
        }
506
507 342
        $criteria = $this->prepareQueryOrNewObj($criteria);
0 ignored issues
show
Bug introduced by
It seems like $criteria can also be of type object; however, Doctrine\ODM\MongoDB\Per...:prepareQueryOrNewObj() 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...
508 342
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
509 342
        $criteria = $this->addFilterToPreparedQuery($criteria);
510
511 342
        $options = [];
512 342
        if ($sort !== null) {
513 92
            $options['sort'] = $this->prepareSort($sort);
514
        }
515 342
        $result = $this->collection->findOne($criteria, $options);
516
517 342
        if ($this->class->isLockable) {
518 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
519 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
520 1
                throw LockException::lockFailed($result);
521
            }
522
        }
523
524 341
        return $this->createDocument($result, $document, $hints);
525
    }
526
527
    /**
528
     * Finds documents by a set of criteria.
529
     *
530
     * @param array    $criteria Query criteria
531
     * @param array    $sort     Sort array for Cursor::sort()
532
     * @param int|null $limit    Limit for Cursor::limit()
533
     * @param int|null $skip     Skip for Cursor::skip()
534
     * @return Iterator
535
     */
536 22
    public function loadAll(array $criteria = [], ?array $sort = null, $limit = null, $skip = null)
537
    {
538 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
539 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
540 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
541
542 22
        $options = [];
543 22
        if ($sort !== null) {
544 11
            $options['sort'] = $this->prepareSort($sort);
545
        }
546
547 22
        if ($limit !== null) {
548 10
            $options['limit'] = $limit;
549
        }
550
551 22
        if ($skip !== null) {
552 1
            $options['skip'] = $skip;
553
        }
554
555 22
        $baseCursor = $this->collection->find($criteria, $options);
556 22
        $cursor = $this->wrapCursor($baseCursor);
557
558 22
        return $cursor;
559
    }
560
561
    /**
562
     * @param object $document
563
     *
564
     * @return array
565
     * @throws MongoDBException
566
     */
567 267
    private function getShardKeyQuery($document)
568
    {
569 267
        if (! $this->class->isSharded()) {
570 263
            return [];
571
        }
572
573 4
        $shardKey = $this->class->getShardKey();
574 4
        $keys = array_keys($shardKey['keys']);
575 4
        $data = $this->uow->getDocumentActualData($document);
576
577 4
        $shardKeyQueryPart = [];
578 4
        foreach ($keys as $key) {
579 4
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
580 4
            $this->guardMissingShardKey($document, $key, $data);
581
582 4
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
583 1
                $reference = $this->prepareReference(
584 1
                    $key,
585 1
                    $data[$mapping['fieldName']],
586 1
                    $mapping,
587 1
                    false
588
                );
589 1
                foreach ($reference as $keyValue) {
590 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
591
                }
592
            } else {
593 3
                $value = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
594 4
                $shardKeyQueryPart[$key] = $value;
595
            }
596
        }
597
598 4
        return $shardKeyQueryPart;
599
    }
600
601
    /**
602
     * Wraps the supplied base cursor in the corresponding ODM class.
603
     *
604
     */
605 22
    private function wrapCursor(Cursor $baseCursor): Iterator
606
    {
607 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
608
    }
609
610
    /**
611
     * Checks whether the given managed document exists in the database.
612
     *
613
     * @param object $document
614
     * @return bool TRUE if the document exists in the database, FALSE otherwise.
615
     */
616 3
    public function exists($document)
617
    {
618 3
        $id = $this->class->getIdentifierObject($document);
619 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
620
    }
621
622
    /**
623
     * Locks document by storing the lock mode on the mapped lock field.
624
     *
625
     * @param object $document
626
     * @param int    $lockMode
627
     */
628 5
    public function lock($document, $lockMode)
629
    {
630 5
        $id = $this->uow->getDocumentIdentifier($document);
631 5
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
632 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
633 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
634 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
635 5
    }
636
637
    /**
638
     * Releases any lock that exists on this document.
639
     *
640
     * @param object $document
641
     */
642 1
    public function unlock($document)
643
    {
644 1
        $id = $this->uow->getDocumentIdentifier($document);
645 1
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
646 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
647 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
648 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
649 1
    }
650
651
    /**
652
     * Creates or fills a single document object from an query result.
653
     *
654
     * @param object $result   The query result.
655
     * @param object $document The document object to fill, if any.
656
     * @param array  $hints    Hints for document creation.
657
     * @return object The filled and managed document object or NULL, if the query result is empty.
658
     */
659 341
    private function createDocument($result, $document = null, array $hints = [])
660
    {
661 341
        if ($result === null) {
662 111
            return null;
663
        }
664
665 300
        if ($document !== null) {
666 38
            $hints[Query::HINT_REFRESH] = true;
667 38
            $id = $this->class->getPHPIdentifierValue($result['_id']);
668 38
            $this->uow->registerManaged($document, $id, $result);
669
        }
670
671 300
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
672
    }
673
674
    /**
675
     * Loads a PersistentCollection data. Used in the initialize() method.
676
     *
677
     */
678 163
    public function loadCollection(PersistentCollectionInterface $collection)
679
    {
680 163
        $mapping = $collection->getMapping();
681 163
        switch ($mapping['association']) {
682
            case ClassMetadata::EMBED_MANY:
683 109
                $this->loadEmbedManyCollection($collection);
684 109
                break;
685
686
            case ClassMetadata::REFERENCE_MANY:
687 76
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
688 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
689
                } else {
690 72
                    if ($mapping['isOwningSide']) {
691 60
                        $this->loadReferenceManyCollectionOwningSide($collection);
692
                    } else {
693 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
694
                    }
695
                }
696 76
                break;
697
        }
698 163
    }
699
700 109
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection)
701
    {
702 109
        $embeddedDocuments = $collection->getMongoData();
703 109
        $mapping = $collection->getMapping();
704 109
        $owner = $collection->getOwner();
705 109
        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...
706 82
            foreach ($embeddedDocuments as $key => $embeddedDocument) {
707 82
                $className = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
708 82
                $embeddedMetadata = $this->dm->getClassMetadata($className);
709 82
                $embeddedDocumentObject = $embeddedMetadata->newInstance();
710
711 82
                $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
712
713 82
                $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
714 82
                $id = $data[$embeddedMetadata->identifier] ?? null;
715
716 82
                if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
717 81
                    $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
718
                }
719 82
                if (CollectionHelper::isHash($mapping['strategy'])) {
720 10
                    $collection->set($key, $embeddedDocumentObject);
721
                } else {
722 82
                    $collection->add($embeddedDocumentObject);
723
                }
724
            }
725
        }
726 109
    }
727
728 60
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection)
729
    {
730 60
        $hints = $collection->getHints();
731 60
        $mapping = $collection->getMapping();
732 60
        $groupedIds = [];
733
734 60
        $sorted = isset($mapping['sort']) && $mapping['sort'];
735
736 60
        foreach ($collection->getMongoData() as $key => $reference) {
737 54
            $className = $this->uow->getClassNameForAssociation($mapping, $reference);
738 54
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
739 54
            $id = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
740
741
            // create a reference to the class and id
742 54
            $reference = $this->dm->getReference($className, $id);
743
744
            // no custom sort so add the references right now in the order they are embedded
745 54
            if (! $sorted) {
746 53
                if (CollectionHelper::isHash($mapping['strategy'])) {
747 2
                    $collection->set($key, $reference);
748
                } else {
749 51
                    $collection->add($reference);
750
                }
751
            }
752
753
            // only query for the referenced object if it is not already initialized or the collection is sorted
754 54
            if (($reference instanceof Proxy && ! $reference->__isInitialized__) || $sorted) {
0 ignored issues
show
Bug introduced by
Accessing __isInitialized__ on the interface Doctrine\ODM\MongoDB\Proxy\Proxy suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
755 54
                $groupedIds[$className][] = $identifier;
756
            }
757
        }
758 60
        foreach ($groupedIds as $className => $ids) {
759 39
            $class = $this->dm->getClassMetadata($className);
760 39
            $mongoCollection = $this->dm->getDocumentCollection($className);
761 39
            $criteria = $this->cm->merge(
762 39
                ['_id' => ['$in' => array_values($ids)]],
763 39
                $this->dm->getFilterCollection()->getFilterCriteria($class),
764 39
                $mapping['criteria'] ?? []
765
            );
766 39
            $criteria = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
767
768 39
            $options = [];
769 39
            if (isset($mapping['sort'])) {
770 39
                $options['sort'] = $this->prepareSort($mapping['sort']);
771
            }
772 39
            if (isset($mapping['limit'])) {
773
                $options['limit'] = $mapping['limit'];
774
            }
775 39
            if (isset($mapping['skip'])) {
776
                $options['skip'] = $mapping['skip'];
777
            }
778 39
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
779
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
780
            }
781
782 39
            $cursor = $mongoCollection->find($criteria, $options);
783 39
            $documents = $cursor->toArray();
784 39
            foreach ($documents as $documentData) {
785 38
                $document = $this->uow->getById($documentData['_id'], $class);
786 38
                if ($document instanceof Proxy && ! $document->__isInitialized()) {
787 38
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
788 38
                    $this->uow->setOriginalDocumentData($document, $data);
789 38
                    $document->__isInitialized__ = true;
0 ignored issues
show
Bug introduced by
Accessing __isInitialized__ on the interface Doctrine\ODM\MongoDB\Proxy\Proxy suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
790
                }
791 38
                if ($sorted) {
792 39
                    $collection->add($document);
793
                }
794
            }
795
        }
796 60
    }
797
798 17
    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection)
799
    {
800 17
        $query = $this->createReferenceManyInverseSideQuery($collection);
801 17
        $documents = $query->execute()->toArray();
802 17
        foreach ($documents as $key => $document) {
803 16
            $collection->add($document);
804
        }
805 17
    }
806
807
    /**
808
     *
809
     * @return Query
810
     */
811 17
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection)
812
    {
813 17
        $hints = $collection->getHints();
814 17
        $mapping = $collection->getMapping();
815 17
        $owner = $collection->getOwner();
816 17
        $ownerClass = $this->dm->getClassMetadata(get_class($owner));
817 17
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
818 17
        $mappedByMapping = $targetClass->fieldMappings[$mapping['mappedBy']] ?? [];
819 17
        $mappedByFieldName = ClassMetadata::getReferenceFieldName($mappedByMapping['storeAs'] ?? ClassMetadata::REFERENCE_STORE_AS_DB_REF, $mapping['mappedBy']);
820
821 17
        $criteria = $this->cm->merge(
822 17
            [$mappedByFieldName => $ownerClass->getIdentifierObject($owner)],
823 17
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
824 17
            $mapping['criteria'] ?? []
825
        );
826 17
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
827 17
        $qb = $this->dm->createQueryBuilder($mapping['targetDocument'])
828 17
            ->setQueryArray($criteria);
829
830 17
        if (isset($mapping['sort'])) {
831 17
            $qb->sort($mapping['sort']);
832
        }
833 17
        if (isset($mapping['limit'])) {
834 2
            $qb->limit($mapping['limit']);
835
        }
836 17
        if (isset($mapping['skip'])) {
837
            $qb->skip($mapping['skip']);
838
        }
839
840 17
        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
841
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
842
        }
843
844 17
        foreach ($mapping['prime'] as $field) {
845 4
            $qb->field($field)->prime(true);
846
        }
847
848 17
        return $qb->getQuery();
849
    }
850
851 5
    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection)
852
    {
853 5
        $cursor = $this->createReferenceManyWithRepositoryMethodCursor($collection);
854 5
        $mapping = $collection->getMapping();
855 5
        $documents = $cursor->toArray();
856 5
        foreach ($documents as $key => $obj) {
857 5
            if (CollectionHelper::isHash($mapping['strategy'])) {
858 1
                $collection->set($key, $obj);
859
            } else {
860 5
                $collection->add($obj);
861
            }
862
        }
863 5
    }
864
865
    /**
866
     *
867
     * @return \Iterator
868
     */
869 5
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection)
870
    {
871 5
        $mapping = $collection->getMapping();
872 5
        $repositoryMethod = $mapping['repositoryMethod'];
873 5
        $cursor = $this->dm->getRepository($mapping['targetDocument'])
874 5
            ->$repositoryMethod($collection->getOwner());
875
876 5
        if (! $cursor instanceof Iterator) {
877
            throw new \BadMethodCallException("Expected repository method {$repositoryMethod} to return an iterable object");
878
        }
879
880 5
        if (! empty($mapping['prime'])) {
881 1
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
882 1
            $primers = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));
883 1
            $class = $this->dm->getClassMetadata($mapping['targetDocument']);
884
885 1
            $cursor = new PrimingIterator($cursor, $class, $referencePrimer, $primers, $collection->getHints());
886
        }
887
888 5
        return $cursor;
889
    }
890
891
    /**
892
     * Prepare a projection array by converting keys, which are PHP property
893
     * names, to MongoDB field names.
894
     *
895
     * @param array $fields
896
     * @return array
897
     */
898 14
    public function prepareProjection(array $fields)
899
    {
900 14
        $preparedFields = [];
901
902 14
        foreach ($fields as $key => $value) {
903 14
            $preparedFields[$this->prepareFieldName($key)] = $value;
904
        }
905
906 14
        return $preparedFields;
907
    }
908
909
    /**
910
     * @param string $sort
911
     * @return int
912
     */
913 25
    private function getSortDirection($sort)
914
    {
915 25
        switch (strtolower((string) $sort)) {
916 25
            case 'desc':
917 15
                return -1;
918
919 22
            case 'asc':
920 13
                return 1;
921
        }
922
923 12
        return $sort;
924
    }
925
926
    /**
927
     * Prepare a sort specification array by converting keys to MongoDB field
928
     * names and changing direction strings to int.
929
     *
930
     * @param array $fields
931
     * @return array
932
     */
933 141
    public function prepareSort(array $fields)
934
    {
935 141
        $sortFields = [];
936
937 141
        foreach ($fields as $key => $value) {
938 25
            $sortFields[$this->prepareFieldName($key)] = $this->getSortDirection($value);
939
        }
940
941 141
        return $sortFields;
942
    }
943
944
    /**
945
     * Prepare a mongodb field name and convert the PHP property names to MongoDB field names.
946
     *
947
     * @param string $fieldName
948
     * @return string
949
     */
950 433
    public function prepareFieldName($fieldName)
951
    {
952 433
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);
953
954 433
        return $fieldNames[0][0];
955
    }
956
957
    /**
958
     * Adds discriminator criteria to an already-prepared query.
959
     *
960
     * This method should be used once for query criteria and not be used for
961
     * nested expressions. It should be called before
962
     * {@link DocumentPerister::addFilterToPreparedQuery()}.
963
     *
964
     * @param array $preparedQuery
965
     * @return array
966
     */
967 492
    public function addDiscriminatorToPreparedQuery(array $preparedQuery)
968
    {
969
        /* If the class has a discriminator field, which is not already in the
970
         * criteria, inject it now. The field/values need no preparation.
971
         */
972 492
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
973 29
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
974 29
            if (count($discriminatorValues) === 1) {
975 21
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
976
            } else {
977 10
                $preparedQuery[$this->class->discriminatorField] = ['$in' => $discriminatorValues];
978
            }
979
        }
980
981 492
        return $preparedQuery;
982
    }
983
984
    /**
985
     * Adds filter criteria to an already-prepared query.
986
     *
987
     * This method should be used once for query criteria and not be used for
988
     * nested expressions. It should be called after
989
     * {@link DocumentPerister::addDiscriminatorToPreparedQuery()}.
990
     *
991
     * @param array $preparedQuery
992
     * @return array
993
     */
994 493
    public function addFilterToPreparedQuery(array $preparedQuery)
995
    {
996
        /* If filter criteria exists for this class, prepare it and merge
997
         * over the existing query.
998
         *
999
         * @todo Consider recursive merging in case the filter criteria and
1000
         * prepared query both contain top-level $and/$or operators.
1001
         */
1002 493
        $filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class);
1003 493
        if ($filterCriteria) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filterCriteria 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...
1004 18
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
1005
        }
1006
1007 493
        return $preparedQuery;
1008
    }
1009
1010
    /**
1011
     * Prepares the query criteria or new document object.
1012
     *
1013
     * PHP field names and types will be converted to those used by MongoDB.
1014
     *
1015
     * @param array $query
1016
     * @param bool  $isNewObj
1017
     * @return array
1018
     */
1019 525
    public function prepareQueryOrNewObj(array $query, $isNewObj = false)
1020
    {
1021 525
        $preparedQuery = [];
1022
1023 525
        foreach ($query as $key => $value) {
1024
            // Recursively prepare logical query clauses
1025 483
            if (in_array($key, ['$and', '$or', '$nor']) && is_array($value)) {
1026 20
                foreach ($value as $k2 => $v2) {
1027 20
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
1028
                }
1029 20
                continue;
1030
            }
1031
1032 483
            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
1033 38
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
1034 38
                continue;
1035
            }
1036
1037 483
            $preparedQueryElements = $this->prepareQueryElement((string) $key, $value, null, true, $isNewObj);
1038 483
            foreach ($preparedQueryElements as list($preparedKey, $preparedValue)) {
1039 483
                $preparedQuery[$preparedKey] = is_array($preparedValue)
1040 133
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
1041 483
                    : Type::convertPHPToDatabaseValue($preparedValue);
1042
            }
1043
        }
1044
1045 525
        return $preparedQuery;
1046
    }
1047
1048
    /**
1049
     * Prepares a query value and converts the PHP value to the database value
1050
     * if it is an identifier.
1051
     *
1052
     * It also handles converting $fieldName to the database name if they are different.
1053
     *
1054
     * @param string        $fieldName
1055
     * @param mixed         $value
1056
     * @param ClassMetadata $class        Defaults to $this->class
1057
     * @param bool          $prepareValue Whether or not to prepare the value
1058
     * @param bool          $inNewObj     Whether or not newObj is being prepared
1059
     * @return array An array of tuples containing prepared field names and values
1060
     */
1061 876
    private function prepareQueryElement($fieldName, $value = null, $class = null, $prepareValue = true, $inNewObj = false)
1062
    {
1063 876
        $class = $class ?? $this->class;
1064
1065
        // @todo Consider inlining calls to ClassMetadata methods
1066
1067
        // Process all non-identifier fields by translating field names
1068 876
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
1069 247
            $mapping = $class->fieldMappings[$fieldName];
1070 247
            $fieldName = $mapping['name'];
1071
1072 247
            if (! $prepareValue) {
1073 52
                return [[$fieldName, $value]];
1074
            }
1075
1076
            // Prepare mapped, embedded objects
1077 205
            if (! empty($mapping['embedded']) && is_object($value) &&
1078 205
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
1079 3
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
1080
            }
1081
1082 203
            if (! empty($mapping['reference']) && is_object($value) && ! ($value instanceof ObjectId)) {
0 ignored issues
show
Bug introduced by
The class MongoDB\BSON\ObjectId does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
1083
                try {
1084 14
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
1085 1
                } catch (MappingException $e) {
0 ignored issues
show
Bug introduced by
The class Doctrine\Common\Persiste...apping\MappingException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
1086
                    // do nothing in case passed object is not mapped document
1087
                }
1088
            }
1089
1090
            // No further preparation unless we're dealing with a simple reference
1091
            // We can't have expressions in empty() with PHP < 5.5, so store it in a variable
1092 190
            $arrayValue = (array) $value;
1093 190
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty($arrayValue)) {
1094 126
                return [[$fieldName, $value]];
1095
            }
1096
1097
            // Additional preparation for one or more simple reference values
1098 91
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1099
1100 91
            if (! is_array($value)) {
1101 87
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1102
            }
1103
1104
            // Objects without operators or with DBRef fields can be converted immediately
1105 6
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1106 3
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1107
            }
1108
1109 6
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1110
        }
1111
1112
        // Process identifier fields
1113 789
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1114 334
            $fieldName = '_id';
1115
1116 334
            if (! $prepareValue) {
1117 42
                return [[$fieldName, $value]];
1118
            }
1119
1120 295
            if (! is_array($value)) {
1121 272
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1122
            }
1123
1124
            // Objects without operators or with DBRef fields can be converted immediately
1125 61
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1126 6
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1127
            }
1128
1129 56
            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1130
        }
1131
1132
        // No processing for unmapped, non-identifier, non-dotted field names
1133 553
        if (strpos($fieldName, '.') === false) {
1134 414
            return [[$fieldName, $value]];
1135
        }
1136
1137
        /* Process "fieldName.objectProperty" queries (on arrays or objects).
1138
         *
1139
         * We can limit parsing here, since at most three segments are
1140
         * significant: "fieldName.objectProperty" with an optional index or key
1141
         * for collections stored as either BSON arrays or objects.
1142
         */
1143 152
        $e = explode('.', $fieldName, 4);
1144
1145
        // No further processing for unmapped fields
1146 152
        if (! isset($class->fieldMappings[$e[0]])) {
1147 6
            return [[$fieldName, $value]];
1148
        }
1149
1150 147
        $mapping = $class->fieldMappings[$e[0]];
1151 147
        $e[0] = $mapping['name'];
1152
1153
        // Hash and raw fields will not be prepared beyond the field name
1154 147
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1155 1
            $fieldName = implode('.', $e);
1156
1157 1
            return [[$fieldName, $value]];
1158
        }
1159
1160 146
        if ($mapping['type'] === 'many' && CollectionHelper::isHash($mapping['strategy'])
1161 146
                && isset($e[2])) {
1162 1
            $objectProperty = $e[2];
1163 1
            $objectPropertyPrefix = $e[1] . '.';
1164 1
            $nextObjectProperty = implode('.', array_slice($e, 3));
1165 145
        } elseif ($e[1] !== '$') {
1166 144
            $fieldName = $e[0] . '.' . $e[1];
1167 144
            $objectProperty = $e[1];
1168 144
            $objectPropertyPrefix = '';
1169 144
            $nextObjectProperty = implode('.', array_slice($e, 2));
1170 1
        } elseif (isset($e[2])) {
1171 1
            $fieldName = $e[0] . '.' . $e[1] . '.' . $e[2];
1172 1
            $objectProperty = $e[2];
1173 1
            $objectPropertyPrefix = $e[1] . '.';
1174 1
            $nextObjectProperty = implode('.', array_slice($e, 3));
1175
        } else {
1176 1
            $fieldName = $e[0] . '.' . $e[1];
1177
1178 1
            return [[$fieldName, $value]];
1179
        }
1180
1181
        // No further processing for fields without a targetDocument mapping
1182 146
        if (! isset($mapping['targetDocument'])) {
1183 3
            if ($nextObjectProperty) {
1184
                $fieldName .= '.' . $nextObjectProperty;
1185
            }
1186
1187 3
            return [[$fieldName, $value]];
1188
        }
1189
1190 143
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1191
1192
        // No further processing for unmapped targetDocument fields
1193 143
        if (! $targetClass->hasField($objectProperty)) {
1194 25
            if ($nextObjectProperty) {
1195
                $fieldName .= '.' . $nextObjectProperty;
1196
            }
1197
1198 25
            return [[$fieldName, $value]];
1199
        }
1200
1201 123
        $targetMapping = $targetClass->getFieldMapping($objectProperty);
1202 123
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1203
1204
        // Prepare DBRef identifiers or the mapped field's property path
1205 123
        $fieldName = ($objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID)
1206 105
            ? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1207 123
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1208
1209
        // Process targetDocument identifier fields
1210 123
        if ($objectPropertyIsId) {
1211 106
            if (! $prepareValue) {
1212 7
                return [[$fieldName, $value]];
1213
            }
1214
1215 99
            if (! is_array($value)) {
1216 85
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1217
            }
1218
1219
            // Objects without operators or with DBRef fields can be converted immediately
1220 16
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1221 6
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1222
            }
1223
1224 16
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1225
        }
1226
1227
        /* The property path may include a third field segment, excluding the
1228
         * collection item pointer. If present, this next object property must
1229
         * be processed recursively.
1230
         */
1231 17
        if ($nextObjectProperty) {
1232
            // Respect the targetDocument's class metadata when recursing
1233 14
            $nextTargetClass = isset($targetMapping['targetDocument'])
1234 8
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1235 14
                : null;
1236
1237 14
            $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1238
1239 14
            return array_map(function ($preparedTuple) use ($fieldName) {
1240 14
                list($key, $value) = $preparedTuple;
1241
1242 14
                return [$fieldName . '.' . $key, $value];
1243 14
            }, $fieldNames);
1244
        }
1245
1246 5
        return [[$fieldName, $value]];
1247
    }
1248
1249
    /**
1250
     * Prepares a query expression.
1251
     *
1252
     * @param array|object  $expression
1253
     * @param ClassMetadata $class
1254
     * @return array
1255
     */
1256 78
    private function prepareQueryExpression($expression, $class)
1257
    {
1258 78
        foreach ($expression as $k => $v) {
1259
            // Ignore query operators whose arguments need no type conversion
1260 78
            if (in_array($k, ['$exists', '$type', '$mod', '$size'])) {
1261 16
                continue;
1262
            }
1263
1264
            // Process query operators whose argument arrays need type conversion
1265 78
            if (in_array($k, ['$in', '$nin', '$all']) && is_array($v)) {
1266 76
                foreach ($v as $k2 => $v2) {
1267 76
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
1268
                }
1269 76
                continue;
1270
            }
1271
1272
            // Recursively process expressions within a $not operator
1273 18
            if ($k === '$not' && is_array($v)) {
1274 15
                $expression[$k] = $this->prepareQueryExpression($v, $class);
1275 15
                continue;
1276
            }
1277
1278 18
            $expression[$k] = $class->getDatabaseIdentifierValue($v);
1279
        }
1280
1281 78
        return $expression;
1282
    }
1283
1284
    /**
1285
     * Checks whether the value has DBRef fields.
1286
     *
1287
     * This method doesn't check if the the value is a complete DBRef object,
1288
     * although it should return true for a DBRef. Rather, we're checking that
1289
     * the value has one or more fields for a DBref. In practice, this could be
1290
     * $elemMatch criteria for matching a DBRef.
1291
     *
1292
     * @param mixed $value
1293
     * @return bool
1294
     */
1295 79
    private function hasDBRefFields($value)
1296
    {
1297 79
        if (! is_array($value) && ! is_object($value)) {
1298
            return false;
1299
        }
1300
1301 79
        if (is_object($value)) {
1302
            $value = get_object_vars($value);
1303
        }
1304
1305 79
        foreach ($value as $key => $_) {
1306 79
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
1307 79
                return true;
1308
            }
1309
        }
1310
1311 78
        return false;
1312
    }
1313
1314
    /**
1315
     * Checks whether the value has query operators.
1316
     *
1317
     * @param mixed $value
1318
     * @return bool
1319
     */
1320 83
    private function hasQueryOperators($value)
1321
    {
1322 83
        if (! is_array($value) && ! is_object($value)) {
1323
            return false;
1324
        }
1325
1326 83
        if (is_object($value)) {
1327
            $value = get_object_vars($value);
1328
        }
1329
1330 83
        foreach ($value as $key => $_) {
1331 83
            if (isset($key[0]) && $key[0] === '$') {
1332 83
                return true;
1333
            }
1334
        }
1335
1336 11
        return false;
1337
    }
1338
1339
    /**
1340
     * Gets the array of discriminator values for the given ClassMetadata
1341
     *
1342
     * @return array
1343
     */
1344 29
    private function getClassDiscriminatorValues(ClassMetadata $metadata)
1345
    {
1346 29
        $discriminatorValues = [$metadata->discriminatorValue];
1347 29
        foreach ($metadata->subClasses as $className) {
1348 8
            $key = array_search($className, $metadata->discriminatorMap);
1349 8
            if ($key) {
1350 8
                $discriminatorValues[] = $key;
1351
            }
1352
        }
1353
1354
        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
1355 29
        if ($metadata->defaultDiscriminatorValue && array_search($metadata->defaultDiscriminatorValue, $discriminatorValues) !== false) {
1356 2
            $discriminatorValues[] = null;
1357
        }
1358
1359 29
        return $discriminatorValues;
1360
    }
1361
1362 546
    private function handleCollections($document, $options)
1363
    {
1364
        // Collection deletions (deletions of complete collections)
1365 546
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1366 103
            if ($this->uow->isCollectionScheduledForDeletion($coll)) {
1367 103
                $this->cp->delete($coll, $options);
1368
            }
1369
        }
1370
        // Collection updates (deleteRows, updateRows, insertRows)
1371 546
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1372 103
            if ($this->uow->isCollectionScheduledForUpdate($coll)) {
1373 103
                $this->cp->update($coll, $options);
1374
            }
1375
        }
1376
        // Take new snapshots from visited collections
1377 546
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
1378 226
            $coll->takeSnapshot();
1379
        }
1380 546
    }
1381
1382
    /**
1383
     * If the document is new, ignore shard key field value, otherwise throw an exception.
1384
     * Also, shard key field should be present in actual document data.
1385
     *
1386
     * @param object $document
1387
     * @param string $shardKeyField
1388
     * @param array  $actualDocumentData
1389
     *
1390
     * @throws MongoDBException
1391
     */
1392 4
    private function guardMissingShardKey($document, $shardKeyField, $actualDocumentData)
1393
    {
1394 4
        $dcs = $this->uow->getDocumentChangeSet($document);
1395 4
        $isUpdate = $this->uow->isScheduledForUpdate($document);
1396
1397 4
        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
1398 4
        $fieldName = $fieldMapping['fieldName'];
1399
1400 4
        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] !== $dcs[$fieldName][1]) {
1401
            throw MongoDBException::shardKeyFieldCannotBeChanged($shardKeyField, $this->class->getName());
0 ignored issues
show
Bug introduced by
Consider using $this->class->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
1402
        }
1403
1404 4
        if (! isset($actualDocumentData[$fieldName])) {
1405
            throw MongoDBException::shardKeyFieldMissing($shardKeyField, $this->class->getName());
0 ignored issues
show
Bug introduced by
Consider using $this->class->name. There is an issue with getName() and APC-enabled PHP versions.
Loading history...
1406
        }
1407 4
    }
1408
1409
    /**
1410
     * Get shard key aware query for single document.
1411
     *
1412
     * @param object $document
1413
     *
1414
     * @return array
1415
     */
1416 263
    private function getQueryForDocument($document)
1417
    {
1418 263
        $id = $this->uow->getDocumentIdentifier($document);
1419 263
        $id = $this->class->getDatabaseIdentifierValue($id);
1420
1421 263
        $shardKeyQueryPart = $this->getShardKeyQuery($document);
1422 263
        $query = array_merge(['_id' => $id], $shardKeyQueryPart);
1423
1424 263
        return $query;
1425
    }
1426
1427
    /**
1428
     * @param array $options
1429
     *
1430
     * @return array
1431
     */
1432 547
    private function getWriteOptions(array $options = [])
1433
    {
1434 547
        $defaultOptions = $this->dm->getConfiguration()->getDefaultCommitOptions();
1435 547
        $documentOptions = [];
1436 547
        if ($this->class->hasWriteConcern()) {
1437 9
            $documentOptions['w'] = $this->class->getWriteConcern();
1438
        }
1439
1440 547
        return array_merge($defaultOptions, $documentOptions, $options);
1441
    }
1442
1443
    /**
1444
     * @param string $fieldName
1445
     * @param mixed  $value
1446
     * @param array  $mapping
1447
     * @param bool   $inNewObj
1448
     * @return array
1449
     */
1450 15
    private function prepareReference($fieldName, $value, array $mapping, $inNewObj)
1451
    {
1452 15
        $reference = $this->dm->createReference($value, $mapping);
1453 14
        if ($inNewObj || $mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_ID) {
1454 8
            return [[$fieldName, $reference]];
1455
        }
1456
1457 6
        switch ($mapping['storeAs']) {
1458
            case ClassMetadata::REFERENCE_STORE_AS_REF:
1459
                $keys = ['id' => true];
1460
                break;
1461
1462
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF:
1463
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB:
1464 6
                $keys = ['$ref' => true, '$id' => true, '$db' => true];
1465
1466 6
                if ($mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_DB_REF) {
1467 5
                    unset($keys['$db']);
1468
                }
1469
1470 6
                if (isset($mapping['targetDocument'])) {
1471 4
                    unset($keys['$ref'], $keys['$db']);
1472
                }
1473 6
                break;
1474
1475
            default:
1476
                throw new \InvalidArgumentException("Reference type {$mapping['storeAs']} is invalid.");
1477
        }
1478
1479 6
        if ($mapping['type'] === 'many') {
1480 2
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
1481
        }
1482
1483 4
        return array_map(
1484 4
            function ($key) use ($reference, $fieldName) {
1485 4
                return [$fieldName . '.' . $key, $reference[$key]];
1486 4
            },
1487 4
            array_keys($keys)
1488
        );
1489
    }
1490
}
1491