Select   B
last analyzed

Complexity

Total Complexity 43

Size/Duplication

Total Lines 448
Duplicated Lines 0 %

Test Coverage

Coverage 93.5%

Importance

Changes 8
Bugs 2 Features 0
Metric Value
eloc 114
dl 0
loc 448
ccs 115
cts 123
cp 0.935
rs 8.96
c 8
b 2
f 0
wmc 43

20 Methods

Rating   Name   Duplication   Size   Complexity  
A scope() 0 5 1
A __call() 0 15 3
A buildQuery() 0 3 1
A __clone() 0 4 1
A __destruct() 0 3 1
A count() 0 11 3
A wherePK() 0 12 3
A load() 0 19 4
A with() 0 19 4
A getBuilder() 0 3 1
A fetchAll() 0 3 1
A offset() 0 5 1
A getIterator() 0 13 1
A __construct() 0 15 1
A limit() 0 5 1
A sqlStatement() 0 3 1
A fetchData() 0 12 2
A loadSubclasses() 0 4 1
B buildCompositePKQuery() 0 32 10
A fetchOne() 0 13 2

How to fix   Complexity   

Complex Class

Complex classes like Select 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.

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 Select, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Cycle\ORM;
6
7
use Countable;
8
use Cycle\Database\Injection\Parameter;
9
use Cycle\Database\Query\SelectQuery;
10
use Cycle\ORM\Heap\Node;
11
use Cycle\ORM\Service\EntityFactoryInterface;
12
use Cycle\ORM\Service\MapperProviderInterface;
13
use Cycle\ORM\Service\SourceProviderInterface;
14
use Cycle\ORM\Select\JoinableLoader;
15
use Cycle\ORM\Select\QueryBuilder;
16
use Cycle\ORM\Select\RootLoader;
17
use Cycle\ORM\Select\ScopeInterface;
18
use InvalidArgumentException;
19
use IteratorAggregate;
20
use Spiral\Pagination\PaginableInterface;
21
22
/**
23
 * Query builder and entity selector. Mocks SelectQuery. Attention, Selector does not mount RootLoader scope by default.
24
 *
25
 * Trait provides the ability to transparently configure underlying loader query.
26
 *
27
 * @method $this distinct()
28
 * @method $this where(...$args)
29
 * @method $this andWhere(...$args);
30
 * @method $this orWhere(...$args);
31
 * @method $this having(...$args);
32
 * @method $this andHaving(...$args);
33
 * @method $this orHaving(...$args);
34
 * @method $this orderBy($expression, $direction = 'ASC');
35
 * @method $this forUpdate()
36
 * @method $this whereJson(string $path, mixed $value)
37
 * @method $this orWhereJson(string $path, mixed $value)
38
 * @method $this whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
39
 * @method $this orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
40
 * @method $this whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
41
 * @method $this orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
42
 * @method $this whereJsonContainsKey(string $path)
43
 * @method $this orWhereJsonContainsKey(string $path)
44
 * @method $this whereJsonDoesntContainKey(string $path)
45
 * @method $this orWhereJsonDoesntContainKey(string $path)
46
 * @method $this whereJsonLength(string $path, int $length, string $operator = '=')
47
 * @method $this orWhereJsonLength(string $path, int $length, string $operator = '=')
48
 * @method mixed avg($identifier) Perform aggregation (AVG) based on column or expression value.
49
 * @method mixed min($identifier) Perform aggregation (MIN) based on column or expression value.
50
 * @method mixed max($identifier) Perform aggregation (MAX) based on column or expression value.
51
 * @method mixed sum($identifier) Perform aggregation (SUM) based on column or expression value.
52
 *
53
 * @template-covariant TEntity of object
54
 */
55
class Select implements IteratorAggregate, Countable, PaginableInterface
56
{
57
    // load relation data within same query
58
    public const SINGLE_QUERY = JoinableLoader::INLOAD;
59
60
    // load related data after the query
61
    public const OUTER_QUERY = JoinableLoader::POSTLOAD;
62 6834
63
    private RootLoader $loader;
64
65
    private QueryBuilder $builder;
66 6834
    private MapperProviderInterface $mapperProvider;
67 6834
    private Heap\HeapInterface $heap;
68 6834
    private SchemaInterface $schema;
69 6834
    private EntityFactoryInterface $entityFactory;
70 6834
71 6834
    /**
72 6834
     * @param class-string<TEntity>|string $role
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<TEntity>|string at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<TEntity>|string.
Loading history...
73 6834
     */
74 6834
    public function __construct(
75
        ORMInterface $orm,
76 6834
        string $role
77
    ) {
78
        $this->heap = $orm->getHeap();
79
        $this->schema = $orm->getSchema();
80
        $this->mapperProvider = $orm->getService(MapperProviderInterface::class);
81
        $this->entityFactory = $orm->getService(EntityFactoryInterface::class);
82 6834
        $this->loader = new RootLoader(
83
            $orm->getSchema(),
84 6834
            $orm->getService(SourceProviderInterface::class),
85
            $orm->getFactory(),
86
            $orm->resolveRole($role)
87
        );
88
        $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader);
89
    }
90 5932
91
    /**
92 5932
     * Remove nested loaders and clean ORM link.
93
     */
94 72
    public function __destruct()
95 72
    {
96 72
        unset($this->loader, $this->builder);
97
    }
98
99 5916
    /**
100 5916
     * Bypassing call to primary select query.
101 5916
     */
102
    public function __call(string $name, array $arguments): mixed
103
    {
104
        if (in_array(strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM', 'COUNT'])) {
105
            // aggregations
106
            return $this->builder->withQuery(
107
                $this->loader->buildQuery()
108
            )->__call($name, $arguments);
109
        }
110
111
        $result = $this->builder->__call($name, $arguments);
112 3232
        if ($result instanceof QueryBuilder) {
113
            return $this;
114 3232
        }
115 3232
116
        return $result;
117
    }
118
119
    /**
120
     * Cloning with loader tree cloning.
121
     *
122
     * @attention at this moment binded query parameters would't be cloned!
123 3440
     */
124
    public function __clone()
125 3440
    {
126
        $this->loader = clone $this->loader;
127 3440
        $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader);
128
    }
129
130
    /**
131
     * Create new Selector with applied scope. By default no scope used.
132
     *
133 8
     * @return static<TEntity>
134
     */
135 8
    public function scope(ScopeInterface $scope = null): self
136
    {
137
        $this->loader->setScope($scope);
138
139
        return $this;
140
    }
141 128
142
    /**
143 128
     * Get Query proxy.
144
     */
145
    public function getBuilder(): QueryBuilder
146
    {
147
        return $this->builder;
148
    }
149
150
    /**
151
     * Compiled SQL query, changes in this query would not affect Selector state (but bound parameters will).
152
     */
153 2136
    public function buildQuery(): SelectQuery
154
    {
155 2136
        return $this->loader->buildQuery();
156
    }
157 2136
158 392
    /**
159
     * Shortcut to where method to set AND condition for entity primary key.
160 1744
     *
161
     * @psalm-param string|int|list<string|int>|object ...$ids
162 1744
     *
163 16
     * @return static<TEntity>
164 1744
     */
165
    public function wherePK(string|int|array|object ...$ids): self
166
    {
167
        $pk = $this->loader->getPrimaryFields();
168
169
        if (\count($pk) > 1) {
170
            return $this->buildCompositePKQuery($pk, $ids);
171
        }
172 72
        $pk = \current($pk);
173
174 72
        return \count($ids) > 1
175
            ? $this->__call('where', [$pk, new Parameter($ids)])
176 72
            : $this->__call('where', [$pk, current($ids)]);
177 72
    }
178 24
179 48
    /**
180
     * Attention, column will be quoted by driver!
181
     *
182 72
     * @param string|null $column When column is null DISTINCT(PK) will be generated.
183
     */
184
    public function count(string $column = null): int
185
    {
186
        if ($column === null) {
187
            // @tuneyourserver solves the issue with counting on queries with joins.
188 3376
            $pk = $this->loader->getPK();
189
            $column = \is_array($pk)
190 3376
                ? '*'
191
                : \sprintf('DISTINCT(%s)', $pk);
192 3376
        }
193
194
        return (int) $this->__call('count', [$column]);
195
    }
196
197
    /**
198 8
     * @return static<TEntity>
199
     */
200 8
    public function limit(int $limit): self
201
    {
202 8
        $this->loader->getQuery()->limit($limit);
203
204
        return $this;
205
    }
206
207
    /**
208
     * @return static<TEntity>
209
     */
210
    public function offset(int $offset): self
211
    {
212
        $this->loader->getQuery()->offset($offset);
213
214
        return $this;
215
    }
216
217
    /**
218
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
219
     * for data pre-loading. ORM loaders by default will select the most efficient way to load
220
     * related data which might include additional select query or left join. Loaded data will
221
     * automatically pre-populate record relations. You can specify nested relations using "."
222
     * separator.
223
     *
224
     * Examples:
225
     *
226
     * // Select users and load their comments (will cast 2 queries, HAS_MANY comments)
227
     * User::find()->with('comments');
228
     *
229
     * // You can load chain of relations - select user and load their comments and post related to
230
     * //comment
231
     * User::find()->with('comments.post');
232
     *
233
     * // We can also specify custom where conditions on data loading, let's load only public
234
     * // comments.
235
     * User::find()->load('comments', [
236
     *      'where' => ['{@}.status' => 'public']
237
     * ]);
238
     *
239
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
240
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
241
     *
242
     * // In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
243
     * // conditions, let's pre-load all approved user tags, we can use same placeholder for pivot
244
     * // table alias
245
     * User::find()->load('tags', [
246
     *      'wherePivot' => ['{@}.approved' => true]
247
     * ]);
248
     *
249
     * // In most of cases you don't need to worry about how data was loaded, using external query
250
     * // or left join, however if you want to change such behaviour you can force load method
251
     * // using {@see Select::SINGLE_QUERY}
252
     * User::find()->load('tags', [
253
     *      'method'     => Select::SINGLE_QUERY,
254
     *      'wherePivot' => ['{@}.approved' => true]
255
     * ]);
256
     *
257
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
258
     * support different loading types.
259
     *
260 2914
     * You can specify multiple loaders using array as first argument.
261
     *
262 2914
     * Example:
263 2914
     * User::find()->load(['posts', 'comments', 'profile']);
264
     *
265 2906
     * Attention, consider disabling entity map if you want to use recursive loading (i.e
266
     * post.tags.posts), but first think why you even need recursive relation loading.
267
     *
268 16
     * @see with()
269 16
     *
270
     * @return static<TEntity>
271 16
     */
272
    public function load(string|array $relation, array $options = []): self
273
    {
274
        if (\is_string($relation)) {
0 ignored issues
show
introduced by
The condition is_string($relation) is always false.
Loading history...
275
            $this->loader->loadRelation($relation, $options, false, true);
276
277
            return $this;
278 16
        }
279
280
        foreach ($relation as $name => $subOption) {
281
            if (\is_string($subOption)) {
282
                // array of relation names
283
                $this->load($subOption, $options);
284
            } else {
285
                // multiple relations or relation with addition load options
286
                $this->load($name, $subOption + $options);
287
            }
288
        }
289
290
        return $this;
291
    }
292
293
    /**
294
     * With method is very similar to load() one, except it will always include related data to
295
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
296
     * using same database as parent record.
297
     *
298
     * Method generally used to filter data based on some relation condition. Attention, with()
299
     * method WILL NOT load relation data, it will only make it accessible in query.
300
     *
301
     * By default joined tables will be available in query based on relation name, you can change
302
     * joined table alias using relation option "alias".
303
     *
304
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
305
     * other scenario you will not able to paginate data well.
306
     *
307
     * Examples:
308
     *
309
     * // Find all users who have comments comments
310
     * User::find()->with('comments');
311
     *
312
     * // Find all users who have approved comments (we can use comments table alias in where
313
     * statement). User::find()->with('comments')->where('comments.approved', true);
314
     *
315
     * // Find all users who have posts which have approved comments
316
     * User::find()->with('posts.comments')->where('posts_comments.approved', true);
317
     *
318
     * // Custom join alias for post comments relation
319
     * $user->with('posts.comments', [
320
     *      'as' => 'comments'
321
     * ])->where('comments.approved', true);
322
     *
323
     * // If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation
324
     * // name plus "_pivot" postfix. Let's load all users with approved tags.
325
     * $user->with('tags')->where('tags_pivot.approved', true);
326
     *
327
     * // You can also use custom alias for pivot table as well
328
     * User::find()->with('tags', [
329
     *      'pivotAlias' => 'tags_connection'
330
     * ])
331
     * ->where('tags_connection.approved', false);
332
     *
333
     * You can safely combine with() and load() methods.
334
     *
335
     * // Load all users with approved comments and pre-load all their comments
336
     * User::find()->with('comments')->where('comments.approved', true)->load('comments');
337
     *
338
     * // You can also use custom conditions in this case, let's find all users with approved
339
     * // comments and pre-load such approved comments
340
     * User::find()->with('comments')->where('comments.approved', true)
341
     *             ->load('comments', [
342
     *                  'where' => ['{@}.approved' => true]
343
     *              ]);
344
     *
345
     * // As you might notice previous construction will create 2 queries, however we can simplify
346
     * // this construction to use already joined table as source of data for relation via "using"
347
     * // keyword
348
     * User::find()->with('comments')
349
     *             ->where('comments.approved', true)
350 488
     *             ->load('comments', ['using' => 'comments']);
351
     *
352 488
     * // You will get only one query with INNER JOIN, to better understand this example let's use
353 488
     * // custom alias for comments in with() method.
354
     * User::find()->with('comments', ['as' => 'commentsR'])
355 488
     *             ->where('commentsR.approved', true)
356
     *             ->load('comments', ['using' => 'commentsR']);
357
     *
358
     * @see load()
359
     *
360
     * @return static<TEntity>
361
     */
362
    public function with(string|array $relation, array $options = []): self
363
    {
364
        if (\is_string($relation)) {
0 ignored issues
show
introduced by
The condition is_string($relation) is always false.
Loading history...
365
            $this->loader->loadRelation($relation, $options, true, false);
366
367
            return $this;
368
        }
369
370
        foreach ($relation as $name => $subOption) {
371
            if (\is_string($subOption)) {
372
                //Array of relation names
373
                $this->with($subOption, []);
374
            } else {
375
                //Multiple relations or relation with addition load options
376 3000
                $this->with($name, $subOption);
377
            }
378 3000
        }
379 3000
380 3000
        return $this;
381 3000
    }
382
383 3000
    /**
384 304
     * Find one entity or return null. Method provides the ability to configure custom query parameters.
385
     *
386
     * @return TEntity|null
0 ignored issues
show
Bug introduced by
The type Cycle\ORM\TEntity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
387
     */
388 2936
    public function fetchOne(array $query = null): ?object
389
    {
390
        $select = (clone $this)->where($query)->limit(1);
391
        $node = $select->loader->createNode();
392
        $select->loader->loadData($node, true);
393
        $data = $node->getResult();
394
395
        if (!isset($data[0])) {
396 1984
            return null;
397
        }
398 1984
399
        /** @var TEntity $result */
400
        return $this->entityFactory->make($this->loader->getTarget(), $data[0], Node::MANAGED, typecast: true);
401
    }
402
403
    /**
404 2132
     * Fetch all records in a form of array.
405
     *
406 2132
     * @return list<TEntity>
0 ignored issues
show
Bug introduced by
The type Cycle\ORM\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
407 2132
     */
408
    public function fetchAll(): iterable
409 2084
    {
410 2084
        return \iterator_to_array($this->getIterator(), false);
0 ignored issues
show
Bug Best Practice introduced by
The expression return iterator_to_array...->getIterator(), false) returns the type array which is incompatible with the documented return type Cycle\ORM\list.
Loading history...
411 2084
    }
412 2084
413 2084
    /**
414 2084
     * @return Iterator<TEntity>
415
     */
416
    public function getIterator(bool $findInHeap = false): Iterator
417
    {
418
        $node = $this->loader->createNode();
419
        $this->loader->loadData($node, true);
420
421
        return Iterator::createWithServices(
422
            $this->heap,
423
            $this->schema,
424
            $this->entityFactory,
425 1704
            $this->loader->getTarget(),
426
            $node->getResult(),
427 1704
            $findInHeap,
428 1704
            typecast: true
429
        );
430 1704
    }
431
432
    /**
433 1704
     * Load data tree from database and linked loaders in a form of array.
434
     *
435 1704
     * @return array<array-key, array<string, mixed>>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<array-key, array<string, mixed>> at position 2 could not be parsed: Unknown type name 'array-key' at position 2 in array<array-key, array<string, mixed>>.
Loading history...
436
     */
437
    public function fetchData(bool $typecast = true): iterable
438
    {
439
        $node = $this->loader->createNode();
440
        $this->loader->loadData($node, false);
441 8
442
        if (!$typecast) {
443 8
            return $node->getResult();
444
        }
445
446 264
        $mapper = $this->mapperProvider->getMapper($this->loader->getTarget());
447
448 264
        return \array_map([$mapper, 'cast'], $node->getResult());
449 264
    }
450
451
    /**
452 392
     * Compiled SQL statement.
453
     */
454 392
    public function sqlStatement(): string
455 392
    {
456 392
        return $this->buildQuery()->sqlStatement();
457 392
    }
458 8
459
    public function loadSubclasses(bool $load = true): self
460 384
    {
461 8
        $this->loader->setSubclassesLoading($load);
462 8
        return $this;
463
    }
464
465
    /**
466 376
     * @param list<non-empty-string> $pk
467 376
     * @param list<array|int|object|string> $args
468 376
     *
469 80
     * @return static<TEntity>
470 312
     */
471
    private function buildCompositePKQuery(array $pk, array $args): self
472 376
    {
473 8
        $prepared = [];
474
        foreach ($args as $index => $values) {
475
            $values = $values instanceof Parameter ? $values->getValue() : $values;
476 368
            if (!\is_array($values)) {
477
                throw new InvalidArgumentException('Composite primary key must be defined using an array.');
478
            }
479
            if (\count($pk) !== \count($values)) {
480 368
                throw new InvalidArgumentException(
481 368
                    \sprintf('Primary key should contain %d values.', \count($pk))
482 368
                );
483
            }
484
485
            $isAssoc = !\array_is_list($values);
486 368
            foreach ($values as $key => $value) {
487
                if ($isAssoc && !\in_array($key, $pk, true)) {
488
                    throw new InvalidArgumentException(\sprintf('Primary key `%s` not found.', $key));
489
                }
490
491
                $key = $isAssoc ? $key : $pk[$key];
492
                $prepared[$index][$key] = $value;
493
            }
494
        }
495
496
        $this->__call('where', [static function (Select\QueryBuilder $q) use ($prepared) {
497
            foreach ($prepared as $set) {
498
                $q->orWhere($set);
499
            }
500
        }]);
501
502
        return $this;
503
    }
504
}
505