Select   B
last analyzed

Complexity

Total Complexity 43

Size/Duplication

Total Lines 471
Duplicated Lines 0 %

Test Coverage

Coverage 93.5%

Importance

Changes 9
Bugs 4 Features 0
Metric Value
eloc 114
dl 0
loc 471
ccs 115
cts 123
cp 0.935
rs 8.96
c 9
b 4
f 0
wmc 43

20 Methods

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

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 Cycle\Database\Injection\Parameter;
8
use Cycle\Database\Query\SelectQuery;
9
use Cycle\ORM\Heap\Node;
10
use Cycle\ORM\Service\EntityFactoryInterface;
11
use Cycle\ORM\Service\MapperProviderInterface;
12
use Cycle\ORM\Service\SourceProviderInterface;
13
use Cycle\ORM\Select\JoinableLoader;
14
use Cycle\ORM\Select\QueryBuilder;
15
use Cycle\ORM\Select\RootLoader;
16
use Cycle\ORM\Select\ScopeInterface;
17
use Spiral\Pagination\PaginableInterface;
18
19
/**
20
 * Query builder and entity selector. Mocks SelectQuery. Attention, Selector does not mount RootLoader scope by default.
21
 *
22
 * Trait provides the ability to transparently configure underlying loader query.
23
 *
24
 * @method $this distinct()
25
 * @method $this where(...$args)
26
 * @method $this andWhere(...$args);
27
 * @method $this orWhere(...$args);
28
 * @method $this having(...$args);
29
 * @method $this andHaving(...$args);
30
 * @method $this orHaving(...$args);
31
 * @method $this orderBy($expression, $direction = 'ASC');
32
 * @method $this forUpdate()
33
 * @method $this whereJson(string $path, mixed $value)
34
 * @method $this orWhereJson(string $path, mixed $value)
35
 * @method $this whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
36
 * @method $this orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true)
37
 * @method $this whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
38
 * @method $this orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true)
39
 * @method $this whereJsonContainsKey(string $path)
40
 * @method $this orWhereJsonContainsKey(string $path)
41
 * @method $this whereJsonDoesntContainKey(string $path)
42
 * @method $this orWhereJsonDoesntContainKey(string $path)
43
 * @method $this whereJsonLength(string $path, int $length, string $operator = '=')
44
 * @method $this orWhereJsonLength(string $path, int $length, string $operator = '=')
45
 * @method mixed avg($identifier) Perform aggregation (AVG) based on column or expression value.
46
 * @method mixed min($identifier) Perform aggregation (MIN) based on column or expression value.
47
 * @method mixed max($identifier) Perform aggregation (MAX) based on column or expression value.
48
 * @method mixed sum($identifier) Perform aggregation (SUM) based on column or expression value.
49
 *
50
 * @template-covariant TEntity of object
51
 */
52
class Select implements \IteratorAggregate, \Countable, PaginableInterface
53
{
54
    // load relation data within same query
55
    public const SINGLE_QUERY = JoinableLoader::INLOAD;
56
57
    // load related data after the query
58
    public const OUTER_QUERY = JoinableLoader::POSTLOAD;
59
60
    private RootLoader $loader;
61
    private QueryBuilder $builder;
62 6834
    private MapperProviderInterface $mapperProvider;
63
    private Heap\HeapInterface $heap;
64
    private SchemaInterface $schema;
65
    private EntityFactoryInterface $entityFactory;
66 6834
67 6834
    /**
68 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...
69 6834
     */
70 6834
    public function __construct(
71 6834
        ORMInterface $orm,
72 6834
        string $role,
73 6834
    ) {
74 6834
        $this->heap = $orm->getHeap();
75
        $this->schema = $orm->getSchema();
76 6834
        $this->mapperProvider = $orm->getService(MapperProviderInterface::class);
77
        $this->entityFactory = $orm->getService(EntityFactoryInterface::class);
78
        $this->loader = new RootLoader(
79
            $orm->getSchema(),
80
            $orm->getService(SourceProviderInterface::class),
81
            $orm->getFactory(),
82 6834
            $orm->resolveRole($role),
83
        );
84 6834
        $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader);
85
    }
86
87
    /**
88
     * Create new Selector with applied scope. By default no scope used.
89
     *
90 5932
     * @return static<TEntity>
91
     */
92 5932
    public function scope(?ScopeInterface $scope = null): self
93
    {
94 72
        $this->loader->setScope($scope);
95 72
96 72
        return $this;
97
    }
98
99 5916
    /**
100 5916
     * Get Query proxy.
101 5916
     */
102
    public function getBuilder(): QueryBuilder
103
    {
104
        return $this->builder;
105
    }
106
107
    /**
108
     * Compiled SQL query, changes in this query would not affect Selector state (but bound parameters will).
109
     */
110
    public function buildQuery(): SelectQuery
111
    {
112 3232
        return $this->loader->buildQuery();
113
    }
114 3232
115 3232
    /**
116
     * Shortcut to where method to set AND condition for entity primary key.
117
     *
118
     * @psalm-param string|int|list<string|int>|object ...$ids
119
     *
120
     * @return static<TEntity>
121
     */
122
    public function wherePK(string|int|array|object ...$ids): self
123 3440
    {
124
        $pk = $this->loader->getPrimaryFields();
125 3440
126
        if (\count($pk) > 1) {
127 3440
            return $this->buildCompositePKQuery($pk, $ids);
128
        }
129
        $pk = \current($pk);
130
131
        return \count($ids) > 1
132
            ? $this->__call('where', [$pk, new Parameter($ids)])
133 8
            : $this->__call('where', [$pk, \current($ids)]);
134
    }
135 8
136
    /**
137
     * Attention, column will be quoted by driver!
138
     *
139
     * @param non-empty-string|null $column When column is null DISTINCT(PK) will be generated.
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string|null at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string|null.
Loading history...
140
     */
141 128
    public function count(?string $column = null): int
142
    {
143 128
        if ($column === null) {
144
            // @tuneyourserver solves the issue with counting on queries with joins.
145
            $pk = $this->loader->getPK();
146
            $column = \is_array($pk)
147
                ? '*'
148
                : \sprintf('DISTINCT(%s)', $pk);
149
        }
150
151
        return (int) $this->__call('count', [$column]);
152
    }
153 2136
154
    /**
155 2136
     * @return static<TEntity>
156
     */
157 2136
    public function limit(int $limit): self
158 392
    {
159
        $this->loader->getQuery()->limit($limit);
160 1744
161
        return $this;
162 1744
    }
163 16
164 1744
    /**
165
     * @return static<TEntity>
166
     */
167
    public function offset(int $offset): self
168
    {
169
        $this->loader->getQuery()->offset($offset);
170
171
        return $this;
172 72
    }
173
174 72
    /**
175
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
176 72
     * for data pre-loading. ORM loaders by default will select the most efficient way to load
177 72
     * related data which might include additional select query or left join. Loaded data will
178 24
     * automatically pre-populate record relations. You can specify nested relations using "."
179 48
     * separator.
180
     *
181
     * Examples:
182 72
     *
183
     *     // Select users and load their comments (will cast 2 queries, HAS_MANY comments)
184
     *     User::find()->with('comments');
185
     *
186
     *     // You can load chain of relations - select user and load their comments and post related to comment
187
     *     User::find()->with('comments.post');
188 3376
     *
189
     *     // We can also specify custom where conditions on data loading, let's load only public comments.
190 3376
     *     User::find()->load('comments', [
191
     *         'where' => ['{@}.status' => 'public']
192 3376
     *     ]);
193
     *
194
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
195
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
196
     *
197
     *     // In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
198 8
     *     // conditions, let's pre-load all approved user tags, we can use same placeholder for pivot
199
     *     // table alias
200 8
     *     User::find()->load('tags', [
201
     *          'wherePivot' => ['{@}.approved' => true]
202 8
     *     ]);
203
     *
204
     *     // In most of cases you don't need to worry about how data was loaded, using external query
205
     *     // or left join, however if you want to change such behaviour you can force load method
206
     *     // using {@see Select::SINGLE_QUERY}
207
     *     User::find()->load('tags', [
208
     *          'method'     => Select::SINGLE_QUERY,
209
     *          'wherePivot' => ['{@}.approved' => true]
210
     *     ]);
211
     *
212
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
213
     * support different loading types.
214
     *
215
     * You can specify multiple loaders using array as first argument.
216
     *
217
     * Example:
218
     *
219
     *     User::find()->load(['posts', 'comments', 'profile']);
220
     *
221
     * Attention, consider disabling entity map if you want to use recursive loading (i.e
222
     * post.tags.posts), but first think why you even need recursive relation loading.
223
     *
224
     * @see with()
225
     *
226
     * @return static<TEntity>
227
     */
228
    public function load(string|array $relation, array $options = []): self
229
    {
230
        if (\is_string($relation)) {
0 ignored issues
show
introduced by
The condition is_string($relation) is always false.
Loading history...
231
            $this->loader->loadRelation($relation, $options, false, true);
232
233
            return $this;
234
        }
235
236
        foreach ($relation as $name => $subOption) {
237
            if (\is_string($subOption)) {
238
                // array of relation names
239
                $this->load($subOption, $options);
240
            } else {
241
                // multiple relations or relation with addition load options
242
                $this->load($name, $subOption + $options);
243
            }
244
        }
245
246
        return $this;
247
    }
248
249
    /**
250
     * With method is very similar to load() one, except it will always include related data to
251
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
252
     * using same database as parent record.
253
     *
254
     * Method generally used to filter data based on some relation condition. Attention, with()
255
     * method WILL NOT load relation data, it will only make it accessible in query.
256
     *
257
     * By default joined tables will be available in query based on relation name, you can change
258
     * joined table alias using relation option "alias".
259
     *
260 2914
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
261
     * other scenario you will not able to paginate data well.
262 2914
     *
263 2914
     * Examples:
264
     *
265 2906
     *     // Find all users who have comments comments
266
     *     User::find()->with('comments');
267
     *
268 16
     *     // Find all users who have approved comments (we can use comments table alias in where statement)
269 16
     *     User::find()->with('comments')->where('comments.approved', true);
270
     *
271 16
     *     // Find all users who have posts which have approved comments
272
     *     User::find()->with('posts.comments')->where('posts_comments.approved', true);
273
     *
274
     *     // Custom join alias for post comments relation
275
     *     $user->with('posts.comments', [
276
     *         'as' => 'comments'
277
     *     ])->where('comments.approved', true);
278 16
     *
279
     *     // If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation name plus "_pivot" postfix. Let's load all users with approved tags.
280
     *     $user->with('tags')->where('tags_pivot.approved', true);
281
     *
282
     *     // You can also use custom alias for pivot table as well
283
     *     User::find()->with('tags', [
284
     *         'pivotAlias' => 'tags_connection'
285
     *     ])
286
     *     ->where('tags_connection.approved', false);
287
     *
288
     *
289
     * You can safely combine with() and load() methods.
290
     *
291
     *     // Load all users with approved comments and pre-load all their comments
292
     *     User::find()
293
     *         ->with('comments')
294
     *         ->where('comments.approved', true)->load('comments');
295
     *
296
     * You can also use custom conditions in this case, let's find all users with approved
297
     * comments and pre-load such approved comments
298
     *
299
     *     User::find()
300
     *             ->with('comments')
301
     *             ->where('comments.approved', true)
302
     *             ->load('comments', [
303
     *                 'where' => ['{@}.approved' => true]
304
     *             ]);
305
     *
306
     * As you might notice previous construction will create 2 queries, however we can simplify
307
     * this construction to use already joined table as source of data for relation via "using" keyword
308
     *
309
     *     User::find()
310
     *         ->with('comments')
311
     *         ->where('comments.approved', true)
312
     *         ->load('comments', ['using' => 'comments']);
313
     *
314
     * You will get only one query with INNER JOIN, to better understand this example let's use
315
     * custom alias for comments in with() method.
316
     *
317
     *     User::find()
318
     *         ->with('comments', ['as' => 'commentsR'])
319
     *         ->where('commentsR.approved', true)
320
     *         ->load('comments', ['using' => 'commentsR']);
321
     *
322
     * To use with() twice on the same relation, you can use `alias` option.
323
     *
324
     *     Country::find()
325
     *         // Find all translations
326
     *         ->with('translations', [ 'as' => 'trans'])
327
     *         ->load('translations', ['using' => 'trans'])
328
     *         // Second `with` for sorting only
329
     *         ->with('translations', [
330
     *             // Alias for SQL
331
     *             'as' => 'transEn',
332
     *             // Alias for ORM to not to overwrite previous `with`
333
     *              'alias' => 'translations-en',
334
     *              'method' => JoinableLoader::LEFT_JOIN,
335
     *              'where' => ['locale' => 'en'],
336
     *          ])
337
     *          ->orderBy('transEn.title', 'ASC');
338
     *
339
     * @return static<TEntity>
340
     *
341
     * @see load()
342
     */
343
    public function with(string|array $relation, array $options = []): self
344
    {
345
        if (\is_string($relation)) {
0 ignored issues
show
introduced by
The condition is_string($relation) is always false.
Loading history...
346
            $this->loader->loadRelation($relation, $options, true, false);
347
348
            return $this;
349
        }
350 488
351
        foreach ($relation as $name => $subOption) {
352 488
            if (\is_string($subOption)) {
353 488
                //Array of relation names
354
                $this->with($subOption, []);
355 488
            } else {
356
                //Multiple relations or relation with addition load options
357
                $this->with($name, $subOption);
358
            }
359
        }
360
361
        return $this;
362
    }
363
364
    /**
365
     * Find one entity or return null. Method provides the ability to configure custom query parameters.
366
     *
367
     * @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...
368
     */
369
    public function fetchOne(?array $query = null): ?object
370
    {
371
        $select = (clone $this)->where($query)->limit(1);
372
        $node = $select->loader->createNode();
373
        $select->loader->loadData($node, true);
374
        $data = $node->getResult();
375
376 3000
        if (!isset($data[0])) {
377
            return null;
378 3000
        }
379 3000
380 3000
        /** @var TEntity $result */
381 3000
        return $this->entityFactory->make($this->loader->getTarget(), $data[0], Node::MANAGED, typecast: true);
382
    }
383 3000
384 304
    /**
385
     * Fetch all records in a form of array.
386
     *
387
     * @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...
388 2936
     */
389
    public function fetchAll(): iterable
390
    {
391
        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...
392
    }
393
394
    /**
395
     * @return Iterator<TEntity>
396 1984
     */
397
    public function getIterator(bool $findInHeap = false): Iterator
398 1984
    {
399
        $node = $this->loader->createNode();
400
        $this->loader->loadData($node, true);
401
402
        return Iterator::createWithServices(
403
            $this->heap,
404 2132
            $this->schema,
405
            $this->entityFactory,
406 2132
            $this->loader->getTarget(),
407 2132
            $node->getResult(),
408
            $findInHeap,
409 2084
            typecast: true,
410 2084
        );
411 2084
    }
412 2084
413 2084
    /**
414 2084
     * Load data tree from database and linked loaders in a form of array.
415
     *
416
     * @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...
417
     */
418
    public function fetchData(bool $typecast = true): iterable
419
    {
420
        $node = $this->loader->createNode();
421
        $this->loader->loadData($node, false);
422
423
        if (!$typecast) {
424
            return $node->getResult();
425 1704
        }
426
427 1704
        $mapper = $this->mapperProvider->getMapper($this->loader->getTarget());
428 1704
429
        return \array_map([$mapper, 'cast'], $node->getResult());
430 1704
    }
431
432
    /**
433 1704
     * Compiled SQL statement.
434
     */
435 1704
    public function sqlStatement(): string
436
    {
437
        return $this->buildQuery()->sqlStatement();
438
    }
439
440
    public function loadSubclasses(bool $load = true): self
441 8
    {
442
        $this->loader->setSubclassesLoading($load);
443 8
        return $this;
444
    }
445
446 264
    /**
447
     * Bypassing call to primary select query.
448 264
     */
449 264
    public function __call(string $name, array $arguments): mixed
450
    {
451
        if (\in_array(\strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM', 'COUNT'])) {
452 392
            // aggregations
453
            return $this->builder->withQuery(
454 392
                $this->loader->buildQuery(),
455 392
            )->__call($name, $arguments);
456 392
        }
457 392
458 8
        $result = $this->builder->__call($name, $arguments);
459
        if ($result instanceof QueryBuilder) {
460 384
            return $this;
461 8
        }
462 8
463
        return $result;
464
    }
465
466 376
    /**
467 376
     * Cloning with loader tree cloning.
468 376
     *
469 80
     * @attention at this moment binded query parameters would't be cloned!
470 312
     */
471
    public function __clone()
472 376
    {
473 8
        $this->loader = clone $this->loader;
474
        $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader);
475
    }
476 368
477
    /**
478
     * Remove nested loaders and clean ORM link.
479
     */
480 368
    public function __destruct()
481 368
    {
482 368
        unset($this->loader, $this->builder);
483
    }
484
485
    /**
486 368
     * @param list<non-empty-string> $pk
487
     * @param list<array|int|object|string> $args
488
     *
489
     * @return static<TEntity>
490
     */
491
    private function buildCompositePKQuery(array $pk, array $args): self
492
    {
493
        $prepared = [];
494
        foreach ($args as $index => $values) {
495
            $values = $values instanceof Parameter ? $values->getValue() : $values;
496
            if (!\is_array($values)) {
497
                throw new \InvalidArgumentException('Composite primary key must be defined using an array.');
498
            }
499
            if (\count($pk) !== \count($values)) {
500
                throw new \InvalidArgumentException(
501
                    \sprintf('Primary key should contain %d values.', \count($pk)),
502
                );
503
            }
504
505
            $isAssoc = !\array_is_list($values);
506
            foreach ($values as $key => $value) {
507
                if ($isAssoc && !\in_array($key, $pk, true)) {
508
                    throw new \InvalidArgumentException(\sprintf('Primary key `%s` not found.', $key));
509
                }
510
511
                $key = $isAssoc ? $key : $pk[$key];
512
                $prepared[$index][$key] = $value;
513
            }
514
        }
515
516
        $this->__call('where', [static function (Select\QueryBuilder $q) use ($prepared): void {
517
            foreach ($prepared as $set) {
518
                $q->orWhere($set);
519
            }
520
        }]);
521
522
        return $this;
523
    }
524
}
525