Completed
Pull Request — master (#1984)
by Maciej
26:38 queued 26s
created

DocumentPersister::lock()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 8
ccs 5
cts 5
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 2
crap 1
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
 * @internal
67
 */
68
final class DocumentPersister
69
{
70
    /** @var PersistenceBuilder */
71
    private $pb;
72
73
    /** @var DocumentManager */
74
    private $dm;
75
76
    /** @var UnitOfWork */
77
    private $uow;
78
79
    /** @var ClassMetadata */
80
    private $class;
81
82
    /** @var Collection|null */
83
    private $collection;
84
85
    /** @var Bucket|null */
86
    private $bucket;
87
88
    /**
89
     * Array of queued inserts for the persister to insert.
90
     *
91
     * @var array
92
     */
93
    private $queuedInserts = [];
94
95
    /**
96
     * Array of queued inserts for the persister to insert.
97
     *
98
     * @var array
99
     */
100
    private $queuedUpserts = [];
101
102
    /** @var CriteriaMerger */
103
    private $cm;
104
105
    /** @var CollectionPersister */
106
    private $cp;
107
108
    /** @var HydratorFactory */
109
    private $hydratorFactory;
110
111
    public function __construct(
112
        PersistenceBuilder $pb,
113
        DocumentManager $dm,
114
        UnitOfWork $uow,
115 1133
        HydratorFactory $hydratorFactory,
116
        ClassMetadata $class,
117
        ?CriteriaMerger $cm = null
118
    ) {
119
        $this->pb              = $pb;
120
        $this->dm              = $dm;
121
        $this->cm              = $cm ?: new CriteriaMerger();
122
        $this->uow             = $uow;
123 1133
        $this->hydratorFactory = $hydratorFactory;
124 13
        $this->class           = $class;
125
        $this->cp              = $this->uow->getCollectionPersister();
126 1133
127 1133
        if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) {
128 1133
            return;
129 1133
        }
130 1133
131 1133
        $this->collection = $dm->getDocumentCollection($class->name);
132 1133
133 1133
        if (! $class->isFile) {
134
            return;
135 1133
        }
136 1126
137
        $this->bucket = $dm->getDocumentBucket($class->name);
138
    }
139 10
140 10
    public function getInserts() : array
141
    {
142
        return $this->queuedInserts;
143
    }
144
145
    public function isQueuedForInsert(object $document) : bool
146
    {
147
        return isset($this->queuedInserts[spl_object_hash($document)]);
148
    }
149
150
    /**
151
     * Adds a document to the queued insertions.
152
     * The document remains queued until {@link executeInserts} is invoked.
153
     */
154
    public function addInsert(object $document) : void
155
    {
156 522
        $this->queuedInserts[spl_object_hash($document)] = $document;
157
    }
158 522
159 522
    public function getUpserts() : array
160
    {
161
        return $this->queuedUpserts;
162
    }
163
164
    public function isQueuedForUpsert(object $document) : bool
165
    {
166
        return isset($this->queuedUpserts[spl_object_hash($document)]);
167
    }
168
169
    /**
170
     * Adds a document to the queued upserts.
171
     * The document remains queued until {@link executeUpserts} is invoked.
172
     */
173
    public function addUpsert(object $document) : void
174
    {
175 85
        $this->queuedUpserts[spl_object_hash($document)] = $document;
176
    }
177 85
178 85
    /**
179
     * Gets the ClassMetadata instance of the document class this persister is
180
     * used for.
181
     */
182
    public function getClassMetadata() : ClassMetadata
183
    {
184
        return $this->class;
185
    }
186
187
    /**
188
     * Executes all queued document insertions.
189
     *
190
     * Queued documents without an ID will inserted in a batch and queued
191
     * documents with an ID will be upserted individually.
192
     *
193
     * If no inserts are queued, invoking this method is a NOOP.
194
     *
195
     * @throws DriverException
196
     */
197
    public function executeInserts(array $options = []) : void
198
    {
199 522
        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...
200
            return;
201 522
        }
202
203
        $inserts = [];
204
        $options = $this->getWriteOptions($options);
205 522
        foreach ($this->queuedInserts as $oid => $document) {
206 522
            $data = $this->pb->prepareInsertData($document);
207 522
208 522
            // Set the initial version for each insert
209
            if ($this->class->isVersioned) {
210
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
211 511
                $nextVersion    = null;
212 40
                if ($versionMapping['type'] === 'int') {
213 40
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
214 40
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
215 38
                } elseif ($versionMapping['type'] === 'date') {
216 38
                    $nextVersionDateTime = new DateTime();
217 2
                    $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
218 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
219 2
                }
220 2
                $data[$versionMapping['name']] = $nextVersion;
221
            }
222 40
223
            $inserts[] = $data;
224
        }
225 511
226
        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...
227
            try {
228 511
                $this->collection->insertMany($inserts, $options);
229
            } 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...
230 511
                $this->queuedInserts = [];
231 6
                throw $e;
232 6
            }
233 6
        }
234
235
        /* All collections except for ones using addToSet have already been
236
         * saved. We have left these to be handled separately to avoid checking
237
         * collection for uniqueness on PHP side.
238
         */
239
        foreach ($this->queuedInserts as $document) {
240
            $this->handleCollections($document, $options);
241 511
        }
242 511
243
        $this->queuedInserts = [];
244
    }
245 511
246 511
    /**
247
     * Executes all queued document upserts.
248
     *
249
     * Queued documents with an ID are upserted individually.
250
     *
251
     * If no upserts are queued, invoking this method is a NOOP.
252
     */
253
    public function executeUpserts(array $options = []) : void
254
    {
255 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...
256
            return;
257 85
        }
258
259
        $options = $this->getWriteOptions($options);
260
        foreach ($this->queuedUpserts as $oid => $document) {
261 85
            try {
262 85
                $this->executeUpsert($document, $options);
263
                $this->handleCollections($document, $options);
264 85
                unset($this->queuedUpserts[$oid]);
265 85
            } 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...
266 85
                unset($this->queuedUpserts[$oid]);
267
                throw $e;
268
            }
269
        }
270
    }
271
272 85
    /**
273
     * Executes a single upsert in {@link executeUpserts}
274
     */
275
    private function executeUpsert(object $document, array $options) : void
276
    {
277 85
        $options['upsert'] = true;
278
        $criteria          = $this->getQueryForDocument($document);
279 85
280 85
        $data = $this->pb->prepareUpsertData($document);
281
282 85
        // Set the initial version for each upsert
283
        if ($this->class->isVersioned) {
284
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
285 85
            $nextVersion    = null;
286 3
            if ($versionMapping['type'] === 'int') {
287 3
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
288 3
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
289 2
            } elseif ($versionMapping['type'] === 'date') {
290 2
                $nextVersionDateTime = new DateTime();
291 1
                $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
292 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
293 1
            }
294 1
            $data['$set'][$versionMapping['name']] = $nextVersion;
295
        }
296 3
297
        foreach (array_keys($criteria) as $field) {
298
            unset($data['$set'][$field]);
299 85
            unset($data['$inc'][$field]);
300 85
            unset($data['$setOnInsert'][$field]);
301 85
        }
302 85
303
        // Do not send empty update operators
304
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
305
            if (! empty($data[$operator])) {
306 85
                continue;
307 85
            }
308 70
309
            unset($data[$operator]);
310
        }
311 85
312
        /* If there are no modifiers remaining, we're upserting a document with
313
         * an identifier as its only field. Since a document with the identifier
314
         * may already exist, the desired behavior is "insert if not exists" and
315
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
316
         * the identifier to the same value in our criteria.
317
         *
318
         * This will fail for versions before MongoDB 2.6, which require an
319
         * empty $set modifier. The best we can do (without attempting to check
320
         * server versions in advance) is attempt the 2.6+ behavior and retry
321
         * after the relevant exception.
322
         *
323
         * See: https://jira.mongodb.org/browse/SERVER-12266
324
         */
325
        if (empty($data)) {
326
            $retry = true;
327 85
            $data  = ['$set' => ['_id' => $criteria['_id']]];
328 16
        }
329 16
330
        try {
331
            $this->collection->updateOne($criteria, $data, $options);
332
            return;
333 85
        } 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...
334 85
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
335
                throw $e;
336
            }
337
        }
338
339
        $this->collection->updateOne($criteria, ['$set' => new stdClass()], $options);
340
    }
341
342
    /**
343
     * Updates the already persisted document if it has any new changesets.
344
     *
345
     * @throws LockException
346
     */
347
    public function update(object $document, array $options = []) : void
348
    {
349 227
        $update = $this->pb->prepareUpdateData($document);
350
351 227
        $query = $this->getQueryForDocument($document);
352
353 227
        foreach (array_keys($query) as $field) {
354
            unset($update['$set'][$field]);
355 225
        }
356 225
357
        if (empty($update['$set'])) {
358
            unset($update['$set']);
359 225
        }
360 100
361
        // Include versioning logic to set the new version value in the database
362
        // and to ensure the version has not changed since this document object instance
363
        // was fetched from the database
364
        $nextVersion = null;
365
        if ($this->class->isVersioned) {
366 225
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
367 225
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
368 33
            if ($versionMapping['type'] === 'int') {
369 33
                $nextVersion                             = $currentVersion + 1;
370 33
                $update['$inc'][$versionMapping['name']] = 1;
371 30
                $query[$versionMapping['name']]          = $currentVersion;
372 30
            } elseif ($versionMapping['type'] === 'date') {
373 30
                $nextVersion                             = new DateTime();
374 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
375 3
                $query[$versionMapping['name']]          = Type::convertPHPToDatabaseValue($currentVersion);
376 3
            }
377 3
        }
378
379
        if (! empty($update)) {
380
            // Include locking logic so that if the document object in memory is currently
381 225
            // locked then it will remove it, otherwise it ensures the document is not locked.
382
            if ($this->class->isLockable) {
383
                $isLocked    = $this->class->reflFields[$this->class->lockField]->getValue($document);
384 151
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
385 11
                if ($isLocked) {
386 11
                    $update['$unset'] = [$lockMapping['name'] => true];
387 11
                } else {
388 2
                    $query[$lockMapping['name']] = ['$exists' => false];
389
                }
390 9
            }
391
392
            $options = $this->getWriteOptions($options);
393
394 151
            $result = $this->collection->updateOne($query, $update, $options);
395
396 151
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
397
                throw LockException::lockFailed($document);
398 151
            } elseif ($this->class->isVersioned) {
399 6
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
400 146
            }
401 28
        }
402
403
        $this->handleCollections($document, $options);
404
    }
405 220
406 220
    /**
407
     * Removes document from mongo
408
     *
409
     * @throws LockException
410
     */
411
    public function delete(object $document, array $options = []) : void
412
    {
413 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...
414
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
415 36
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
416 1
417 1
            $this->bucket->delete($databaseIdentifier);
418
419 1
            return;
420
        }
421 1
422
        $query = $this->getQueryForDocument($document);
423
424 35
        if ($this->class->isLockable) {
425
            $query[$this->class->lockField] = ['$exists' => false];
426 35
        }
427 2
428
        $options = $this->getWriteOptions($options);
429
430 35
        $result = $this->collection->deleteOne($query, $options);
431
432 35
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
433
            throw LockException::lockFailed($document);
434 35
        }
435 2
    }
436
437 33
    /**
438
     * Refreshes a managed document.
439
     */
440
    public function refresh(object $document) : void
441
    {
442 23
        $query = $this->getQueryForDocument($document);
443
        $data  = $this->collection->findOne($query);
444 23
        if ($data === null) {
445 23
            throw MongoDBException::cannotRefreshDocument();
446 23
        }
447
        $data = $this->hydratorFactory->hydrate($document, (array) $data);
448
        $this->uow->setOriginalDocumentData($document, $data);
449 23
    }
450 23
451 23
    /**
452
     * Finds a document by a set of criteria.
453
     *
454
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
455
     * be used to match an _id value.
456
     *
457
     * @param mixed $criteria Query criteria
458
     *
459
     * @throws LockException
460
     *
461
     * @todo Check identity map? loadById method? Try to guess whether
462
     *     $criteria is the id?
463
     */
464
    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...
465
    {
466 366
        // TODO: remove this
467
        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...
468
            $criteria = ['_id' => $criteria];
469 366
        }
470
471
        $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...
472
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
473 366
        $criteria = $this->addFilterToPreparedQuery($criteria);
474 366
475 366
        $options = [];
476
        if ($sort !== null) {
477 366
            $options['sort'] = $this->prepareSort($sort);
478 366
        }
479 95
        $result = $this->collection->findOne($criteria, $options);
480
        $result = $result !== null ? (array) $result : null;
481 366
482 366
        if ($this->class->isLockable) {
483
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
484 366
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
485 1
                throw LockException::lockFailed($document);
486 1
            }
487 1
        }
488
489
        if ($result === null) {
490
            return null;
491 365
        }
492 115
493
        return $this->createDocument($result, $document, $hints);
494
    }
495 321
496
    /**
497
     * Finds documents by a set of criteria.
498
     */
499
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null) : Iterator
500
    {
501 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
502
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
503 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
504 22
505 22
        $options = [];
506
        if ($sort !== null) {
507 22
            $options['sort'] = $this->prepareSort($sort);
508 22
        }
509 11
510
        if ($limit !== null) {
511
            $options['limit'] = $limit;
512 22
        }
513 10
514
        if ($skip !== null) {
515
            $options['skip'] = $skip;
516 22
        }
517 1
518
        $baseCursor = $this->collection->find($criteria, $options);
519
        return $this->wrapCursor($baseCursor);
520 22
    }
521 22
522
    /**
523
     * @throws MongoDBException
524
     */
525
    private function getShardKeyQuery(object $document) : array
526
    {
527 307
        if (! $this->class->isSharded()) {
528
            return [];
529 307
        }
530 297
531
        $shardKey = $this->class->getShardKey();
532
        $keys     = array_keys($shardKey['keys']);
533 10
        $data     = $this->uow->getDocumentActualData($document);
534 10
535 10
        $shardKeyQueryPart = [];
536
        foreach ($keys as $key) {
537 10
            assert(is_string($key));
538 10
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
539 10
            $this->guardMissingShardKey($document, $key, $data);
540 10
541 10
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
542
                $reference = $this->prepareReference(
543 8
                    $key,
544 1
                    $data[$mapping['fieldName']],
545 1
                    $mapping,
546 1
                    false
547 1
                );
548 1
                foreach ($reference as $keyValue) {
549
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
550 1
                }
551 1
            } else {
552
                $value                   = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
553
                $shardKeyQueryPart[$key] = $value;
554 7
            }
555 7
        }
556
557
        return $shardKeyQueryPart;
558
    }
559 8
560
    /**
561
     * Wraps the supplied base cursor in the corresponding ODM class.
562
     */
563
    private function wrapCursor(Cursor $baseCursor) : Iterator
564
    {
565 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
566
    }
567 22
568
    /**
569
     * Checks whether the given managed document exists in the database.
570
     */
571
    public function exists(object $document) : bool
572
    {
573 3
        $id = $this->class->getIdentifierObject($document);
574
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
575 3
    }
576 3
577
    /**
578
     * Locks document by storing the lock mode on the mapped lock field.
579
     */
580
    public function lock(object $document, int $lockMode) : void
581
    {
582 5
        $id          = $this->uow->getDocumentIdentifier($document);
583
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
584 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
585 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
586 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
587 5
    }
588 5
589 5
    /**
590
     * Releases any lock that exists on this document.
591
     */
592
    public function unlock(object $document) : void
593
    {
594 1
        $id          = $this->uow->getDocumentIdentifier($document);
595
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
596 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
597 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
598 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
599 1
    }
600 1
601 1
    /**
602
     * Creates or fills a single document object from an query result.
603
     *
604
     * @param array  $result   The query result.
605
     * @param object $document The document object to fill, if any.
606
     * @param array  $hints    Hints for document creation.
607
     *
608
     * @return object|null The filled and managed document object or NULL, if the query result is empty.
609
     */
610
    private function createDocument(array $result, ?object $document = null, array $hints = []) : ?object
611
    {
612 321
        if ($document !== null) {
613
            $hints[Query::HINT_REFRESH] = true;
614 321
            $id                         = $this->class->getPHPIdentifierValue($result['_id']);
615 26
            $this->uow->registerManaged($document, $id, $result);
616 26
        }
617 26
618
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
619
    }
620 321
621
    /**
622
     * Loads a PersistentCollection data. Used in the initialize() method.
623
     */
624
    public function loadCollection(PersistentCollectionInterface $collection) : void
625
    {
626 178
        $mapping = $collection->getMapping();
627
        switch ($mapping['association']) {
628 178
            case ClassMetadata::EMBED_MANY:
629 178
                $this->loadEmbedManyCollection($collection);
630
                break;
631 126
632 126
            case ClassMetadata::REFERENCE_MANY:
633
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
634
                    $this->loadReferenceManyWithRepositoryMethod($collection);
635 75
                } else {
636 5
                    if ($mapping['isOwningSide']) {
637
                        $this->loadReferenceManyCollectionOwningSide($collection);
638 71
                    } else {
639 59
                        $this->loadReferenceManyCollectionInverseSide($collection);
640
                    }
641 17
                }
642
                break;
643
        }
644 75
    }
645
646 178
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection) : void
647
    {
648 126
        $embeddedDocuments = $collection->getMongoData();
649
        $mapping           = $collection->getMapping();
650 126
        $owner             = $collection->getOwner();
651 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...
652 126
            return;
653 126
        }
654 75
655
        foreach ($embeddedDocuments as $key => $embeddedDocument) {
656
            $className              = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
657 97
            $embeddedMetadata       = $this->dm->getClassMetadata($className);
658 97
            $embeddedDocumentObject = $embeddedMetadata->newInstance();
659 97
660 97
            $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
661
662 97
            $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
663
            $id   = $data[$embeddedMetadata->identifier] ?? null;
664 97
665 97
            if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
666
                $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
667 97
            }
668 96
            if (CollectionHelper::isHash($mapping['strategy'])) {
669
                $collection->set($key, $embeddedDocumentObject);
670 97
            } else {
671 25
                $collection->add($embeddedDocumentObject);
672
            }
673 80
        }
674
    }
675
676 97
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection) : void
677
    {
678 59
        $hints      = $collection->getHints();
679
        $mapping    = $collection->getMapping();
680 59
        $groupedIds = [];
681 59
682 59
        $sorted = isset($mapping['sort']) && $mapping['sort'];
683
684 59
        foreach ($collection->getMongoData() as $key => $reference) {
685
            $className  = $this->uow->getClassNameForAssociation($mapping, $reference);
686 59
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
687 53
            $id         = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
688 53
689 53
            // create a reference to the class and id
690
            $reference = $this->dm->getReference($className, $id);
691
692 53
            // no custom sort so add the references right now in the order they are embedded
693
            if (! $sorted) {
694
                if (CollectionHelper::isHash($mapping['strategy'])) {
695 53
                    $collection->set($key, $reference);
696 52
                } else {
697 2
                    $collection->add($reference);
698
                }
699 50
            }
700
701
            // only query for the referenced object if it is not already initialized or the collection is sorted
702
            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...
703
                continue;
704 53
            }
705 22
706
            $groupedIds[$className][] = $identifier;
707
        }
708 38
        foreach ($groupedIds as $className => $ids) {
709
            $class           = $this->dm->getClassMetadata($className);
710 59
            $mongoCollection = $this->dm->getDocumentCollection($className);
711 38
            $criteria        = $this->cm->merge(
712 38
                ['_id' => ['$in' => array_values($ids)]],
713 38
                $this->dm->getFilterCollection()->getFilterCriteria($class),
714 38
                $mapping['criteria'] ?? []
715 38
            );
716 38
            $criteria        = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
717
718 38
            $options = [];
719
            if (isset($mapping['sort'])) {
720 38
                $options['sort'] = $this->prepareSort($mapping['sort']);
721 38
            }
722 38
            if (isset($mapping['limit'])) {
723
                $options['limit'] = $mapping['limit'];
724 38
            }
725
            if (isset($mapping['skip'])) {
726
                $options['skip'] = $mapping['skip'];
727 38
            }
728
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
729
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
730 38
            }
731
732
            $cursor    = $mongoCollection->find($criteria, $options);
733
            $documents = $cursor->toArray();
734 38
            foreach ($documents as $documentData) {
735 38
                $document = $this->uow->getById($documentData['_id'], $class);
736 38
                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...
737 37
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
738 37
                    $this->uow->setOriginalDocumentData($document, $data);
739 37
                }
740 37
741
                if (! $sorted) {
742
                    continue;
743 37
                }
744 36
745
                $collection->add($document);
746
            }
747 1
        }
748
    }
749
750 59
    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection) : void
751
    {
752 17
        $query    = $this->createReferenceManyInverseSideQuery($collection);
753
        $iterator = $query->execute();
754 17
        assert($iterator instanceof Iterator);
755 17
        $documents = $iterator->toArray();
756 17
        foreach ($documents as $key => $document) {
757 17
            $collection->add($document);
758 17
        }
759 16
    }
760
761 17
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection) : Query
762
    {
763 17
        $hints   = $collection->getHints();
764
        $mapping = $collection->getMapping();
765 17
        $owner   = $collection->getOwner();
766 17
767 17
        if ($owner === null) {
768
            throw PersistentCollectionException::ownerRequiredToLoadCollection();
769 17
        }
770
771
        $ownerClass        = $this->dm->getClassMetadata(get_class($owner));
772
        $targetClass       = $this->dm->getClassMetadata($mapping['targetDocument']);
773 17
        $mappedByMapping   = $targetClass->fieldMappings[$mapping['mappedBy']] ?? [];
774 17
        $mappedByFieldName = ClassMetadata::getReferenceFieldName($mappedByMapping['storeAs'] ?? ClassMetadata::REFERENCE_STORE_AS_DB_REF, $mapping['mappedBy']);
775 17
776 17
        $criteria = $this->cm->merge(
777
            [$mappedByFieldName => $ownerClass->getIdentifierObject($owner)],
778 17
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
779 17
            $mapping['criteria'] ?? []
780 17
        );
781 17
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
782
        $qb       = $this->dm->createQueryBuilder($mapping['targetDocument'])
783 17
            ->setQueryArray($criteria);
784 17
785 17
        if (isset($mapping['sort'])) {
786
            $qb->sort($mapping['sort']);
787 17
        }
788 17
        if (isset($mapping['limit'])) {
789
            $qb->limit($mapping['limit']);
790 17
        }
791 2
        if (isset($mapping['skip'])) {
792
            $qb->skip($mapping['skip']);
793 17
        }
794
795
        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
796
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
797 17
        }
798
799
        foreach ($mapping['prime'] as $field) {
800
            $qb->field($field)->prime(true);
801 17
        }
802 4
803
        return $qb->getQuery();
804
    }
805 17
806
    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection) : void
807
    {
808 5
        $cursor    = $this->createReferenceManyWithRepositoryMethodCursor($collection);
809
        $mapping   = $collection->getMapping();
810 5
        $documents = $cursor->toArray();
811 5
        foreach ($documents as $key => $obj) {
812 5
            if (CollectionHelper::isHash($mapping['strategy'])) {
813 5
                $collection->set($key, $obj);
814 5
            } else {
815 1
                $collection->add($obj);
816
            }
817 4
        }
818
    }
819
820 5
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection) : Iterator
821
    {
822 5
        $mapping          = $collection->getMapping();
823
        $repositoryMethod = $mapping['repositoryMethod'];
824 5
        $cursor           = $this->dm->getRepository($mapping['targetDocument'])
825 5
            ->$repositoryMethod($collection->getOwner());
826 5
827 5
        if (! $cursor instanceof Iterator) {
828
            throw new BadMethodCallException(sprintf('Expected repository method %s to return an iterable object', $repositoryMethod));
829 5
        }
830
831
        if (! empty($mapping['prime'])) {
832
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
833 5
            $primers         = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));
834 1
            $class           = $this->dm->getClassMetadata($mapping['targetDocument']);
835 1
836 1
            assert(is_array($primers));
837
838 1
            $cursor = new PrimingIterator($cursor, $class, $referencePrimer, $primers, $collection->getHints());
839
        }
840 1
841
        return $cursor;
842
    }
843 5
844
    /**
845
     * Prepare a projection array by converting keys, which are PHP property
846
     * names, to MongoDB field names.
847
     */
848
    public function prepareProjection(array $fields) : array
849
    {
850 15
        $preparedFields = [];
851
852 15
        foreach ($fields as $key => $value) {
853
            $preparedFields[$this->prepareFieldName($key)] = $value;
854 15
        }
855 15
856
        return $preparedFields;
857
    }
858 15
859
    /**
860
     * @param int|string $sort
861
     *
862
     * @return int|string|null
863
     */
864
    private function getSortDirection($sort)
865
    {
866 26
        switch (strtolower((string) $sort)) {
867
            case 'desc':
868 26
                return -1;
869 26
870 15
            case 'asc':
871
                return 1;
872 23
        }
873 13
874
        return $sort;
875
    }
876 13
877
    /**
878
     * Prepare a sort specification array by converting keys to MongoDB field
879
     * names and changing direction strings to int.
880
     */
881
    public function prepareSort(array $fields) : array
882
    {
883 142
        $sortFields = [];
884
885 142
        foreach ($fields as $key => $value) {
886
            if (is_array($value)) {
887 142
                $sortFields[$this->prepareFieldName($key)] = $value;
888 26
            } else {
889 1
                $sortFields[$this->prepareFieldName($key)] = $this->getSortDirection($value);
890
            }
891 26
        }
892
893
        return $sortFields;
894
    }
895 142
896
    /**
897
     * Prepare a mongodb field name and convert the PHP property names to
898
     * MongoDB field names.
899
     */
900
    public function prepareFieldName(string $fieldName) : string
901
    {
902 439
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);
903
904 439
        return $fieldNames[0][0];
905
    }
906 439
907
    /**
908
     * Adds discriminator criteria to an already-prepared query.
909
     *
910
     * This method should be used once for query criteria and not be used for
911
     * nested expressions. It should be called before
912
     * {@link DocumentPerister::addFilterToPreparedQuery()}.
913
     */
914
    public function addDiscriminatorToPreparedQuery(array $preparedQuery) : array
915
    {
916 522
        /* If the class has a discriminator field, which is not already in the
917
         * criteria, inject it now. The field/values need no preparation.
918
         */
919
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
920
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
921 522
            if (count($discriminatorValues) === 1) {
922 27
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
923 27
            } else {
924 19
                $preparedQuery[$this->class->discriminatorField] = ['$in' => $discriminatorValues];
925
            }
926 10
        }
927
928
        return $preparedQuery;
929
    }
930 522
931
    /**
932
     * Adds filter criteria to an already-prepared query.
933
     *
934
     * This method should be used once for query criteria and not be used for
935
     * nested expressions. It should be called after
936
     * {@link DocumentPerister::addDiscriminatorToPreparedQuery()}.
937
     */
938
    public function addFilterToPreparedQuery(array $preparedQuery) : array
939
    {
940 523
        /* If filter criteria exists for this class, prepare it and merge
941
         * over the existing query.
942
         *
943
         * @todo Consider recursive merging in case the filter criteria and
944
         * prepared query both contain top-level $and/$or operators.
945
         */
946
        $filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class);
947
        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...
948 523
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
949 523
        }
950 18
951
        return $preparedQuery;
952
    }
953 523
954
    /**
955
     * Prepares the query criteria or new document object.
956
     *
957
     * PHP field names and types will be converted to those used by MongoDB.
958
     */
959
    public function prepareQueryOrNewObj(array $query, bool $isNewObj = false) : array
960
    {
961 554
        $preparedQuery = [];
962
963 554
        foreach ($query as $key => $value) {
964
            // Recursively prepare logical query clauses
965 554
            if (in_array($key, ['$and', '$or', '$nor'], true) && is_array($value)) {
966
                foreach ($value as $k2 => $v2) {
967 512
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
968 20
                }
969 20
                continue;
970
            }
971 20
972
            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
973
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
974 512
                continue;
975 40
            }
976 40
977
            $preparedQueryElements = $this->prepareQueryElement((string) $key, $value, null, true, $isNewObj);
978
            foreach ($preparedQueryElements as [$preparedKey, $preparedValue]) {
979 512
                $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...
980 512
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
981 512
                    : Type::convertPHPToDatabaseValue($preparedValue);
982 132
            }
983 466
        }
984
985
        return $preparedQuery;
986
    }
987 554
988
    /**
989
     * Prepares a query value and converts the PHP value to the database value
990
     * if it is an identifier.
991
     *
992
     * It also handles converting $fieldName to the database name if they are
993
     * different.
994
     *
995
     * @param mixed $value
996
     */
997
    private function prepareQueryElement(string $fieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false) : array
998
    {
999 911
        $class = $class ?? $this->class;
1000
1001 911
        // @todo Consider inlining calls to ClassMetadata methods
1002
1003
        // Process all non-identifier fields by translating field names
1004
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
1005
            $mapping   = $class->fieldMappings[$fieldName];
1006 911
            $fieldName = $mapping['name'];
1007 258
1008 258
            if (! $prepareValue) {
1009
                return [[$fieldName, $value]];
1010 258
            }
1011 54
1012
            // Prepare mapped, embedded objects
1013
            if (! empty($mapping['embedded']) && is_object($value) &&
1014
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
1015 214
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
1016 214
            }
1017 3
1018
            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...
1019
                try {
1020 212
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
1021
                } 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...
1022 14
                    // do nothing in case passed object is not mapped document
1023 1
                }
1024
            }
1025
1026
            // No further preparation unless we're dealing with a simple reference
1027
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty((array) $value)) {
1028
                return [[$fieldName, $value]];
1029 199
            }
1030 132
1031
            // Additional preparation for one or more simple reference values
1032
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1033
1034 94
            if (! is_array($value)) {
1035
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1036 94
            }
1037 90
1038
            // Objects without operators or with DBRef fields can be converted immediately
1039
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1040
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1041 6
            }
1042 3
1043
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1044
        }
1045 6
1046
        // Process identifier fields
1047
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1048
            $fieldName = '_id';
1049 819
1050 357
            if (! $prepareValue) {
1051
                return [[$fieldName, $value]];
1052 357
            }
1053 42
1054
            if (! is_array($value)) {
1055
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1056 318
            }
1057 292
1058
            // Objects without operators or with DBRef fields can be converted immediately
1059
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1060
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1061 60
            }
1062 6
1063
            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1064
        }
1065 55
1066
        // No processing for unmapped, non-identifier, non-dotted field names
1067
        if (strpos($fieldName, '.') === false) {
1068
            return [[$fieldName, $value]];
1069 563
        }
1070 418
1071
        /* Process "fieldName.objectProperty" queries (on arrays or objects).
1072
         *
1073
         * We can limit parsing here, since at most three segments are
1074
         * significant: "fieldName.objectProperty" with an optional index or key
1075
         * for collections stored as either BSON arrays or objects.
1076
         */
1077
        $e = explode('.', $fieldName, 4);
1078
1079 157
        // No further processing for unmapped fields
1080
        if (! isset($class->fieldMappings[$e[0]])) {
1081
            return [[$fieldName, $value]];
1082 157
        }
1083 6
1084
        $mapping = $class->fieldMappings[$e[0]];
1085
        $e[0]    = $mapping['name'];
1086 152
1087 152
        // Hash and raw fields will not be prepared beyond the field name
1088
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1089
            $fieldName = implode('.', $e);
1090 152
1091 1
            return [[$fieldName, $value]];
1092
        }
1093 1
1094
        if ($mapping['type'] === 'many' && CollectionHelper::isHash($mapping['strategy'])
1095
                && isset($e[2])) {
1096 151
            $objectProperty       = $e[2];
1097 151
            $objectPropertyPrefix = $e[1] . '.';
1098 1
            $nextObjectProperty   = implode('.', array_slice($e, 3));
1099 1
        } elseif ($e[1] !== '$') {
1100 1
            $fieldName            = $e[0] . '.' . $e[1];
1101 150
            $objectProperty       = $e[1];
1102 149
            $objectPropertyPrefix = '';
1103 149
            $nextObjectProperty   = implode('.', array_slice($e, 2));
1104 149
        } elseif (isset($e[2])) {
1105 149
            $fieldName            = $e[0] . '.' . $e[1] . '.' . $e[2];
1106 1
            $objectProperty       = $e[2];
1107 1
            $objectPropertyPrefix = $e[1] . '.';
1108 1
            $nextObjectProperty   = implode('.', array_slice($e, 3));
1109 1
        } else {
1110 1
            $fieldName = $e[0] . '.' . $e[1];
1111
1112 1
            return [[$fieldName, $value]];
1113
        }
1114 1
1115
        // No further processing for fields without a targetDocument mapping
1116
        if (! isset($mapping['targetDocument'])) {
1117
            if ($nextObjectProperty) {
1118 151
                $fieldName .= '.' . $nextObjectProperty;
1119 3
            }
1120
1121
            return [[$fieldName, $value]];
1122
        }
1123 3
1124
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1125
1126 148
        // No further processing for unmapped targetDocument fields
1127
        if (! $targetClass->hasField($objectProperty)) {
1128
            if ($nextObjectProperty) {
1129 148
                $fieldName .= '.' . $nextObjectProperty;
1130 25
            }
1131
1132
            return [[$fieldName, $value]];
1133
        }
1134 25
1135
        $targetMapping      = $targetClass->getFieldMapping($objectProperty);
1136
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1137 128
1138 128
        // Prepare DBRef identifiers or the mapped field's property path
1139
        $fieldName = $objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID
1140
            ? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1141 128
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1142 108
1143 128
        // Process targetDocument identifier fields
1144
        if ($objectPropertyIsId) {
1145
            if (! $prepareValue) {
1146 128
                return [[$fieldName, $value]];
1147 109
            }
1148 7
1149
            if (! is_array($value)) {
1150
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1151 102
            }
1152 88
1153
            // Objects without operators or with DBRef fields can be converted immediately
1154
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1155
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1156 16
            }
1157 6
1158
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1159
        }
1160 16
1161
        /* The property path may include a third field segment, excluding the
1162
         * collection item pointer. If present, this next object property must
1163
         * be processed recursively.
1164
         */
1165
        if ($nextObjectProperty) {
1166
            // Respect the targetDocument's class metadata when recursing
1167 19
            $nextTargetClass = isset($targetMapping['targetDocument'])
1168
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1169 16
                : null;
1170 10
1171 16
            if (empty($targetMapping['reference'])) {
1172
                $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1173 16
            } else {
1174 14
                // No recursive processing for references as most probably somebody is querying DBRef or alike
1175
                if ($nextObjectProperty[0] !== '$' && in_array($targetMapping['storeAs'], [ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB, ClassMetadata::REFERENCE_STORE_AS_DB_REF])) {
1176
                    $nextObjectProperty = '$' . $nextObjectProperty;
1177 4
                }
1178 1
                $fieldNames = [[$nextObjectProperty, $value]];
1179
            }
1180 4
1181
            return array_map(static function ($preparedTuple) use ($fieldName) {
1182
                [$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...
1183
1184 16
                return [$fieldName . '.' . $key, $value];
1185
            }, $fieldNames);
1186 16
        }
1187 16
1188
        return [[$fieldName, $value]];
1189
    }
1190 5
1191
    private function prepareQueryExpression(array $expression, ClassMetadata $class) : array
1192
    {
1193 77
        foreach ($expression as $k => $v) {
1194
            // Ignore query operators whose arguments need no type conversion
1195 77
            if (in_array($k, ['$exists', '$type', '$mod', '$size'])) {
1196
                continue;
1197 77
            }
1198 16
1199
            // Process query operators whose argument arrays need type conversion
1200
            if (in_array($k, ['$in', '$nin', '$all']) && is_array($v)) {
1201
                foreach ($v as $k2 => $v2) {
1202 77
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
1203 75
                }
1204 75
                continue;
1205
            }
1206 75
1207
            // Recursively process expressions within a $not operator
1208
            if ($k === '$not' && is_array($v)) {
1209
                $expression[$k] = $this->prepareQueryExpression($v, $class);
1210 18
                continue;
1211 15
            }
1212 15
1213
            $expression[$k] = $class->getDatabaseIdentifierValue($v);
1214
        }
1215 18
1216
        return $expression;
1217
    }
1218 77
1219
    /**
1220
     * Checks whether the value has DBRef fields.
1221
     *
1222
     * This method doesn't check if the the value is a complete DBRef object,
1223
     * although it should return true for a DBRef. Rather, we're checking that
1224
     * the value has one or more fields for a DBref. In practice, this could be
1225
     * $elemMatch criteria for matching a DBRef.
1226
     *
1227
     * @param mixed $value
1228
     */
1229
    private function hasDBRefFields($value) : bool
1230
    {
1231 78
        if (! is_array($value) && ! is_object($value)) {
1232
            return false;
1233 78
        }
1234
1235
        if (is_object($value)) {
1236
            $value = get_object_vars($value);
1237 78
        }
1238
1239
        foreach ($value as $key => $_) {
1240
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
1241 78
                return true;
1242 78
            }
1243 4
        }
1244
1245
        return false;
1246
    }
1247 77
1248
    /**
1249
     * Checks whether the value has query operators.
1250
     *
1251
     * @param mixed $value
1252
     */
1253
    private function hasQueryOperators($value) : bool
1254
    {
1255 82
        if (! is_array($value) && ! is_object($value)) {
1256
            return false;
1257 82
        }
1258
1259
        if (is_object($value)) {
1260
            $value = get_object_vars($value);
1261 82
        }
1262
1263
        foreach ($value as $key => $_) {
1264
            if (isset($key[0]) && $key[0] === '$') {
1265 82
                return true;
1266 82
            }
1267 78
        }
1268
1269
        return false;
1270
    }
1271 11
1272
    /**
1273
     * Gets the array of discriminator values for the given ClassMetadata
1274
     */
1275
    private function getClassDiscriminatorValues(ClassMetadata $metadata) : array
1276
    {
1277 27
        $discriminatorValues = [$metadata->discriminatorValue];
1278
        foreach ($metadata->subClasses as $className) {
1279 27
            $key = array_search($className, $metadata->discriminatorMap);
1280 27
            if (! $key) {
1281 8
                continue;
1282 8
            }
1283
1284
            $discriminatorValues[] = $key;
1285
        }
1286 8
1287
        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
1288
        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...
1289
            $discriminatorValues[] = null;
1290 27
        }
1291 2
1292
        return $discriminatorValues;
1293
    }
1294 27
1295
    private function handleCollections(object $document, array $options) : void
1296
    {
1297 584
        // Collection deletions (deletions of complete collections)
1298
        $collections = [];
1299
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1300 584
            if (! $this->uow->isCollectionScheduledForDeletion($coll)) {
1301 584
                continue;
1302 113
            }
1303 102
1304
            $collections[] = $coll;
1305
        }
1306 33
        if (! empty($collections)) {
1307
            $this->cp->delete($document, $collections, $options);
1308 584
        }
1309 33
        // Collection updates (deleteRows, updateRows, insertRows)
1310
        $collections = [];
1311
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1312 584
            if (! $this->uow->isCollectionScheduledForUpdate($coll)) {
1313 584
                continue;
1314 113
            }
1315 29
1316
            $collections[] = $coll;
1317
        }
1318 105
        if (! empty($collections)) {
1319
            $this->cp->update($document, $collections, $options);
1320 584
        }
1321 105
        // Take new snapshots from visited collections
1322
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
1323
            $coll->takeSnapshot();
1324 584
        }
1325 252
    }
1326
1327 584
    /**
1328
     * If the document is new, ignore shard key field value, otherwise throw an
1329
     * exception. Also, shard key field should be present in actual document
1330
     * data.
1331
     *
1332
     * @throws MongoDBException
1333
     */
1334
    private function guardMissingShardKey(object $document, string $shardKeyField, array $actualDocumentData) : void
1335
    {
1336 10
        $dcs      = $this->uow->getDocumentChangeSet($document);
1337
        $isUpdate = $this->uow->isScheduledForUpdate($document);
1338 10
1339 10
        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
1340
        $fieldName    = $fieldMapping['fieldName'];
1341 10
1342 10
        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] !== $dcs[$fieldName][1]) {
1343
            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...
1344 10
        }
1345 2
1346
        if (! isset($actualDocumentData[$fieldName])) {
1347
            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...
1348 8
        }
1349
    }
1350
1351 8
    /**
1352
     * Get shard key aware query for single document.
1353
     */
1354
    private function getQueryForDocument(object $document) : array
1355
    {
1356 303
        $id = $this->uow->getDocumentIdentifier($document);
1357
        $id = $this->class->getDatabaseIdentifierValue($id);
1358 303
1359 303
        $shardKeyQueryPart = $this->getShardKeyQuery($document);
1360
        return array_merge(['_id' => $id], $shardKeyQueryPart);
1361 303
    }
1362 301
1363
    private function getWriteOptions(array $options = []) : array
1364
    {
1365 595
        $defaultOptions  = $this->dm->getConfiguration()->getDefaultCommitOptions();
1366
        $documentOptions = [];
1367 595
        if ($this->class->hasWriteConcern()) {
1368 595
            $documentOptions['w'] = $this->class->getWriteConcern();
1369 595
        }
1370 9
1371
        return array_merge($defaultOptions, $documentOptions, $options);
1372
    }
1373 595
1374
    private function prepareReference(string $fieldName, $value, array $mapping, bool $inNewObj) : array
1375
    {
1376 15
        $reference = $this->dm->createReference($value, $mapping);
1377
        if ($inNewObj || $mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_ID) {
1378 15
            return [[$fieldName, $reference]];
1379 14
        }
1380 8
1381
        switch ($mapping['storeAs']) {
1382
            case ClassMetadata::REFERENCE_STORE_AS_REF:
1383 6
                $keys = ['id' => true];
1384
                break;
1385
1386
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF:
1387
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB:
1388
                $keys = ['$ref' => true, '$id' => true, '$db' => true];
1389
1390 6
                if ($mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_DB_REF) {
1391
                    unset($keys['$db']);
1392 6
                }
1393 5
1394
                if (isset($mapping['targetDocument'])) {
1395
                    unset($keys['$ref'], $keys['$db']);
1396 6
                }
1397 4
                break;
1398
1399 6
            default:
1400
                throw new InvalidArgumentException(sprintf('Reference type %s is invalid.', $mapping['storeAs']));
1401
        }
1402
1403
        if ($mapping['type'] === 'many') {
1404
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
1405 6
        }
1406 2
1407
        return array_map(
1408
            static function ($key) use ($reference, $fieldName) {
1409 4
                return [$fieldName . '.' . $key, $reference[$key]];
1410
            },
1411 4
            array_keys($keys)
1412 4
        );
1413 4
    }
1414
}
1415