Completed
Pull Request — master (#1984)
by Maciej
23:09
created

Query::__construct()   B

Complexity

Conditions 10
Paths 17

Size

Total Lines 36

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 10.0094

Importance

Changes 0
Metric Value
dl 0
loc 36
ccs 21
cts 22
cp 0.9545
rs 7.6666
c 0
b 0
f 0
cc 10
nc 17
nop 9
crap 10.0094

How to fix   Complexity    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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