Passed
Pull Request — 2.x (#528)
by Aleksei
17:59
created

Select::addGroupByPK()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 16
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

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