RecordSelector::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
cc 1
nc 1
nop 2
1
<?php
2
/**
3
 * Spiral, Core Components
4
 *
5
 * @author Wolfy-J
6
 */
7
8
namespace Spiral\ORM\Entities;
9
10
use Psr\SimpleCache\CacheInterface;
11
use Spiral\Core\Component;
12
use Spiral\Core\Traits\SaturateTrait;
13
use Spiral\Database\Builders\SelectQuery;
14
use Spiral\Models\EntityInterface;
15
use Spiral\ORM\Entities\Loaders\RootLoader;
16
use Spiral\ORM\Entities\Nodes\OutputNode;
17
use Spiral\ORM\Exceptions\SelectorException;
18
use Spiral\ORM\ORMInterface;
19
use Spiral\ORM\RecordInterface;
20
use Spiral\Pagination\CountingInterface;
21
use Spiral\Pagination\PaginatorAwareInterface;
22
use Spiral\Pagination\PaginatorInterface;
23
24
/**
25
 * Attention, RecordSelector DOES NOT extends QueryBuilder but mocks it!
26
 *
27
 * @method $this where(...$args);
28
 * @method $this andWhere(...$args);
29
 * @method $this orWhere(...$args);
30
 *
31
 * @method $this having(...$args);
32
 * @method $this andHaving(...$args);
33
 * @method $this orHaving(...$args);
34
 *
35
 * @method $this paginate($limit = 25, $page = 'page')
36
 *
37
 * @method $this orderBy($expression, $direction = 'ASC');
38
 *
39
 * @method $this distinct()
40
 *
41
 * @method int avg($identifier) Perform aggregation (AVG) based on column or expression value.
42
 * @method int min($identifier) Perform aggregation (MIN) based on column or expression value.
43
 * @method int max($identifier) Perform aggregation (MAX) based on column or expression value.
44
 * @method int sum($identifier) Perform aggregation (SUM) based on column or expression value.
45
 */
46
class RecordSelector extends Component implements \IteratorAggregate, \Countable, PaginatorAwareInterface
47
{
48
    use SaturateTrait;
49
50
    /**
51
     * @var string
52
     */
53
    private $class;
54
55
    /**
56
     * @invisible
57
     * @var ORMInterface
58
     */
59
    private $orm;
60
61
    /**
62
     * @var RootLoader
63
     */
64
    private $loader;
65
66
    /**
67
     * @param string       $class
68
     * @param ORMInterface $orm
69
     */
70
    public function __construct(string $class, ORMInterface $orm)
71
    {
72
        $this->class = $class;
73
        $this->orm = $orm;
74
75
        $this->loader = new RootLoader(
76
            $class,
77
            $orm->define($class, ORMInterface::R_SCHEMA),
78
            $orm
79
        );
80
    }
81
82
    /**
83
     * Get associated ORM instance, can be used to create separate query/selection using same
84
     * (nested) memory scope for ORM cache.
85
     *
86
     * @see ORM::selector()
87
     * @return ORMInterface
88
     */
89
    public function getORM(): ORMInterface
90
    {
91
        return $this->orm;
92
    }
93
94
    /**
95
     * Get associated class.
96
     *
97
     * @return string
98
     */
99
    public function getClass(): string
100
    {
101
        return $this->class;
102
    }
103
104
    /**
105
     * Get alias used for primary table.
106
     *
107
     * @return string
108
     */
109
    public function getAlias(): string
110
    {
111
        return $this->loader->getAlias();
112
    }
113
114
    /**
115
     * Columns to be selected, please note, primary will always be included, DO not include
116
     * column aliases in here, aliases will be added automatically. Creates selector as response.
117
     *
118
     * @param array $columns
119
     *
120
     * @return RecordSelector
121
     */
122
    public function withColumns(array $columns): self
123
    {
124
        $selector = clone $this;
125
        $selector->loader = $selector->loader->withColumns($columns);
126
127
        return $selector;
128
    }
129
130
    /**
131
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
132
     * for
133
     * data preloading. ORM loaders by default will select the most efficient way to load related
134
     * data which might include additional select query or left join. Loaded data will
135
     * automatically pre-populate record relations. You can specify nested relations using "."
136
     * separator.
137
     *
138
     * Examples:
139
     *
140
     * //Select users and load their comments (will cast 2 queries, HAS_MANY comments)
141
     * User::find()->with('comments');
142
     *
143
     * //You can load chain of relations - select user and load their comments and post related to
144
     * //comment
145
     * User::find()->with('comments.post');
146
     *
147
     * //We can also specify custom where conditions on data loading, let's load only public
148
     * comments. User::find()->load('comments', [
149
     *      'where' => ['{@}.status' => 'public']
150
     * ]);
151
     *
152
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
153
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
154
     *
155
     * //In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
156
     * conditions,
157
     * //let's pre-load all approved user tags, we can use same placeholder for pivot table alias
158
     * User::find()->load('tags', [
159
     *      'wherePivot' => ['{@}.approved' => true]
160
     * ]);
161
     *
162
     * //In most of cases you don't need to worry about how data was loaded, using external query
163
     * or
164
     * //left join, however if you want to change such behaviour you can force load method to
165
     * INLOAD
166
     * User::find()->load('tags', [
167
     *      'method'     => Loader::INLOAD,
168
     *      'wherePivot' => ['{@}.approved' => true]
169
     * ]);
170
     *
171
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
172
     * support different loading types.
173
     *
174
     * You can specify multiple loaders using array as first argument.
175
     *
176
     * Example:
177
     * User::find()->load(['posts', 'comments', 'profile']);
178
     *
179
     * Attention, consider disabling entity map if you want to use recursive loading (i.e.
180
     * post.tags.posts), but first think why you even need recursive relation loading.
181
     *
182
     * @see with()
183
     *
184
     * @param string|array $relation
185
     * @param array        $options
186
     *
187
     * @return $this|RecordSelector
188
     */
189
    public function load($relation, array $options = []): self
190
    {
191
        if (is_array($relation)) {
192
            foreach ($relation as $name => $subOption) {
193
                if (is_string($subOption)) {
194
                    //Array of relation names
195
                    $this->load($subOption, $options);
196
                } else {
197
                    //Multiple relations or relation with addition load options
198
                    $this->load($name, $subOption + $options);
199
                }
200
            }
201
202
            return $this;
203
        }
204
205
        //We are requesting primary loaded to pre-load nested relation
206
        $this->loader->loadRelation($relation, $options);
207
208
        return $this;
209
    }
210
211
    /**
212
     * With method is very similar to load() one, except it will always include related data to
213
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
214
     * using same database as parent record.
215
     *
216
     * Method generally used to filter data based on some relation condition.
217
     * Attention, with() method WILL NOT load relation data, it will only make it accessible in
218
     * query.
219
     *
220
     * By default joined tables will be available in query based on relation name, you can change
221
     * joined table alias using relation option "alias".
222
     *
223
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
224
     * other scenario you will not able to paginate data well.
225
     *
226
     * Examples:
227
     *
228
     * //Find all users who have comments comments
229
     * User::find()->with('comments');
230
     *
231
     * //Find all users who have approved comments (we can use comments table alias in where
232
     * statement).
233
     * User::find()->with('comments')->where('comments.approved', true);
234
     *
235
     * //Find all users who have posts which have approved comments
236
     * User::find()->with('posts.comments')->where('posts_comments.approved', true);
237
     *
238
     * //Custom join alias for post comments relation
239
     * $user->with('posts.comments', [
240
     *      'alias' => 'comments'
241
     * ])->where('comments.approved', true);
242
     *
243
     * //If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation
244
     * name
245
     * //plus "_pivot" postfix. Let's load all users with approved tags.
246
     * $user->with('tags')->where('tags_pivot.approved', true);
247
     *
248
     * //You can also use custom alias for pivot table as well
249
     * User::find()->with('tags', [
250
     *      'pivotAlias' => 'tags_connection'
251
     * ])
252
     * ->where('tags_connection.approved', false);
253
     *
254
     * You can safely combine with() and load() methods.
255
     *
256
     * //Load all users with approved comments and pre-load all their comments
257
     * User::find()->with('comments')->where('comments.approved', true)
258
     *             ->load('comments');
259
     *
260
     * //You can also use custom conditions in this case, let's find all users with approved
261
     * comments
262
     * //and pre-load such approved comments
263
     * User::find()->with('comments')->where('comments.approved', true)
264
     *             ->load('comments', [
265
     *                  'where' => ['{@}.approved' => true]
266
     *              ]);
267
     *
268
     * //As you might notice previous construction will create 2 queries, however we can simplify
269
     * //this construction to use already joined table as source of data for relation via "using"
270
     * //keyword
271
     * User::find()->with('comments')
272
     *             ->where('comments.approved', true)
273
     *             ->load('comments', ['using' => 'comments']);
274
     *
275
     * //You will get only one query with INNER JOIN, to better understand this example let's use
276
     * //custom alias for comments in with() method.
277
     * User::find()->with('comments', ['alias' => 'commentsR'])
278
     *             ->where('commentsR.approved', true)
279
     *             ->load('comments', ['using' => 'commentsR']);
280
     *
281
     * @see load()
282
     *
283
     * @param string|array $relation
284
     * @param array        $options
285
     *
286
     * @return $this|RecordSelector
287
     */
288
    public function with($relation, array $options = []): self
289
    {
290
        if (is_array($relation)) {
291
            foreach ($relation as $name => $options) {
292
                if (is_string($options)) {
293
                    //Array of relation names
294
                    $this->with($options, []);
295
                } else {
296
                    //Multiple relations or relation with addition load options
297
                    $this->with($name, $options);
298
                }
299
            }
300
301
            return $this;
302
        }
303
304
        //Requesting primary loader to join nested relation, will only work for ORM loaders
305
        $this->loader->loadRelation($relation, $options, true);
306
307
        return $this;
308
    }
309
310
    /**
311
     * Shortcut to where method to set AND condition for parent record primary key.
312
     *
313
     * @param string|int $id
314
     *
315
     * @return RecordSelector
316
     *
317
     * @throws SelectorException
318
     */
319
    public function wherePK($id): self
320
    {
321
        if (empty($this->loader->primaryKey())) {
322
            //This MUST never happen due ORM requires PK now for every entity
323
            throw new SelectorException("Unable to set wherePK condition, no proper PK were defined");
324
        }
325
326
        //Adding condition to initial query
327
        $this->loader->initialQuery()->where([
328
            //Must be already aliased
329
            $this->loader->primaryKey() => $id
330
        ]);
331
332
        return $this;
333
    }
334
335
    /**
336
     * Find one entity or return null.
337
     *
338
     * @param array|null $query
339
     *
340
     * @return EntityInterface|null
341
     */
342
    public function findOne(array $query = null)
343
    {
344
        $data = (clone $this)->where($query)->fetchData();
345
346
        if (empty($data[0])) {
347
            return null;
348
        }
349
350
        return $this->orm->make($this->class, $data[0], ORMInterface::STATE_LOADED, true);
351
    }
352
353
    /**
354
     * Fetch all records in a form of array.
355
     *
356
     * @param string              $cacheKey
357
     * @param int|\DateInterval   $ttl
358
     * @param CacheInterface|null $cache Can be automatically resoled via ORM container scope.
359
     *
360
     * @return RecordInterface[]
361
     */
362
    public function fetchAll(
363
        string $cacheKey = '',
364
        $ttl = 0,
365
        CacheInterface $cache = null
366
    ): array {
367
        return iterator_to_array($this->getIterator($cacheKey, $ttl, $cache));
368
    }
369
370
    /**
371
     * Get RecordIterator (entity iterator) for a requested data. Provide cache key and lifetime in
372
     * order to cache request data.
373
     *
374
     * @param string              $cacheKey
375
     * @param int|\DateInterval   $ttl
376
     * @param CacheInterface|null $cache Can be automatically resoled via ORM container scope.
377
     *
378
     * @return RecordIterator|RecordInterface[]
379
     */
380
    public function getIterator(
381
        string $cacheKey = '',
382
        $ttl = 0,
383
        CacheInterface $cache = null
384
    ): RecordIterator {
385
        if (!empty($cacheKey)) {
386
            /**
387
             * When no cache is provided saturate it using container scope
388
             *
389
             * @var CacheInterface $cache
390
             */
391
            $cache = $this->saturate($cache, CacheInterface::class);
392
393
            if ($cache->has($cacheKey)) {
394
                $data = $cache->get($cacheKey);
395
            } else {
396
                //Cache parsed tree with all sub queries executed!
397
                $cache->set($cacheKey, $data = $this->fetchData(), $ttl);
398
            }
399
        } else {
400
            $data = $this->fetchData();
401
        }
402
403
        return new RecordIterator($data, $this->class, $this->orm);
404
    }
405
406
    /**
407
     * Attention, column will be quoted by driver!
408
     *
409
     * @param string|null $column When column is null DISTINCT(PK) will be generated.
410
     *
411
     * @return int
412
     */
413
    public function count(string $column = null): int
414
    {
415
        if (is_null($column)) {
416
            if (!empty($this->loader->primaryKey())) {
417
                //@tuneyourserver solves the issue with counting on queries with joins.
418
                $column = "DISTINCT({$this->loader->primaryKey()})";
419
            } else {
420
                $column = '*';
421
            }
422
        }
423
424
        return $this->compiledQuery()->count($column);
425
    }
426
427
    /**
428
     * Query used as basement for relation.
429
     *
430
     * @return SelectQuery
431
     */
432
    public function initialQuery(): SelectQuery
433
    {
434
        return $this->loader->initialQuery();
435
    }
436
437
    /**
438
     * Get compiled version of SelectQuery, attentionly only first level query access is allowed.
439
     *
440
     * @return SelectQuery
441
     */
442
    public function compiledQuery(): SelectQuery
443
    {
444
        return $this->loader->compiledQuery();
445
    }
446
447
    /**
448
     * Compiled SQL statement.
449
     *
450
     * @return string
451
     */
452
    public function sqlStatement(): string
453
    {
454
        return $this->loader->compiledQuery()->sqlStatement();
455
    }
456
457
    /**
458
     * Load data tree from databases and linked loaders in a form of array.
459
     *
460
     * @param OutputNode $node When empty node will be created automatically by root relation
461
     *                         loader.
462
     *
463
     * @return array
464
     */
465
    public function fetchData(OutputNode $node = null): array
466
    {
467
        /** @var OutputNode $node */
468
        $node = $node ?? $this->loader->createNode();
469
470
        //Working with parser defined by loader itself
471
        $this->loader->loadData($node);
472
473
        return $node->getResult();
474
    }
475
476
    /**
477
     * {@inheritdoc}
478
     */
479
    public function hasPaginator(): bool
480
    {
481
        return $this->loader->initialQuery()->hasPaginator();
482
    }
483
484
    /**
485
     * {@inheritdoc}
486
     */
487
    public function setPaginator(PaginatorInterface $paginator)
488
    {
489
        $this->loader->initialQuery()->setPaginator($paginator);
490
    }
491
492
    /**
493
     * {@inheritdoc}
494
     */
495
    public function getPaginator(bool $prepare = true): PaginatorInterface
496
    {
497
        $paginator = $this->loader->compiledQuery()->getPaginator(false);
498
499
        if ($prepare && $paginator instanceof CountingInterface) {
500
            $paginator = $paginator->withCount($this->count());
501
        }
502
503
        return $paginator;
504
    }
505
506
    /**
507
     * Bypassing call to primary select query.
508
     *
509
     * @param string $name
510
     * @param        $arguments
511
     *
512
     * @return $this|mixed
513
     */
514
    public function __call(string $name, array $arguments)
515
    {
516
        if (in_array(strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM'])) {
517
            //One of aggregation requests
518
            $result = call_user_func_array([$this->compiledQuery(), $name], $arguments);
519
        } else {
520
            //Where condition or statement
521
            $result = call_user_func_array([$this->loader->initialQuery(), $name], $arguments);
522
        }
523
524
        if ($result === $this->loader->initialQuery()) {
525
            return $this;
526
        }
527
528
        return $result;
529
    }
530
531
    /**
532
     * Cloning with loader tree cloning.
533
     *
534
     * @attention at this moment binded query parameters would't be cloned!
535
     */
536
    public function __clone()
537
    {
538
        $this->loader = clone $this->loader;
539
    }
540
541
    /**
542
     * Remove nested loaders and clean ORM link.
543
     */
544
    public function __destruct()
545
    {
546
        $this->orm = null;
547
        $this->loader = null;
548
    }
549
550
    /**
551
     * @return \Interop\Container\ContainerInterface|null
552
     */
553
    protected function iocContainer()
554
    {
555
        if ($this->orm instanceof Component) {
556
            //Working inside ORM container scope
557
            return $this->orm->iocContainer();
558
        }
559
560
        return parent::iocContainer();
561
    }
562
}
563