Completed
Pull Request — master (#1984)
by Maciej
19:41
created

DocumentPersister::executeInserts()   B

Complexity

Conditions 9
Paths 26

Size

Total Lines 48

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 9.0036

Importance

Changes 0
Metric Value
dl 0
loc 48
ccs 27
cts 28
cp 0.9643
rs 7.5789
c 0
b 0
f 0
cc 9
nc 26
nop 1
crap 9.0036
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 1197
    public function __construct(
112
        PersistenceBuilder $pb,
113
        DocumentManager $dm,
114
        UnitOfWork $uow,
115
        HydratorFactory $hydratorFactory,
116
        ClassMetadata $class,
117
        ?CriteriaMerger $cm = null
118
    ) {
119 1197
        $this->pb              = $pb;
120 1197
        $this->dm              = $dm;
121 1197
        $this->cm              = $cm ?: new CriteriaMerger();
122 1197
        $this->uow             = $uow;
123 1197
        $this->hydratorFactory = $hydratorFactory;
124 1197
        $this->class           = $class;
125 1197
        $this->cp              = $this->uow->getCollectionPersister();
126
127 1197
        if ($class->isMappedSuperclass || $class->isEmbeddedDocument || $class->isQueryResultDocument) {
128 93
            return;
129
        }
130
131 1194
        $this->collection = $dm->getDocumentCollection($class->name);
132
133 1194
        if (! $class->isFile) {
134 1183
            return;
135
        }
136
137 18
        $this->bucket = $dm->getDocumentBucket($class->name);
138 18
    }
139
140
    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 531
    public function addInsert(object $document) : void
155
    {
156 531
        $this->queuedInserts[spl_object_hash($document)] = $document;
157 531
    }
158
159
    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 85
    public function addUpsert(object $document) : void
174
    {
175 85
        $this->queuedUpserts[spl_object_hash($document)] = $document;
176 85
    }
177
178
    /**
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 531
    public function executeInserts(array $options = []) : void
198
    {
199 531
        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
        }
202
203 531
        $inserts = [];
204 531
        $options = $this->getWriteOptions($options);
205 531
        foreach ($this->queuedInserts as $oid => $document) {
206 531
            $data = $this->pb->prepareInsertData($document);
207
208
            // Set the initial version for each insert
209 520
            if ($this->class->isVersioned) {
210 40
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
211 40
                $nextVersion    = null;
212 40
                if ($versionMapping['type'] === 'int') {
213 38
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
214 38
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
215 2
                } elseif ($versionMapping['type'] === 'date') {
216 2
                    $nextVersionDateTime = new DateTime();
217 2
                    $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
218 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
219
                }
220 40
                $data[$versionMapping['name']] = $nextVersion;
221
            }
222
223 520
            $inserts[] = $data;
224
        }
225
226 520
        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 520
                $this->collection->insertMany($inserts, $options);
229 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...
230 6
                $this->queuedInserts = [];
231 6
                throw $e;
232
            }
233
        }
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 520
        foreach ($this->queuedInserts as $document) {
240 520
            $this->handleCollections($document, $options);
241
        }
242
243 520
        $this->queuedInserts = [];
244 520
    }
245
246
    /**
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 85
    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
        }
258
259 85
        $options = $this->getWriteOptions($options);
260 85
        foreach ($this->queuedUpserts as $oid => $document) {
261
            try {
262 85
                $this->executeUpsert($document, $options);
263 85
                $this->handleCollections($document, $options);
264 85
                unset($this->queuedUpserts[$oid]);
265
            } 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
                unset($this->queuedUpserts[$oid]);
267
                throw $e;
268
            }
269
        }
270 85
    }
271
272
    /**
273
     * Executes a single upsert in {@link executeUpserts}
274
     */
275 85
    private function executeUpsert(object $document, array $options) : void
276
    {
277 85
        $options['upsert'] = true;
278 85
        $criteria          = $this->getQueryForDocument($document);
279
280 85
        $data = $this->pb->prepareUpsertData($document);
281
282
        // Set the initial version for each upsert
283 85
        if ($this->class->isVersioned) {
284 3
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
285 3
            $nextVersion    = null;
286 3
            if ($versionMapping['type'] === 'int') {
287 2
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
288 2
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
289 1
            } elseif ($versionMapping['type'] === 'date') {
290 1
                $nextVersionDateTime = new DateTime();
291 1
                $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
292 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
293
            }
294 3
            $data['$set'][$versionMapping['name']] = $nextVersion;
295
        }
296
297 85
        foreach (array_keys($criteria) as $field) {
298 85
            unset($data['$set'][$field]);
299 85
            unset($data['$inc'][$field]);
300 85
            unset($data['$setOnInsert'][$field]);
301
        }
302
303
        // Do not send empty update operators
304 85
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
305 85
            if (! empty($data[$operator])) {
306 70
                continue;
307
            }
308
309 85
            unset($data[$operator]);
310
        }
311
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 85
        if (empty($data)) {
326 16
            $retry = true;
327 16
            $data  = ['$set' => ['_id' => $criteria['_id']]];
328
        }
329
330
        try {
331 85
            $this->collection->updateOne($criteria, $data, $options);
332 85
            return;
333
        } 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
            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 234
    public function update(object $document, array $options = []) : void
348
    {
349 234
        $update = $this->pb->prepareUpdateData($document);
350
351 234
        $query = $this->getQueryForDocument($document);
352
353 232
        foreach (array_keys($query) as $field) {
354 232
            unset($update['$set'][$field]);
355
        }
356
357 232
        if (empty($update['$set'])) {
358 101
            unset($update['$set']);
359
        }
360
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 232
        $nextVersion = null;
365 232
        if ($this->class->isVersioned) {
366 33
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
367 33
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
368 33
            if ($versionMapping['type'] === 'int') {
369 30
                $nextVersion                             = $currentVersion + 1;
370 30
                $update['$inc'][$versionMapping['name']] = 1;
371 30
                $query[$versionMapping['name']]          = $currentVersion;
372 3
            } elseif ($versionMapping['type'] === 'date') {
373 3
                $nextVersion                             = new DateTime();
374 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
375 3
                $query[$versionMapping['name']]          = Type::convertPHPToDatabaseValue($currentVersion);
376
            }
377
        }
378
379 232
        if (! empty($update)) {
380
            // Include locking logic so that if the document object in memory is currently
381
            // locked then it will remove it, otherwise it ensures the document is not locked.
382 158
            if ($this->class->isLockable) {
383 11
                $isLocked    = $this->class->reflFields[$this->class->lockField]->getValue($document);
384 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
385 11
                if ($isLocked) {
386 2
                    $update['$unset'] = [$lockMapping['name'] => true];
387
                } else {
388 9
                    $query[$lockMapping['name']] = ['$exists' => false];
389
                }
390
            }
391
392 158
            $options = $this->getWriteOptions($options);
393
394 158
            $result = $this->collection->updateOne($query, $update, $options);
395
396 158
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
397 6
                throw LockException::lockFailed($document);
398 153
            } elseif ($this->class->isVersioned) {
399 28
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
400
            }
401
        }
402
403 227
        $this->handleCollections($document, $options);
404 227
    }
405
406
    /**
407
     * Removes document from mongo
408
     *
409
     * @throws LockException
410
     */
411 36
    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 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
415 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
416
417 1
            $this->bucket->delete($databaseIdentifier);
418
419 1
            return;
420
        }
421
422 35
        $query = $this->getQueryForDocument($document);
423
424 35
        if ($this->class->isLockable) {
425 2
            $query[$this->class->lockField] = ['$exists' => false];
426
        }
427
428 35
        $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 2
            throw LockException::lockFailed($document);
434
        }
435 33
    }
436
437
    /**
438
     * Refreshes a managed document.
439
     */
440 23
    public function refresh(object $document) : void
441
    {
442 23
        $query = $this->getQueryForDocument($document);
443 23
        $data  = $this->collection->findOne($query);
444 23
        if ($data === null) {
445
            throw MongoDBException::cannotRefreshDocument();
446
        }
447 23
        $data = $this->hydratorFactory->hydrate($document, (array) $data);
448 23
        $this->uow->setOriginalDocumentData($document, $data);
449 23
    }
450
451
    /**
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 366
    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
        // TODO: remove this
467 366
        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
        }
470
471 366
        $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 366
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
473 366
        $criteria = $this->addFilterToPreparedQuery($criteria);
474
475 366
        $options = [];
476 366
        if ($sort !== null) {
477 95
            $options['sort'] = $this->prepareSort($sort);
478
        }
479 366
        $result = $this->collection->findOne($criteria, $options);
480 366
        $result = $result !== null ? (array) $result : null;
481
482 366
        if ($this->class->isLockable) {
483 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
484 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
485 1
                throw LockException::lockFailed($document);
486
            }
487
        }
488
489 365
        if ($result === null) {
490 115
            return null;
491
        }
492
493 321
        return $this->createDocument($result, $document, $hints);
494
    }
495
496
    /**
497
     * Finds documents by a set of criteria.
498
     */
499 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null) : Iterator
500
    {
501 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
502 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
503 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
504
505 22
        $options = [];
506 22
        if ($sort !== null) {
507 11
            $options['sort'] = $this->prepareSort($sort);
508
        }
509
510 22
        if ($limit !== null) {
511 10
            $options['limit'] = $limit;
512
        }
513
514 22
        if ($skip !== null) {
515 1
            $options['skip'] = $skip;
516
        }
517
518 22
        $baseCursor = $this->collection->find($criteria, $options);
519 22
        return $this->wrapCursor($baseCursor);
520
    }
521
522
    /**
523
     * @throws MongoDBException
524
     */
525 314
    private function getShardKeyQuery(object $document) : array
526
    {
527 314
        if (! $this->class->isSharded()) {
528 304
            return [];
529
        }
530
531 10
        $shardKey = $this->class->getShardKey();
532 10
        $keys     = array_keys($shardKey['keys']);
533 10
        $data     = $this->uow->getDocumentActualData($document);
534
535 10
        $shardKeyQueryPart = [];
536 10
        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
541 8
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
542 1
                $reference = $this->prepareReference(
543 1
                    $key,
544 1
                    $data[$mapping['fieldName']],
545 1
                    $mapping,
546 1
                    false
547
                );
548 1
                foreach ($reference as $keyValue) {
549 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
550
                }
551
            } else {
552 7
                $value                   = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
553 7
                $shardKeyQueryPart[$key] = $value;
554
            }
555
        }
556
557 8
        return $shardKeyQueryPart;
558
    }
559
560
    /**
561
     * Wraps the supplied base cursor in the corresponding ODM class.
562
     */
563 22
    private function wrapCursor(Cursor $baseCursor) : Iterator
564
    {
565 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
566
    }
567
568
    /**
569
     * Checks whether the given managed document exists in the database.
570
     */
571 3
    public function exists(object $document) : bool
572
    {
573 3
        $id = $this->class->getIdentifierObject($document);
574 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
575
    }
576
577
    /**
578
     * Locks document by storing the lock mode on the mapped lock field.
579
     */
580 5
    public function lock(object $document, int $lockMode) : void
581
    {
582 5
        $id          = $this->uow->getDocumentIdentifier($document);
583 5
        $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
589
    /**
590
     * Releases any lock that exists on this document.
591
     */
592 1
    public function unlock(object $document) : void
593
    {
594 1
        $id          = $this->uow->getDocumentIdentifier($document);
595 1
        $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
601
    /**
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 321
    private function createDocument(array $result, ?object $document = null, array $hints = []) : ?object
611
    {
612 321
        if ($document !== null) {
613 26
            $hints[Query::HINT_REFRESH] = true;
614 26
            $id                         = $this->class->getPHPIdentifierValue($result['_id']);
615 26
            $this->uow->registerManaged($document, $id, $result);
616
        }
617
618 321
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
619
    }
620
621
    /**
622
     * Loads a PersistentCollection data. Used in the initialize() method.
623
     */
624 178
    public function loadCollection(PersistentCollectionInterface $collection) : void
625
    {
626 178
        $mapping = $collection->getMapping();
627 178
        switch ($mapping['association']) {
628
            case ClassMetadata::EMBED_MANY:
629 126
                $this->loadEmbedManyCollection($collection);
630 126
                break;
631
632
            case ClassMetadata::REFERENCE_MANY:
633 75
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
634 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
635
                } else {
636 71
                    if ($mapping['isOwningSide']) {
637 59
                        $this->loadReferenceManyCollectionOwningSide($collection);
638
                    } else {
639 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
640
                    }
641
                }
642 75
                break;
643
        }
644 178
    }
645
646 126
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection) : void
647
    {
648 126
        $embeddedDocuments = $collection->getMongoData();
649 126
        $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 75
            return;
653
        }
654
655 97
        foreach ($embeddedDocuments as $key => $embeddedDocument) {
656 97
            $className              = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
657 97
            $embeddedMetadata       = $this->dm->getClassMetadata($className);
658 97
            $embeddedDocumentObject = $embeddedMetadata->newInstance();
659
660 97
            $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
661
662 97
            $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
663 97
            $id   = $data[$embeddedMetadata->identifier] ?? null;
664
665 97
            if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
666 96
                $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
667
            }
668 97
            if (CollectionHelper::isHash($mapping['strategy'])) {
669 25
                $collection->set($key, $embeddedDocumentObject);
670
            } else {
671 80
                $collection->add($embeddedDocumentObject);
672
            }
673
        }
674 97
    }
675
676 59
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection) : void
677
    {
678 59
        $hints      = $collection->getHints();
679 59
        $mapping    = $collection->getMapping();
680 59
        $groupedIds = [];
681
682 59
        $sorted = isset($mapping['sort']) && $mapping['sort'];
683
684 59
        foreach ($collection->getMongoData() as $key => $reference) {
685 53
            $className  = $this->uow->getClassNameForAssociation($mapping, $reference);
686 53
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
687 53
            $id         = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
688
689
            // create a reference to the class and id
690 53
            $reference = $this->dm->getReference($className, $id);
691
692
            // no custom sort so add the references right now in the order they are embedded
693 53
            if (! $sorted) {
694 52
                if (CollectionHelper::isHash($mapping['strategy'])) {
695 2
                    $collection->set($key, $reference);
696
                } else {
697 50
                    $collection->add($reference);
698
                }
699
            }
700
701
            // only query for the referenced object if it is not already initialized or the collection is sorted
702 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...
703 22
                continue;
704
            }
705
706 38
            $groupedIds[$className][] = $identifier;
707
        }
708 59
        foreach ($groupedIds as $className => $ids) {
709 38
            $class           = $this->dm->getClassMetadata($className);
710 38
            $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
            );
716 38
            $criteria        = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
717
718 38
            $options = [];
719 38
            if (isset($mapping['sort'])) {
720 38
                $options['sort'] = $this->prepareSort($mapping['sort']);
721
            }
722 38
            if (isset($mapping['limit'])) {
723
                $options['limit'] = $mapping['limit'];
724
            }
725 38
            if (isset($mapping['skip'])) {
726
                $options['skip'] = $mapping['skip'];
727
            }
728 38
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
729
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
730
            }
731
732 38
            $cursor    = $mongoCollection->find($criteria, $options);
733 38
            $documents = $cursor->toArray();
734 38
            foreach ($documents as $documentData) {
735 37
                $document = $this->uow->getById($documentData['_id'], $class);
736 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...
737 37
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
738 37
                    $this->uow->setOriginalDocumentData($document, $data);
739
                }
740
741 37
                if (! $sorted) {
742 36
                    continue;
743
                }
744
745 1
                $collection->add($document);
746
            }
747
        }
748 59
    }
749
750 17
    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection) : void
751
    {
752 17
        $query    = $this->createReferenceManyInverseSideQuery($collection);
753 17
        $iterator = $query->execute();
754 17
        assert($iterator instanceof Iterator);
755 17
        $documents = $iterator->toArray();
756 17
        foreach ($documents as $key => $document) {
757 16
            $collection->add($document);
758
        }
759 17
    }
760
761 17
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection) : Query
762
    {
763 17
        $hints   = $collection->getHints();
764 17
        $mapping = $collection->getMapping();
765 17
        $owner   = $collection->getOwner();
766
767 17
        if ($owner === null) {
768
            throw PersistentCollectionException::ownerRequiredToLoadCollection();
769
        }
770
771 17
        $ownerClass        = $this->dm->getClassMetadata(get_class($owner));
772 17
        $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
776 17
        $criteria = $this->cm->merge(
777 17
            [$mappedByFieldName => $ownerClass->getIdentifierObject($owner)],
778 17
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
779 17
            $mapping['criteria'] ?? []
780
        );
781 17
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
782 17
        $qb       = $this->dm->createQueryBuilder($mapping['targetDocument'])
783 17
            ->setQueryArray($criteria);
784
785 17
        if (isset($mapping['sort'])) {
786 17
            $qb->sort($mapping['sort']);
787
        }
788 17
        if (isset($mapping['limit'])) {
789 2
            $qb->limit($mapping['limit']);
790
        }
791 17
        if (isset($mapping['skip'])) {
792
            $qb->skip($mapping['skip']);
793
        }
794
795 17
        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
796
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
797
        }
798
799 17
        foreach ($mapping['prime'] as $field) {
800 4
            $qb->field($field)->prime(true);
801
        }
802
803 17
        return $qb->getQuery();
804
    }
805
806 5
    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection) : void
807
    {
808 5
        $cursor    = $this->createReferenceManyWithRepositoryMethodCursor($collection);
809 5
        $mapping   = $collection->getMapping();
810 5
        $documents = $cursor->toArray();
811 5
        foreach ($documents as $key => $obj) {
812 5
            if (CollectionHelper::isHash($mapping['strategy'])) {
813 1
                $collection->set($key, $obj);
814
            } else {
815 4
                $collection->add($obj);
816
            }
817
        }
818 5
    }
819
820 5
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection) : Iterator
821
    {
822 5
        $mapping          = $collection->getMapping();
823 5
        $repositoryMethod = $mapping['repositoryMethod'];
824 5
        $cursor           = $this->dm->getRepository($mapping['targetDocument'])
825 5
            ->$repositoryMethod($collection->getOwner());
826
827 5
        if (! $cursor instanceof Iterator) {
828
            throw new BadMethodCallException(sprintf('Expected repository method %s to return an iterable object', $repositoryMethod));
829
        }
830
831 5
        if (! empty($mapping['prime'])) {
832 1
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
833 1
            $primers         = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));
834 1
            $class           = $this->dm->getClassMetadata($mapping['targetDocument']);
835
836 1
            assert(is_array($primers));
837
838 1
            $cursor = new PrimingIterator($cursor, $class, $referencePrimer, $primers, $collection->getHints());
839
        }
840
841 5
        return $cursor;
842
    }
843
844
    /**
845
     * Prepare a projection array by converting keys, which are PHP property
846
     * names, to MongoDB field names.
847
     */
848 15
    public function prepareProjection(array $fields) : array
849
    {
850 15
        $preparedFields = [];
851
852 15
        foreach ($fields as $key => $value) {
853 15
            $preparedFields[$this->prepareFieldName($key)] = $value;
854
        }
855
856 15
        return $preparedFields;
857
    }
858
859
    /**
860
     * @param int|string $sort
861
     *
862
     * @return int|string|null
863
     */
864 26
    private function getSortDirection($sort)
865
    {
866 26
        switch (strtolower((string) $sort)) {
867 26
            case 'desc':
868 15
                return -1;
869
870 23
            case 'asc':
871 13
                return 1;
872
        }
873
874 13
        return $sort;
875
    }
876
877
    /**
878
     * Prepare a sort specification array by converting keys to MongoDB field
879
     * names and changing direction strings to int.
880
     */
881 142
    public function prepareSort(array $fields) : array
882
    {
883 142
        $sortFields = [];
884
885 142
        foreach ($fields as $key => $value) {
886 26
            if (is_array($value)) {
887 1
                $sortFields[$this->prepareFieldName($key)] = $value;
888
            } else {
889 26
                $sortFields[$this->prepareFieldName($key)] = $this->getSortDirection($value);
890
            }
891
        }
892
893 142
        return $sortFields;
894
    }
895
896
    /**
897
     * Prepare a mongodb field name and convert the PHP property names to
898
     * MongoDB field names.
899
     */
900 459
    public function prepareFieldName(string $fieldName) : string
901
    {
902 459
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);
903
904 459
        return $fieldNames[0][0];
905
    }
906
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 522
    public function addDiscriminatorToPreparedQuery(array $preparedQuery) : array
915
    {
916
        /* 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 522
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
920 27
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
921 27
            if (count($discriminatorValues) === 1) {
922 19
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
923
            } else {
924 10
                $preparedQuery[$this->class->discriminatorField] = ['$in' => $discriminatorValues];
925
            }
926
        }
927
928 522
        return $preparedQuery;
929
    }
930
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 523
    public function addFilterToPreparedQuery(array $preparedQuery) : array
939
    {
940
        /* 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 523
        $filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class);
947 523
        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 18
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
949
        }
950
951 523
        return $preparedQuery;
952
    }
953
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 597
    public function prepareQueryOrNewObj(array $query, bool $isNewObj = false) : array
960
    {
961 597
        $preparedQuery = [];
962
963 597
        foreach ($query as $key => $value) {
964
            // Recursively prepare logical query clauses
965 555
            if (in_array($key, ['$and', '$or', '$nor'], true) && is_array($value)) {
966 20
                foreach ($value as $k2 => $v2) {
967 20
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
968
                }
969 20
                continue;
970
            }
971
972 555
            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
973 74
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
974 74
                continue;
975
            }
976
977 555
            $preparedQueryElements = $this->prepareQueryElement((string) $key, $value, null, true, $isNewObj);
978 555
            foreach ($preparedQueryElements as [$preparedKey, $preparedValue]) {
979 555
                $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 153
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
981 504
                    : Type::convertPHPToDatabaseValue($preparedValue);
982
            }
983
        }
984
985 597
        return $preparedQuery;
986
    }
987
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 974
    private function prepareQueryElement(string $fieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false) : array
998
    {
999 974
        $class = $class ?? $this->class;
1000
1001
        // @todo Consider inlining calls to ClassMetadata methods
1002
1003
        // Process all non-identifier fields by translating field names
1004 974
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
1005 278
            $mapping   = $class->fieldMappings[$fieldName];
1006 278
            $fieldName = $mapping['name'];
1007
1008 278
            if (! $prepareValue) {
1009 74
                return [[$fieldName, $value]];
1010
            }
1011
1012
            // Prepare mapped, embedded objects
1013 214
            if (! empty($mapping['embedded']) && is_object($value) &&
1014 214
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
1015 3
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
1016
            }
1017
1018 212
            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 14
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
1021 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...
1022
                    // do nothing in case passed object is not mapped document
1023
                }
1024
            }
1025
1026
            // No further preparation unless we're dealing with a simple reference
1027 199
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty((array) $value)) {
1028 132
                return [[$fieldName, $value]];
1029
            }
1030
1031
            // Additional preparation for one or more simple reference values
1032 94
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1033
1034 94
            if (! is_array($value)) {
1035 90
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1036
            }
1037
1038
            // Objects without operators or with DBRef fields can be converted immediately
1039 6
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1040 3
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1041
            }
1042
1043 6
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1044
        }
1045
1046
        // Process identifier fields
1047 866
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1048 357
            $fieldName = '_id';
1049
1050 357
            if (! $prepareValue) {
1051 42
                return [[$fieldName, $value]];
1052
            }
1053
1054 318
            if (! is_array($value)) {
1055 292
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1056
            }
1057
1058
            // Objects without operators or with DBRef fields can be converted immediately
1059 60
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1060 6
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1061
            }
1062
1063 55
            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1064
        }
1065
1066
        // No processing for unmapped, non-identifier, non-dotted field names
1067 610
        if (strpos($fieldName, '.') === false) {
1068 462
            return [[$fieldName, $value]];
1069
        }
1070
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 160
        $e = explode('.', $fieldName, 4);
1078
1079
        // No further processing for unmapped fields
1080 160
        if (! isset($class->fieldMappings[$e[0]])) {
1081 6
            return [[$fieldName, $value]];
1082
        }
1083
1084 155
        $mapping = $class->fieldMappings[$e[0]];
1085 155
        $e[0]    = $mapping['name'];
1086
1087
        // Hash and raw fields will not be prepared beyond the field name
1088 155
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1089 1
            $fieldName = implode('.', $e);
1090
1091 1
            return [[$fieldName, $value]];
1092
        }
1093
1094 154
        if ($mapping['type'] === 'many' && CollectionHelper::isHash($mapping['strategy'])
1095 154
                && isset($e[2])) {
1096 1
            $objectProperty       = $e[2];
1097 1
            $objectPropertyPrefix = $e[1] . '.';
1098 1
            $nextObjectProperty   = implode('.', array_slice($e, 3));
1099 153
        } elseif ($e[1] !== '$') {
1100 152
            $fieldName            = $e[0] . '.' . $e[1];
1101 152
            $objectProperty       = $e[1];
1102 152
            $objectPropertyPrefix = '';
1103 152
            $nextObjectProperty   = implode('.', array_slice($e, 2));
1104 1
        } elseif (isset($e[2])) {
1105 1
            $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
        } else {
1110 1
            $fieldName = $e[0] . '.' . $e[1];
1111
1112 1
            return [[$fieldName, $value]];
1113
        }
1114
1115
        // No further processing for fields without a targetDocument mapping
1116 154
        if (! isset($mapping['targetDocument'])) {
1117 5
            if ($nextObjectProperty) {
1118
                $fieldName .= '.' . $nextObjectProperty;
1119
            }
1120
1121 5
            return [[$fieldName, $value]];
1122
        }
1123
1124 149
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1125
1126
        // No further processing for unmapped targetDocument fields
1127 149
        if (! $targetClass->hasField($objectProperty)) {
1128 26
            if ($nextObjectProperty) {
1129
                $fieldName .= '.' . $nextObjectProperty;
1130
            }
1131
1132 26
            return [[$fieldName, $value]];
1133
        }
1134
1135 128
        $targetMapping      = $targetClass->getFieldMapping($objectProperty);
1136 128
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1137
1138
        // Prepare DBRef identifiers or the mapped field's property path
1139 128
        $fieldName = $objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID
1140 108
            ? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1141 128
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1142
1143
        // Process targetDocument identifier fields
1144 128
        if ($objectPropertyIsId) {
1145 109
            if (! $prepareValue) {
1146 7
                return [[$fieldName, $value]];
1147
            }
1148
1149 102
            if (! is_array($value)) {
1150 88
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1151
            }
1152
1153
            // Objects without operators or with DBRef fields can be converted immediately
1154 16
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1155 6
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1156
            }
1157
1158 16
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1159
        }
1160
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 19
        if ($nextObjectProperty) {
1166
            // Respect the targetDocument's class metadata when recursing
1167 16
            $nextTargetClass = isset($targetMapping['targetDocument'])
1168 10
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1169 16
                : null;
1170
1171 16
            if (empty($targetMapping['reference'])) {
1172 14
                $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1173
            } else {
1174
                // No recursive processing for references as most probably somebody is querying DBRef or alike
1175 4
                if ($nextObjectProperty[0] !== '$' && in_array($targetMapping['storeAs'], [ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB, ClassMetadata::REFERENCE_STORE_AS_DB_REF])) {
1176 1
                    $nextObjectProperty = '$' . $nextObjectProperty;
1177
                }
1178 4
                $fieldNames = [[$nextObjectProperty, $value]];
1179
            }
1180
1181
            return array_map(static function ($preparedTuple) use ($fieldName) {
1182 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...
1183
1184 16
                return [$fieldName . '.' . $key, $value];
1185 16
            }, $fieldNames);
1186
        }
1187
1188 5
        return [[$fieldName, $value]];
1189
    }
1190
1191 77
    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 16
                continue;
1197
            }
1198
1199
            // Process query operators whose argument arrays need type conversion
1200 77
            if (in_array($k, ['$in', '$nin', '$all']) && is_array($v)) {
1201 75
                foreach ($v as $k2 => $v2) {
1202 75
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
1203
                }
1204 75
                continue;
1205
            }
1206
1207
            // Recursively process expressions within a $not operator
1208 18
            if ($k === '$not' && is_array($v)) {
1209 15
                $expression[$k] = $this->prepareQueryExpression($v, $class);
1210 15
                continue;
1211
            }
1212
1213 18
            $expression[$k] = $class->getDatabaseIdentifierValue($v);
1214
        }
1215
1216 77
        return $expression;
1217
    }
1218
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 78
    private function hasDBRefFields($value) : bool
1230
    {
1231 78
        if (! is_array($value) && ! is_object($value)) {
1232
            return false;
1233
        }
1234
1235 78
        if (is_object($value)) {
1236
            $value = get_object_vars($value);
1237
        }
1238
1239 78
        foreach ($value as $key => $_) {
1240 78
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
1241 4
                return true;
1242
            }
1243
        }
1244
1245 77
        return false;
1246
    }
1247
1248
    /**
1249
     * Checks whether the value has query operators.
1250
     *
1251
     * @param mixed $value
1252
     */
1253 82
    private function hasQueryOperators($value) : bool
1254
    {
1255 82
        if (! is_array($value) && ! is_object($value)) {
1256
            return false;
1257
        }
1258
1259 82
        if (is_object($value)) {
1260
            $value = get_object_vars($value);
1261
        }
1262
1263 82
        foreach ($value as $key => $_) {
1264 82
            if (isset($key[0]) && $key[0] === '$') {
1265 78
                return true;
1266
            }
1267
        }
1268
1269 11
        return false;
1270
    }
1271
1272
    /**
1273
     * Gets the array of discriminator values for the given ClassMetadata
1274
     */
1275 27
    private function getClassDiscriminatorValues(ClassMetadata $metadata) : array
1276
    {
1277 27
        $discriminatorValues = [$metadata->discriminatorValue];
1278 27
        foreach ($metadata->subClasses as $className) {
1279 8
            $key = array_search($className, $metadata->discriminatorMap);
1280 8
            if (! $key) {
1281
                continue;
1282
            }
1283
1284 8
            $discriminatorValues[] = $key;
1285
        }
1286
1287
        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
1288 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...
1289 2
            $discriminatorValues[] = null;
1290
        }
1291
1292 27
        return $discriminatorValues;
1293
    }
1294
1295 593
    private function handleCollections(object $document, array $options) : void
1296
    {
1297
        // Collection deletions (deletions of complete collections)
1298 593
        $collections = [];
1299 593
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1300 113
            if (! $this->uow->isCollectionScheduledForDeletion($coll)) {
1301 102
                continue;
1302
            }
1303
1304 33
            $collections[] = $coll;
1305
        }
1306 593
        if (! empty($collections)) {
1307 33
            $this->cp->delete($document, $collections, $options);
1308
        }
1309
        // Collection updates (deleteRows, updateRows, insertRows)
1310 593
        $collections = [];
1311 593
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1312 113
            if (! $this->uow->isCollectionScheduledForUpdate($coll)) {
1313 29
                continue;
1314
            }
1315
1316 105
            $collections[] = $coll;
1317
        }
1318 593
        if (! empty($collections)) {
1319 105
            $this->cp->update($document, $collections, $options);
1320
        }
1321
        // Take new snapshots from visited collections
1322 593
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
1323 252
            $coll->takeSnapshot();
1324
        }
1325 593
    }
1326
1327
    /**
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 10
    private function guardMissingShardKey(object $document, string $shardKeyField, array $actualDocumentData) : void
1335
    {
1336 10
        $dcs      = $this->uow->getDocumentChangeSet($document);
1337 10
        $isUpdate = $this->uow->isScheduledForUpdate($document);
1338
1339 10
        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
1340 10
        $fieldName    = $fieldMapping['fieldName'];
1341
1342 10
        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] !== $dcs[$fieldName][1]) {
1343 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...
1344
        }
1345
1346 8
        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
        }
1349 8
    }
1350
1351
    /**
1352
     * Get shard key aware query for single document.
1353
     */
1354 310
    private function getQueryForDocument(object $document) : array
1355
    {
1356 310
        $id = $this->uow->getDocumentIdentifier($document);
1357 310
        $id = $this->class->getDatabaseIdentifierValue($id);
1358
1359 310
        $shardKeyQueryPart = $this->getShardKeyQuery($document);
1360 308
        return array_merge(['_id' => $id], $shardKeyQueryPart);
1361
    }
1362
1363 604
    private function getWriteOptions(array $options = []) : array
1364
    {
1365 604
        $defaultOptions  = $this->dm->getConfiguration()->getDefaultCommitOptions();
1366 604
        $documentOptions = [];
1367 604
        if ($this->class->hasWriteConcern()) {
1368 9
            $documentOptions['w'] = $this->class->getWriteConcern();
1369
        }
1370
1371 604
        return array_merge($defaultOptions, $documentOptions, $options);
1372
    }
1373
1374 15
    private function prepareReference(string $fieldName, $value, array $mapping, bool $inNewObj) : array
1375
    {
1376 15
        $reference = $this->dm->createReference($value, $mapping);
1377 14
        if ($inNewObj || $mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_ID) {
1378 8
            return [[$fieldName, $reference]];
1379
        }
1380
1381 6
        switch ($mapping['storeAs']) {
1382
            case ClassMetadata::REFERENCE_STORE_AS_REF:
1383
                $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 6
                $keys = ['$ref' => true, '$id' => true, '$db' => true];
1389
1390 6
                if ($mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_DB_REF) {
1391 5
                    unset($keys['$db']);
1392
                }
1393
1394 6
                if (isset($mapping['targetDocument'])) {
1395 4
                    unset($keys['$ref'], $keys['$db']);
1396
                }
1397 6
                break;
1398
1399
            default:
1400
                throw new InvalidArgumentException(sprintf('Reference type %s is invalid.', $mapping['storeAs']));
1401
        }
1402
1403 6
        if ($mapping['type'] === 'many') {
1404 2
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
1405
        }
1406
1407 4
        return array_map(
1408
            static function ($key) use ($reference, $fieldName) {
1409 4
                return [$fieldName . '.' . $key, $reference[$key]];
1410 4
            },
1411 4
            array_keys($keys)
1412
        );
1413
    }
1414
}
1415