Passed
Pull Request — 2.x (#528)
by Alexander
16:56
created

Select::loadData()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

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