Completed
Push — master ( 280e05...09b86b )
by Maciej
21s queued 10s
created

Query::isFirstKeyUpdateOperator()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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