Completed
Push — master ( 09b86b...ba0798 )
by Andreas
20:05 queued 12s
created

Query   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 459
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 7

Test Coverage

Coverage 91.76%

Importance

Changes 0
Metric Value
wmc 60
lcom 1
cbo 7
dl 0
loc 459
ccs 156
cts 170
cp 0.9176
rs 3.6
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A getQuery() 0 4 1
A getSingleResult() 0 6 2
A getType() 0 4 1
A setHydrate() 0 4 1
A setReadOnly() 0 4 1
A setRefresh() 0 4 1
A toArray() 0 4 1
A getQueryOptions() 0 9 1
A makeIterator() 0 15 3
A renameQueryOptions() 0 21 2
B __construct() 0 40 11
A __clone() 0 4 1
A debug() 0 4 2
B execute() 0 30 9
A getClass() 0 4 1
A getDocumentManager() 0 4 1
A getIterator() 0 21 5
C runQuery() 0 94 14
A isFirstKeyUpdateOperator() 0 7 2

How to fix   Complexity   

Complex Class

Complex classes like Query often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Query, and based on these observations, apply Extract Interface, too.

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 const E_USER_DEPRECATED;
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
use function sprintf;
40
use function trigger_error;
41
42
/**
43
 * ODM Query wraps the raw Doctrine MongoDB queries to add additional functionality
44
 * and to hydrate the raw arrays of data to Doctrine document objects.
45
 *
46
 * @final
47
 */
48
class Query implements IteratorAggregate
49
{
50
    public const TYPE_FIND            = 1;
51
    public const TYPE_FIND_AND_UPDATE = 2;
52
    public const TYPE_FIND_AND_REMOVE = 3;
53
    public const TYPE_INSERT          = 4;
54
    public const TYPE_UPDATE          = 5;
55
    public const TYPE_REMOVE          = 6;
56
    public const TYPE_DISTINCT        = 9;
57
    public const TYPE_COUNT           = 11;
58
59
    public const HINT_REFRESH = 1;
60
    // 2 was used for HINT_SLAVE_OKAY, which was removed in 2.0
61
    public const HINT_READ_PREFERENCE = 3;
62
    public const HINT_READ_ONLY       = 5;
63
64
    /**
65
     * The DocumentManager instance.
66
     *
67
     * @var DocumentManager
68
     */
69
    private $dm;
70
71
    /**
72
     * The ClassMetadata instance.
73
     *
74
     * @var ClassMetadata
75
     */
76
    private $class;
77
78
    /**
79
     * Whether to hydrate results as document class instances.
80
     *
81
     * @var bool
82
     */
83
    private $hydrate = true;
84
85
    /**
86
     * Array of primer Closure instances.
87
     *
88
     * @var array
89
     */
90
    private $primers = [];
91
92
    /**
93
     * Hints for UnitOfWork behavior.
94
     *
95
     * @var array
96
     */
97
    private $unitOfWorkHints = [];
98
99
    /**
100
     * The Collection instance.
101
     *
102
     * @var Collection
103
     */
104
    protected $collection;
105
106
    /**
107
     * Query structure generated by the Builder class.
108
     *
109
     * @var array
110
     */
111
    private $query;
112
113
    /** @var Iterator|null */
114
    private $iterator;
115
116
    /**
117
     * Query options
118
     *
119
     * @var array
120
     */
121
    private $options;
122
123 164
    public function __construct(DocumentManager $dm, ClassMetadata $class, Collection $collection, array $query = [], array $options = [], bool $hydrate = true, bool $refresh = false, array $primers = [], bool $readOnly = false)
124
    {
125 164
        if (self::class !== static::class) {
126
            @trigger_error(sprintf('The class "%s" extends "%s" which will be final in MongoDB ODM 2.0.', static::class, self::class), E_USER_DEPRECATED);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

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