Completed
Push — master ( 0f8e7d...60c4e2 )
by Anton
05:19
created

RecordSelector::sqlStatement()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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