Completed
Push — master ( 3d5802...f04b72 )
by Anton
09:30
created

RecordSelector   B

Complexity

Total Complexity 36

Size/Duplication

Total Lines 484
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Importance

Changes 0
Metric Value
dl 0
loc 484
rs 8.8
c 0
b 0
f 0
wmc 36
lcom 1
cbo 9

21 Methods

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