Completed
Push — master ( 55e1b9...dc9765 )
by Maciej
20s queued 10s
created

DocumentPersister::executeUpserts()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4.5923

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 8
cts 12
cp 0.6667
rs 9.6666
c 0
b 0
f 0
cc 4
nc 5
nop 1
crap 4.5923
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) {
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...
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) {
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...
220
            try {
221 508
                $this->collection->insertMany($inserts, $options);
222 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...
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) {
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...
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) {
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...
259
                unset($this->queuedUpserts[$oid]);
260
                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) {
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...
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) {
0 ignored issues
show
Bug introduced by
The class MongoDB\GridFS\Bucket 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...
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
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...
458
    {
459
        // TODO: remove this
460 365
        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...
461
            $criteria = ['_id' => $criteria];
462
        }
463
464 365
        $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...
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 7
                $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 80
                $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) {
0 ignored issues
show
Bug introduced by
The class ProxyManager\Proxy\GhostObjectInterface 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...
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()) {
0 ignored issues
show
Bug introduced by
The class ProxyManager\Proxy\GhostObjectInterface 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...
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 1
                $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 4
                $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) {
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...
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)
0 ignored issues
show
Bug introduced by
The variable $preparedKey does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $preparedValue does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
969 132
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
970 465
                    : 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)) {
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...
1008
                try {
1009 14
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
1010 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...
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;
0 ignored issues
show
Bug introduced by
The variable $key does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
Bug introduced by
The variable $value does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
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 4
                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 78
                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)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $metadata->defaultDiscriminatorValue of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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());
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...
1335
        }
1336
1337 8
        if (! isset($actualDocumentData[$fieldName])) {
1338
            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...
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