Completed
Branch feature/pre-split (211a78)
by Anton
07:24 queued 03:41
created

RecordSelector::sqlStatement()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 30
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 17
nc 6
nop 1
dl 0
loc 30
rs 8.5806
c 0
b 0
f 0
1
<?php
2
/**
3
 * Spiral Framework.
4
 *
5
 * @license   MIT
6
 * @author    Anton Titov (Wolfy-J)
7
 */
8
9
namespace Spiral\ORM\Entities;
10
11
use Psr\Log\LoggerAwareInterface;
12
use Spiral\Cache\CacheInterface;
13
use Spiral\Core\Traits\SaturateTrait;
14
use Spiral\Database\Builders\Prototypes\AbstractSelect;
15
use Spiral\Database\Entities\QueryCompiler;
16
use Spiral\Database\Query\PDOQuery;
17
use Spiral\Debug\Traits\BenchmarkTrait;
18
use Spiral\Debug\Traits\LoggerTrait;
19
use Spiral\ORM\Entities\Loaders\RootLoader;
20
use Spiral\ORM\Exceptions\SelectorException;
21
use Spiral\ORM\ORM;
22
use Spiral\ORM\RecordEntity;
23
use Spiral\ORM\RecordInterface;
24
25
/**
26
 * Selectors provide QueryBuilder (see Database) like syntax and support for ORM records to be
27
 * fetched from database. In addition, selection uses set of internal data loaders dedicated to
28
 * every of record relation and used to pre-load (joins) or post-load (separate query) data for
29
 * this relations, including additional where conditions and using relation data for parent record
30
 * filtering queries.
31
 *
32
 * Selector loaders may not only be related to SQL databases, but might load data from external
33
 * sources.
34
 *
35
 * @see  with()
36
 * @see  load()
37
 * @see  LoaderInterface
38
 * @see  AbstractSelect
39
 *
40
 * @todo update count
41
 * @todo update paginator usage
42
 */
43
class RecordSelector extends AbstractSelect implements LoggerAwareInterface
44
{
45
    use LoggerTrait, BenchmarkTrait, SaturateTrait;
46
47
    const DEFAULT_COUNTING_FIELD = '*';
48
49
    /**
50
     * Class name of record to be loaded.
51
     *
52
     * @var string
53
     */
54
    protected $class = '';
55
56
    /**
57
     * Data columns are set of columns automatically created by inner loaders using
58
     * generateColumns() method, this is not the same column set as one provided by user using
59
     * columns() method. Do not define columns using generateColumns() method outside of loaders.
60
     *
61
     * @see addColumns()
62
     *
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
     *
84
     * @var ORM
85
     */
86
    protected $orm = null;
87
88
    /**
89
     * @param string $class
90
     * @param ORM    $orm
91
     * @param Loader $loader
92
     */
93
    public function __construct($class, ORM $orm = null, Loader $loader = null)
94
    {
95
        $this->class = $class;
96
        $this->orm = $this->saturate($orm, ORM::class);
97
98
        $this->columns = $this->dataColumns = [];
99
100
        //We aways need primary loader
101
        if (empty($this->loader = $loader)) {
102
            //Selector always need primary data loaded to define data structure and perform query
103
            //parsing, in most of cases we can easily use RootLoader associated with primary record
104
            //schema
105
            $this->loader = new RootLoader(null, $this->orm->schema($class), $this->orm);
0 ignored issues
show
Documentation introduced by
null is of type null, but the function expects a object<Spiral\ORM\ORM>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
106
        }
107
108
        //Every ORM loader has ability to declare it's primary database, we are going to use
109
        //primary loader database to initiate selector
110
        $database = $this->loader->dbalDatabase();
111
112
        //AbstractSelect construction
113
        parent::__construct(
114
            $database,
115
            $database->driver()->queryCompiler($database->getPrefix())
116
        );
117
    }
118
119
    /**
120
     * Primary selection table.
121
     *
122
     * @return string
123
     */
124
    public function primaryTable()
125
    {
126
        return $this->loader->getTable();
127
    }
128
129
    /**
130
     * Primary alias points to table related to parent record.
131
     *
132
     * @return string
133
     */
134
    public function primaryAlias()
135
    {
136
        return $this->loader->getAlias();
137
    }
138
139
    /**
140
     * {@inheritdoc}
141
     */
142
    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...
143
    {
144
        $this->columns = $this->fetchIdentifiers(func_get_args());
145
146
        return $this;
147
    }
148
149
    /**
150
     * Automatically generate set of columns for specified table or alias, method used by loaders
151
     * in cases where data is joined.
152
     *
153
     * @param string $table   Source table name or alias.
154
     * @param array  $columns Original set of record columns.
155
     *
156
     * @return int
157
     */
158
    public function registerColumns($table, array $columns)
159
    {
160
        $offset = count($this->dataColumns);
161
162
        foreach ($columns as $column) {
163
            $columnAlias = 'c' . (++$this->countColumns);
164
            $this->dataColumns[] = $table . '.' . $column . ' AS ' . $columnAlias;
165
        }
166
167
        return $offset;
168
    }
169
170
    /**
171
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
172
     * for
173
     * data preloading. ORM loaders by default will select the most efficient way to load related
174
     * data which might include additional select query or left join. Loaded data will
175
     * automatically pre-populate record relations. You can specify nested relations using "."
176
     * separator.
177
     *
178
     * Examples:
179
     *
180
     * //Select users and load their comments (will cast 2 queries, HAS_MANY comments)
181
     * User::find()->with('comments');
182
     *
183
     * //You can load chain of relations - select user and load their comments and post related to
184
     * //comment
185
     * User::find()->with('comments.post');
186
     *
187
     * //We can also specify custom where conditions on data loading, let's load only public
188
     * comments. User::find()->load('comments', [
189
     *      'where' => ['{@}.status' => 'public']
190
     * ]);
191
     *
192
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
193
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
194
     *
195
     * //In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
196
     * conditions,
197
     * //let's pre-load all approved user tags, we can use same placeholder for pivot table alias
198
     * User::find()->load('tags', [
199
     *      'wherePivot' => ['{@}.approved' => true]
200
     * ]);
201
     *
202
     * //In most of cases you don't need to worry about how data was loaded, using external query
203
     * or
204
     * //left join, however if you want to change such behaviour you can force load method to
205
     * INLOAD
206
     * User::find()->load('tags', [
207
     *      'method'     => Loader::INLOAD,
208
     *      'wherePivot' => ['{@}.approved' => true]
209
     * ]);
210
     *
211
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
212
     * support different loading types.
213
     *
214
     * You can specify multiple loaders using array as first argument.
215
     *
216
     * Example:
217
     * User::find()->load(['posts', 'comments', 'profile']);
218
     *
219
     * @see with()
220
     *
221
     * @param string $relation
222
     * @param array  $options
223
     *
224
     * @return $this
225
     */
226 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...
227
    {
228
        if (is_array($relation)) {
229
            foreach ($relation as $name => $subOption) {
230
                if (is_string($subOption)) {
231
                    //Array of relation names
232
                    $this->load($subOption, $options);
233
                } else {
234
                    //Multiple relations or relation with addition load options
235
                    $this->load($name, $subOption + $options);
236
                }
237
            }
238
239
            return $this;
240
        }
241
242
        //We are requesting primary loaded to pre-load nested relation
243
        $this->loader->loader($relation, $options);
244
245
        return $this;
246
    }
247
248
    /**
249
     * With method is very similar to load() one, except it will always include related data to
250
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
251
     * using same database as parent record.
252
     *
253
     * Method generally used to filter data based on some relation condition.
254
     * Attention, with() method WILL NOT load relation data, it will only make it accessible in
255
     * query.
256
     *
257
     * By default joined tables will be available in query based on realtion name, you can change
258
     * joined table alias using relation option "alias".
259
     *
260
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
261
     * other scenario you will not able to paginate data well.
262
     *
263
     * Examples:
264
     *
265
     * //Find all users who have comments comments
266
     * User::find()->with('comments');
267
     *
268
     * //Find all users who have approved comments (we can use comments table alias in where
269
     * statement).
270
     * User::find()->with('comments')->where('comments.approved', true);
271
     *
272
     * //Find all users who have posts which have approved comments
273
     * User::find()->with('posts.comments')->where('posts_comments.approved', true);
274
     *
275
     * //Custom join alias for post comments relation
276
     * $user->with('posts.comments', [
277
     *      'alias' => 'comments'
278
     * ])->where('comments.approved', true);
279
     *
280
     * //If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation
281
     * name
282
     * //plus "_pivot" postfix. Let's load all users with approved tags.
283
     * $user->with('tags')->where('tags_pivot.approved', true);
284
     *
285
     * //You can also use custom alias for pivot table as well
286
     * User::find()->with('tags', [
287
     *      'pivotAlias' => 'tags_connection'
288
     * ])
289
     * ->where('tags_connection.approved', false);
290
     *
291
     * You can safely combine with() and load() methods.
292
     *
293
     * //Load all users with approved comments and pre-load all their comments
294
     * User::find()->with('comments')->where('comments.approved', true)
295
     *             ->load('comments');
296
     *
297
     * //You can also use custom conditions in this case, let's find all users with approved
298
     * comments
299
     * //and pre-load such approved comments
300
     * User::find()->with('comments')->where('comments.approved', true)
301
     *             ->load('comments', [
302
     *                  'where' => ['{@}.approved' => true]
303
     *              ]);
304
     *
305
     * //As you might notice previous construction will create 2 queries, however we can simplify
306
     * //this construction to use already joined table as source of data for relation via "using"
307
     * //keyword
308
     * User::find()->with('comments')->where('comments.approved', true)
309
     *             ->load('comments', ['using' => 'comments']);
310
     *
311
     * //You will get only one query with INNER JOIN, to better understand this example let's use
312
     * //custom alias for comments in with() method.
313
     * User::find()->with('comments', ['alias' => 'commentsR'])->where('commentsR.approved', true)
314
     *             ->load('comments', ['using' => 'commentsR']);
315
     *
316
     * @see load()
317
     *
318
     * @param string $relation
319
     * @param array  $options
320
     *
321
     * @return $this
322
     */
323 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...
324
    {
325
        if (is_array($relation)) {
326
            foreach ($relation as $name => $options) {
327
                if (is_string($options)) {
328
                    //Array of relation names
329
                    $this->with($options, []);
330
                } else {
331
                    //Multiple relations or relation with addition load options
332
                    $this->with($name, $options);
333
                }
334
            }
335
336
            return $this;
337
        }
338
339
        //Requesting primary loader to join nested relation, will only work for ORM loaders
340
        $this->loader->joiner($relation, $options);
341
342
        return $this;
343
    }
344
345
    /**
346
     * Fetch one record from database using it's primary key. You can use INLOAD and JOIN_ONLY
347
     * loaders with HAS_MANY or MANY_TO_MANY relations with this method as no limit were used.
348
     *
349
     * @see findOne()
350
     *
351
     * @param mixed $id Primary key value.
352
     *
353
     * @return RecordEntity|null
354
     *
355
     * @throws SelectorException
356
     */
357
    public function findByPK($id)
358
    {
359
        $primaryKey = $this->loader->getPrimaryKey();
360
361
        if (empty($primaryKey)) {
362
            throw new SelectorException(
363
                'Unable to fetch data by primary key, no primary key found'
364
            );
365
        }
366
367
        //No limit here
368
        return $this->findOne([$primaryKey => $id], false);
369
    }
370
371
    /**
372
     * Fetch one record from database. Attention, LIMIT statement will be used, meaning you can not
373
     * use loaders for HAS_MANY or MANY_TO_MANY relations with data inload (joins), use default
374
     * loading method.
375
     *
376
     * @see findByPK()
377
     *
378
     * @param array $where     Selection WHERE statement.
379
     * @param bool  $withLimit Use limit 1.
380
     *
381
     * @return RecordEntity|null
382
     */
383
    public function findOne(array $where = [], $withLimit = true)
384
    {
385
        if (!empty($where)) {
386
            $this->where($where);
387
        }
388
389
        $data = $this->limit($withLimit ? 1 : null)->fetchData();
390
        if (empty($data)) {
391
            return;
392
        }
393
394
        //Letting ORM to do it's job
395
        return $this->orm->record($this->class, $data[0]);
396
    }
397
398
    /**
399
     * {@inheritdoc}
400
     */
401
    public function sqlStatement(QueryCompiler $compiler = null)
402
    {
403
        if (empty($compiler)) {
404
            $compiler = $this->compiler->resetQuoter();
405
        }
406
407
        //Primary loader may add custom conditions to select query
408
        $this->loader->configureSelector($this);
409
410
        if (empty($columns = $this->columns)) {
411
            //If no user columns were specified we are going to use columns defined by our loaders
412
            //in addition it will return RecordIterator instance as result instead of QueryResult
413
            $columns = !empty($this->dataColumns) ? $this->dataColumns : ['*'];
414
        }
415
416
        //TODO: paginator
417
418
        return $compiler->compileSelect(
419
            ["{$this->primaryTable()} AS {$this->primaryAlias()}"],
420
            $this->distinct,
421
            $columns,
422
            $this->joinTokens,
423
            $this->whereTokens,
424
            $this->havingTokens,
425
            $this->grouping,
426
            $this->ordering,
427
            $this->getLimit(),
428
            $this->getOffset()
429
        );
430
    }
431
432
    /**
433
     * {@inheritdoc}
434
     *
435
     * Return type will depend if custom columns set were used.
436
     *
437
     * @return PDOQuery|RecordIterator
438
     */
439
    public function getIterator()
440
    {
441
        if (!empty($this->columns) || !empty($this->grouping)) {
442
            //QueryResult for user requests
443
            return $this->run();
444
        }
445
446
        /*
447
         * We are getting copy of ORM with cloned cache, so all our entities are isolated in it.
448
         */
449
450
        return new RecordIterator($this->fetchData(), $this->class, clone $this->orm);
451
    }
452
453
    /**
454
     * All records.
455
     *
456
     * @return RecordInterface[]
457
     */
458
    public function all()
459
    {
460
        return $this->getIterator()->all();
0 ignored issues
show
Bug introduced by
The method all does only exist in Spiral\ORM\Entities\RecordIterator, but not in PDOStatement and Spiral\Database\ResultInterface.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
461
    }
462
463
    /**
464
     * Execute query and every related query to compile records data in tree form - every relation
465
     * data will be included as sub key.
466
     *
467
     * Attention, Selector will cache compiled data tree and not query itself to keep data integrity
468
     * and to skip data compilation on second query.
469
     *
470
     * @return array
471
     */
472
    public function fetchData()
473
    {
474
        //Pagination!
475
        $this->applyPagination();
0 ignored issues
show
Documentation Bug introduced by
The method applyPagination does not exist on object<Spiral\ORM\Entities\RecordSelector>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
476
477
        //Generating statement
478
        $statement = $this->sqlStatement();
479
480
        if (!empty($this->cacheLifetime)) {
481
            $cacheKey = $this->cacheKey ?: md5(serialize([$statement, $this->getParameters()]));
482
483
            if (empty($this->cacheStore)) {
484
                $this->cacheStore = $this->orm->container()->get(CacheInterface::class)->store();
485
            }
486
487
            if ($this->cacheStore->has($cacheKey)) {
488
                $this->logger()->debug('Selector result were fetched from cache.');
489
490
                //We are going to store parsed result, not queries
491
                return $this->cacheStore->get($cacheKey);
492
            }
493
        }
494
495
        //We are bypassing run() method here to prevent query caching, we will prefer to cache
496
        //parsed data rather that database response
497
        $result = $this->database->query($statement, $this->getParameters());
498
499
        //In many cases (too many inloads, too complex queries) parsing can take significant amount
500
        //of time, so we better profile it
501
        $benchmark = $this->benchmark('parseResult', $statement);
502
503
        //Here we are feeding selected data to our primary loaded to parse it and and create
504
        //data tree for our records
505
        $this->loader->parseResult($result, $rowsCount);
0 ignored issues
show
Documentation introduced by
$result is of type object<Spiral\Database\R...e>|object<PDOStatement>, but the function expects a object<Spiral\Database\Query\PDOQuery>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
506
507
        $this->benchmark($benchmark);
508
509
        //Memory freeing
510
        $result->close();
0 ignored issues
show
Bug introduced by
The method close does only exist in Spiral\Database\ResultInterface, but not in PDOStatement.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
511
512
        //This must force loader to execute all post loaders (including ODM and etc)
513
        $this->loader->loadData();
514
515
        //Now we can request our primary loader for compiled data
516
        $data = $this->loader->getResult();
517
518
        //Memory free! Attention, it will not reset columns aliases but only make possible to run
519
        //query again
520
        $this->loader->clean();
521
522
        if (!empty($this->cacheLifetime) && !empty($cacheKey)) {
523
            //We are caching full records tree, not queries
524
            $this->cacheStore->set($cacheKey, $data, $this->cacheLifetime);
525
        }
526
527
        return $data;
528
    }
529
530
    /**
531
     * {@inheritdoc}
532
     */
533
    public function count($column = self::DEFAULT_COUNTING_FIELD)
534
    {
535
        if ($column == self::DEFAULT_COUNTING_FIELD && !empty($this->loader->getPrimaryKey())) {
536
            $column = 'DISTINCT(' . $this->loader->getPrimaryKey() . ')';
537
        }
538
539
        return parent::count($column);
540
    }
541
542
    /**
543
     * Cloning selector presets.
544
     */
545
    public function __clone()
546
    {
547
        $this->loader = clone $this->loader;
548
    }
549
}
550