Completed
Pull Request — master (#1803)
by Maciej
20:22
created

DocumentPersister::prepareQueryExpression()   B

Complexity

Conditions 8
Paths 6

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 8

Importance

Changes 0
Metric Value
dl 0
loc 27
ccs 13
cts 13
cp 1
rs 8.4444
c 0
b 0
f 0
cc 8
nc 6
nop 2
crap 8
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\Query\CriteriaMerger;
22
use Doctrine\ODM\MongoDB\Query\Query;
23
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
24
use Doctrine\ODM\MongoDB\Types\Type;
25
use Doctrine\ODM\MongoDB\UnitOfWork;
26
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
27
use InvalidArgumentException;
28
use MongoDB\BSON\ObjectId;
29
use MongoDB\Collection;
30
use MongoDB\Driver\Cursor;
31
use MongoDB\Driver\Exception\Exception as DriverException;
32
use MongoDB\Driver\Exception\WriteException;
33
use MongoDB\GridFS\Bucket;
34
use ProxyManager\Proxy\GhostObjectInterface;
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
class DocumentPersister
64
{
65
    /** @var PersistenceBuilder */
66
    private $pb;
67
68
    /** @var DocumentManager */
69
    private $dm;
70
71
    /** @var UnitOfWork */
72
    private $uow;
73
74
    /** @var ClassMetadata */
75
    private $class;
76
77
    /** @var Collection */
78
    private $collection;
79
80
    /** @var Bucket|null */
81
    private $bucket;
82
83
    /**
84
     * Array of queued inserts for the persister to insert.
85
     *
86
     * @var array
87
     */
88
    private $queuedInserts = [];
89
90
    /**
91
     * Array of queued inserts for the persister to insert.
92
     *
93
     * @var array
94
     */
95
    private $queuedUpserts = [];
96
97
    /** @var CriteriaMerger */
98
    private $cm;
99
100
    /** @var CollectionPersister */
101
    private $cp;
102
103
    /** @var HydratorFactory */
104
    private $hydratorFactory;
105
106 1119
    public function __construct(
107
        PersistenceBuilder $pb,
108
        DocumentManager $dm,
109
        UnitOfWork $uow,
110
        HydratorFactory $hydratorFactory,
111
        ClassMetadata $class,
112
        ?CriteriaMerger $cm = null
113
    ) {
114 1119
        $this->pb              = $pb;
115 1119
        $this->dm              = $dm;
116 1119
        $this->cm              = $cm ?: new CriteriaMerger();
117 1119
        $this->uow             = $uow;
118 1119
        $this->hydratorFactory = $hydratorFactory;
119 1119
        $this->class           = $class;
120 1119
        $this->collection      = $dm->getDocumentCollection($class->name);
121 1119
        $this->cp              = $this->uow->getCollectionPersister();
122
123 1119
        if (! $class->isFile) {
124 1111
            return;
125
        }
126
127 10
        $this->bucket = $dm->getDocumentBucket($class->name);
128 10
    }
129
130
    public function getInserts() : array
131
    {
132
        return $this->queuedInserts;
133
    }
134
135
    public function isQueuedForInsert(object $document) : bool
136
    {
137
        return isset($this->queuedInserts[spl_object_hash($document)]);
138
    }
139
140
    /**
141
     * Adds a document to the queued insertions.
142
     * The document remains queued until {@link executeInserts} is invoked.
143
     */
144 510
    public function addInsert(object $document) : void
145
    {
146 510
        $this->queuedInserts[spl_object_hash($document)] = $document;
147 510
    }
148
149
    public function getUpserts() : array
150
    {
151
        return $this->queuedUpserts;
152
    }
153
154
    public function isQueuedForUpsert(object $document) : bool
155
    {
156
        return isset($this->queuedUpserts[spl_object_hash($document)]);
157
    }
158
159
    /**
160
     * Adds a document to the queued upserts.
161
     * The document remains queued until {@link executeUpserts} is invoked.
162
     */
163 85
    public function addUpsert(object $document) : void
164
    {
165 85
        $this->queuedUpserts[spl_object_hash($document)] = $document;
166 85
    }
167
168
    /**
169
     * Gets the ClassMetadata instance of the document class this persister is used for.
170
     */
171
    public function getClassMetadata() : ClassMetadata
172
    {
173
        return $this->class;
174
    }
175
176
    /**
177
     * Executes all queued document insertions.
178
     *
179
     * Queued documents without an ID will inserted in a batch and queued
180
     * documents with an ID will be upserted individually.
181
     *
182
     * If no inserts are queued, invoking this method is a NOOP.
183
     *
184
     * @throws DriverException
185
     */
186 510
    public function executeInserts(array $options = []) : void
187
    {
188 510
        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...
189
            return;
190
        }
191
192 510
        $inserts = [];
193 510
        $options = $this->getWriteOptions($options);
194 510
        foreach ($this->queuedInserts as $oid => $document) {
195 510
            $data = $this->pb->prepareInsertData($document);
196
197
            // Set the initial version for each insert
198 509
            if ($this->class->isVersioned) {
199 38
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
200 38
                $nextVersion    = null;
201 38
                if ($versionMapping['type'] === 'int') {
202 36
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
203 36
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
204 2
                } elseif ($versionMapping['type'] === 'date') {
205 2
                    $nextVersionDateTime = new DateTime();
206 2
                    $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
207 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
208
                }
209 38
                $data[$versionMapping['name']] = $nextVersion;
210
            }
211
212 509
            $inserts[] = $data;
213
        }
214
215 509
        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...
216
            try {
217 509
                $this->collection->insertMany($inserts, $options);
218 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...
219 6
                $this->queuedInserts = [];
220 6
                throw $e;
221
            }
222
        }
223
224
        /* All collections except for ones using addToSet have already been
225
         * saved. We have left these to be handled separately to avoid checking
226
         * collection for uniqueness on PHP side.
227
         */
228 509
        foreach ($this->queuedInserts as $document) {
229 509
            $this->handleCollections($document, $options);
230
        }
231
232 509
        $this->queuedInserts = [];
233 509
    }
234
235
    /**
236
     * Executes all queued document upserts.
237
     *
238
     * Queued documents with an ID are upserted individually.
239
     *
240
     * If no upserts are queued, invoking this method is a NOOP.
241
     */
242 85
    public function executeUpserts(array $options = []) : void
243
    {
244 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...
245
            return;
246
        }
247
248 85
        $options = $this->getWriteOptions($options);
249 85
        foreach ($this->queuedUpserts as $oid => $document) {
250
            try {
251 85
                $this->executeUpsert($document, $options);
252 85
                $this->handleCollections($document, $options);
253 85
                unset($this->queuedUpserts[$oid]);
254
            } 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...
255
                unset($this->queuedUpserts[$oid]);
256 85
                throw $e;
257
            }
258
        }
259 85
    }
260
261
    /**
262
     * Executes a single upsert in {@link executeUpserts}
263
     */
264 85
    private function executeUpsert(object $document, array $options) : void
265
    {
266 85
        $options['upsert'] = true;
267 85
        $criteria          = $this->getQueryForDocument($document);
268
269 85
        $data = $this->pb->prepareUpsertData($document);
270
271
        // Set the initial version for each upsert
272 85
        if ($this->class->isVersioned) {
273 3
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
274 3
            $nextVersion    = null;
275 3
            if ($versionMapping['type'] === 'int') {
276 2
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
277 2
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
278 1
            } elseif ($versionMapping['type'] === 'date') {
279 1
                $nextVersionDateTime = new DateTime();
280 1
                $nextVersion         = Type::convertPHPToDatabaseValue($nextVersionDateTime);
281 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
282
            }
283 3
            $data['$set'][$versionMapping['name']] = $nextVersion;
284
        }
285
286 85
        foreach (array_keys($criteria) as $field) {
287 85
            unset($data['$set'][$field]);
288 85
            unset($data['$inc'][$field]);
289 85
            unset($data['$setOnInsert'][$field]);
290
        }
291
292
        // Do not send empty update operators
293 85
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
294 85
            if (! empty($data[$operator])) {
295 70
                continue;
296
            }
297
298 85
            unset($data[$operator]);
299
        }
300
301
        /* If there are no modifiers remaining, we're upserting a document with
302
         * an identifier as its only field. Since a document with the identifier
303
         * may already exist, the desired behavior is "insert if not exists" and
304
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
305
         * the identifier to the same value in our criteria.
306
         *
307
         * This will fail for versions before MongoDB 2.6, which require an
308
         * empty $set modifier. The best we can do (without attempting to check
309
         * server versions in advance) is attempt the 2.6+ behavior and retry
310
         * after the relevant exception.
311
         *
312
         * See: https://jira.mongodb.org/browse/SERVER-12266
313
         */
314 85
        if (empty($data)) {
315 16
            $retry = true;
316 16
            $data  = ['$set' => ['_id' => $criteria['_id']]];
317
        }
318
319
        try {
320 85
            $this->collection->updateOne($criteria, $data, $options);
321 85
            return;
322
        } 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...
323
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
324
                throw $e;
325
            }
326
        }
327
328
        $this->collection->updateOne($criteria, ['$set' => new stdClass()], $options);
329
    }
330
331
    /**
332
     * Updates the already persisted document if it has any new changesets.
333
     *
334
     * @throws LockException
335
     */
336 219
    public function update(object $document, array $options = []) : void
337
    {
338 219
        $update = $this->pb->prepareUpdateData($document);
339
340 219
        $query = $this->getQueryForDocument($document);
341
342 217
        foreach (array_keys($query) as $field) {
343 217
            unset($update['$set'][$field]);
344
        }
345
346 217
        if (empty($update['$set'])) {
347 94
            unset($update['$set']);
348
        }
349
350
        // Include versioning logic to set the new version value in the database
351
        // and to ensure the version has not changed since this document object instance
352
        // was fetched from the database
353 217
        $nextVersion = null;
354 217
        if ($this->class->isVersioned) {
355 31
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
356 31
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
357 31
            if ($versionMapping['type'] === 'int') {
358 28
                $nextVersion                             = $currentVersion + 1;
359 28
                $update['$inc'][$versionMapping['name']] = 1;
360 28
                $query[$versionMapping['name']]          = $currentVersion;
361 3
            } elseif ($versionMapping['type'] === 'date') {
362 3
                $nextVersion                             = new DateTime();
363 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
364 3
                $query[$versionMapping['name']]          = Type::convertPHPToDatabaseValue($currentVersion);
365
            }
366
        }
367
368 217
        if (! empty($update)) {
369
            // Include locking logic so that if the document object in memory is currently
370
            // locked then it will remove it, otherwise it ensures the document is not locked.
371 149
            if ($this->class->isLockable) {
372 11
                $isLocked    = $this->class->reflFields[$this->class->lockField]->getValue($document);
373 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
374 11
                if ($isLocked) {
375 2
                    $update['$unset'] = [$lockMapping['name'] => true];
376
                } else {
377 9
                    $query[$lockMapping['name']] = ['$exists' => false];
378
                }
379
            }
380
381 149
            $options = $this->getWriteOptions($options);
382
383 149
            $result = $this->collection->updateOne($query, $update, $options);
384
385 149
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
386 5
                throw LockException::lockFailed($document);
387 145
            } elseif ($this->class->isVersioned) {
388 27
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
389
            }
390
        }
391
392 213
        $this->handleCollections($document, $options);
393 213
    }
394
395
    /**
396
     * Removes document from mongo
397
     *
398
     * @throws LockException
399
     */
400 36
    public function delete(object $document, array $options = []) : void
401
    {
402 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...
403 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
404 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
405
406 1
            $this->bucket->delete($databaseIdentifier);
407
408 1
            return;
409
        }
410
411 35
        $query = $this->getQueryForDocument($document);
412
413 35
        if ($this->class->isLockable) {
414 2
            $query[$this->class->lockField] = ['$exists' => false];
415
        }
416
417 35
        $options = $this->getWriteOptions($options);
418
419 35
        $result = $this->collection->deleteOne($query, $options);
420
421 35
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
422 2
            throw LockException::lockFailed($document);
423
        }
424 33
    }
425
426
    /**
427
     * Refreshes a managed document.
428
     */
429 23
    public function refresh(object $document) : void
430
    {
431 23
        $query = $this->getQueryForDocument($document);
432 23
        $data = $this->collection->findOne($query);
433 23
        if ($data === null) {
434
            throw MongoDBException::cannotRefreshDocument();
435
        }
436 23
        $data = $this->hydratorFactory->hydrate($document, (array) $data);
437 23
        $this->uow->setOriginalDocumentData($document, $data);
438 23
    }
439
440
    /**
441
     * Finds a document by a set of criteria.
442
     *
443
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
444
     * be used to match an _id value.
445
     *
446
     * @param mixed $criteria Query criteria
447
     *
448
     * @throws LockException
449
     *
450
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
451
     */
452 371
    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...
453
    {
454
        // TODO: remove this
455 371
        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...
456
            $criteria = ['_id' => $criteria];
457
        }
458
459 371
        $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...
460 371
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
461 371
        $criteria = $this->addFilterToPreparedQuery($criteria);
462
463 371
        $options = [];
464 371
        if ($sort !== null) {
465 95
            $options['sort'] = $this->prepareSort($sort);
466
        }
467 371
        $result = $this->collection->findOne($criteria, $options);
468 371
        $result = $result !== null ? (array) $result : null;
469
470 371
        if ($this->class->isLockable) {
471 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
472 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
473 1
                throw LockException::lockFailed($document);
474
            }
475
        }
476
477 370
        if ($result === null) {
478 116
            return null;
479
        }
480
481 326
        return $this->createDocument($result, $document, $hints);
482
    }
483
484
    /**
485
     * Finds documents by a set of criteria.
486
     */
487 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null) : Iterator
488
    {
489 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
490 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
491 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
492
493 22
        $options = [];
494 22
        if ($sort !== null) {
495 11
            $options['sort'] = $this->prepareSort($sort);
496
        }
497
498 22
        if ($limit !== null) {
499 10
            $options['limit'] = $limit;
500
        }
501
502 22
        if ($skip !== null) {
503 1
            $options['skip'] = $skip;
504
        }
505
506 22
        $baseCursor = $this->collection->find($criteria, $options);
507 22
        return $this->wrapCursor($baseCursor);
508
    }
509
510
    /**
511
     * @throws MongoDBException
512
     */
513 299
    private function getShardKeyQuery(object $document) : array
514
    {
515 299
        if (! $this->class->isSharded()) {
516 289
            return [];
517
        }
518
519 10
        $shardKey = $this->class->getShardKey();
520 10
        $keys     = array_keys($shardKey['keys']);
521 10
        $data     = $this->uow->getDocumentActualData($document);
522
523 10
        $shardKeyQueryPart = [];
524 10
        foreach ($keys as $key) {
525 10
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
526 10
            $this->guardMissingShardKey($document, $key, $data);
527
528 8
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
529 1
                $reference = $this->prepareReference(
530 1
                    $key,
531 1
                    $data[$mapping['fieldName']],
532 1
                    $mapping,
533 1
                    false
534
                );
535 1
                foreach ($reference as $keyValue) {
536 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
537
                }
538
            } else {
539 7
                $value                   = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
540 8
                $shardKeyQueryPart[$key] = $value;
541
            }
542
        }
543
544 8
        return $shardKeyQueryPart;
545
    }
546
547
    /**
548
     * Wraps the supplied base cursor in the corresponding ODM class.
549
     */
550 22
    private function wrapCursor(Cursor $baseCursor) : Iterator
551
    {
552 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
553
    }
554
555
    /**
556
     * Checks whether the given managed document exists in the database.
557
     */
558 3
    public function exists(object $document) : bool
559
    {
560 3
        $id = $this->class->getIdentifierObject($document);
561 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
562
    }
563
564
    /**
565
     * Locks document by storing the lock mode on the mapped lock field.
566
     */
567 5
    public function lock(object $document, int $lockMode) : void
568
    {
569 5
        $id          = $this->uow->getDocumentIdentifier($document);
570 5
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
571 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
572 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
573 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
574 5
    }
575
576
    /**
577
     * Releases any lock that exists on this document.
578
     */
579 1
    public function unlock(object $document) : void
580
    {
581 1
        $id          = $this->uow->getDocumentIdentifier($document);
582 1
        $criteria    = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
583 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
584 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
585 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
586 1
    }
587
588
    /**
589
     * Creates or fills a single document object from an query result.
590
     *
591
     * @param array|null $result   The query result.
592
     * @param object     $document The document object to fill, if any.
593
     * @param array      $hints    Hints for document creation.
594
     * @return object|null The filled and managed document object or NULL, if the query result is empty.
595
     */
596 326
    private function createDocument(array $result, ?object $document = null, array $hints = []) : ?object
597
    {
598 326
        if ($document !== null) {
599 29
            $hints[Query::HINT_REFRESH] = true;
600 29
            $id                         = $this->class->getPHPIdentifierValue($result['_id']);
601 29
            $this->uow->registerManaged($document, $id, $result);
602
        }
603
604 326
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
605
    }
606
607
    /**
608
     * Loads a PersistentCollection data. Used in the initialize() method.
609
     */
610 184
    public function loadCollection(PersistentCollectionInterface $collection) : void
611
    {
612 184
        $mapping = $collection->getMapping();
613 184
        switch ($mapping['association']) {
614
            case ClassMetadata::EMBED_MANY:
615 129
                $this->loadEmbedManyCollection($collection);
616 129
                break;
617
618
            case ClassMetadata::REFERENCE_MANY:
619 78
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
620 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
621
                } else {
622 74
                    if ($mapping['isOwningSide']) {
623 62
                        $this->loadReferenceManyCollectionOwningSide($collection);
624
                    } else {
625 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
626
                    }
627
                }
628 78
                break;
629
        }
630 184
    }
631
632 129
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection) : void
633
    {
634 129
        $embeddedDocuments = $collection->getMongoData();
635 129
        $mapping           = $collection->getMapping();
636 129
        $owner             = $collection->getOwner();
637 129
        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...
638 75
            return;
639
        }
640
641 100
        foreach ($embeddedDocuments as $key => $embeddedDocument) {
642 100
            $className              = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
643 100
            $embeddedMetadata       = $this->dm->getClassMetadata($className);
644 100
            $embeddedDocumentObject = $embeddedMetadata->newInstance();
645
646 100
            $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
647
648 100
            $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
649 100
            $id   = $data[$embeddedMetadata->identifier] ?? null;
650
651 100
            if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
652 99
                $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
653
            }
654 100
            if (CollectionHelper::isHash($mapping['strategy'])) {
655 25
                $collection->set($key, $embeddedDocumentObject);
656
            } else {
657 100
                $collection->add($embeddedDocumentObject);
658
            }
659
        }
660 100
    }
661
662 62
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection) : void
663
    {
664 62
        $hints      = $collection->getHints();
665 62
        $mapping    = $collection->getMapping();
666 62
        $groupedIds = [];
667
668 62
        $sorted = isset($mapping['sort']) && $mapping['sort'];
669
670 62
        foreach ($collection->getMongoData() as $key => $reference) {
671 56
            $className  = $this->uow->getClassNameForAssociation($mapping, $reference);
672 56
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
673 56
            $id         = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
674
675
            // create a reference to the class and id
676 56
            $reference = $this->dm->getReference($className, $id);
677
678
            // no custom sort so add the references right now in the order they are embedded
679 56
            if (! $sorted) {
680 55
                if (CollectionHelper::isHash($mapping['strategy'])) {
681 2
                    $collection->set($key, $reference);
682
                } else {
683 53
                    $collection->add($reference);
684
                }
685
            }
686
687
            // only query for the referenced object if it is not already initialized or the collection is sorted
688 56
            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...
689 22
                continue;
690
            }
691
692 41
            $groupedIds[$className][] = $identifier;
693
        }
694 62
        foreach ($groupedIds as $className => $ids) {
695 41
            $class           = $this->dm->getClassMetadata($className);
696 41
            $mongoCollection = $this->dm->getDocumentCollection($className);
697 41
            $criteria        = $this->cm->merge(
698 41
                ['_id' => ['$in' => array_values($ids)]],
699 41
                $this->dm->getFilterCollection()->getFilterCriteria($class),
700 41
                $mapping['criteria'] ?? []
701
            );
702 41
            $criteria        = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
703
704 41
            $options = [];
705 41
            if (isset($mapping['sort'])) {
706 41
                $options['sort'] = $this->prepareSort($mapping['sort']);
707
            }
708 41
            if (isset($mapping['limit'])) {
709
                $options['limit'] = $mapping['limit'];
710
            }
711 41
            if (isset($mapping['skip'])) {
712
                $options['skip'] = $mapping['skip'];
713
            }
714 41
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
715
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
716
            }
717
718 41
            $cursor    = $mongoCollection->find($criteria, $options);
719 41
            $documents = $cursor->toArray();
720 41
            foreach ($documents as $documentData) {
721 40
                $document = $this->uow->getById($documentData['_id'], $class);
722 40
                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...
723 40
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
724 40
                    $this->uow->setOriginalDocumentData($document, $data);
725
                }
726
727 40
                if (! $sorted) {
728 39
                    continue;
729
                }
730
731 41
                $collection->add($document);
732
            }
733
        }
734 62
    }
735
736 17
    private function loadReferenceManyCollectionInverseSide(PersistentCollectionInterface $collection) : void
737
    {
738 17
        $query     = $this->createReferenceManyInverseSideQuery($collection);
739 17
        $documents = $query->execute()->toArray();
740 17
        foreach ($documents as $key => $document) {
741 16
            $collection->add($document);
742
        }
743 17
    }
744
745 17
    public function createReferenceManyInverseSideQuery(PersistentCollectionInterface $collection) : Query
746
    {
747 17
        $hints             = $collection->getHints();
748 17
        $mapping           = $collection->getMapping();
749 17
        $owner             = $collection->getOwner();
750 17
        $ownerClass        = $this->dm->getClassMetadata(get_class($owner));
751 17
        $targetClass       = $this->dm->getClassMetadata($mapping['targetDocument']);
752 17
        $mappedByMapping   = $targetClass->fieldMappings[$mapping['mappedBy']] ?? [];
753 17
        $mappedByFieldName = ClassMetadata::getReferenceFieldName($mappedByMapping['storeAs'] ?? ClassMetadata::REFERENCE_STORE_AS_DB_REF, $mapping['mappedBy']);
754
755 17
        $criteria = $this->cm->merge(
756 17
            [$mappedByFieldName => $ownerClass->getIdentifierObject($owner)],
757 17
            $this->dm->getFilterCollection()->getFilterCriteria($targetClass),
758 17
            $mapping['criteria'] ?? []
759
        );
760 17
        $criteria = $this->uow->getDocumentPersister($mapping['targetDocument'])->prepareQueryOrNewObj($criteria);
761 17
        $qb       = $this->dm->createQueryBuilder($mapping['targetDocument'])
762 17
            ->setQueryArray($criteria);
763
764 17
        if (isset($mapping['sort'])) {
765 17
            $qb->sort($mapping['sort']);
766
        }
767 17
        if (isset($mapping['limit'])) {
768 2
            $qb->limit($mapping['limit']);
769
        }
770 17
        if (isset($mapping['skip'])) {
771
            $qb->skip($mapping['skip']);
772
        }
773
774 17
        if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
775
            $qb->setReadPreference($hints[Query::HINT_READ_PREFERENCE]);
776
        }
777
778 17
        foreach ($mapping['prime'] as $field) {
779 4
            $qb->field($field)->prime(true);
780
        }
781
782 17
        return $qb->getQuery();
783
    }
784
785 5
    private function loadReferenceManyWithRepositoryMethod(PersistentCollectionInterface $collection) : void
786
    {
787 5
        $cursor    = $this->createReferenceManyWithRepositoryMethodCursor($collection);
788 5
        $mapping   = $collection->getMapping();
789 5
        $documents = $cursor->toArray();
790 5
        foreach ($documents as $key => $obj) {
791 5
            if (CollectionHelper::isHash($mapping['strategy'])) {
792 1
                $collection->set($key, $obj);
793
            } else {
794 5
                $collection->add($obj);
795
            }
796
        }
797 5
    }
798
799 5
    public function createReferenceManyWithRepositoryMethodCursor(PersistentCollectionInterface $collection) : Iterator
800
    {
801 5
        $mapping          = $collection->getMapping();
802 5
        $repositoryMethod = $mapping['repositoryMethod'];
803 5
        $cursor           = $this->dm->getRepository($mapping['targetDocument'])
804 5
            ->$repositoryMethod($collection->getOwner());
805
806 5
        if (! $cursor instanceof Iterator) {
807
            throw new BadMethodCallException(sprintf('Expected repository method %s to return an iterable object', $repositoryMethod));
808
        }
809
810 5
        if (! empty($mapping['prime'])) {
811 1
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
812 1
            $primers         = array_combine($mapping['prime'], array_fill(0, count($mapping['prime']), true));
813 1
            $class           = $this->dm->getClassMetadata($mapping['targetDocument']);
814
815 1
            $cursor = new PrimingIterator($cursor, $class, $referencePrimer, $primers, $collection->getHints());
816
        }
817
818 5
        return $cursor;
819
    }
820
821
    /**
822
     * Prepare a projection array by converting keys, which are PHP property
823
     * names, to MongoDB field names.
824
     */
825 14
    public function prepareProjection(array $fields) : array
826
    {
827 14
        $preparedFields = [];
828
829 14
        foreach ($fields as $key => $value) {
830 14
            $preparedFields[$this->prepareFieldName($key)] = $value;
831
        }
832
833 14
        return $preparedFields;
834
    }
835
836
    /**
837
     * @param int|string $sort
838
     *
839
     * @return int|string|null
840
     */
841 25
    private function getSortDirection($sort)
842
    {
843 25
        switch (strtolower((string) $sort)) {
844 25
            case 'desc':
845 15
                return -1;
846
847 22
            case 'asc':
848 13
                return 1;
849
        }
850
851 12
        return $sort;
852
    }
853
854
    /**
855
     * Prepare a sort specification array by converting keys to MongoDB field
856
     * names and changing direction strings to int.
857
     */
858 144
    public function prepareSort(array $fields) : array
859
    {
860 144
        $sortFields = [];
861
862 144
        foreach ($fields as $key => $value) {
863 25
            $sortFields[$this->prepareFieldName($key)] = $this->getSortDirection($value);
864
        }
865
866 144
        return $sortFields;
867
    }
868
869
    /**
870
     * Prepare a mongodb field name and convert the PHP property names to MongoDB field names.
871
     */
872 436
    public function prepareFieldName(string $fieldName) : string
873
    {
874 436
        $fieldNames = $this->prepareQueryElement($fieldName, null, null, false);
875
876 436
        return $fieldNames[0][0];
877
    }
878
879
    /**
880
     * Adds discriminator criteria to an already-prepared query.
881
     *
882
     * This method should be used once for query criteria and not be used for
883
     * nested expressions. It should be called before
884
     * {@link DocumentPerister::addFilterToPreparedQuery()}.
885
     */
886 525
    public function addDiscriminatorToPreparedQuery(array $preparedQuery) : array
887
    {
888
        /* If the class has a discriminator field, which is not already in the
889
         * criteria, inject it now. The field/values need no preparation.
890
         */
891 525
        if ($this->class->hasDiscriminator() && ! isset($preparedQuery[$this->class->discriminatorField])) {
892 27
            $discriminatorValues = $this->getClassDiscriminatorValues($this->class);
893 27
            if (count($discriminatorValues) === 1) {
894 19
                $preparedQuery[$this->class->discriminatorField] = $discriminatorValues[0];
895
            } else {
896 10
                $preparedQuery[$this->class->discriminatorField] = ['$in' => $discriminatorValues];
897
            }
898
        }
899
900 525
        return $preparedQuery;
901
    }
902
903
    /**
904
     * Adds filter criteria to an already-prepared query.
905
     *
906
     * This method should be used once for query criteria and not be used for
907
     * nested expressions. It should be called after
908
     * {@link DocumentPerister::addDiscriminatorToPreparedQuery()}.
909
     */
910 526
    public function addFilterToPreparedQuery(array $preparedQuery) : array
911
    {
912
        /* If filter criteria exists for this class, prepare it and merge
913
         * over the existing query.
914
         *
915
         * @todo Consider recursive merging in case the filter criteria and
916
         * prepared query both contain top-level $and/$or operators.
917
         */
918 526
        $filterCriteria = $this->dm->getFilterCollection()->getFilterCriteria($this->class);
919 526
        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...
920 18
            $preparedQuery = $this->cm->merge($preparedQuery, $this->prepareQueryOrNewObj($filterCriteria));
921
        }
922
923 526
        return $preparedQuery;
924
    }
925
926
    /**
927
     * Prepares the query criteria or new document object.
928
     *
929
     * PHP field names and types will be converted to those used by MongoDB.
930
     */
931 558
    public function prepareQueryOrNewObj(array $query, bool $isNewObj = false) : array
932
    {
933 558
        $preparedQuery = [];
934
935 558
        foreach ($query as $key => $value) {
936
            // Recursively prepare logical query clauses
937 517
            if (in_array($key, ['$and', '$or', '$nor']) && is_array($value)) {
938 20
                foreach ($value as $k2 => $v2) {
939 20
                    $preparedQuery[$key][$k2] = $this->prepareQueryOrNewObj($v2, $isNewObj);
940
                }
941 20
                continue;
942
            }
943
944 517
            if (isset($key[0]) && $key[0] === '$' && is_array($value)) {
945 40
                $preparedQuery[$key] = $this->prepareQueryOrNewObj($value, $isNewObj);
946 40
                continue;
947
            }
948
949 517
            $preparedQueryElements = $this->prepareQueryElement((string) $key, $value, null, true, $isNewObj);
950 517
            foreach ($preparedQueryElements as [$preparedKey, $preparedValue]) {
951 517
                $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...
952 135
                    ? array_map('\Doctrine\ODM\MongoDB\Types\Type::convertPHPToDatabaseValue', $preparedValue)
953 517
                    : Type::convertPHPToDatabaseValue($preparedValue);
954
            }
955
        }
956
957 558
        return $preparedQuery;
958
    }
959
960
    /**
961
     * Prepares a query value and converts the PHP value to the database value
962
     * if it is an identifier.
963
     *
964
     * It also handles converting $fieldName to the database name if they are different.
965
     *
966
     * @param mixed $value
967
     */
968 913
    private function prepareQueryElement(string $fieldName, $value = null, ?ClassMetadata $class = null, bool $prepareValue = true, bool $inNewObj = false) : array
969
    {
970 913
        $class = $class ?? $this->class;
971
972
        // @todo Consider inlining calls to ClassMetadata methods
973
974
        // Process all non-identifier fields by translating field names
975 913
        if ($class->hasField($fieldName) && ! $class->isIdentifier($fieldName)) {
976 257
            $mapping   = $class->fieldMappings[$fieldName];
977 257
            $fieldName = $mapping['name'];
978
979 257
            if (! $prepareValue) {
980 52
                return [[$fieldName, $value]];
981
            }
982
983
            // Prepare mapped, embedded objects
984 215
            if (! empty($mapping['embedded']) && is_object($value) &&
985 215
                ! $this->dm->getMetadataFactory()->isTransient(get_class($value))) {
986 3
                return [[$fieldName, $this->pb->prepareEmbeddedDocumentValue($mapping, $value)]];
987
            }
988
989 213
            if (! empty($mapping['reference']) && is_object($value) && ! ($value instanceof ObjectId)) {
0 ignored issues
show
Bug introduced by
The class MongoDB\BSON\ObjectId does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

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

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

2. Missing use statement

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

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

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

Loading history...
990
                try {
991 14
                    return $this->prepareReference($fieldName, $value, $mapping, $inNewObj);
992 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...
993
                    // do nothing in case passed object is not mapped document
994
                }
995
            }
996
997
            // No further preparation unless we're dealing with a simple reference
998
            // We can't have expressions in empty() with PHP < 5.5, so store it in a variable
999 200
            $arrayValue = (array) $value;
1000 200
            if (empty($mapping['reference']) || $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID || empty($arrayValue)) {
1001 133
                return [[$fieldName, $value]];
1002
            }
1003
1004
            // Additional preparation for one or more simple reference values
1005 94
            $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1006
1007 94
            if (! is_array($value)) {
1008 90
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1009
            }
1010
1011
            // Objects without operators or with DBRef fields can be converted immediately
1012 6
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1013 3
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1014
            }
1015
1016 6
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1017
        }
1018
1019
        // Process identifier fields
1020 822
        if (($class->hasField($fieldName) && $class->isIdentifier($fieldName)) || $fieldName === '_id') {
1021 361
            $fieldName = '_id';
1022
1023 361
            if (! $prepareValue) {
1024 42
                return [[$fieldName, $value]];
1025
            }
1026
1027 322
            if (! is_array($value)) {
1028 296
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1029
            }
1030
1031
            // Objects without operators or with DBRef fields can be converted immediately
1032 63
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1033 6
                return [[$fieldName, $class->getDatabaseIdentifierValue($value)]];
1034
            }
1035
1036 58
            return [[$fieldName, $this->prepareQueryExpression($value, $class)]];
1037
        }
1038
1039
        // No processing for unmapped, non-identifier, non-dotted field names
1040 561
        if (strpos($fieldName, '.') === false) {
1041 416
            return [[$fieldName, $value]];
1042
        }
1043
1044
        /* Process "fieldName.objectProperty" queries (on arrays or objects).
1045
         *
1046
         * We can limit parsing here, since at most three segments are
1047
         * significant: "fieldName.objectProperty" with an optional index or key
1048
         * for collections stored as either BSON arrays or objects.
1049
         */
1050 157
        $e = explode('.', $fieldName, 4);
1051
1052
        // No further processing for unmapped fields
1053 157
        if (! isset($class->fieldMappings[$e[0]])) {
1054 6
            return [[$fieldName, $value]];
1055
        }
1056
1057 152
        $mapping = $class->fieldMappings[$e[0]];
1058 152
        $e[0]    = $mapping['name'];
1059
1060
        // Hash and raw fields will not be prepared beyond the field name
1061 152
        if ($mapping['type'] === Type::HASH || $mapping['type'] === Type::RAW) {
1062 1
            $fieldName = implode('.', $e);
1063
1064 1
            return [[$fieldName, $value]];
1065
        }
1066
1067 151
        if ($mapping['type'] === 'many' && CollectionHelper::isHash($mapping['strategy'])
1068 151
                && isset($e[2])) {
1069 1
            $objectProperty       = $e[2];
1070 1
            $objectPropertyPrefix = $e[1] . '.';
1071 1
            $nextObjectProperty   = implode('.', array_slice($e, 3));
1072 150
        } elseif ($e[1] !== '$') {
1073 149
            $fieldName            = $e[0] . '.' . $e[1];
1074 149
            $objectProperty       = $e[1];
1075 149
            $objectPropertyPrefix = '';
1076 149
            $nextObjectProperty   = implode('.', array_slice($e, 2));
1077 1
        } elseif (isset($e[2])) {
1078 1
            $fieldName            = $e[0] . '.' . $e[1] . '.' . $e[2];
1079 1
            $objectProperty       = $e[2];
1080 1
            $objectPropertyPrefix = $e[1] . '.';
1081 1
            $nextObjectProperty   = implode('.', array_slice($e, 3));
1082
        } else {
1083 1
            $fieldName = $e[0] . '.' . $e[1];
1084
1085 1
            return [[$fieldName, $value]];
1086
        }
1087
1088
        // No further processing for fields without a targetDocument mapping
1089 151
        if (! isset($mapping['targetDocument'])) {
1090 3
            if ($nextObjectProperty) {
1091
                $fieldName .= '.' . $nextObjectProperty;
1092
            }
1093
1094 3
            return [[$fieldName, $value]];
1095
        }
1096
1097 148
        $targetClass = $this->dm->getClassMetadata($mapping['targetDocument']);
1098
1099
        // No further processing for unmapped targetDocument fields
1100 148
        if (! $targetClass->hasField($objectProperty)) {
1101 25
            if ($nextObjectProperty) {
1102
                $fieldName .= '.' . $nextObjectProperty;
1103
            }
1104
1105 25
            return [[$fieldName, $value]];
1106
        }
1107
1108 128
        $targetMapping      = $targetClass->getFieldMapping($objectProperty);
1109 128
        $objectPropertyIsId = $targetClass->isIdentifier($objectProperty);
1110
1111
        // Prepare DBRef identifiers or the mapped field's property path
1112 128
        $fieldName = $objectPropertyIsId && ! empty($mapping['reference']) && $mapping['storeAs'] !== ClassMetadata::REFERENCE_STORE_AS_ID
1113 108
            ? ClassMetadata::getReferenceFieldName($mapping['storeAs'], $e[0])
1114 128
            : $e[0] . '.' . $objectPropertyPrefix . $targetMapping['name'];
1115
1116
        // Process targetDocument identifier fields
1117 128
        if ($objectPropertyIsId) {
1118 109
            if (! $prepareValue) {
1119 7
                return [[$fieldName, $value]];
1120
            }
1121
1122 102
            if (! is_array($value)) {
1123 88
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1124
            }
1125
1126
            // Objects without operators or with DBRef fields can be converted immediately
1127 16
            if (! $this->hasQueryOperators($value) || $this->hasDBRefFields($value)) {
1128 6
                return [[$fieldName, $targetClass->getDatabaseIdentifierValue($value)]];
1129
            }
1130
1131 16
            return [[$fieldName, $this->prepareQueryExpression($value, $targetClass)]];
1132
        }
1133
1134
        /* The property path may include a third field segment, excluding the
1135
         * collection item pointer. If present, this next object property must
1136
         * be processed recursively.
1137
         */
1138 19
        if ($nextObjectProperty) {
1139
            // Respect the targetDocument's class metadata when recursing
1140 16
            $nextTargetClass = isset($targetMapping['targetDocument'])
1141 10
                ? $this->dm->getClassMetadata($targetMapping['targetDocument'])
1142 16
                : null;
1143
1144 16
            if (empty($targetMapping['reference'])) {
1145 14
                $fieldNames = $this->prepareQueryElement($nextObjectProperty, $value, $nextTargetClass, $prepareValue);
1146
            } else {
1147
                // No recursive processing for references as most probably somebody is querying DBRef or alike
1148 4
                if ($nextObjectProperty[0] !== '$' && in_array($targetMapping['storeAs'], [ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB, ClassMetadata::REFERENCE_STORE_AS_DB_REF])) {
1149 1
                    $nextObjectProperty = '$' . $nextObjectProperty;
1150
                }
1151 4
                $fieldNames = [[$nextObjectProperty, $value]];
1152
            }
1153
1154
            return array_map(static function ($preparedTuple) use ($fieldName) {
1155 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...
1156
1157 16
                return [$fieldName . '.' . $key, $value];
1158 16
            }, $fieldNames);
1159
        }
1160
1161 5
        return [[$fieldName, $value]];
1162
    }
1163
1164
    /**
1165
     * Prepares a query expression.
1166
     *
1167
     * @param array|object $expression
1168
     */
1169 80
    private function prepareQueryExpression($expression, ClassMetadata $class) : array
1170
    {
1171 80
        foreach ($expression as $k => $v) {
1172
            // Ignore query operators whose arguments need no type conversion
1173 80
            if (in_array($k, ['$exists', '$type', '$mod', '$size'])) {
1174 16
                continue;
1175
            }
1176
1177
            // Process query operators whose argument arrays need type conversion
1178 80
            if (in_array($k, ['$in', '$nin', '$all']) && is_array($v)) {
1179 78
                foreach ($v as $k2 => $v2) {
1180 78
                    $expression[$k][$k2] = $class->getDatabaseIdentifierValue($v2);
1181
                }
1182 78
                continue;
1183
            }
1184
1185
            // Recursively process expressions within a $not operator
1186 18
            if ($k === '$not' && is_array($v)) {
1187 15
                $expression[$k] = $this->prepareQueryExpression($v, $class);
1188 15
                continue;
1189
            }
1190
1191 18
            $expression[$k] = $class->getDatabaseIdentifierValue($v);
1192
        }
1193
1194 80
        return $expression;
1195
    }
1196
1197
    /**
1198
     * Checks whether the value has DBRef fields.
1199
     *
1200
     * This method doesn't check if the the value is a complete DBRef object,
1201
     * although it should return true for a DBRef. Rather, we're checking that
1202
     * the value has one or more fields for a DBref. In practice, this could be
1203
     * $elemMatch criteria for matching a DBRef.
1204
     *
1205
     * @param mixed $value
1206
     */
1207 81
    private function hasDBRefFields($value) : bool
1208
    {
1209 81
        if (! is_array($value) && ! is_object($value)) {
1210
            return false;
1211
        }
1212
1213 81
        if (is_object($value)) {
1214
            $value = get_object_vars($value);
1215
        }
1216
1217 81
        foreach ($value as $key => $_) {
1218 81
            if ($key === '$ref' || $key === '$id' || $key === '$db') {
1219 81
                return true;
1220
            }
1221
        }
1222
1223 80
        return false;
1224
    }
1225
1226
    /**
1227
     * Checks whether the value has query operators.
1228
     *
1229
     * @param mixed $value
1230
     */
1231 85
    private function hasQueryOperators($value) : bool
1232
    {
1233 85
        if (! is_array($value) && ! is_object($value)) {
1234
            return false;
1235
        }
1236
1237 85
        if (is_object($value)) {
1238
            $value = get_object_vars($value);
1239
        }
1240
1241 85
        foreach ($value as $key => $_) {
1242 85
            if (isset($key[0]) && $key[0] === '$') {
1243 85
                return true;
1244
            }
1245
        }
1246
1247 11
        return false;
1248
    }
1249
1250
    /**
1251
     * Gets the array of discriminator values for the given ClassMetadata
1252
     */
1253 27
    private function getClassDiscriminatorValues(ClassMetadata $metadata) : array
1254
    {
1255 27
        $discriminatorValues = [$metadata->discriminatorValue];
1256 27
        foreach ($metadata->subClasses as $className) {
1257 8
            $key = array_search($className, $metadata->discriminatorMap);
1258 8
            if (! $key) {
1259
                continue;
1260
            }
1261
1262 8
            $discriminatorValues[] = $key;
1263
        }
1264
1265
        // If a defaultDiscriminatorValue is set and it is among the discriminators being queries, add NULL to the list
1266 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...
1267 2
            $discriminatorValues[] = null;
1268
        }
1269
1270 27
        return $discriminatorValues;
1271
    }
1272
1273 582
    private function handleCollections(object $document, array $options) : void
1274
    {
1275
        // Collection deletions (deletions of complete collections)
1276 582
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1277 107
            if (! $this->uow->isCollectionScheduledForDeletion($coll)) {
1278 96
                continue;
1279
            }
1280
1281 31
            $this->cp->delete($coll, $options);
1282
        }
1283
        // Collection updates (deleteRows, updateRows, insertRows)
1284 582
        foreach ($this->uow->getScheduledCollections($document) as $coll) {
1285 107
            if (! $this->uow->isCollectionScheduledForUpdate($coll)) {
1286 29
                continue;
1287
            }
1288
1289 99
            $this->cp->update($coll, $options);
1290
        }
1291
        // Take new snapshots from visited collections
1292 582
        foreach ($this->uow->getVisitedCollections($document) as $coll) {
1293 250
            $coll->takeSnapshot();
1294
        }
1295 582
    }
1296
1297
    /**
1298
     * If the document is new, ignore shard key field value, otherwise throw an exception.
1299
     * Also, shard key field should be present in actual document data.
1300
     *
1301
     * @throws MongoDBException
1302
     */
1303 10
    private function guardMissingShardKey(object $document, string $shardKeyField, array $actualDocumentData) : void
1304
    {
1305 10
        $dcs      = $this->uow->getDocumentChangeSet($document);
1306 10
        $isUpdate = $this->uow->isScheduledForUpdate($document);
1307
1308 10
        $fieldMapping = $this->class->getFieldMappingByDbFieldName($shardKeyField);
1309 10
        $fieldName    = $fieldMapping['fieldName'];
1310
1311 10
        if ($isUpdate && isset($dcs[$fieldName]) && $dcs[$fieldName][0] !== $dcs[$fieldName][1]) {
1312 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...
1313
        }
1314
1315 8
        if (! isset($actualDocumentData[$fieldName])) {
1316
            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...
1317
        }
1318 8
    }
1319
1320
    /**
1321
     * Get shard key aware query for single document.
1322
     */
1323 295
    private function getQueryForDocument(object $document) : array
1324
    {
1325 295
        $id = $this->uow->getDocumentIdentifier($document);
1326 295
        $id = $this->class->getDatabaseIdentifierValue($id);
1327
1328 295
        $shardKeyQueryPart = $this->getShardKeyQuery($document);
1329 293
        return array_merge(['_id' => $id], $shardKeyQueryPart);
1330
    }
1331
1332 583
    private function getWriteOptions(array $options = []) : array
1333
    {
1334 583
        $defaultOptions  = $this->dm->getConfiguration()->getDefaultCommitOptions();
1335 583
        $documentOptions = [];
1336 583
        if ($this->class->hasWriteConcern()) {
1337 9
            $documentOptions['w'] = $this->class->getWriteConcern();
1338
        }
1339
1340 583
        return array_merge($defaultOptions, $documentOptions, $options);
1341
    }
1342
1343 15
    private function prepareReference(string $fieldName, $value, array $mapping, bool $inNewObj) : array
1344
    {
1345 15
        $reference = $this->dm->createReference($value, $mapping);
1346 14
        if ($inNewObj || $mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_ID) {
1347 8
            return [[$fieldName, $reference]];
1348
        }
1349
1350 6
        switch ($mapping['storeAs']) {
1351
            case ClassMetadata::REFERENCE_STORE_AS_REF:
1352
                $keys = ['id' => true];
1353
                break;
1354
1355
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF:
1356
            case ClassMetadata::REFERENCE_STORE_AS_DB_REF_WITH_DB:
1357 6
                $keys = ['$ref' => true, '$id' => true, '$db' => true];
1358
1359 6
                if ($mapping['storeAs'] === ClassMetadata::REFERENCE_STORE_AS_DB_REF) {
1360 5
                    unset($keys['$db']);
1361
                }
1362
1363 6
                if (isset($mapping['targetDocument'])) {
1364 4
                    unset($keys['$ref'], $keys['$db']);
1365
                }
1366 6
                break;
1367
1368
            default:
1369
                throw new InvalidArgumentException(sprintf('Reference type %s is invalid.', $mapping['storeAs']));
1370
        }
1371
1372 6
        if ($mapping['type'] === 'many') {
1373 2
            return [[$fieldName, ['$elemMatch' => array_intersect_key($reference, $keys)]]];
1374
        }
1375
1376 4
        return array_map(
1377
            static function ($key) use ($reference, $fieldName) {
1378 4
                return [$fieldName . '.' . $key, $reference[$key]];
1379 4
            },
1380 4
            array_keys($keys)
1381
        );
1382
    }
1383
}
1384