Passed
Pull Request — master (#185)
by
unknown
02:16
created

Select::scope()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 5
rs 10
1
<?php
2
3
/**
4
 * Cycle DataMapper ORM
5
 *
6
 * @license   MIT
7
 * @author    Anton Titov (Wolfy-J)
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\ORM;
13
14
use Countable;
15
use Cycle\ORM\Heap\Node;
16
use Cycle\ORM\Select\ConstrainInterface;
17
use Cycle\ORM\Select\JoinableLoader;
18
use Cycle\ORM\Select\QueryBuilder;
19
use Cycle\ORM\Select\RootLoader;
20
use Cycle\ORM\Select\ScopeInterface;
21
use IteratorAggregate;
22
use Spiral\Database\Query\SelectQuery;
23
use Spiral\Pagination\PaginableInterface;
24
25
/**
26
 * Query builder and entity selector. Mocks SelectQuery. Attention, Selector does not mount RootLoader scope by default.
27
 *
28
 * Trait provides the ability to transparently configure underlying loader query.
29
 *
30
 * @method Select distinct()
31
 * @method Select where(...$args);
32
 * @method Select andWhere(...$args);
33
 * @method Select orWhere(...$args);
34
 * @method Select having(...$args);
35
 * @method Select andHaving(...$args);
36
 * @method Select orHaving(...$args);
37
 * @method Select orderBy($expression, $direction = 'ASC');
38
 *
39
 * @method mixed avg($identifier) Perform aggregation (AVG) based on column or expression value.
40
 * @method mixed min($identifier) Perform aggregation (MIN) based on column or expression value.
41
 * @method mixed max($identifier) Perform aggregation (MAX) based on column or expression value.
42
 * @method mixed sum($identifier) Perform aggregation (SUM) based on column or expression value.
43
 */
44
final class Select implements IteratorAggregate, Countable, PaginableInterface
45
{
46
    // load relation data within same query
47
    public const SINGLE_QUERY = JoinableLoader::INLOAD;
48
49
    // load related data after the query
50
    public const OUTER_QUERY = JoinableLoader::POSTLOAD;
51
52
    /** @var ORMInterface @internal */
53
    private $orm;
54
55
    /** @var RootLoader */
56
    private $loader;
57
58
    /** @var QueryBuilder */
59
    private $builder;
60
61
    /**
62
     * @param ORMInterface $orm
63
     * @param string       $role
64
     */
65
    public function __construct(ORMInterface $orm, string $role)
66
    {
67
        $this->orm = $orm;
68
        $this->loader = new RootLoader($orm, $this->orm->resolveRole($role));
69
        $this->builder = new QueryBuilder($this->getLoader()->getQuery(), $this->loader);
70
    }
71
72
    /**
73
     * Remove nested loaders and clean ORM link.
74
     */
75
    public function __destruct()
76
    {
77
        $this->orm = null;
78
        $this->loader = null;
79
        $this->builder = null;
80
    }
81
82
    /**
83
     * Bypassing call to primary select query.
84
     *
85
     * @param string $name
86
     * @param array  $arguments
87
     * @return Select|mixed
88
     */
89
    public function __call(string $name, array $arguments)
90
    {
91
        if (in_array(strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM', 'COUNT'])) {
92
            // aggregations
93
            return $this->builder->withQuery(
94
                $this->loader->buildQuery()
95
            )->__call($name, $arguments);
96
        }
97
98
        $result = $this->builder->__call($name, $arguments);
99
        if ($result instanceof QueryBuilder) {
100
            return $this;
101
        }
102
103
        return $result;
104
    }
105
106
    /**
107
     * Cloning with loader tree cloning.
108
     *
109
     * @attention at this moment binded query parameters would't be cloned!
110
     */
111
    public function __clone()
112
    {
113
        $this->loader = clone $this->loader;
114
        $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader);
115
    }
116
117
    /**
118
     * Create new Selector with applied scope. By default no scope used.
119
     *
120
     * @param ScopeInterface|null $scope
121
     * @return Select
122
     */
123
    public function scope(?ScopeInterface $scope): self
124
    {
125
        $this->loader->setScope($scope);
126
127
        return $this;
128
    }
129
130
    /**
131
     * @deprecated Will be dropped in next major release. Use {@see scope()} instead.
132
     * @param ConstrainInterface|null $constrain
133
     * @return Select
134
     */
135
    public function constrain(?ConstrainInterface $constrain = null): self
136
    {
137
        return $this->scope($constrain);
138
    }
139
140
    /**
141
     * Get Query proxy.
142
     *
143
     * @return QueryBuilder
144
     */
145
    public function getBuilder(): QueryBuilder
146
    {
147
        return $this->builder;
148
    }
149
150
    /**
151
     * Compiled SQL query, changes in this query would not affect Selector state (but binded parameters will).
152
     *
153
     * @return SelectQuery
154
     */
155
    public function buildQuery(): SelectQuery
156
    {
157
        return $this->loader->buildQuery();
158
    }
159
160
    /**
161
     * Shortcut to where method to set AND condition for entity primary key.
162
     *
163
     * @param string|int $id
164
     * @return $this|Select
165
     */
166
    public function wherePK($id): self
167
    {
168
        return $this->__call('where', [$this->loader->getPK(), $id]);
169
    }
170
171
    /**
172
     * Attention, column will be quoted by driver!
173
     *
174
     * @param string|null $column When column is null DISTINCT(PK) will be generated.
175
     * @return int
176
     */
177
    public function count(string $column = null): int
178
    {
179
        if ($column === null) {
180
            // @tuneyourserver solves the issue with counting on queries with joins.
181
            $column = sprintf('DISTINCT(%s)', $this->loader->getPK());
182
        }
183
184
        return (int) $this->__call('count', [$column]);
185
    }
186
187
    /**
188
     * @inheritdoc
189
     */
190
    public function limit(int $limit): self
191
    {
192
        $this->loader->getQuery()->limit($limit);
193
194
        return $this;
195
    }
196
197
    /**
198
     * @inheritdoc
199
     */
200
    public function offset(int $offset): self
201
    {
202
        $this->loader->getQuery()->offset($offset);
203
204
        return $this;
205
    }
206
207
    /**
208
     * Request primary selector loader to pre-load relation name. Any type of loader can be used
209
     * for data pre-loading. ORM loaders by default will select the most efficient way to load
210
     * related data which might include additional select query or left join. Loaded data will
211
     * automatically pre-populate record relations. You can specify nested relations using "."
212
     * separator.
213
     *
214
     * Examples:
215
     *
216
     * // Select users and load their comments (will cast 2 queries, HAS_MANY comments)
217
     * User::find()->with('comments');
218
     *
219
     * // You can load chain of relations - select user and load their comments and post related to
220
     * //comment
221
     * User::find()->with('comments.post');
222
     *
223
     * // We can also specify custom where conditions on data loading, let's load only public
224
     * // comments.
225
     * User::find()->load('comments', [
226
     *      'where' => ['{@}.status' => 'public']
227
     * ]);
228
     *
229
     * Please note using "{@}" column name, this placeholder is required to prevent collisions and
230
     * it will be automatically replaced with valid table alias of pre-loaded comments table.
231
     *
232
     * // In case where your loaded relation is MANY_TO_MANY you can also specify pivot table
233
     * // conditions, let's pre-load all approved user tags, we can use same placeholder for pivot
234
     * // table alias
235
     * User::find()->load('tags', [
236
     *      'wherePivot' => ['{@}.approved' => true]
237
     * ]);
238
     *
239
     * // In most of cases you don't need to worry about how data was loaded, using external query
240
     * // or left join, however if you want to change such behaviour you can force load method to
241
     * // INLOAD
242
     * User::find()->load('tags', [
243
     *      'method'     => Loader::INLOAD,
244
     *      'wherePivot' => ['{@}.approved' => true]
245
     * ]);
246
     *
247
     * Attention, you will not be able to correctly paginate in this case and only ORM loaders
248
     * support different loading types.
249
     *
250
     * You can specify multiple loaders using array as first argument.
251
     *
252
     * Example:
253
     * User::find()->load(['posts', 'comments', 'profile']);
254
     *
255
     * Attention, consider disabling entity map if you want to use recursive loading (i.e
256
     * post.tags.posts), but first think why you even need recursive relation loading.
257
     *
258
     * @param string|array $relation
259
     * @param array        $options
260
     * @return $this|self
261
     * @see with()
262
     */
263
    public function load($relation, array $options = []): self
264
    {
265
        if (is_string($relation)) {
266
            $this->loader->loadRelation($relation, $options, false, true);
267
268
            return $this;
269
        }
270
271
        foreach ($relation as $name => $subOption) {
272
            if (is_string($subOption)) {
273
                // array of relation names
274
                $this->load($subOption, $options);
275
            } else {
276
                // multiple relations or relation with addition load options
277
                $this->load($name, $subOption + $options);
278
            }
279
        }
280
281
        return $this;
282
    }
283
284
    /**
285
     * With method is very similar to load() one, except it will always include related data to
286
     * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations
287
     * using same database as parent record.
288
     *
289
     * Method generally used to filter data based on some relation condition. Attention, with()
290
     * method WILL NOT load relation data, it will only make it accessible in query.
291
     *
292
     * By default joined tables will be available in query based on relation name, you can change
293
     * joined table alias using relation option "alias".
294
     *
295
     * Do not forget to set DISTINCT flag while including HAS_MANY and MANY_TO_MANY relations. In
296
     * other scenario you will not able to paginate data well.
297
     *
298
     * Examples:
299
     *
300
     * // Find all users who have comments comments
301
     * User::find()->with('comments');
302
     *
303
     * // Find all users who have approved comments (we can use comments table alias in where
304
     * statement). User::find()->with('comments')->where('comments.approved', true);
305
     *
306
     * // Find all users who have posts which have approved comments
307
     * User::find()->with('posts.comments')->where('posts_comments.approved', true);
308
     *
309
     * // Custom join alias for post comments relation
310
     * $user->with('posts.comments', [
311
     *      'as' => 'comments'
312
     * ])->where('comments.approved', true);
313
     *
314
     * // If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation
315
     * // name plus "_pivot" postfix. Let's load all users with approved tags.
316
     * $user->with('tags')->where('tags_pivot.approved', true);
317
     *
318
     * // You can also use custom alias for pivot table as well
319
     * User::find()->with('tags', [
320
     *      'pivotAlias' => 'tags_connection'
321
     * ])
322
     * ->where('tags_connection.approved', false);
323
     *
324
     * You can safely combine with() and load() methods.
325
     *
326
     * // Load all users with approved comments and pre-load all their comments
327
     * User::find()->with('comments')->where('comments.approved', true)->load('comments');
328
     *
329
     * // You can also use custom conditions in this case, let's find all users with approved
330
     * // comments and pre-load such approved comments
331
     * User::find()->with('comments')->where('comments.approved', true)
332
     *             ->load('comments', [
333
     *                  'where' => ['{@}.approved' => true]
334
     *              ]);
335
     *
336
     * // As you might notice previous construction will create 2 queries, however we can simplify
337
     * // this construction to use already joined table as source of data for relation via "using"
338
     * // keyword
339
     * User::find()->with('comments')
340
     *             ->where('comments.approved', true)
341
     *             ->load('comments', ['using' => 'comments']);
342
     *
343
     * // You will get only one query with INNER JOIN, to better understand this example let's use
344
     * // custom alias for comments in with() method.
345
     * User::find()->with('comments', ['as' => 'commentsR'])
346
     *             ->where('commentsR.approved', true)
347
     *             ->load('comments', ['using' => 'commentsR']);
348
     *
349
     * @param string|array $relation
350
     * @param array        $options
351
     * @return $this|Select
352
     * @see load()
353
     */
354
    public function with($relation, array $options = []): self
355
    {
356
        if (is_string($relation)) {
357
            $this->loader->loadRelation($relation, $options, true, false);
358
359
            return $this;
360
        }
361
362
        foreach ($relation as $name => $subOption) {
363
            if (is_string($subOption)) {
364
                //Array of relation names
365
                $this->with($subOption, []);
366
            } else {
367
                //Multiple relations or relation with addition load options
368
                $this->with($name, $subOption);
369
            }
370
        }
371
372
        return $this;
373
    }
374
375
    /**
376
     * Find one entity or return null. Method provides the ability to configure custom query
377
     * parameters. Attention, method does not set a limit on selection (to avoid underselection of
378
     * joined tables), make sure to set the constrain in the query.
379
     *
380
     * @param array|null $query
381
     * @return object|null
382
     */
383
    public function fetchOne(array $query = null)
384
    {
385
        $data = (clone $this)->where($query)->limit(1)->fetchData();
386
387
        if (!isset($data[0])) {
388
            return null;
389
        }
390
391
        return $this->orm->make(
392
            $this->loader->getTarget(),
393
            $data[0],
394
            Node::MANAGED
395
        );
396
    }
397
398
    /**
399
     * Fetch all records in a form of array.
400
     *
401
     * @return object[]
402
     */
403
    public function fetchAll(): array
404
    {
405
        return iterator_to_array($this->getIterator());
406
    }
407
408
    /**
409
     * @return Iterator
410
     */
411
    public function getIterator(): Iterator
412
    {
413
        return new Iterator(
414
            $this->orm,
415
            $this->loader->getTarget(),
416
            $this->fetchData()
417
        );
418
    }
419
420
    /**
421
     * Load data tree from database and linked loaders in a form of array.
422
     *
423
     * @return array
424
     */
425
    public function fetchData(): array
426
    {
427
        $node = $this->loader->createNode();
428
        $this->loader->loadData($node);
429
430
        return $node->getResult();
431
    }
432
433
    /**
434
     * Compiled SQL statement.
435
     *
436
     * @return string
437
     */
438
    public function sqlStatement(): string
439
    {
440
        return $this->buildQuery()->sqlStatement();
441
    }
442
443
    /**
444
     * Return base loader associated with the selector.
445
     *
446
     * @return RootLoader
447
     */
448
    private function getLoader(): RootLoader
449
    {
450
        return $this->loader;
451
    }
452
}
453