Completed
Pull Request — master (#61)
by Anton
04:22
created

RecordSelector::with()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 21
Code Lines 10

Duplication

Lines 21
Ratio 100 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 21
loc 21
rs 9.0534
cc 4
eloc 10
nc 4
nop 2
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
namespace Spiral\ORM\Entities;
9
10
use Psr\Log\LoggerAwareInterface;
11
use Spiral\Cache\CacheInterface;
12
use Spiral\Core\Traits\SaturateTrait;
13
use Spiral\Database\Builders\Prototypes\AbstractSelect;
14
use Spiral\Database\Entities\QueryBuilder;
15
use Spiral\Database\Entities\QueryCompiler;
16
use Spiral\Database\Injections\FragmentInterface;
17
use Spiral\Database\Injections\ParameterInterface;
18
use Spiral\Database\Query\QueryResult;
19
use Spiral\Debug\Traits\BenchmarkTrait;
20
use Spiral\Debug\Traits\LoggerTrait;
21
use Spiral\ORM\Entities\Loaders\RootLoader;
22
use Spiral\ORM\Exceptions\SelectorException;
23
use Spiral\ORM\ORM;
24
use Spiral\ORM\RecordEntity;
25
use Spiral\ORM\RecordInterface;
26
27
/**
28
 * Selectors provide QueryBuilder (see Database) like syntax and support for ORM records to be
29
 * fetched from database. In addition, selection uses set of internal data loaders dedicated to
30
 * every of record relation and used to pre-load (joins) or post-load (separate query) data for
31
 * this relations, including additional where conditions and using relation data for parent record
32
 * filtering queries.
33
 *
34
 * Selector loaders may not only be related to SQL databases, but might load data from external
35
 * sources.
36
 *
37
 * @see with()
38
 * @see load()
39
 * @see LoaderInterface
40
 * @see AbstractSelect
41
 */
42
class RecordSelector extends AbstractSelect implements LoggerAwareInterface
43
{
44
    /**
45
     * Selector provides set of profiling functionality helps to understand what is going on with
46
     * query and data parsing.
47
     */
48
    use LoggerTrait, BenchmarkTrait, SaturateTrait;
49
50
    /**
51
     * Class name of record to be loaded.
52
     *
53
     * @var string
54
     */
55
    protected $class = '';
56
57
    /**
58
     * Data columns are set of columns automatically created by inner loaders using
59
     * generateColumns() method, this is not the same column set as one provided by user using
60
     * columns() method. Do not define columns using generateColumns() method outside of loaders.
61
     *
62
     * @see generateColumns()
63
     * @var array
64
     */
65
    protected $dataColumns = [];
66
67
    /**
68
     * We have to track count of loader columns to define correct offsets.
69
     *
70
     * @var int
71
     */
72
    protected $countColumns = 0;
73
74
    /**
75
     * Primary selection loader.
76
     *
77
     * @var Loader
78
     */
79
    protected $loader = null;
80
81
    /**
82
     * @invisible
83
     * @var ORM
84
     */
85
    protected $orm = null;
86
87
    /**
88
     * @param string $class
89
     * @param ORM    $orm
90
     * @param Loader $loader
91
     */
92
    public function __construct($class, ORM $orm = null, Loader $loader = null)
93
    {
94
        $this->class = $class;
95
        $this->orm = $this->saturate($orm, ORM::class);
96
        $this->columns = $this->dataColumns = [];
97
98
        //We aways need primary loader
99
        if (empty($this->loader = $loader)) {
100
            //Selector always need primary data loaded to define data structure and perform query
101
            //parsing, in most of cases we can easily use RootLoader associated with primary record
102
            //schema
103
            $this->loader = new RootLoader($this->orm, null, $this->orm->schema($class));
104
        }
105
106
        //Every ORM loader has ability to declare it's primary database, we are going to use
107
        //primary loader database to initiate selector
108
        $database = $this->loader->dbalDatabase();
109
110
        //AbstractSelect construction
111
        parent::__construct($database, $database->driver()->queryCompiler($database->getPrefix()));
112
    }
113
114
    /**
115
     * Primary selection table.
116
     *
117
     * @return string
118
     */
119
    public function primaryTable()
120
    {
121
        return $this->loader->getTable();
122
    }
123
124
    /**
125
     * Primary alias points to table related to parent record.
126
     *
127
     * @return string
128
     */
129
    public function primaryAlias()
130
    {
131
        return $this->loader->getAlias();
132
    }
133
134
    /**
135
     * {@inheritdoc}
136
     */
137
    public function columns($columns = ['*'])
0 ignored issues
show
Unused Code introduced by
The parameter $columns is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
138
    {
139
        $this->columns = $this->fetchIdentifiers(func_get_args());
140
141
        return $this;
142
    }
143
144
    /**
145
     * Automatically generate set of columns for specified table or alias, method used by loaders
146
     * in cases where data is joined.
147
     *
148
     * @param string $table   Source table name or alias.
149
     * @param array  $columns Original set of record columns.
150
     * @return int
151
     */
152
    public function generateColumns($table, array $columns)
153
    {
154
        $offset = count($this->dataColumns);
155
        foreach ($columns as $column) {
156
            $columnAlias = 'c' . (++$this->countColumns);
157
            $this->dataColumns[] = $table . '.' . $column . ' AS ' . $columnAlias;
158
        }
159
160
        return $offset;
161
    }
162
163
    /**
164
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
165
     * for
166
     * data preloading. ORM loaders by default will select the most efficient way to load related
167
     * data which might include additional select query or left join. Loaded data will
168
     * automatically pre-populate record relations. You can specify nested relations using "."
169
     * separator.
170
     *
171
     * Examples:
172
     *
173
     * //Select users and load their comments (will cast 2 queries, HAS_MANY comments)
174
     * User::find()->with('comments');
175
     *
176
     * //You can load chain of relations - select user and load their comments and post related to
177
     * //comment
178
     * User::find()->with('comments.post');
179
     *
180
     * //We can also specify custom where conditions on data loading, let's load only public
181
     * comments. User::find()->load('comments', [
182
     *      'where' => ['{@}.status' => 'public']
183
     * ]);
184
     *
185
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
186
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
187
     *
188
     * //In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
189
     * conditions,
190
     * //let's pre-load all approved user tags, we can use same placeholder for pivot table alias
191
     * User::find()->load('tags', [
192
     *      'wherePivot' => ['{@}.approved' => true]
193
     * ]);
194
     *
195
     * //In most of cases you don't need to worry about how data was loaded, using external query
196
     * or
197
     * //left join, however if you want to change such behaviour you can force load method to
198
     * INLOAD
199
     * User::find()->load('tags', [
200
     *      'method'     => Loader::INLOAD,
201
     *      'wherePivot' => ['{@}.approved' => true]
202
     * ]);
203
     *
204
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
205
     * support different loading types.
206
     *
207
     * You can specify multiple loaders using array as first argument.
208
     *
209
     * Example:
210
     * User::find()->load(['posts', 'comments', 'profile']);
211
     *
212
     * @see with()
213
     * @param string $relation
214
     * @param array  $options
215
     * @return $this
216
     */
217 View Code Duplication
    public function load($relation, array $options = [])
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
218
    {
219
        if (is_array($relation)) {
220
            foreach ($relation as $name => $subOption) {
221
                if (is_string($subOption)) {
222
                    //Array of relation names
223
                    $this->load($subOption, $options);
224
                } else {
225
                    //Multiple relations or relation with addition load options
226
                    $this->load($name, $subOption + $options);
227
                }
228
            }
229
230
            return $this;
231
        }
232
233
        //We are requesting primary loaded to pre-load nested relation
234
        $this->loader->loader($relation, $options);
235
236
        return $this;
237
    }
238
239
    /**
240
     * With method is very similar to load() one, except it will always include related data to
241
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
242
     * using same database as parent record.
243
     *
244
     * Method generally used to filter data based on some relation condition.
245
     * Attention, with() method WILL NOT load relation data, it will only make it accessible in
246
     * query.
247
     *
248
     * By default joined tables will be available in query based on realtion name, you can change
249
     * joined table alias using relation option "alias".
250
     *
251
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
252
     * other scenario you will not able to paginate data well.
253
     *
254
     * Examples:
255
     *
256
     * //Find all users who have comments comments
257
     * User::find()->with('comments');
258
     *
259
     * //Find all users who have approved comments (we can use comments table alias in where
260
     * statement).
261
     * User::find()->with('comments')->where('comments.approved', true);
262
     *
263
     * //Find all users who have posts which have approved comments
264
     * User::find()->with('posts.comments')->where('posts_comments.approved', true);
265
     *
266
     * //Custom join alias for post comments relation
267
     * $user->with('posts.comments', [
268
     *      'alias' => 'comments'
269
     * ])->where('comments.approved', true);
270
     *
271
     * //If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation
272
     * name
273
     * //plus "_pivot" postfix. Let's load all users with approved tags.
274
     * $user->with('tags')->where('tags_pivot.approved', true);
275
     *
276
     * //You can also use custom alias for pivot table as well
277
     * User::find()->with('tags', [
278
     *      'pivotAlias' => 'tags_connection'
279
     * ])
280
     * ->where('tags_connection.approved', false);
281
     *
282
     * You can safely combine with() and load() methods.
283
     *
284
     * //Load all users with approved comments and pre-load all their comments
285
     * User::find()->with('comments')->where('comments.approved', true)
286
     *             ->load('comments');
287
     *
288
     * //You can also use custom conditions in this case, let's find all users with approved
289
     * comments
290
     * //and pre-load such approved comments
291
     * User::find()->with('comments')->where('comments.approved', true)
292
     *             ->load('comments', [
293
     *                  'where' => ['{@}.approved' => true]
294
     *              ]);
295
     *
296
     * //As you might notice previous construction will create 2 queries, however we can simplify
297
     * //this construction to use already joined table as source of data for relation via "using"
298
     * //keyword
299
     * User::find()->with('comments')->where('comments.approved', true)
300
     *             ->load('comments', ['using' => 'comments']);
301
     *
302
     * //You will get only one query with INNER JOIN, to better understand this example let's use
303
     * //custom alias for comments in with() method.
304
     * User::find()->with('comments', ['alias' => 'commentsR'])->where('commentsR.approved', true)
305
     *             ->load('comments', ['using' => 'commentsR']);
306
     *
307
     * @see load()
308
     * @param string $relation
309
     * @param array  $options
310
     * @return $this
311
     */
312 View Code Duplication
    public function with($relation, array $options = [])
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
313
    {
314
        if (is_array($relation)) {
315
            foreach ($relation as $name => $options) {
316
                if (is_string($options)) {
317
                    //Array of relation names
318
                    $this->with($options, []);
319
                } else {
320
                    //Multiple relations or relation with addition load options
321
                    $this->with($name, $options);
322
                }
323
            }
324
325
            return $this;
326
        }
327
328
        //Requesting primary loader to join nested relation, will only work for ORM loaders
329
        $this->loader->joiner($relation, $options);
330
331
        return $this;
332
    }
333
334
    /**
335
     * Fetch one record from database using it's primary key. You can use INLOAD and JOIN_ONLY
336
     * loaders with HAS_MANY or MANY_TO_MANY relations with this method as no limit were used.
337
     *
338
     * @see findOne()
339
     * @param mixed $id Primary key value.
340
     * @return RecordEntity|null
341
     * @throws SelectorException
342
     */
343
    public function findByPK($id)
344
    {
345
        $primaryKey = $this->loader->getPrimaryKey();
346
347
        if (empty($primaryKey)) {
348
            throw new SelectorException(
349
                "Unable to fetch data by primary key, no primary key found."
350
            );
351
        }
352
353
        //No limit here
354
        return $this->findOne([$primaryKey => $id], false);
355
    }
356
357
    /**
358
     * Fetch one record from database. Attention, LIMIT statement will be used, meaning you can not
359
     * use loaders for HAS_MANY or MANY_TO_MANY relations with data inload (joins), use default
360
     * loading method.
361
     *
362
     * @see findByPK()
363
     * @param array $where     Selection WHERE statement.
364
     * @param bool  $withLimit Use limit 1.
365
     * @return RecordEntity|null
366
     */
367
    public function findOne(array $where = [], $withLimit = true)
368
    {
369
        if (!empty($where)) {
370
            $this->where($where);
371
        }
372
373
        $data = $this->limit($withLimit ? 1 : null)->fetchData();
374
        if (empty($data)) {
375
            return null;
376
        }
377
378
        //Letting ORM to do it's job
379
        return $this->orm->record($this->class, $data[0]);
380
    }
381
382
    /**
383
     * {@inheritdoc}
384
     */
385
    public function sqlStatement(QueryCompiler $compiler = null)
386
    {
387
        if (empty($compiler)) {
388
            $compiler = $this->compiler->resetQuoter();
389
        }
390
391
        //Primary loader may add custom conditions to select query
392
        $this->loader->configureSelector($this);
393
394
        if (empty($columns = $this->columns)) {
395
            //If no user columns were specified we are going to use columns defined by our loaders
396
            //in addition it will return RecordIterator instance as result instead of QueryResult
397
            $columns = !empty($this->dataColumns) ? $this->dataColumns : ['*'];
398
        }
399
400
        return $compiler->compileSelect(
401
            ["{$this->primaryTable()} AS {$this->primaryAlias()}"],
402
            $this->distinct,
403
            $columns,
404
            $this->joinTokens,
405
            $this->whereTokens,
406
            $this->havingTokens,
407
            $this->grouping,
408
            $this->ordering,
409
            $this->limit,
410
            $this->offset
411
        );
412
    }
413
414
    /**
415
     * {@inheritdoc}
416
     *
417
     * Return type will depend if custom columns set were used.
418
     *
419
     * @param array $callbacks Callbacks to be used in record iterator as magic methods.
420
     * @return QueryResult|RecordIterator
421
     */
422
    public function getIterator(array $callbacks = [])
423
    {
424
        if (!empty($this->columns) || !empty($this->grouping)) {
425
            //QueryResult for user requests
426
            return $this->run();
427
        }
428
429
        /*
430
         * We are getting copy of ORM with cloned cache, so all our entities are isolated in it.
431
         */
432
        return new RecordIterator(
433
            clone $this->orm,
434
            $this->class,
435
            $this->fetchData(),
436
            true,
437
            $callbacks
438
        );
439
    }
440
441
    /**
442
     * All records.
443
     *
444
     * @return RecordInterface[]
445
     */
446
    public function all()
447
    {
448
        return $this->getIterator()->all();
449
    }
450
451
    /**
452
     * Execute query and every related query to compile records data in tree form - every relation
453
     * data will be included as sub key.
454
     *
455
     * Attention, Selector will cache compiled data tree and not query itself to keep data integrity
456
     * and to skip data compilation on second query.
457
     *
458
     * @return array
459
     */
460
    public function fetchData()
461
    {
462
        //Pagination!
463
        $this->applyPagination();
464
465
        //Generating statement
466
        $statement = $this->sqlStatement();
467
468
        if (!empty($this->cacheLifetime)) {
469
            $cacheKey = $this->cacheKey ?: md5(serialize([$statement, $this->getParameters()]));
470
471
            if (empty($this->cacheStore)) {
472
                $this->cacheStore = $this->orm->container()->get(CacheInterface::class)->store();
473
            }
474
475
            if ($this->cacheStore->has($cacheKey)) {
476
                $this->logger()->debug("Selector result were fetched from cache.");
477
478
                //We are going to store parsed result, not queries
479
                return $this->cacheStore->get($cacheKey);
480
            }
481
        }
482
483
        //We are bypassing run() method here to prevent query caching, we will prefer to cache
484
        //parsed data rather that database response
485
        $result = $this->database->query($statement, $this->getParameters());
486
487
        //In many cases (too many inloads, too complex queries) parsing can take significant amount
488
        //of time, so we better profile it
489
        $benchmark = $this->benchmark('parseResult', $statement);
490
491
        //Here we are feeding selected data to our primary loaded to parse it and and create
492
        //data tree for our records
493
        $this->loader->parseResult($result, $rowsCount);
494
495
        $this->benchmark($benchmark);
496
497
        //Memory freeing
498
        $result->close();
499
500
        //This must force loader to execute all post loaders (including ODM and etc)
501
        $this->loader->loadData();
502
503
        //Now we can request our primary loader for compiled data
504
        $data = $this->loader->getResult();
505
506
        //Memory free! Attention, it will not reset columns aliases but only make possible to run
507
        //query again
508
        $this->loader->clean();
509
510
        if (!empty($this->cacheLifetime) && !empty($cacheKey)) {
511
            //We are caching full records tree, not queries
512
            $this->cacheStore->set($cacheKey, $data, $this->cacheLifetime);
513
        }
514
515
        return $data;
516
    }
517
}