Completed
Branch dbal-improvement (e43d29)
by Anton
06:02
created

RecordSelector::fetchData()   B

Complexity

Conditions 7
Paths 13

Size

Total Lines 57
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 57
rs 7.676
cc 7
eloc 21
nc 13
nop 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
        return new RecordIterator($this->orm, $this->class, $this->fetchData(), true, $callbacks);
430
    }
431
432
    /**
433
     * All records.
434
     *
435
     * @return RecordInterface[]
436
     */
437
    public function all()
438
    {
439
        return $this->getIterator()->all();
440
    }
441
442
    /**
443
     * Execute query and every related query to compile records data in tree form - every relation
444
     * data will be included as sub key.
445
     *
446
     * Attention, Selector will cache compiled data tree and not query itself to keep data integrity
447
     * and to skip data compilation on second query.
448
     *
449
     * @return array
450
     */
451
    public function fetchData()
452
    {
453
        //Pagination!
454
        $this->applyPagination();
455
456
        //Generating statement
457
        $statement = $this->sqlStatement();
458
459
        if (!empty($this->cacheLifetime)) {
460
            $cacheKey = $this->cacheKey ?: md5(serialize([$statement, $this->getParameters()]));
461
462
            if (empty($this->cacheStore)) {
463
                $this->cacheStore = $this->orm->container()->get(CacheInterface::class)->store();
1 ignored issue
show
Bug introduced by
The method container() cannot be called from this context as it is declared protected in class Spiral\Core\Component.

This check looks for access to methods that are not accessible from the current context.

If you need to make a method accessible to another context you can raise its visibility level in the defining class.

Loading history...
464
            }
465
466
            if ($this->cacheStore->has($cacheKey)) {
467
                $this->logger()->debug("Selector result were fetched from cache.");
468
469
                //We are going to store parsed result, not queries
470
                return $this->cacheStore->get($cacheKey);
471
            }
472
        }
473
474
        //We are bypassing run() method here to prevent query caching, we will prefer to cache
475
        //parsed data rather that database response
476
        $result = $this->database->query($statement, $this->getParameters());
477
478
        //In many cases (too many inloads, too complex queries) parsing can take significant amount
479
        //of time, so we better profile it
480
        $benchmark = $this->benchmark('parseResult', $statement);
481
482
        //Here we are feeding selected data to our primary loaded to parse it and and create
483
        //data tree for our records
484
        $this->loader->parseResult($result, $rowsCount);
485
486
        $this->benchmark($benchmark);
487
488
        //Memory freeing
489
        $result->close();
490
491
        //This must force loader to execute all post loaders (including ODM and etc)
492
        $this->loader->loadData();
493
494
        //Now we can request our primary loader for compiled data
495
        $data = $this->loader->getResult();
496
497
        //Memory free! Attention, it will not reset columns aliases but only make possible to run
498
        //query again
499
        $this->loader->clean();
500
501
        if (!empty($this->cacheLifetime) && !empty($cacheKey)) {
502
            //We are caching full records tree, not queries
503
            $this->cacheStore->set($cacheKey, $data, $this->cacheLifetime);
504
        }
505
506
        return $data;
507
    }
508
509
    /**
510
     * Update all matched records with provided columns set. You are no allowed to use join
511
     * conditions or with() method, you can update your records manually in cases like that.
512
     *
513
     * @param array $update Array of columns to be updated, compatible with UpdateQuery.
514
     * @return int
515
     * @throws SelectorException
516
     */
517
    public function update(array $update)
518
    {
519
        if (!empty($this->havingTokens)) {
520
            throw new SelectorException(
521
                "Unable to build UPDATE statement using select, HAVING statement not supported."
522
            );
523
        }
524
525
        if (!empty($this->joinTokens)) {
526
            throw new SelectorException(
527
                "Unable to build UPDATE statement using select, JOINS statement not supported."
528
            );
529
        }
530
531
        $statement = $this->updateStatement($update);
532
533
        $normalized = [];
534 View Code Duplication
        foreach ($update as $value) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across 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...
535
            if ($value instanceof QueryBuilder) {
536
                foreach ($value->getParameters() as $parameter) {
0 ignored issues
show
Deprecated Code introduced by
The method Spiral\Database\Entities...uilder::getParameters() has been deprecated with message: scalar values

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
537
                    $normalized[] = $parameter;
538
                }
539
540
                continue;
541
            }
542
543
            if ($value instanceof FragmentInterface && !$value instanceof ParameterInterface) {
544
                continue;
545
            }
546
547
            $normalized[] = $value;
548
        }
549
550
        return $this->database->execute(
551
            $statement,
552
            $this->compiler->orderParameters(
553
                QueryCompiler::UPDATE_QUERY,
554
                $this->whereParameters,
555
                $this->onParameters,
556
                [],
557
                $normalized
558
            )
559
        );
560
    }
561
562
    /**
563
     * Delete all matched records and return count of affected rows. You are no allowed to use join
564
     * conditions or with() method, you can delete your records manually in cases like that.
565
     *
566
     * @return int
567
     * @throws SelectorException
568
     */
569
    public function delete()
570
    {
571
        if (!empty($this->havingTokens)) {
572
            throw new SelectorException(
573
                "Unable to build DELETE statement using select, HAVING statement not supported."
574
            );
575
        }
576
577
        if (!empty($this->joinTokens)) {
578
            throw new SelectorException(
579
                "Unable to build DELETE statement using select, JOINS statement not supported."
580
            );
581
        }
582
583
        return $this->database->execute(
584
            $this->deleteStatement(),
585
            $this->compiler->orderParameters(
586
                QueryCompiler::DELETE_QUERY,
587
                $this->whereParameters,
588
                $this->onParameters
589
            )
590
        );
591
    }
592
593
    /**
594
     * Create update statement based on WHERE statement and columns set provided by Selector.
595
     *
596
     * @param array         $columns
597
     * @param QueryCompiler $compiler
598
     * @return string
599
     */
600 View Code Duplication
    protected function updateStatement(array $columns, QueryCompiler $compiler = null)
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...
601
    {
602
        if (empty($compiler)) {
603
            $compiler = $this->compiler->resetQuoter();
604
        }
605
606
        $this->loader->configureSelector($this, false);
0 ignored issues
show
Unused Code introduced by
The call to Loader::configureSelector() has too many arguments starting with false.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
607
608
        return $compiler->compileUpdate(
609
            "{$this->primaryTable()} AS {$this->primaryAlias()}", $columns, $this->whereTokens
610
        );
611
    }
612
613
    /**
614
     * Create delete statement based on WHERE statement provided by Selector.
615
     *
616
     * @param QueryCompiler $compiler
617
     * @return string
618
     */
619 View Code Duplication
    protected function deleteStatement(QueryCompiler $compiler = null)
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...
620
    {
621
        if (empty($compiler)) {
622
            $compiler = $this->compiler->resetQuoter();
623
        }
624
625
        $this->loader->configureSelector($this, false);
0 ignored issues
show
Unused Code introduced by
The call to Loader::configureSelector() has too many arguments starting with false.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
626
627
        return $compiler->compileDelete(
628
            "{$this->primaryTable()} AS {$this->primaryAlias()}", $this->whereTokens
629
        );
630
    }
631
}