Completed
Push — master ( 8fc9bf...b852d4 )
by Andreas
17s queued 12s
created

Query::setRewindable()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
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\CachingIterator;
10
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
11
use Doctrine\ODM\MongoDB\Iterator\Iterator;
12
use Doctrine\ODM\MongoDB\Iterator\PrimingIterator;
13
use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator;
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
    /** @var bool */
89
    private $rewindable = true;
90
91
    /**
92
     * Hints for UnitOfWork behavior.
93
     *
94
     * @var array
95
     */
96
    private $unitOfWorkHints = [];
97
98
    /**
99
     * The Collection instance.
100
     *
101
     * @var Collection
102
     */
103
    protected $collection;
104
105
    /**
106
     * Query structure generated by the Builder class.
107
     *
108
     * @var array
109
     */
110
    private $query;
111
112
    /** @var Iterator|null */
113
    private $iterator;
114
115
    /**
116
     * Query options
117
     *
118
     * @var array
119
     */
120
    private $options;
121
122 168
    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)
123
    {
124 168
        $primers = array_filter($primers);
125
126 168
        switch ($query['type']) {
127 168
            case self::TYPE_FIND:
128 38
            case self::TYPE_FIND_AND_UPDATE:
129 26
            case self::TYPE_FIND_AND_REMOVE:
130 23
            case self::TYPE_INSERT:
131 22
            case self::TYPE_UPDATE:
132 7
            case self::TYPE_REMOVE:
133 5
            case self::TYPE_DISTINCT:
134 3
            case self::TYPE_COUNT:
135 167
                break;
136
137
            default:
138 1
                throw new InvalidArgumentException('Invalid query type: ' . $query['type']);
139
        }
140
141 167
        $this->collection = $collection;
142 167
        $this->query      = $query;
143 167
        $this->options    = $options;
144 167
        $this->dm         = $dm;
145 167
        $this->class      = $class;
146 167
        $this->hydrate    = $hydrate;
147 167
        $this->primers    = $primers;
148
149 167
        $this->setReadOnly($readOnly);
150 167
        $this->setRefresh($refresh);
151 167
        $this->setRewindable($rewindable);
152
153 167
        if (! isset($query['readPreference'])) {
154 159
            return;
155
        }
156
157 8
        $this->unitOfWorkHints[self::HINT_READ_PREFERENCE] = $query['readPreference'];
158 8
    }
159
160 64
    public function __clone()
161
    {
162 64
        $this->iterator = null;
163 64
    }
164
165
    /**
166
     * Return an array of information about the query structure for debugging.
167
     *
168
     * The $name parameter may be used to return a specific key from the
169
     * internal $query array property. If omitted, the entire array will be
170
     * returned.
171
     */
172 27
    public function debug(?string $name = null)
173
    {
174 27
        return $name !== null ? $this->query[$name] : $this->query;
175
    }
176
177
    /**
178
     * Execute the query and returns the results.
179
     *
180
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array|object|int|null
181
     *
182
     * @throws MongoDBException
183
     */
184 127
    public function execute()
185
    {
186 127
        $results = $this->runQuery();
187
188 126
        if (! $this->hydrate) {
189 10
            return $results;
190
        }
191
192 119
        $uow = $this->dm->getUnitOfWork();
193
194
        /* If a single document is returned from a findAndModify command and it
195
         * includes the identifier field, attempt hydration.
196
         */
197 119
        if (($this->query['type'] === self::TYPE_FIND_AND_UPDATE ||
198 119
                $this->query['type'] === self::TYPE_FIND_AND_REMOVE) &&
199 119
            is_array($results) && isset($results['_id'])) {
200 5
            $results = $uow->getOrCreateDocument($this->class->name, $results, $this->unitOfWorkHints);
201
202 5
            if (! empty($this->primers)) {
203 1
                $referencePrimer = new ReferencePrimer($this->dm, $uow);
204
205 1
                foreach ($this->primers as $fieldName => $primer) {
206 1
                    $primer = is_callable($primer) ? $primer : null;
207 1
                    $referencePrimer->primeReferences($this->class, [$results], $fieldName, $this->unitOfWorkHints, $primer);
208
                }
209
            }
210
        }
211
212 119
        return $results;
213
    }
214
215
    /**
216
     * Gets the ClassMetadata instance.
217
     */
218
    public function getClass() : ClassMetadata
219
    {
220
        return $this->class;
221
    }
222
223
    public function getDocumentManager() : DocumentManager
224
    {
225
        return $this->dm;
226
    }
227
228
    /**
229
     * Execute the query and return its result, which must be an Iterator.
230
     *
231
     * If the query type is not expected to return an Iterator,
232
     * BadMethodCallException will be thrown before executing the query.
233
     * Otherwise, the query will be executed and UnexpectedValueException will
234
     * be thrown if {@link Query::execute()} does not return an Iterator.
235
     *
236
     * @see http://php.net/manual/en/iteratoraggregate.getiterator.php
237
     *
238
     * @throws BadMethodCallException If the query type would not return an Iterator.
239
     * @throws UnexpectedValueException If the query did not return an Iterator.
240
     * @throws MongoDBException
241
     */
242 84
    public function getIterator() : Iterator
243
    {
244 84
        switch ($this->query['type']) {
245 84
            case self::TYPE_FIND:
246 6
            case self::TYPE_DISTINCT:
247 78
                break;
248
249
            default:
250 6
                throw new BadMethodCallException('Iterator would not be returned for query type: ' . $this->query['type']);
251
        }
252
253 78
        if ($this->iterator === null) {
254 78
            $result = $this->execute();
255 78
            if (! $result instanceof Iterator) {
256
                throw new UnexpectedValueException('Iterator was not returned for query type: ' . $this->query['type']);
257
            }
258 78
            $this->iterator = $result;
259
        }
260
261 78
        return $this->iterator;
262
    }
263
264
    /**
265
     * Return the query structure.
266
     */
267 14
    public function getQuery() : array
268
    {
269 14
        return $this->query;
270
    }
271
272
    /**
273
     * Execute the query and return the first result.
274
     *
275
     * @return array|object|null
276
     */
277 64
    public function getSingleResult()
278
    {
279 64
        $clonedQuery                 = clone $this;
280 64
        $clonedQuery->query['limit'] = 1;
281
282 64
        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...
283
    }
284
285
    /**
286
     * Return the query type.
287
     */
288
    public function getType() : int
289
    {
290
        return $this->query['type'];
291
    }
292
293
    /**
294
     * Sets whether or not to hydrate the documents to objects.
295
     */
296 1
    public function setHydrate(bool $hydrate) : void
297
    {
298 1
        $this->hydrate = $hydrate;
299 1
    }
300
301
    /**
302
     * Set whether documents should be registered in UnitOfWork. If document would
303
     * already be managed it will be left intact and new instance returned.
304
     *
305
     * This option has no effect if hydration is disabled.
306
     */
307 167
    public function setReadOnly(bool $readOnly) : void
308
    {
309 167
        $this->unitOfWorkHints[self::HINT_READ_ONLY] = $readOnly;
310 167
    }
311
312
    /**
313
     * Set whether to refresh hydrated documents that are already in the
314
     * identity map.
315
     *
316
     * This option has no effect if hydration is disabled.
317
     */
318 167
    public function setRefresh(bool $refresh) : void
319
    {
320 167
        $this->unitOfWorkHints[self::HINT_REFRESH] = $refresh;
321 167
    }
322
323
    /**
324
     * Set to enable wrapping of resulting Iterator with CachingIterator
325
     */
326 167
    public function setRewindable(bool $rewindable = true) : void
327
    {
328 167
        $this->rewindable = $rewindable;
329 167
    }
330
331
    /**
332
     * Execute the query and return its results as an array.
333
     *
334
     * @see IteratorAggregate::toArray()
335
     */
336 11
    public function toArray() : array
337
    {
338 11
        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...
339
    }
340
341
    /**
342
     * Returns an array containing the specified keys and their values from the
343
     * query array, provided they exist and are not null.
344
     */
345 125
    private function getQueryOptions(string ...$keys) : array
346
    {
347 125
        return array_filter(
348 125
            array_intersect_key($this->query, array_flip($keys)),
349
            static function ($value) {
350 87
                return $value !== null;
351 125
            }
352
        );
353
    }
354
355
    /**
356
     * Decorate the cursor with caching, hydration, and priming behavior.
357
     *
358
     * Note: while this method could strictly take a MongoDB\Driver\Cursor, we
359
     * accept Traversable for testing purposes since Cursor cannot be mocked.
360
     * HydratingIterator, CachingIterator, and BaseIterator expect a Traversable
361
     * so this should not have any adverse effects.
362
     */
363 111
    private function makeIterator(Traversable $cursor) : Iterator
364
    {
365 111
        if ($this->hydrate) {
366 102
            $cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->class, $this->unitOfWorkHints);
367
        }
368
369 111
        $cursor = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor);
370
371 111
        if (! empty($this->primers)) {
372 20
            $referencePrimer = new ReferencePrimer($this->dm, $this->dm->getUnitOfWork());
373 20
            $cursor          = new PrimingIterator($cursor, $this->class, $referencePrimer, $this->primers, $this->unitOfWorkHints);
374
        }
375
376 111
        return $cursor;
377
    }
378
379
    /**
380
     * Returns an array with its keys renamed based on the translation map.
381
     *
382
     * @return array $rename Translation map (from => to) for renaming keys
383
     */
384 116
    private function renameQueryOptions(array $options, array $rename) : array
385
    {
386 116
        if (empty($options)) {
387 46
            return $options;
388
        }
389
390 85
        $options = array_combine(
391 85
            array_map(
392
                static function ($key) use ($rename) {
393 85
                    return $rename[$key] ?? $key;
394 85
                },
395 85
                array_keys($options)
396
            ),
397 85
            array_values($options)
398
        );
399
400
        // Necessary because of https://github.com/phpstan/phpstan/issues/1580
401 85
        assert($options !== false);
402
403 85
        return $options;
404
    }
405
406
    /**
407
     * Execute the query and return its result.
408
     *
409
     * The return value will vary based on the query type. Commands with results
410
     * (e.g. aggregate, inline mapReduce) may return an ArrayIterator. Other
411
     * commands and operations may return a status array or a boolean, depending
412
     * on the driver's write concern. Queries and some mapReduce commands will
413
     * return an Iterator.
414
     *
415
     * @return Iterator|UpdateResult|InsertOneResult|DeleteResult|array|object|int|null
416
     */
417 127
    private function runQuery()
418
    {
419 127
        $options = $this->options;
420
421 127
        switch ($this->query['type']) {
422 127
            case self::TYPE_FIND:
423 111
                $queryOptions = $this->getQueryOptions('select', 'sort', 'skip', 'limit', 'readPreference');
424 111
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
425
426 111
                $cursor = $this->collection->find(
427 111
                    $this->query['query'],
428 111
                    array_merge($options, $queryOptions)
429
                );
430
431 111
                return $this->makeIterator($cursor);
432 24
            case self::TYPE_FIND_AND_UPDATE:
433 6
                $queryOptions                   = $this->getQueryOptions('select', 'sort', 'upsert');
434 6
                $queryOptions                   = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
435 6
                $queryOptions['returnDocument'] = $this->query['new'] ?? false ? FindOneAndUpdate::RETURN_DOCUMENT_AFTER : FindOneAndUpdate::RETURN_DOCUMENT_BEFORE;
436
437 6
                $operation = $this->isFirstKeyUpdateOperator() ? 'findOneAndUpdate' : 'findOneAndReplace';
438
439 6
                return $this->collection->{$operation}(
440 6
                    $this->query['query'],
441 6
                    $this->query['newObj'],
442 6
                    array_merge($options, $queryOptions)
443
                );
444 19
            case self::TYPE_FIND_AND_REMOVE:
445 2
                $queryOptions = $this->getQueryOptions('select', 'sort');
446 2
                $queryOptions = $this->renameQueryOptions($queryOptions, ['select' => 'projection']);
447
448 2
                return $this->collection->findOneAndDelete(
449 2
                    $this->query['query'],
450 2
                    array_merge($options, $queryOptions)
451
                );
452 17
            case self::TYPE_INSERT:
453
                return $this->collection->insertOne($this->query['newObj'], $options);
454 17
            case self::TYPE_UPDATE:
455 13
                $multiple = $this->query['multiple'] ?? false;
456
457 13
                if ($this->isFirstKeyUpdateOperator()) {
458 12
                    $operation = 'updateOne';
459
                } else {
460 1
                    if ($multiple) {
461 1
                        throw new InvalidArgumentException('Combining the "multiple" option without using an update operator as first operation in a query is not supported.');
462
                    }
463
464
                    $operation = 'replaceOne';
465
                }
466
467 12
                if ($multiple) {
468 2
                    return $this->collection->updateMany(
469 2
                        $this->query['query'],
470 2
                        $this->query['newObj'],
471 2
                        array_merge($options, $this->getQueryOptions('upsert'))
472
                    );
473
                }
474
475 10
                return $this->collection->{$operation}(
476 10
                    $this->query['query'],
477 10
                    $this->query['newObj'],
478 10
                    array_merge($options, $this->getQueryOptions('upsert'))
479
                );
480 4
            case self::TYPE_REMOVE:
481 1
                return $this->collection->deleteMany($this->query['query'], $options);
482 3
            case self::TYPE_DISTINCT:
483 2
                $collection = $this->collection;
484 2
                $query      = $this->query;
485
486 2
                return $collection->distinct(
487 2
                    $query['distinct'],
488 2
                    $query['query'],
489 2
                    array_merge($options, $this->getQueryOptions('readPreference'))
490
                );
491 1
            case self::TYPE_COUNT:
492 1
                $collection = $this->collection;
493 1
                $query      = $this->query;
494
495 1
                return $collection->count(
496 1
                    $query['query'],
497 1
                    array_merge($options, $this->getQueryOptions('hint', 'limit', 'skip', 'readPreference'))
498
                );
499
            default:
500
                throw new InvalidArgumentException('Invalid query type: ' . $this->query['type']);
501
        }
502
    }
503
504 19
    private function isFirstKeyUpdateOperator() : bool
505
    {
506 19
        reset($this->query['newObj']);
507 19
        $firstKey = key($this->query['newObj']);
508
509 19
        return is_string($firstKey) && $firstKey[0] === '$';
510
    }
511
}
512