Completed
Pull Request — master (#1860)
by Andreas
14:44
created

DocumentPersister::prepareQueryOrNewObj()   B

Complexity

Conditions 10
Paths 7

Size

Total Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 10

Importance

Changes 0
Metric Value
dl 0
loc 28
ccs 16
cts 16
cp 1
rs 7.6666
c 0
b 0
f 0
cc 10
nc 7
nop 2
crap 10

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\PersistentCollectionInterface;
21
use Doctrine\ODM\MongoDB\Proxy\Proxy;
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 stdClass;
36
use function array_combine;
37
use function array_fill;
38
use function array_intersect_key;
39
use function array_keys;
40
use function array_map;
41
use function array_merge;
42
use function array_search;
43
use function array_slice;
44
use function array_values;
45
use function count;
46
use function explode;
47
use function get_class;
48
use function get_object_vars;
49
use function implode;
50
use function in_array;
51
use function is_array;
52
use function is_object;
53
use function is_scalar;
54
use function max;
55
use function spl_object_hash;
56
use function sprintf;
57
use function strpos;
58
use function strtolower;
59
60
/**
61
 * The DocumentPersister is responsible for persisting documents.
62
 *
63
 */
64
class DocumentPersister
65
{
66
    /** @var PersistenceBuilder */
67
    private $pb;
68
69
    /** @var DocumentManager */
70
    private $dm;
71
72
    /** @var UnitOfWork */
73
    private $uow;
74
75
    /** @var ClassMetadata */
76
    private $class;
77
78
    /** @var Collection */
79
    private $collection;
80
81
    /** @var Bucket|null */
82
    private $bucket;
83
84
    /**
85
     * Array of queued inserts for the persister to insert.
86
     *
87
     * @var array
88
     */
89
    private $queuedInserts = [];
90
91
    /**
92
     * Array of queued inserts for the persister to insert.
93
     *
94
     * @var array
95
     */
96
    private $queuedUpserts = [];
97
98
    /** @var CriteriaMerger */
99
    private $cm;
100
101
    /** @var CollectionPersister */
102
    private $cp;
103
104
    /** @var HydratorFactory */
105
    private $hydratorFactory;
106
107 1089
    public function __construct(
108
        PersistenceBuilder $pb,
109
        DocumentManager $dm,
110
        UnitOfWork $uow,
111
        HydratorFactory $hydratorFactory,
112
        ClassMetadata $class,
113
        ?CriteriaMerger $cm = null
114
    ) {
115 1089
        $this->pb = $pb;
116 1089
        $this->dm = $dm;
117 1089
        $this->cm = $cm ?: new CriteriaMerger();
118 1089
        $this->uow = $uow;
119 1089
        $this->hydratorFactory = $hydratorFactory;
120 1089
        $this->class = $class;
121 1089
        $this->collection = $dm->getDocumentCollection($class->name);
122 1089
        $this->cp = $this->uow->getCollectionPersister();
123
124 1089
        if (! $class->isFile) {
125 1081
            return;
126
        }
127
128 10
        $this->bucket = $dm->getDocumentBucket($class->name);
129 10
    }
130
131
    public function getInserts() : array
132
    {
133
        return $this->queuedInserts;
134
    }
135
136
    public function isQueuedForInsert(object $document) : bool
137
    {
138
        return isset($this->queuedInserts[spl_object_hash($document)]);
139
    }
140
141
    /**
142
     * Adds a document to the queued insertions.
143
     * The document remains queued until {@link executeInserts} is invoked.
144
     */
145 483
    public function addInsert(object $document) : void
146
    {
147 483
        $this->queuedInserts[spl_object_hash($document)] = $document;
148 483
    }
149
150
    public function getUpserts() : array
151
    {
152
        return $this->queuedUpserts;
153
    }
154
155
    public function isQueuedForUpsert(object $document) : bool
156
    {
157
        return isset($this->queuedUpserts[spl_object_hash($document)]);
158
    }
159
160
    /**
161
     * Adds a document to the queued upserts.
162
     * The document remains queued until {@link executeUpserts} is invoked.
163
     */
164 83
    public function addUpsert(object $document) : void
165
    {
166 83
        $this->queuedUpserts[spl_object_hash($document)] = $document;
167 83
    }
168
169
    /**
170
     * Gets the ClassMetadata instance of the document class this persister is used for.
171
     */
172
    public function getClassMetadata() : ClassMetadata
173
    {
174
        return $this->class;
175
    }
176
177
    /**
178
     * Executes all queued document insertions.
179
     *
180
     * Queued documents without an ID will inserted in a batch and queued
181
     * documents with an ID will be upserted individually.
182
     *
183
     * If no inserts are queued, invoking this method is a NOOP.
184
     *
185
     * @throws DriverException
186
     */
187 483
    public function executeInserts(array $options = []) : void
188
    {
189 483
        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...
190
            return;
191
        }
192
193 483
        $inserts = [];
194 483
        $options = $this->getWriteOptions($options);
195 483
        foreach ($this->queuedInserts as $oid => $document) {
196 483
            $data = $this->pb->prepareInsertData($document);
197
198
            // Set the initial version for each insert
199 482
            if ($this->class->isVersioned) {
200 20
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
201 20
                $nextVersion = null;
202 20
                if ($versionMapping['type'] === 'int') {
203 18
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
204 18
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
205 2
                } elseif ($versionMapping['type'] === 'date') {
206 2
                    $nextVersionDateTime = new DateTime();
207 2
                    $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
208 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
209
                }
210 20
                $data[$versionMapping['name']] = $nextVersion;
211
            }
212
213 482
            $inserts[] = $data;
214
        }
215
216 482
        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...
217
            try {
218 482
                $this->collection->insertMany($inserts, $options);
219 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...
220 6
                $this->queuedInserts = [];
221 6
                throw $e;
222
            }
223
        }
224
225
        /* All collections except for ones using addToSet have already been
226
         * saved. We have left these to be handled separately to avoid checking
227
         * collection for uniqueness on PHP side.
228
         */
229 482
        foreach ($this->queuedInserts as $document) {
230 482
            $this->handleCollections($document, $options);
231
        }
232
233 482
        $this->queuedInserts = [];
234 482
    }
235
236
    /**
237
     * Executes all queued document upserts.
238
     *
239
     * Queued documents with an ID are upserted individually.
240
     *
241
     * If no upserts are queued, invoking this method is a NOOP.
242
     */
243 83
    public function executeUpserts(array $options = []) : void
244
    {
245 83
        if (! $this->queuedUpserts) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->queuedUpserts of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

Loading history...
246
            return;
247
        }
248
249 83
        $options = $this->getWriteOptions($options);
250 83
        foreach ($this->queuedUpserts as $oid => $document) {
251
            try {
252 83
                $this->executeUpsert($document, $options);
253 83
                $this->handleCollections($document, $options);
254 83
                unset($this->queuedUpserts[$oid]);
255
            } 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...
256
                unset($this->queuedUpserts[$oid]);
257 83
                throw $e;
258
            }
259
        }
260 83
    }
261
262
    /**
263
     * Executes a single upsert in {@link executeUpserts}
264
     */
265 83
    private function executeUpsert(object $document, array $options) : void
266
    {
267 83
        $options['upsert'] = true;
268 83
        $criteria = $this->getQueryForDocument($document);
269
270 83
        $data = $this->pb->prepareUpsertData($document);
271
272
        // Set the initial version for each upsert
273 83
        if ($this->class->isVersioned) {
274 2
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
275 2
            $nextVersion = null;
276 2
            if ($versionMapping['type'] === 'int') {
277 1
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
278 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
279 1
            } elseif ($versionMapping['type'] === 'date') {
280 1
                $nextVersionDateTime = new DateTime();
281 1
                $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
282 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
283
            }
284 2
            $data['$set'][$versionMapping['name']] = $nextVersion;
285
        }
286
287 83
        foreach (array_keys($criteria) as $field) {
288 83
            unset($data['$set'][$field]);
289 83
            unset($data['$inc'][$field]);
290 83
            unset($data['$setOnInsert'][$field]);
291
        }
292
293
        // Do not send empty update operators
294 83
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
295 83
            if (! empty($data[$operator])) {
296 68
                continue;
297
            }
298
299 83
            unset($data[$operator]);
300
        }
301
302
        /* If there are no modifiers remaining, we're upserting a document with
303
         * an identifier as its only field. Since a document with the identifier
304
         * may already exist, the desired behavior is "insert if not exists" and
305
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
306
         * the identifier to the same value in our criteria.
307
         *
308
         * This will fail for versions before MongoDB 2.6, which require an
309
         * empty $set modifier. The best we can do (without attempting to check
310
         * server versions in advance) is attempt the 2.6+ behavior and retry
311
         * after the relevant exception.
312
         *
313
         * See: https://jira.mongodb.org/browse/SERVER-12266
314
         */
315 83
        if (empty($data)) {
316 16
            $retry = true;
317 16
            $data = ['$set' => ['_id' => $criteria['_id']]];
318
        }
319
320
        try {
321 83
            $this->collection->updateOne($criteria, $data, $options);
322 83
            return;
323
        } 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...
324
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
325
                throw $e;
326
            }
327
        }
328
329
        $this->collection->updateOne($criteria, ['$set' => new stdClass()], $options);
330
    }
331
332
    /**
333
     * Updates the already persisted document if it has any new changesets.
334
     *
335
     * @throws LockException
336
     */
337 197
    public function update(object $document, array $options = []) : void
338
    {
339 197
        $update = $this->pb->prepareUpdateData($document);
340
341 197
        $query = $this->getQueryForDocument($document);
342
343 197
        foreach (array_keys($query) as $field) {
344 197
            unset($update['$set'][$field]);
345
        }
346
347 197
        if (empty($update['$set'])) {
348 90
            unset($update['$set']);
349
        }
350
351
        // Include versioning logic to set the new version value in the database
352
        // and to ensure the version has not changed since this document object instance
353
        // was fetched from the database
354 197
        $nextVersion = null;
355 197
        if ($this->class->isVersioned) {
356 13
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
357 13
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
358 13
            if ($versionMapping['type'] === 'int') {
359 10
                $nextVersion = $currentVersion + 1;
360 10
                $update['$inc'][$versionMapping['name']] = 1;
361 10
                $query[$versionMapping['name']] = $currentVersion;
362 3
            } elseif ($versionMapping['type'] === 'date') {
363 3
                $nextVersion = new DateTime();
364 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
365 3
                $query[$versionMapping['name']] = Type::convertPHPToDatabaseValue($currentVersion);
366
            }
367
        }
368
369 197
        if (! empty($update)) {
370
            // Include locking logic so that if the document object in memory is currently
371
            // locked then it will remove it, otherwise it ensures the document is not locked.
372 130
            if ($this->class->isLockable) {
373 11
                $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document);
374 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
375 11
                if ($isLocked) {
376 2
                    $update['$unset'] = [$lockMapping['name'] => true];
377
                } else {
378 9
                    $query[$lockMapping['name']] = ['$exists' => false];
379
                }
380
            }
381
382 130
            $options = $this->getWriteOptions($options);
383
384 130
            $result = $this->collection->updateOne($query, $update, $options);
385
386 130
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
387 4
                throw LockException::lockFailed($document);
388 126
            } elseif ($this->class->isVersioned) {
389 9
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
390
            }
391
        }
392
393 193
        $this->handleCollections($document, $options);
394 193
    }
395
396
    /**
397
     * Removes document from mongo
398
     *
399
     * @throws LockException
400
     */
401 35
    public function delete(object $document, array $options = []) : void
402
    {
403 35
        if ($this->bucket instanceof Bucket) {
404 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
405 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
406
407 1
            $this->bucket->delete($databaseIdentifier);
408
409 1
            return;
410
        }
411
412 34
        $query = $this->getQueryForDocument($document);
413
414 34
        if ($this->class->isLockable) {
415 2
            $query[$this->class->lockField] = ['$exists' => false];
416
        }
417
418 34
        $options = $this->getWriteOptions($options);
419
420 34
        $result = $this->collection->deleteOne($query, $options);
421
422 34
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
423 2
            throw LockException::lockFailed($document);
424
        }
425 32
    }
426
427
    /**
428
     * Refreshes a managed document.
429
     */
430 22
    public function refresh(object $document) : void
431
    {
432 22
        $query = $this->getQueryForDocument($document);
433 22
        $data = $this->collection->findOne($query);
434 22
        $data = $this->hydratorFactory->hydrate($document, $data);
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type null or object; however, Doctrine\ODM\MongoDB\Hyd...ratorFactory::hydrate() 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...
435 22
        $this->uow->setOriginalDocumentData($document, $data);
436 22
    }
437
438
    /**
439
     * Finds a document by a set of criteria.
440
     *
441
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
442
     * be used to match an _id value.
443
     *
444
     * @param mixed $criteria Query criteria
445
     * @throws LockException
446
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
447
     */
448 352
    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...
449
    {
450
        // TODO: remove this
451 352
        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...
452
            $criteria = ['_id' => $criteria];
453
        }
454
455 352
        $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...
456 352
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
457 352
        $criteria = $this->addFilterToPreparedQuery($criteria);
458
459 352
        $options = [];
460 352
        if ($sort !== null) {
461 92
            $options['sort'] = $this->prepareSort($sort);
462
        }
463 352
        $result = $this->collection->findOne($criteria, $options);
464
465 352
        if ($this->class->isLockable) {
466 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
467 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
468 1
                throw LockException::lockFailed($document);
469
            }
470
        }
471
472 351
        return $this->createDocument($result, $document, $hints);
0 ignored issues
show
Bug introduced by
It seems like $result defined by $this->collection->findOne($criteria, $options) on line 463 can also be of type array or null; however, Doctrine\ODM\MongoDB\Per...ister::createDocument() does only seem to accept object, 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...
473
    }
474
475
    /**
476
     * Finds documents by a set of criteria.
477
     */
478 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null) : Iterator
479
    {
480 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
481 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
482 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
483
484 22
        $options = [];
485 22
        if ($sort !== null) {
486 11
            $options['sort'] = $this->prepareSort($sort);
487
        }
488
489 22
        if ($limit !== null) {
490 10
            $options['limit'] = $limit;
491
        }
492
493 22
        if ($skip !== null) {
494 1
            $options['skip'] = $skip;
495
        }
496
497 22
        $baseCursor = $this->collection->find($criteria, $options);
498 22
        return $this->wrapCursor($baseCursor);
499
    }
500
501
    /**
502
     * @throws MongoDBException
503
     */
504 273
    private function getShardKeyQuery(object $document) : array
505
    {
506 273
        if (! $this->class->isSharded()) {
507 269
            return [];
508
        }
509
510 4
        $shardKey = $this->class->getShardKey();
511 4
        $keys = array_keys($shardKey['keys']);
512 4
        $data = $this->uow->getDocumentActualData($document);
513
514 4
        $shardKeyQueryPart = [];
515 4
        foreach ($keys as $key) {
516 4
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
517 4
            $this->guardMissingShardKey($document, $key, $data);
518
519 4
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
520 1
                $reference = $this->prepareReference(
521 1
                    $key,
522 1
                    $data[$mapping['fieldName']],
523 1
                    $mapping,
524 1
                    false
525
                );
526 1
                foreach ($reference as $keyValue) {
527 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
528
                }
529
            } else {
530 3
                $value = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
531 4
                $shardKeyQueryPart[$key] = $value;
532
            }
533
        }
534
535 4
        return $shardKeyQueryPart;
536
    }
537
538
    /**
539
     * Wraps the supplied base cursor in the corresponding ODM class.
540
     */
541 22
    private function wrapCursor(Cursor $baseCursor) : Iterator
542
    {
543 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
544
    }
545
546
    /**
547
     * Checks whether the given managed document exists in the database.
548
     */
549 3
    public function exists(object $document) : bool
550
    {
551 3
        $id = $this->class->getIdentifierObject($document);
552 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
553
    }
554
555
    /**
556
     * Locks document by storing the lock mode on the mapped lock field.
557
     */
558 5
    public function lock(object $document, int $lockMode) : void
559
    {
560 5
        $id = $this->uow->getDocumentIdentifier($document);
561 5
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
562 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
563 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
564 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
565 5
    }
566
567
    /**
568
     * Releases any lock that exists on this document.
569
     *
570
     */
571 1
    public function unlock(object $document) : void
572
    {
573 1
        $id = $this->uow->getDocumentIdentifier($document);
574 1
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
575 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
576 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
577 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
578 1
    }
579
580
    /**
581
     * Creates or fills a single document object from an query result.
582
     *
583
     * @param object $result   The query result.
584
     * @param object $document The document object to fill, if any.
585
     * @param array  $hints    Hints for document creation.
586
     * @return object The filled and managed document object or NULL, if the query result is empty.
587
     */
588 351
    private function createDocument($result, ?object $document = null, array $hints = []) : ?object
589
    {
590 351
        if ($result === null) {
591 113
            return null;
592
        }
593
594 307
        if ($document !== null) {
595 28
            $hints[Query::HINT_REFRESH] = true;
596 28
            $id = $this->class->getPHPIdentifierValue($result['_id']);
597 28
            $this->uow->registerManaged($document, $id, $result);
0 ignored issues
show
Documentation introduced by
$result is of type object, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
598
        }
599
600 307
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
0 ignored issues
show
Documentation introduced by
$result is of type object, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
601
    }
602
603
    /**
604
     * Loads a PersistentCollection data. Used in the initialize() method.
605
     */
606 164
    public function loadCollection(PersistentCollectionInterface $collection) : void
607
    {
608 164
        $mapping = $collection->getMapping();
609 164
        switch ($mapping['association']) {
610
            case ClassMetadata::EMBED_MANY:
611 109
                $this->loadEmbedManyCollection($collection);
612 109
                break;
613
614
            case ClassMetadata::REFERENCE_MANY:
615 77
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
616 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
617
                } else {
618 73
                    if ($mapping['isOwningSide']) {
619 61
                        $this->loadReferenceManyCollectionOwningSide($collection);
620
                    } else {
621 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
622
                    }
623
                }
624 77
                break;
625
        }
626 164
    }
627
628 109
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection) : void
629
    {
630 109
        $embeddedDocuments = $collection->getMongoData();
631 109
        $mapping = $collection->getMapping();
632 109
        $owner = $collection->getOwner();
633 109
        if (! $embeddedDocuments) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $embeddedDocuments of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

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

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

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