Completed
Push — master ( a8fe50...bce26f )
by Maciej
13s
created

DocumentPersister::addFilterToPreparedQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 15
ccs 5
cts 5
cp 1
rs 9.7666
c 0
b 0
f 0
cc 2
nc 2
nop 1
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ODM\MongoDB\Persisters;
6
7
use Doctrine\Common\Persistence\Mapping\MappingException;
8
use Doctrine\ODM\MongoDB\DocumentManager;
9
use Doctrine\ODM\MongoDB\Hydrator\HydratorFactory;
10
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
11
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
12
use Doctrine\ODM\MongoDB\Iterator\Iterator;
13
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
14
use Doctrine\ODM\MongoDB\LockException;
15
use Doctrine\ODM\MongoDB\LockMode;
16
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
17
use Doctrine\ODM\MongoDB\MongoDBException;
18
use Doctrine\ODM\MongoDB\PersistentCollection\PersistentCollectionInterface;
19
use Doctrine\ODM\MongoDB\Proxy\Proxy;
20
use Doctrine\ODM\MongoDB\Query\CriteriaMerger;
21
use Doctrine\ODM\MongoDB\Query\Query;
22
use Doctrine\ODM\MongoDB\Query\ReferencePrimer;
23
use Doctrine\ODM\MongoDB\Types\Type;
24
use Doctrine\ODM\MongoDB\UnitOfWork;
25
use Doctrine\ODM\MongoDB\Utility\CollectionHelper;
26
use MongoDB\BSON\ObjectId;
27
use MongoDB\Collection;
28
use MongoDB\Driver\Cursor;
29
use MongoDB\Driver\Exception\Exception as DriverException;
30
use MongoDB\Driver\Exception\WriteException;
31
use MongoDB\GridFS\Bucket;
32
use function array_combine;
33
use function array_fill;
34
use function array_intersect_key;
35
use function array_keys;
36
use function array_map;
37
use function array_merge;
38
use function array_search;
39
use function array_slice;
40
use function array_values;
41
use function count;
42
use function explode;
43
use function get_class;
44
use function get_object_vars;
45
use function implode;
46
use function in_array;
47
use function is_array;
48
use function is_object;
49
use function is_scalar;
50
use function max;
51
use function spl_object_hash;
52
use function sprintf;
53
use function strpos;
54
use function strtolower;
55
56
/**
57
 * The DocumentPersister is responsible for persisting documents.
58
 *
59
 */
60
class DocumentPersister
61
{
62
    /** @var PersistenceBuilder */
63
    private $pb;
64
65
    /** @var DocumentManager */
66
    private $dm;
67
68
    /** @var UnitOfWork */
69
    private $uow;
70
71
    /** @var ClassMetadata */
72
    private $class;
73
74
    /** @var Collection */
75
    private $collection;
76
77
    /** @var Bucket|null */
78
    private $bucket;
79
80
    /**
81
     * Array of queued inserts for the persister to insert.
82
     *
83
     * @var array
84
     */
85
    private $queuedInserts = [];
86
87
    /**
88
     * Array of queued inserts for the persister to insert.
89
     *
90
     * @var array
91
     */
92
    private $queuedUpserts = [];
93
94
    /** @var CriteriaMerger */
95
    private $cm;
96
97
    /** @var CollectionPersister */
98
    private $cp;
99
100
    /** @var HydratorFactory */
101
    private $hydratorFactory;
102
103 1121
    public function __construct(
104
        PersistenceBuilder $pb,
105
        DocumentManager $dm,
106
        UnitOfWork $uow,
107
        HydratorFactory $hydratorFactory,
108
        ClassMetadata $class,
109
        ?CriteriaMerger $cm = null
110
    ) {
111 1121
        $this->pb = $pb;
112 1121
        $this->dm = $dm;
113 1121
        $this->cm = $cm ?: new CriteriaMerger();
114 1121
        $this->uow = $uow;
115 1121
        $this->hydratorFactory = $hydratorFactory;
116 1121
        $this->class = $class;
117 1121
        $this->collection = $dm->getDocumentCollection($class->name);
118 1121
        $this->cp = $this->uow->getCollectionPersister();
119
120 1121
        if (! $class->isFile) {
121 1113
            return;
122
        }
123
124 10
        $this->bucket = $dm->getDocumentBucket($class->name);
125 10
    }
126
127
    public function getInserts(): array
128
    {
129
        return $this->queuedInserts;
130
    }
131
132
    public function isQueuedForInsert(object $document): bool
133
    {
134
        return isset($this->queuedInserts[spl_object_hash($document)]);
135
    }
136
137
    /**
138
     * Adds a document to the queued insertions.
139
     * The document remains queued until {@link executeInserts} is invoked.
140
     */
141 511
    public function addInsert(object $document): void
142
    {
143 511
        $this->queuedInserts[spl_object_hash($document)] = $document;
144 511
    }
145
146
    public function getUpserts(): array
147
    {
148
        return $this->queuedUpserts;
149
    }
150
151
    public function isQueuedForUpsert(object $document): bool
152
    {
153
        return isset($this->queuedUpserts[spl_object_hash($document)]);
154
    }
155
156
    /**
157
     * Adds a document to the queued upserts.
158
     * The document remains queued until {@link executeUpserts} is invoked.
159
     */
160 85
    public function addUpsert(object $document): void
161
    {
162 85
        $this->queuedUpserts[spl_object_hash($document)] = $document;
163 85
    }
164
165
    /**
166
     * Gets the ClassMetadata instance of the document class this persister is used for.
167
     */
168
    public function getClassMetadata(): ClassMetadata
169
    {
170
        return $this->class;
171
    }
172
173
    /**
174
     * Executes all queued document insertions.
175
     *
176
     * Queued documents without an ID will inserted in a batch and queued
177
     * documents with an ID will be upserted individually.
178
     *
179
     * If no inserts are queued, invoking this method is a NOOP.
180
     *
181
     * @throws DriverException
182
     */
183 511
    public function executeInserts(array $options = []): void
184
    {
185 511
        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...
186
            return;
187
        }
188
189 511
        $inserts = [];
190 511
        $options = $this->getWriteOptions($options);
191 511
        foreach ($this->queuedInserts as $oid => $document) {
192 511
            $data = $this->pb->prepareInsertData($document);
193
194
            // Set the initial version for each insert
195 510
            if ($this->class->isVersioned) {
196 38
                $versionMapping = $this->class->fieldMappings[$this->class->versionField];
197 38
                $nextVersion = null;
198 38
                if ($versionMapping['type'] === 'int') {
199 36
                    $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
200 36
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
201 2
                } elseif ($versionMapping['type'] === 'date') {
202 2
                    $nextVersionDateTime = new \DateTime();
203 2
                    $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
204 2
                    $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
205
                }
206 38
                $data[$versionMapping['name']] = $nextVersion;
207
            }
208
209 510
            $inserts[] = $data;
210
        }
211
212 510
        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...
213
            try {
214 510
                $this->collection->insertMany($inserts, $options);
215 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...
216 6
                $this->queuedInserts = [];
217 6
                throw $e;
218
            }
219
        }
220
221
        /* All collections except for ones using addToSet have already been
222
         * saved. We have left these to be handled separately to avoid checking
223
         * collection for uniqueness on PHP side.
224
         */
225 510
        foreach ($this->queuedInserts as $document) {
226 510
            $this->handleCollections($document, $options);
227
        }
228
229 510
        $this->queuedInserts = [];
230 510
    }
231
232
    /**
233
     * Executes all queued document upserts.
234
     *
235
     * Queued documents with an ID are upserted individually.
236
     *
237
     * If no upserts are queued, invoking this method is a NOOP.
238
     */
239 85
    public function executeUpserts(array $options = []): void
240
    {
241 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...
242
            return;
243
        }
244
245 85
        $options = $this->getWriteOptions($options);
246 85
        foreach ($this->queuedUpserts as $oid => $document) {
247
            try {
248 85
                $this->executeUpsert($document, $options);
249 85
                $this->handleCollections($document, $options);
250 85
                unset($this->queuedUpserts[$oid]);
251
            } 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...
252
                unset($this->queuedUpserts[$oid]);
253 85
                throw $e;
254
            }
255
        }
256 85
    }
257
258
    /**
259
     * Executes a single upsert in {@link executeUpserts}
260
     */
261 85
    private function executeUpsert(object $document, array $options): void
262
    {
263 85
        $options['upsert'] = true;
264 85
        $criteria = $this->getQueryForDocument($document);
265
266 85
        $data = $this->pb->prepareUpsertData($document);
267
268
        // Set the initial version for each upsert
269 85
        if ($this->class->isVersioned) {
270 3
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
271 3
            $nextVersion = null;
272 3
            if ($versionMapping['type'] === 'int') {
273 2
                $nextVersion = max(1, (int) $this->class->reflFields[$this->class->versionField]->getValue($document));
274 2
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
275 1
            } elseif ($versionMapping['type'] === 'date') {
276 1
                $nextVersionDateTime = new \DateTime();
277 1
                $nextVersion = Type::convertPHPToDatabaseValue($nextVersionDateTime);
278 1
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersionDateTime);
279
            }
280 3
            $data['$set'][$versionMapping['name']] = $nextVersion;
281
        }
282
283 85
        foreach (array_keys($criteria) as $field) {
284 85
            unset($data['$set'][$field]);
285 85
            unset($data['$inc'][$field]);
286 85
            unset($data['$setOnInsert'][$field]);
287
        }
288
289
        // Do not send empty update operators
290 85
        foreach (['$set', '$inc', '$setOnInsert'] as $operator) {
291 85
            if (! empty($data[$operator])) {
292 70
                continue;
293
            }
294
295 85
            unset($data[$operator]);
296
        }
297
298
        /* If there are no modifiers remaining, we're upserting a document with
299
         * an identifier as its only field. Since a document with the identifier
300
         * may already exist, the desired behavior is "insert if not exists" and
301
         * NOOP otherwise. MongoDB 2.6+ does not allow empty modifiers, so $set
302
         * the identifier to the same value in our criteria.
303
         *
304
         * This will fail for versions before MongoDB 2.6, which require an
305
         * empty $set modifier. The best we can do (without attempting to check
306
         * server versions in advance) is attempt the 2.6+ behavior and retry
307
         * after the relevant exception.
308
         *
309
         * See: https://jira.mongodb.org/browse/SERVER-12266
310
         */
311 85
        if (empty($data)) {
312 16
            $retry = true;
313 16
            $data = ['$set' => ['_id' => $criteria['_id']]];
314
        }
315
316
        try {
317 85
            $this->collection->updateOne($criteria, $data, $options);
318 85
            return;
319
        } 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...
320
            if (empty($retry) || strpos($e->getMessage(), 'Mod on _id not allowed') === false) {
321
                throw $e;
322
            }
323
        }
324
325
        $this->collection->updateOne($criteria, ['$set' => new \stdClass()], $options);
326
    }
327
328
    /**
329
     * Updates the already persisted document if it has any new changesets.
330
     *
331
     * @throws LockException
332
     */
333 219
    public function update(object $document, array $options = []): void
334
    {
335 219
        $update = $this->pb->prepareUpdateData($document);
336
337 219
        $query = $this->getQueryForDocument($document);
338
339 217
        foreach (array_keys($query) as $field) {
340 217
            unset($update['$set'][$field]);
341
        }
342
343 217
        if (empty($update['$set'])) {
344 94
            unset($update['$set']);
345
        }
346
347
        // Include versioning logic to set the new version value in the database
348
        // and to ensure the version has not changed since this document object instance
349
        // was fetched from the database
350 217
        $nextVersion = null;
351 217
        if ($this->class->isVersioned) {
352 31
            $versionMapping = $this->class->fieldMappings[$this->class->versionField];
353 31
            $currentVersion = $this->class->reflFields[$this->class->versionField]->getValue($document);
354 31
            if ($versionMapping['type'] === 'int') {
355 28
                $nextVersion = $currentVersion + 1;
356 28
                $update['$inc'][$versionMapping['name']] = 1;
357 28
                $query[$versionMapping['name']] = $currentVersion;
358 3
            } elseif ($versionMapping['type'] === 'date') {
359 3
                $nextVersion = new \DateTime();
360 3
                $update['$set'][$versionMapping['name']] = Type::convertPHPToDatabaseValue($nextVersion);
361 3
                $query[$versionMapping['name']] = Type::convertPHPToDatabaseValue($currentVersion);
362
            }
363
        }
364
365 217
        if (! empty($update)) {
366
            // Include locking logic so that if the document object in memory is currently
367
            // locked then it will remove it, otherwise it ensures the document is not locked.
368 149
            if ($this->class->isLockable) {
369 11
                $isLocked = $this->class->reflFields[$this->class->lockField]->getValue($document);
370 11
                $lockMapping = $this->class->fieldMappings[$this->class->lockField];
371 11
                if ($isLocked) {
372 2
                    $update['$unset'] = [$lockMapping['name'] => true];
373
                } else {
374 9
                    $query[$lockMapping['name']] = ['$exists' => false];
375
                }
376
            }
377
378 149
            $options = $this->getWriteOptions($options);
379
380 149
            $result = $this->collection->updateOne($query, $update, $options);
381
382 149
            if (($this->class->isVersioned || $this->class->isLockable) && $result->getModifiedCount() !== 1) {
383 5
                throw LockException::lockFailed($document);
384 145
            } elseif ($this->class->isVersioned) {
385 27
                $this->class->reflFields[$this->class->versionField]->setValue($document, $nextVersion);
386
            }
387
        }
388
389 213
        $this->handleCollections($document, $options);
390 213
    }
391
392
    /**
393
     * Removes document from mongo
394
     *
395
     * @throws LockException
396
     */
397 36
    public function delete(object $document, array $options = []): void
398
    {
399 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...
400 1
            $documentIdentifier = $this->uow->getDocumentIdentifier($document);
401 1
            $databaseIdentifier = $this->class->getDatabaseIdentifierValue($documentIdentifier);
402
403 1
            $this->bucket->delete($databaseIdentifier);
404
405 1
            return;
406
        }
407
408 35
        $query = $this->getQueryForDocument($document);
409
410 35
        if ($this->class->isLockable) {
411 2
            $query[$this->class->lockField] = ['$exists' => false];
412
        }
413
414 35
        $options = $this->getWriteOptions($options);
415
416 35
        $result = $this->collection->deleteOne($query, $options);
417
418 35
        if (($this->class->isVersioned || $this->class->isLockable) && ! $result->getDeletedCount()) {
419 2
            throw LockException::lockFailed($document);
420
        }
421 33
    }
422
423
    /**
424
     * Refreshes a managed document.
425
     */
426 23
    public function refresh(object $document): void
427
    {
428 23
        $query = $this->getQueryForDocument($document);
429 23
        $data = $this->collection->findOne($query);
430 23
        $data = $this->hydratorFactory->hydrate($document, $data);
431 23
        $this->uow->setOriginalDocumentData($document, $data);
432 23
    }
433
434
    /**
435
     * Finds a document by a set of criteria.
436
     *
437
     * If a scalar or MongoDB\BSON\ObjectId is provided for $criteria, it will
438
     * be used to match an _id value.
439
     *
440
     * @param mixed $criteria Query criteria
441
     * @throws LockException
442
     * @todo Check identity map? loadById method? Try to guess whether $criteria is the id?
443
     */
444 372
    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...
445
    {
446
        // TODO: remove this
447 372
        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...
448
            $criteria = ['_id' => $criteria];
449
        }
450
451 372
        $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...
452 372
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
453 372
        $criteria = $this->addFilterToPreparedQuery($criteria);
454
455 372
        $options = [];
456 372
        if ($sort !== null) {
457 95
            $options['sort'] = $this->prepareSort($sort);
458
        }
459 372
        $result = $this->collection->findOne($criteria, $options);
460
461 372
        if ($this->class->isLockable) {
462 1
            $lockMapping = $this->class->fieldMappings[$this->class->lockField];
463 1
            if (isset($result[$lockMapping['name']]) && $result[$lockMapping['name']] === LockMode::PESSIMISTIC_WRITE) {
464 1
                throw LockException::lockFailed($document);
465
            }
466
        }
467
468 371
        return $this->createDocument($result, $document, $hints);
469
    }
470
471
    /**
472
     * Finds documents by a set of criteria.
473
     */
474 22
    public function loadAll(array $criteria = [], ?array $sort = null, ?int $limit = null, ?int $skip = null): Iterator
475
    {
476 22
        $criteria = $this->prepareQueryOrNewObj($criteria);
477 22
        $criteria = $this->addDiscriminatorToPreparedQuery($criteria);
478 22
        $criteria = $this->addFilterToPreparedQuery($criteria);
479
480 22
        $options = [];
481 22
        if ($sort !== null) {
482 11
            $options['sort'] = $this->prepareSort($sort);
483
        }
484
485 22
        if ($limit !== null) {
486 10
            $options['limit'] = $limit;
487
        }
488
489 22
        if ($skip !== null) {
490 1
            $options['skip'] = $skip;
491
        }
492
493 22
        $baseCursor = $this->collection->find($criteria, $options);
494 22
        $cursor = $this->wrapCursor($baseCursor);
495
496 22
        return $cursor;
497
    }
498
499
    /**
500
     * @throws MongoDBException
501
     */
502 299
    private function getShardKeyQuery(object $document): array
503
    {
504 299
        if (! $this->class->isSharded()) {
505 289
            return [];
506
        }
507
508 10
        $shardKey = $this->class->getShardKey();
509 10
        $keys = array_keys($shardKey['keys']);
510 10
        $data = $this->uow->getDocumentActualData($document);
511
512 10
        $shardKeyQueryPart = [];
513 10
        foreach ($keys as $key) {
514 10
            $mapping = $this->class->getFieldMappingByDbFieldName($key);
515 10
            $this->guardMissingShardKey($document, $key, $data);
516
517 8
            if (isset($mapping['association']) && $mapping['association'] === ClassMetadata::REFERENCE_ONE) {
518 1
                $reference = $this->prepareReference(
519 1
                    $key,
520 1
                    $data[$mapping['fieldName']],
521 1
                    $mapping,
522 1
                    false
523
                );
524 1
                foreach ($reference as $keyValue) {
525 1
                    $shardKeyQueryPart[$keyValue[0]] = $keyValue[1];
526
                }
527
            } else {
528 7
                $value = Type::getType($mapping['type'])->convertToDatabaseValue($data[$mapping['fieldName']]);
529 8
                $shardKeyQueryPart[$key] = $value;
530
            }
531
        }
532
533 8
        return $shardKeyQueryPart;
534
    }
535
536
    /**
537
     * Wraps the supplied base cursor in the corresponding ODM class.
538
     */
539 22
    private function wrapCursor(Cursor $baseCursor): Iterator
540
    {
541 22
        return new CachingIterator(new HydratingIterator($baseCursor, $this->dm->getUnitOfWork(), $this->class));
542
    }
543
544
    /**
545
     * Checks whether the given managed document exists in the database.
546
     */
547 3
    public function exists(object $document): bool
548
    {
549 3
        $id = $this->class->getIdentifierObject($document);
550 3
        return (bool) $this->collection->findOne(['_id' => $id], ['_id']);
551
    }
552
553
    /**
554
     * Locks document by storing the lock mode on the mapped lock field.
555
     */
556 5
    public function lock(object $document, int $lockMode): void
557
    {
558 5
        $id = $this->uow->getDocumentIdentifier($document);
559 5
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
560 5
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
561 5
        $this->collection->updateOne($criteria, ['$set' => [$lockMapping['name'] => $lockMode]]);
562 5
        $this->class->reflFields[$this->class->lockField]->setValue($document, $lockMode);
563 5
    }
564
565
    /**
566
     * Releases any lock that exists on this document.
567
     *
568
     */
569 1
    public function unlock(object $document): void
570
    {
571 1
        $id = $this->uow->getDocumentIdentifier($document);
572 1
        $criteria = ['_id' => $this->class->getDatabaseIdentifierValue($id)];
573 1
        $lockMapping = $this->class->fieldMappings[$this->class->lockField];
574 1
        $this->collection->updateOne($criteria, ['$unset' => [$lockMapping['name'] => true]]);
575 1
        $this->class->reflFields[$this->class->lockField]->setValue($document, null);
576 1
    }
577
578
    /**
579
     * Creates or fills a single document object from an query result.
580
     *
581
     * @param object $result   The query result.
582
     * @param object $document The document object to fill, if any.
583
     * @param array  $hints    Hints for document creation.
584
     * @return object The filled and managed document object or NULL, if the query result is empty.
585
     */
586 371
    private function createDocument($result, ?object $document = null, array $hints = []): ?object
587
    {
588 371
        if ($result === null) {
589 116
            return null;
590
        }
591
592 327
        if ($document !== null) {
593 28
            $hints[Query::HINT_REFRESH] = true;
594 28
            $id = $this->class->getPHPIdentifierValue($result['_id']);
595 28
            $this->uow->registerManaged($document, $id, $result);
596
        }
597
598 327
        return $this->uow->getOrCreateDocument($this->class->name, $result, $hints, $document);
599
    }
600
601
    /**
602
     * Loads a PersistentCollection data. Used in the initialize() method.
603
     */
604 184
    public function loadCollection(PersistentCollectionInterface $collection): void
605
    {
606 184
        $mapping = $collection->getMapping();
607 184
        switch ($mapping['association']) {
608
            case ClassMetadata::EMBED_MANY:
609 129
                $this->loadEmbedManyCollection($collection);
610 129
                break;
611
612
            case ClassMetadata::REFERENCE_MANY:
613 78
                if (isset($mapping['repositoryMethod']) && $mapping['repositoryMethod']) {
614 5
                    $this->loadReferenceManyWithRepositoryMethod($collection);
615
                } else {
616 74
                    if ($mapping['isOwningSide']) {
617 62
                        $this->loadReferenceManyCollectionOwningSide($collection);
618
                    } else {
619 17
                        $this->loadReferenceManyCollectionInverseSide($collection);
620
                    }
621
                }
622 78
                break;
623
        }
624 184
    }
625
626 129
    private function loadEmbedManyCollection(PersistentCollectionInterface $collection): void
627
    {
628 129
        $embeddedDocuments = $collection->getMongoData();
629 129
        $mapping = $collection->getMapping();
630 129
        $owner = $collection->getOwner();
631 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...
632 75
            return;
633
        }
634
635 100
        foreach ($embeddedDocuments as $key => $embeddedDocument) {
636 100
            $className = $this->uow->getClassNameForAssociation($mapping, $embeddedDocument);
637 100
            $embeddedMetadata = $this->dm->getClassMetadata($className);
638 100
            $embeddedDocumentObject = $embeddedMetadata->newInstance();
639
640 100
            $this->uow->setParentAssociation($embeddedDocumentObject, $mapping, $owner, $mapping['name'] . '.' . $key);
641
642 100
            $data = $this->hydratorFactory->hydrate($embeddedDocumentObject, $embeddedDocument, $collection->getHints());
643 100
            $id = $data[$embeddedMetadata->identifier] ?? null;
644
645 100
            if (empty($collection->getHints()[Query::HINT_READ_ONLY])) {
646 99
                $this->uow->registerManaged($embeddedDocumentObject, $id, $data);
647
            }
648 100
            if (CollectionHelper::isHash($mapping['strategy'])) {
649 25
                $collection->set($key, $embeddedDocumentObject);
650
            } else {
651 100
                $collection->add($embeddedDocumentObject);
652
            }
653
        }
654 100
    }
655
656 62
    private function loadReferenceManyCollectionOwningSide(PersistentCollectionInterface $collection): void
657
    {
658 62
        $hints = $collection->getHints();
659 62
        $mapping = $collection->getMapping();
660 62
        $groupedIds = [];
661
662 62
        $sorted = isset($mapping['sort']) && $mapping['sort'];
663
664 62
        foreach ($collection->getMongoData() as $key => $reference) {
665 56
            $className = $this->uow->getClassNameForAssociation($mapping, $reference);
666 56
            $identifier = ClassMetadata::getReferenceId($reference, $mapping['storeAs']);
667 56
            $id = $this->dm->getClassMetadata($className)->getPHPIdentifierValue($identifier);
668
669
            // create a reference to the class and id
670 56
            $reference = $this->dm->getReference($className, $id);
671
672
            // no custom sort so add the references right now in the order they are embedded
673 56
            if (! $sorted) {
674 55
                if (CollectionHelper::isHash($mapping['strategy'])) {
675 2
                    $collection->set($key, $reference);
676
                } else {
677 53
                    $collection->add($reference);
678
                }
679
            }
680
681
            // only query for the referenced object if it is not already initialized or the collection is sorted
682 56
            if (! (($reference instanceof Proxy && ! $reference->__isInitialized__)) && ! $sorted) {
0 ignored issues
show
Bug introduced by
Accessing __isInitialized__ on the interface Doctrine\ODM\MongoDB\Proxy\Proxy suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
683 22
                continue;
684
            }
685
686 41
            $groupedIds[$className][] = $identifier;
687
        }
688 62
        foreach ($groupedIds as $className => $ids) {
689 41
            $class = $this->dm->getClassMetadata($className);
690 41
            $mongoCollection = $this->dm->getDocumentCollection($className);
691 41
            $criteria = $this->cm->merge(
692 41
                ['_id' => ['$in' => array_values($ids)]],
693 41
                $this->dm->getFilterCollection()->getFilterCriteria($class),
694 41
                $mapping['criteria'] ?? []
695
            );
696 41
            $criteria = $this->uow->getDocumentPersister($className)->prepareQueryOrNewObj($criteria);
697
698 41
            $options = [];
699 41
            if (isset($mapping['sort'])) {
700 41
                $options['sort'] = $this->prepareSort($mapping['sort']);
701
            }
702 41
            if (isset($mapping['limit'])) {
703
                $options['limit'] = $mapping['limit'];
704
            }
705 41
            if (isset($mapping['skip'])) {
706
                $options['skip'] = $mapping['skip'];
707
            }
708 41
            if (! empty($hints[Query::HINT_READ_PREFERENCE])) {
709
                $options['readPreference'] = $hints[Query::HINT_READ_PREFERENCE];
710
            }
711
712 41
            $cursor = $mongoCollection->find($criteria, $options);
713 41
            $documents = $cursor->toArray();
714 41
            foreach ($documents as $documentData) {
715 40
                $document = $this->uow->getById($documentData['_id'], $class);
716 40
                if ($document instanceof Proxy && ! $document->__isInitialized()) {
717 40
                    $data = $this->hydratorFactory->hydrate($document, $documentData);
718 40
                    $this->uow->setOriginalDocumentData($document, $data);
719 40
                    $document->__isInitialized__ = true;
0 ignored issues
show
Bug introduced by
Accessing __isInitialized__ on the interface Doctrine\ODM\MongoDB\Proxy\Proxy suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

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