Completed
Pull Request — master (#2116)
by
unknown
24:13
created

Query::makeIterator()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

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 4
nc 8
nop 1
crap 4
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Doctrine\ODM\MongoDB\Query;
6
7
use BadMethodCallException;
8
use Doctrine\ODM\MongoDB\DocumentManager;
9
use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator;
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\Mapping\ClassMetadata;
15
use Doctrine\ODM\MongoDB\MongoDBException;
16
use InvalidArgumentException;
17
use IteratorAggregate;
18
use MongoDB\Collection;
19
use MongoDB\DeleteResult;
20
use MongoDB\InsertOneResult;
21
use MongoDB\Operation\FindOneAndUpdate;
22
use MongoDB\UpdateResult;
23
use Traversable;
24
use UnexpectedValueException;
25
use function array_combine;
26
use function array_filter;
27
use function array_flip;
28
use function array_intersect_key;
29
use function array_keys;
30
use function array_map;
31
use function array_merge;
32
use function array_values;
33
use function assert;
34
use function is_array;
35
use function is_callable;
36
use function is_string;
37
use function key;
38
use function reset;
39
40
/**
41
 * ODM Query wraps the raw Doctrine MongoDB queries to add additional functionality
42
 * and to hydrate the raw arrays of data to Doctrine document objects.
43
 */
44
final class Query implements IteratorAggregate
45
{
46
    public const TYPE_FIND            = 1;
47
    public const TYPE_FIND_AND_UPDATE = 2;
48
    public const TYPE_FIND_AND_REMOVE = 3;
49
    public const TYPE_INSERT          = 4;
50
    public const TYPE_UPDATE          = 5;
51
    public const TYPE_REMOVE          = 6;
52
    public const TYPE_DISTINCT        = 9;
53
    public const TYPE_COUNT           = 11;
54
55
    public const HINT_REFRESH = 1;
56
    // 2 was used for HINT_SLAVE_OKAY, which was removed in 2.0
57
    public const HINT_READ_PREFERENCE = 3;
58
    public const HINT_READ_ONLY       = 5;
59
60
    /**
61
     * The DocumentManager instance.
62
     *
63
     * @var DocumentManager
64
     */
65
    private $dm;
66
67
    /**
68
     * The ClassMetadata instance.
69
     *
70
     * @var ClassMetadata
71
     */
72
    private $class;
73
74
    /**
75
     * Whether to hydrate results as document class instances.
76
     *
77
     * @var bool
78
     */
79
    private $hydrate = true;
80
81
    /**
82
     * Array of primer Closure instances.
83
     *
84
     * @var array
85
     */
86
    private $primers = [];
87
88
    /**
89
     * @var bool
90
     */
91
    private $rewindable = true;
92
93
    /**
94
     * Hints for UnitOfWork behavior.
95
     *
96
     * @var array
97
     */
98
    private $unitOfWorkHints = [];
99
100
    /**
101
     * The Collection instance.
102
     *
103
     * @var Collection
104
     */
105
    protected $collection;
106
107
    /**
108
     * Query structure generated by the Builder class.
109
     *
110
     * @var array
111
     */
112
    private $query;
113
114
    /** @var Iterator|null */
115
    private $iterator;
116
117
    /**
118 164
     * Query options
119
     *
120 164
     * @var array
121
     */
122 164
    private $options;
123 164
124 38
    public function __construct(DocumentManager $dm, ClassMetadata $class, Collection $collection, array $query = [], array $options = [], bool $hydrate = true, bool $refresh = false, array $primers = [], bool $readOnly = false, bool $rewindable = true)
125 26
    {
126 23
        $primers = array_filter($primers);
127 22
128 7
        switch ($query['type']) {
129 5
            case self::TYPE_FIND:
130 3
            case self::TYPE_FIND_AND_UPDATE:
131 163
            case self::TYPE_FIND_AND_REMOVE:
132
            case self::TYPE_INSERT:
133
            case self::TYPE_UPDATE:
134 1
            case self::TYPE_REMOVE:
135
            case self::TYPE_DISTINCT:
136
            case self::TYPE_COUNT:
137 163
                break;
138 163
139 163
            default:
140 163
                throw new InvalidArgumentException('Invalid query type: ' . $query['type']);
141 163
        }
142 163
143 163
        $this->collection = $collection;
144
        $this->query      = $query;
145 163
        $this->options    = $options;
146 163
        $this->dm         = $dm;
147
        $this->class      = $class;
148 163
        $this->hydrate    = $hydrate;
149 155
        $this->primers    = $primers;
150
151
        $this->setReadOnly($readOnly);
152 8
        $this->setRefresh($refresh);
153 8
        $this->setRewindable($rewindable);
154
155 64
        if (! isset($query['readPreference'])) {
156
            return;
157 64
        }
158 64
159
        $this->unitOfWorkHints[self::HINT_READ_PREFERENCE] = $query['readPreference'];
160
    }
161
162
    public function __clone()
163
    {
164
        $this->iterator = null;
165
    }
166
167 27
    /**
168
     * Return an array of information about the query structure for debugging.
169 27
     *
170
     * The $name parameter may be used to return a specific key from the
171
     * internal $query array property. If omitted, the entire array will be
172
     * returned.
173
     */
174
    public function debug(?string $name = null)
175
    {
176
        return $name !== null ? $this->query[$name] : $this->query;
177
    }
178
179 123
    /**
180
     * Execute the query and returns the results.
181 123
     *
182
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array|object|int|null
183 122
     *
184 9
     * @throws MongoDBException
185
     */
186
    public function execute()
187 116
    {
188
        $results = $this->runQuery();
189
190
        if (! $this->hydrate) {
191
            return $results;
192 116
        }
193 116
194 116
        $uow = $this->dm->getUnitOfWork();
195 5
196
        /* If a single document is returned from a findAndModify command and it
197 5
         * includes the identifier field, attempt hydration.
198 1
         */
199
        if (($this->query['type'] === self::TYPE_FIND_AND_UPDATE ||
200 1
                $this->query['type'] === self::TYPE_FIND_AND_REMOVE) &&
201 1
            is_array($results) && isset($results['_id'])) {
202 1
            $results = $uow->getOrCreateDocument($this->class->name, $results, $this->unitOfWorkHints);
203
204
            if (! empty($this->primers)) {
205
                $referencePrimer = new ReferencePrimer($this->dm, $uow);
206
207 116
                foreach ($this->primers as $fieldName => $primer) {
208
                    $primer = is_callable($primer) ? $primer : null;
209
                    $referencePrimer->primeReferences($this->class, [$results], $fieldName, $this->unitOfWorkHints, $primer);
210
                }
211
            }
212
        }
213
214
        return $results;
215
    }
216
217
    /**
218
     * Gets the ClassMetadata instance.
219
     */
220
    public function getClass() : ClassMetadata
221
    {
222
        return $this->class;
223
    }
224
225
    public function getDocumentManager() : DocumentManager
226
    {
227
        return $this->dm;
228
    }
229
230
    /**
231
     * Execute the query and return its result, which must be an Iterator.
232
     *
233
     * If the query type is not expected to return an Iterator,
234
     * BadMethodCallException will be thrown before executing the query.
235
     * Otherwise, the query will be executed and UnexpectedValueException will
236
     * be thrown if {@link Query::execute()} does not return an Iterator.
237 83
     *
238
     * @see http://php.net/manual/en/iteratoraggregate.getiterator.php
239 83
     *
240 83
     * @throws BadMethodCallException If the query type would not return an Iterator.
241 6
     * @throws UnexpectedValueException If the query did not return an Iterator.
242 77
     * @throws MongoDBException
243
     */
244
    public function getIterator() : Iterator
245 6
    {
246
        switch ($this->query['type']) {
247
            case self::TYPE_FIND:
248 77
            case self::TYPE_DISTINCT:
249 77
                break;
250 77
251
            default:
252
                throw new BadMethodCallException('Iterator would not be returned for query type: ' . $this->query['type']);
253 77
        }
254
255
        if ($this->iterator === null) {
256 77
            $result = $this->execute();
257
            if (! $result instanceof Iterator) {
258
                throw new UnexpectedValueException('Iterator was not returned for query type: ' . $this->query['type']);
259
            }
260
            $this->iterator = $result;
261
        }
262 14
263
        return $this->iterator;
264 14
    }
265
266
    /**
267
     * Return the query structure.
268
     */
269
    public function getQuery() : array
270
    {
271
        return $this->query;
272 64
    }
273
274 64
    /**
275 64
     * Execute the query and return the first result.
276
     *
277 64
     * @return array|object|null
278
     */
279
    public function getSingleResult()
280
    {
281
        $clonedQuery                 = clone $this;
282
        $clonedQuery->query['limit'] = 1;
283
284
        return $clonedQuery->getIterator()->current() ?: null;
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Traversable as the method current() does only exist in the following implementations of said interface: APCUIterator, AppendIterator, ArrayIterator, CachingIterator, CallbackFilterIterator, DirectoryIterator, Doctrine\ODM\MongoDB\Iterator\CachingIterator, Doctrine\ODM\MongoDB\Iterator\HydratingIterator, Doctrine\ODM\MongoDB\Iterator\PrimingIterator, Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator, Doctrine\ODM\MongoDB\Tools\Console\MetadataFilter, EmptyIterator, FilesystemIterator, FilterIterator, Generator, GlobIterator, HttpMessage, HttpRequestPool, Imagick, ImagickPixelIterator, InfiniteIterator, IteratorIterator, LimitIterator, MongoCommandCursor, MongoCursor, MongoGridFSCursor, MultipleIterator, NoRewindIterator, ParentIterator, Phar, PharData, RecursiveArrayIterator, RecursiveCachingIterator, RecursiveCallbackFilterIterator, RecursiveDirectoryIterator, RecursiveFilterIterator, RecursiveIteratorIterator, RecursiveRegexIterator, RecursiveTreeIterator, RegexIterator, SQLiteResult, SimpleXMLIterator, SplDoublyLinkedList, SplFileObject, SplFixedArray, SplHeap, SplMaxHeap, SplMinHeap, SplObjectStorage, SplPriorityQueue, SplQueue, SplStack, SplTempFileObject.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
285
    }
286
287
    /**
288
     * Return the query type.
289
     */
290
    public function getType() : int
291
    {
292
        return $this->query['type'];
293
    }
294
295
    /**
296
     * Sets whether or not to hydrate the documents to objects.
297
     */
298
    public function setHydrate(bool $hydrate) : void
299
    {
300
        $this->hydrate = $hydrate;
301
    }
302 163
303
    /**
304 163
     * Set whether documents should be registered in UnitOfWork. If document would
305 163
     * already be managed it will be left intact and new instance returned.
306
     *
307
     * This option has no effect if hydration is disabled.
308
     */
309
    public function setReadOnly(bool $readOnly) : void
310
    {
311
        $this->unitOfWorkHints[self::HINT_READ_ONLY] = $readOnly;
312
    }
313 163
314
    /**
315 163
     * Set whether to refresh hydrated documents that are already in the
316 163
     * identity map.
317
     *
318
     * This option has no effect if hydration is disabled.
319
     */
320
    public function setRefresh(bool $refresh) : void
321
    {
322
        $this->unitOfWorkHints[self::HINT_REFRESH] = $refresh;
323 11
    }
324
325 11
    /**
326
     * Set to enable wrapping of resulting Iterator with CachingIterator
327
     */
328
    public function setRewindable(bool $rewindable = true) : void
329
    {
330
        $this->rewindable = $rewindable;
331
    }
332 121
333
    /**
334 121
     * Execute the query and return its results as an array.
335 121
     *
336
     * @see IteratorAggregate::toArray()
337 85
     */
338 121
    public function toArray() : array
339
    {
340
        return $this->getIterator()->toArray();
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Traversable as the method toArray() does only exist in the following implementations of said interface: Doctrine\ODM\MongoDB\Iterator\CachingIterator, Doctrine\ODM\MongoDB\Iterator\PrimingIterator, Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator, Doctrine\ODM\MongoDB\Query\Query, SplFixedArray.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
341
    }
342
343
    /**
344
     * Returns an array containing the specified keys and their values from the
345
     * query array, provided they exist and are not null.
346
     */
347
    private function getQueryOptions(string ...$keys) : array
348
    {
349
        return array_filter(
350 107
            array_intersect_key($this->query, array_flip($keys)),
351
            static function ($value) {
352 107
                return $value !== null;
353 99
            }
354
        );
355
    }
356 107
357
    /**
358 107
     * Decorate the cursor with caching, hydration, and priming behavior.
359 20
     *
360 20
     * Note: while this method could strictly take a MongoDB\Driver\Cursor, we
361
     * accept Traversable for testing purposes since Cursor cannot be mocked.
362
     * HydratingIterator, CachingIterator, and BaseIterator expect a Traversable 
363 107
     * so this should not have any adverse effects.
364
     */
365
    private function makeIterator(Traversable $cursor) : Iterator
366
    {
367
        if ($this->hydrate) {
368
            $cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->class, $this->unitOfWorkHints);
369
        }
370
371 112
        $cursor = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor);
372
373 112
        if (! empty($this->primers)) {
374 44
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
375
            $cursor          = new PrimingIterator($cursor, $this->class, $referencePrimer, $this->primers, $this->unitOfWorkHints);
376
        }
377 83
378 83
        return $cursor;
379
    }
380 83
381 83
    /**
382 83
     * Returns an array with its keys renamed based on the translation map.
383
     *
384 83
     * @return array $rename Translation map (from => to) for renaming keys
385
     */
386
    private function renameQueryOptions(array $options, array $rename) : array
387
    {
388 83
        if (empty($options)) {
389
            return $options;
390 83
        }
391
392
        $options = array_combine(
393
            array_map(
394
                static function ($key) use ($rename) {
395
                    return $rename[$key] ?? $key;
396
                },
397
                array_keys($options)
398
            ),
399
            array_values($options)
400
        );
401
402
        // Necessary because of https://github.com/phpstan/phpstan/issues/1580
403
        assert($options !== false);
404 123
405
        return $options;
406 123
    }
407
408 123
    /**
409 123
     * Execute the query and return its result.
410 107
     *
411 107
     * The return value will vary based on the query type. Commands with results
412
     * (e.g. aggregate, inline mapReduce) may return an ArrayIterator. Other
413 107
     * commands and operations may return a status array or a boolean, depending
414 107
     * on the driver's write concern. Queries and some mapReduce commands will
415 107
     * return an Iterator.
416
     *
417
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array|object|int|null
418 107
     */
419 24
    private function runQuery()
420 6
    {
421 6
        $options = $this->options;
422 6
423
        switch ($this->query['type']) {
424 6
            case self::TYPE_FIND:
425
                $queryOptions = $this->getQueryOptions('select', 'sort', 'skip', 'limit', 'readPreference');
426 6
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
427 6
428 6
                $cursor = $this->collection->find(
429 6
                    $this->query['query'],
430
                    array_merge($options, $queryOptions)
431 19
                );
432 2
433 2
                return $this->makeIterator($cursor);
434
            case self::TYPE_FIND_AND_UPDATE:
435 2
                $queryOptions                   = $this->getQueryOptions('select', 'sort', 'upsert');
436 2
                $queryOptions                   = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
437 2
                $queryOptions['returnDocument'] = $this->query['new'] ?? false ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE;
438
439 17
                $operation = $this->isFirstKeyUpdateOperator() ? 'findOneAndUpdate' : 'findOneAndReplace';
440
441 17
                return $this->collection->{$operation}(
442 13
                    $this->query['query'],
443
                    $this->query['newObj'],
444 13
                    array_merge($options, $queryOptions)
445 12
                );
446
            case self::TYPE_FIND_AND_REMOVE:
447 1
                $queryOptions = $this->getQueryOptions('select', 'sort');
448 1
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
449
450
                return $this->collection->findOneAndDelete(
451
                    $this->query['query'],
452
                    array_merge($options, $queryOptions)
453
                );
454 12
            case self::TYPE_INSERT:
455 2
                return $this->collection->insertOne($this->query['newObj'], $options);
456 2
            case self::TYPE_UPDATE:
457 2
                $multiple = $this->query['multiple'] ?? false;
458 2
459
                if ($this->isFirstKeyUpdateOperator()) {
460
                    $operation = 'updateOne';
461
                } else {
462 10
                    if ($multiple) {
463 10
                        throw new InvalidArgumentException('Combining the "multiple" option without using an update operator as first operation in a query is not supported.');
464 10
                    }
465 10
466
                    $operation = 'replaceOne';
467 4
                }
468 1
469 3
                if ($multiple) {
470 2
                    return $this->collection->updateMany(
471 2
                        $this->query['query'],
472
                        $this->query['newObj'],
473 2
                        array_merge($options, $this->getQueryOptions('upsert'))
474 2
                    );
475 2
                }
476 2
477
                return $this->collection->{$operation}(
478 1
                    $this->query['query'],
479 1
                    $this->query['newObj'],
480 1
                    array_merge($options, $this->getQueryOptions('upsert'))
481
                );
482 1
            case self::TYPE_REMOVE:
483 1
                return $this->collection->deleteMany($this->query['query'], $options);
484 1
            case self::TYPE_DISTINCT:
485
                $collection = $this->collection;
486
                $query      = $this->query;
487
488
                return $collection->distinct(
489
                    $query['distinct'],
490
                    $query['query'],
491 19
                    array_merge($options, $this->getQueryOptions('readPreference'))
492
                );
493 19
            case self::TYPE_COUNT:
494 19
                $collection = $this->collection;
495
                $query      = $this->query;
496 19
497
                return $collection->count(
498
                    $query['query'],
499
                    array_merge($options, $this->getQueryOptions('hint', 'limit', 'skip', 'readPreference'))
500
                );
501
            default:
502
                throw new InvalidArgumentException('Invalid query type: ' . $this->query['type']);
503
        }
504
    }
505
506
    private function isFirstKeyUpdateOperator() : bool
507
    {
508
        reset($this->query['newObj']);
509
        $firstKey = key($this->query['newObj']);
510
511
        return is_string($firstKey) && $firstKey[0] === '$';
512
    }
513
}
514