1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Cycle\ORM; |
||
6 | |||
7 | use Cycle\Database\Injection\Parameter; |
||
8 | use Cycle\Database\Query\SelectQuery; |
||
9 | use Cycle\ORM\Heap\Node; |
||
10 | use Cycle\ORM\Service\EntityFactoryInterface; |
||
11 | use Cycle\ORM\Service\MapperProviderInterface; |
||
12 | use Cycle\ORM\Service\SourceProviderInterface; |
||
13 | use Cycle\ORM\Select\JoinableLoader; |
||
14 | use Cycle\ORM\Select\QueryBuilder; |
||
15 | use Cycle\ORM\Select\RootLoader; |
||
16 | use Cycle\ORM\Select\ScopeInterface; |
||
17 | use Spiral\Pagination\PaginableInterface; |
||
18 | |||
19 | /** |
||
20 | * Query builder and entity selector. Mocks SelectQuery. Attention, Selector does not mount RootLoader scope by default. |
||
21 | * |
||
22 | * Trait provides the ability to transparently configure underlying loader query. |
||
23 | * |
||
24 | * @method $this distinct() |
||
25 | * @method $this where(...$args) |
||
26 | * @method $this andWhere(...$args); |
||
27 | * @method $this orWhere(...$args); |
||
28 | * @method $this having(...$args); |
||
29 | * @method $this andHaving(...$args); |
||
30 | * @method $this orHaving(...$args); |
||
31 | * @method $this orderBy($expression, $direction = 'ASC'); |
||
32 | * @method $this forUpdate() |
||
33 | * @method $this whereJson(string $path, mixed $value) |
||
34 | * @method $this orWhereJson(string $path, mixed $value) |
||
35 | * @method $this whereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true) |
||
36 | * @method $this orWhereJsonContains(string $path, mixed $value, bool $encode = true, bool $validate = true) |
||
37 | * @method $this whereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true) |
||
38 | * @method $this orWhereJsonDoesntContain(string $path, mixed $value, bool $encode = true, bool $validate = true) |
||
39 | * @method $this whereJsonContainsKey(string $path) |
||
40 | * @method $this orWhereJsonContainsKey(string $path) |
||
41 | * @method $this whereJsonDoesntContainKey(string $path) |
||
42 | * @method $this orWhereJsonDoesntContainKey(string $path) |
||
43 | * @method $this whereJsonLength(string $path, int $length, string $operator = '=') |
||
44 | * @method $this orWhereJsonLength(string $path, int $length, string $operator = '=') |
||
45 | * @method mixed avg($identifier) Perform aggregation (AVG) based on column or expression value. |
||
46 | * @method mixed min($identifier) Perform aggregation (MIN) based on column or expression value. |
||
47 | * @method mixed max($identifier) Perform aggregation (MAX) based on column or expression value. |
||
48 | * @method mixed sum($identifier) Perform aggregation (SUM) based on column or expression value. |
||
49 | * |
||
50 | * @template-covariant TEntity of object |
||
51 | */ |
||
52 | class Select implements \IteratorAggregate, \Countable, PaginableInterface |
||
53 | { |
||
54 | // load relation data within same query |
||
55 | public const SINGLE_QUERY = JoinableLoader::INLOAD; |
||
56 | |||
57 | // load related data after the query |
||
58 | public const OUTER_QUERY = JoinableLoader::POSTLOAD; |
||
59 | |||
60 | private RootLoader $loader; |
||
61 | private QueryBuilder $builder; |
||
62 | 6834 | private MapperProviderInterface $mapperProvider; |
|
63 | private Heap\HeapInterface $heap; |
||
64 | private SchemaInterface $schema; |
||
65 | private EntityFactoryInterface $entityFactory; |
||
66 | 6834 | ||
67 | 6834 | /** |
|
68 | 6834 | * @param class-string<TEntity>|string $role |
|
0 ignored issues
–
show
Documentation
Bug
introduced
by
![]() |
|||
69 | 6834 | */ |
|
70 | 6834 | public function __construct( |
|
71 | 6834 | ORMInterface $orm, |
|
72 | 6834 | string $role, |
|
73 | 6834 | ) { |
|
74 | 6834 | $this->heap = $orm->getHeap(); |
|
75 | $this->schema = $orm->getSchema(); |
||
76 | 6834 | $this->mapperProvider = $orm->getService(MapperProviderInterface::class); |
|
77 | $this->entityFactory = $orm->getService(EntityFactoryInterface::class); |
||
78 | $this->loader = new RootLoader( |
||
79 | $orm->getSchema(), |
||
80 | $orm->getService(SourceProviderInterface::class), |
||
81 | $orm->getFactory(), |
||
82 | 6834 | $orm->resolveRole($role), |
|
83 | ); |
||
84 | 6834 | $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader); |
|
85 | } |
||
86 | |||
87 | /** |
||
88 | * Create new Selector with applied scope. By default no scope used. |
||
89 | * |
||
90 | 5932 | * @return static<TEntity> |
|
91 | */ |
||
92 | 5932 | public function scope(?ScopeInterface $scope = null): self |
|
93 | { |
||
94 | 72 | $this->loader->setScope($scope); |
|
95 | 72 | ||
96 | 72 | return $this; |
|
97 | } |
||
98 | |||
99 | 5916 | /** |
|
100 | 5916 | * Get Query proxy. |
|
101 | 5916 | */ |
|
102 | public function getBuilder(): QueryBuilder |
||
103 | { |
||
104 | return $this->builder; |
||
105 | } |
||
106 | |||
107 | /** |
||
108 | * Compiled SQL query, changes in this query would not affect Selector state (but bound parameters will). |
||
109 | */ |
||
110 | public function buildQuery(): SelectQuery |
||
111 | { |
||
112 | 3232 | return $this->loader->buildQuery(); |
|
113 | } |
||
114 | 3232 | ||
115 | 3232 | /** |
|
116 | * Shortcut to where method to set AND condition for entity primary key. |
||
117 | * |
||
118 | * @psalm-param string|int|list<string|int>|object ...$ids |
||
119 | * |
||
120 | * @return static<TEntity> |
||
121 | */ |
||
122 | public function wherePK(string|int|array|object ...$ids): self |
||
123 | 3440 | { |
|
124 | $pk = $this->loader->getPrimaryFields(); |
||
125 | 3440 | ||
126 | if (\count($pk) > 1) { |
||
127 | 3440 | return $this->buildCompositePKQuery($pk, $ids); |
|
128 | } |
||
129 | $pk = \current($pk); |
||
130 | |||
131 | return \count($ids) > 1 |
||
132 | ? $this->__call('where', [$pk, new Parameter($ids)]) |
||
133 | 8 | : $this->__call('where', [$pk, \current($ids)]); |
|
134 | } |
||
135 | 8 | ||
136 | /** |
||
137 | * Attention, column will be quoted by driver! |
||
138 | * |
||
139 | * @param non-empty-string|null $column When column is null DISTINCT(PK) will be generated. |
||
0 ignored issues
–
show
|
|||
140 | */ |
||
141 | 128 | public function count(?string $column = null): int |
|
142 | { |
||
143 | 128 | if ($column === null) { |
|
144 | // @tuneyourserver solves the issue with counting on queries with joins. |
||
145 | $pk = $this->loader->getPK(); |
||
146 | $column = \is_array($pk) |
||
147 | ? '*' |
||
148 | : \sprintf('DISTINCT(%s)', $pk); |
||
149 | } |
||
150 | |||
151 | return (int) $this->__call('count', [$column]); |
||
152 | } |
||
153 | 2136 | ||
154 | /** |
||
155 | 2136 | * @return static<TEntity> |
|
156 | */ |
||
157 | 2136 | public function limit(int $limit): self |
|
158 | 392 | { |
|
159 | $this->loader->getQuery()->limit($limit); |
||
160 | 1744 | ||
161 | return $this; |
||
162 | 1744 | } |
|
163 | 16 | ||
164 | 1744 | /** |
|
165 | * @return static<TEntity> |
||
166 | */ |
||
167 | public function offset(int $offset): self |
||
168 | { |
||
169 | $this->loader->getQuery()->offset($offset); |
||
170 | |||
171 | return $this; |
||
172 | 72 | } |
|
173 | |||
174 | 72 | /** |
|
175 | * Request primary selector loader to pre-load relation name. Any type of loader can be used |
||
176 | 72 | * for data pre-loading. ORM loaders by default will select the most efficient way to load |
|
177 | 72 | * related data which might include additional select query or left join. Loaded data will |
|
178 | 24 | * automatically pre-populate record relations. You can specify nested relations using "." |
|
179 | 48 | * separator. |
|
180 | * |
||
181 | * Examples: |
||
182 | 72 | * |
|
183 | * // Select users and load their comments (will cast 2 queries, HAS_MANY comments) |
||
184 | * User::find()->with('comments'); |
||
185 | * |
||
186 | * // You can load chain of relations - select user and load their comments and post related to comment |
||
187 | * User::find()->with('comments.post'); |
||
188 | 3376 | * |
|
189 | * // We can also specify custom where conditions on data loading, let's load only public comments. |
||
190 | 3376 | * User::find()->load('comments', [ |
|
191 | * 'where' => ['{@}.status' => 'public'] |
||
192 | 3376 | * ]); |
|
193 | * |
||
194 | * Please note using "{@}" column name, this placeholder is required to prevent collisions and |
||
195 | * it will be automatically replaced with valid table alias of pre-loaded comments table. |
||
196 | * |
||
197 | * // In case where your loaded relation is MANY_TO_MANY you can also specify pivot table |
||
198 | 8 | * // conditions, let's pre-load all approved user tags, we can use same placeholder for pivot |
|
199 | * // table alias |
||
200 | 8 | * User::find()->load('tags', [ |
|
201 | * 'wherePivot' => ['{@}.approved' => true] |
||
202 | 8 | * ]); |
|
203 | * |
||
204 | * // In most of cases you don't need to worry about how data was loaded, using external query |
||
205 | * // or left join, however if you want to change such behaviour you can force load method |
||
206 | * // using {@see Select::SINGLE_QUERY} |
||
207 | * User::find()->load('tags', [ |
||
208 | * 'method' => Select::SINGLE_QUERY, |
||
209 | * 'wherePivot' => ['{@}.approved' => true] |
||
210 | * ]); |
||
211 | * |
||
212 | * Attention, you will not be able to correctly paginate in this case and only ORM loaders |
||
213 | * support different loading types. |
||
214 | * |
||
215 | * You can specify multiple loaders using array as first argument. |
||
216 | * |
||
217 | * Example: |
||
218 | * |
||
219 | * User::find()->load(['posts', 'comments', 'profile']); |
||
220 | * |
||
221 | * Attention, consider disabling entity map if you want to use recursive loading (i.e |
||
222 | * post.tags.posts), but first think why you even need recursive relation loading. |
||
223 | * |
||
224 | * @see with() |
||
225 | * |
||
226 | * @return static<TEntity> |
||
227 | */ |
||
228 | public function load(string|array $relation, array $options = []): self |
||
229 | { |
||
230 | if (\is_string($relation)) { |
||
0 ignored issues
–
show
|
|||
231 | $this->loader->loadRelation($relation, $options, false, true); |
||
232 | |||
233 | return $this; |
||
234 | } |
||
235 | |||
236 | foreach ($relation as $name => $subOption) { |
||
237 | if (\is_string($subOption)) { |
||
238 | // array of relation names |
||
239 | $this->load($subOption, $options); |
||
240 | } else { |
||
241 | // multiple relations or relation with addition load options |
||
242 | $this->load($name, $subOption + $options); |
||
243 | } |
||
244 | } |
||
245 | |||
246 | return $this; |
||
247 | } |
||
248 | |||
249 | /** |
||
250 | * With method is very similar to load() one, except it will always include related data to |
||
251 | * parent query using INNER JOIN, this method can be applied only to ORM loaders and relations |
||
252 | * using same database as parent record. |
||
253 | * |
||
254 | * Method generally used to filter data based on some relation condition. Attention, with() |
||
255 | * method WILL NOT load relation data, it will only make it accessible in query. |
||
256 | * |
||
257 | * By default joined tables will be available in query based on relation name, you can change |
||
258 | * joined table alias using relation option "alias". |
||
259 | * |
||
260 | 2914 | * 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 | 2914 | * |
|
263 | 2914 | * Examples: |
|
264 | * |
||
265 | 2906 | * // Find all users who have comments comments |
|
266 | * User::find()->with('comments'); |
||
267 | * |
||
268 | 16 | * // Find all users who have approved comments (we can use comments table alias in where statement) |
|
269 | 16 | * User::find()->with('comments')->where('comments.approved', true); |
|
270 | * |
||
271 | 16 | * // Find all users who have posts which have approved comments |
|
272 | * User::find()->with('posts.comments')->where('posts_comments.approved', true); |
||
273 | * |
||
274 | * // Custom join alias for post comments relation |
||
275 | * $user->with('posts.comments', [ |
||
276 | * 'as' => 'comments' |
||
277 | * ])->where('comments.approved', true); |
||
278 | 16 | * |
|
279 | * // If you joining MANY_TO_MANY relation you will be able to use pivot table used as relation name plus "_pivot" postfix. Let's load all users with approved tags. |
||
280 | * $user->with('tags')->where('tags_pivot.approved', true); |
||
281 | * |
||
282 | * // You can also use custom alias for pivot table as well |
||
283 | * User::find()->with('tags', [ |
||
284 | * 'pivotAlias' => 'tags_connection' |
||
285 | * ]) |
||
286 | * ->where('tags_connection.approved', false); |
||
287 | * |
||
288 | * |
||
289 | * You can safely combine with() and load() methods. |
||
290 | * |
||
291 | * // Load all users with approved comments and pre-load all their comments |
||
292 | * User::find() |
||
293 | * ->with('comments') |
||
294 | * ->where('comments.approved', true)->load('comments'); |
||
295 | * |
||
296 | * You can also use custom conditions in this case, let's find all users with approved |
||
297 | * comments and pre-load such approved comments |
||
298 | * |
||
299 | * User::find() |
||
300 | * ->with('comments') |
||
301 | * ->where('comments.approved', true) |
||
302 | * ->load('comments', [ |
||
303 | * 'where' => ['{@}.approved' => true] |
||
304 | * ]); |
||
305 | * |
||
306 | * As you might notice previous construction will create 2 queries, however we can simplify |
||
307 | * this construction to use already joined table as source of data for relation via "using" keyword |
||
308 | * |
||
309 | * User::find() |
||
310 | * ->with('comments') |
||
311 | * ->where('comments.approved', true) |
||
312 | * ->load('comments', ['using' => 'comments']); |
||
313 | * |
||
314 | * You will get only one query with INNER JOIN, to better understand this example let's use |
||
315 | * custom alias for comments in with() method. |
||
316 | * |
||
317 | * User::find() |
||
318 | * ->with('comments', ['as' => 'commentsR']) |
||
319 | * ->where('commentsR.approved', true) |
||
320 | * ->load('comments', ['using' => 'commentsR']); |
||
321 | * |
||
322 | * To use with() twice on the same relation, you can use `alias` option. |
||
323 | * |
||
324 | * Country::find() |
||
325 | * // Find all translations |
||
326 | * ->with('translations', [ 'as' => 'trans']) |
||
327 | * ->load('translations', ['using' => 'trans']) |
||
328 | * // Second `with` for sorting only |
||
329 | * ->with('translations', [ |
||
330 | * // Alias for SQL |
||
331 | * 'as' => 'transEn', |
||
332 | * // Alias for ORM to not to overwrite previous `with` |
||
333 | * 'alias' => 'translations-en', |
||
334 | * 'method' => JoinableLoader::LEFT_JOIN, |
||
335 | * 'where' => ['locale' => 'en'], |
||
336 | * ]) |
||
337 | * ->orderBy('transEn.title', 'ASC'); |
||
338 | * |
||
339 | * @return static<TEntity> |
||
340 | * |
||
341 | * @see load() |
||
342 | */ |
||
343 | public function with(string|array $relation, array $options = []): self |
||
344 | { |
||
345 | if (\is_string($relation)) { |
||
0 ignored issues
–
show
|
|||
346 | $this->loader->loadRelation($relation, $options, true, false); |
||
347 | |||
348 | return $this; |
||
349 | } |
||
350 | 488 | ||
351 | foreach ($relation as $name => $subOption) { |
||
352 | 488 | if (\is_string($subOption)) { |
|
353 | 488 | //Array of relation names |
|
354 | $this->with($subOption, []); |
||
355 | 488 | } else { |
|
356 | //Multiple relations or relation with addition load options |
||
357 | $this->with($name, $subOption); |
||
358 | } |
||
359 | } |
||
360 | |||
361 | return $this; |
||
362 | } |
||
363 | |||
364 | /** |
||
365 | * Find one entity or return null. Method provides the ability to configure custom query parameters. |
||
366 | * |
||
367 | * @return TEntity|null |
||
0 ignored issues
–
show
The type
Cycle\ORM\TEntity was not found. Maybe you did not declare it correctly or list all dependencies?
The issue could also be caused by a filter entry in the build configuration.
If the path has been excluded in your configuration, e.g. filter:
dependency_paths: ["lib/*"]
For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths ![]() |
|||
368 | */ |
||
369 | public function fetchOne(?array $query = null): ?object |
||
370 | { |
||
371 | $select = (clone $this)->where($query)->limit(1); |
||
372 | $node = $select->loader->createNode(); |
||
373 | $select->loader->loadData($node, true); |
||
374 | $data = $node->getResult(); |
||
375 | |||
376 | 3000 | if (!isset($data[0])) { |
|
377 | return null; |
||
378 | 3000 | } |
|
379 | 3000 | ||
380 | 3000 | /** @var TEntity $result */ |
|
381 | 3000 | return $this->entityFactory->make($this->loader->getTarget(), $data[0], Node::MANAGED, typecast: true); |
|
382 | } |
||
383 | 3000 | ||
384 | 304 | /** |
|
385 | * Fetch all records in a form of array. |
||
386 | * |
||
387 | * @return list<TEntity> |
||
0 ignored issues
–
show
The type
Cycle\ORM\list was not found. Maybe you did not declare it correctly or list all dependencies?
The issue could also be caused by a filter entry in the build configuration.
If the path has been excluded in your configuration, e.g. filter:
dependency_paths: ["lib/*"]
For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths ![]() |
|||
388 | 2936 | */ |
|
389 | public function fetchAll(): iterable |
||
390 | { |
||
391 | return \iterator_to_array($this->getIterator(), false); |
||
0 ignored issues
–
show
|
|||
392 | } |
||
393 | |||
394 | /** |
||
395 | * @return Iterator<TEntity> |
||
396 | 1984 | */ |
|
397 | public function getIterator(bool $findInHeap = false): Iterator |
||
398 | 1984 | { |
|
399 | $node = $this->loader->createNode(); |
||
400 | $this->loader->loadData($node, true); |
||
401 | |||
402 | return Iterator::createWithServices( |
||
403 | $this->heap, |
||
404 | 2132 | $this->schema, |
|
405 | $this->entityFactory, |
||
406 | 2132 | $this->loader->getTarget(), |
|
407 | 2132 | $node->getResult(), |
|
408 | $findInHeap, |
||
409 | 2084 | typecast: true, |
|
410 | 2084 | ); |
|
411 | 2084 | } |
|
412 | 2084 | ||
413 | 2084 | /** |
|
414 | 2084 | * Load data tree from database and linked loaders in a form of array. |
|
415 | * |
||
416 | * @return array<array-key, array<string, mixed>> |
||
0 ignored issues
–
show
|
|||
417 | */ |
||
418 | public function fetchData(bool $typecast = true): iterable |
||
419 | { |
||
420 | $node = $this->loader->createNode(); |
||
421 | $this->loader->loadData($node, false); |
||
422 | |||
423 | if (!$typecast) { |
||
424 | return $node->getResult(); |
||
425 | 1704 | } |
|
426 | |||
427 | 1704 | $mapper = $this->mapperProvider->getMapper($this->loader->getTarget()); |
|
428 | 1704 | ||
429 | return \array_map([$mapper, 'cast'], $node->getResult()); |
||
430 | 1704 | } |
|
431 | |||
432 | /** |
||
433 | 1704 | * Compiled SQL statement. |
|
434 | */ |
||
435 | 1704 | public function sqlStatement(): string |
|
436 | { |
||
437 | return $this->buildQuery()->sqlStatement(); |
||
438 | } |
||
439 | |||
440 | public function loadSubclasses(bool $load = true): self |
||
441 | 8 | { |
|
442 | $this->loader->setSubclassesLoading($load); |
||
443 | 8 | return $this; |
|
444 | } |
||
445 | |||
446 | 264 | /** |
|
447 | * Bypassing call to primary select query. |
||
448 | 264 | */ |
|
449 | 264 | public function __call(string $name, array $arguments): mixed |
|
450 | { |
||
451 | if (\in_array(\strtoupper($name), ['AVG', 'MIN', 'MAX', 'SUM', 'COUNT'])) { |
||
452 | 392 | // aggregations |
|
453 | return $this->builder->withQuery( |
||
454 | 392 | $this->loader->buildQuery(), |
|
455 | 392 | )->__call($name, $arguments); |
|
456 | 392 | } |
|
457 | 392 | ||
458 | 8 | $result = $this->builder->__call($name, $arguments); |
|
459 | if ($result instanceof QueryBuilder) { |
||
460 | 384 | return $this; |
|
461 | 8 | } |
|
462 | 8 | ||
463 | return $result; |
||
464 | } |
||
465 | |||
466 | 376 | /** |
|
467 | 376 | * Cloning with loader tree cloning. |
|
468 | 376 | * |
|
469 | 80 | * @attention at this moment binded query parameters would't be cloned! |
|
470 | 312 | */ |
|
471 | public function __clone() |
||
472 | 376 | { |
|
473 | 8 | $this->loader = clone $this->loader; |
|
474 | $this->builder = new QueryBuilder($this->loader->getQuery(), $this->loader); |
||
475 | } |
||
476 | 368 | ||
477 | /** |
||
478 | * Remove nested loaders and clean ORM link. |
||
479 | */ |
||
480 | 368 | public function __destruct() |
|
481 | 368 | { |
|
482 | 368 | unset($this->loader, $this->builder); |
|
483 | } |
||
484 | |||
485 | /** |
||
486 | 368 | * @param list<non-empty-string> $pk |
|
487 | * @param list<array|int|object|string> $args |
||
488 | * |
||
489 | * @return static<TEntity> |
||
490 | */ |
||
491 | private function buildCompositePKQuery(array $pk, array $args): self |
||
492 | { |
||
493 | $prepared = []; |
||
494 | foreach ($args as $index => $values) { |
||
495 | $values = $values instanceof Parameter ? $values->getValue() : $values; |
||
496 | if (!\is_array($values)) { |
||
497 | throw new \InvalidArgumentException('Composite primary key must be defined using an array.'); |
||
498 | } |
||
499 | if (\count($pk) !== \count($values)) { |
||
500 | throw new \InvalidArgumentException( |
||
501 | \sprintf('Primary key should contain %d values.', \count($pk)), |
||
502 | ); |
||
503 | } |
||
504 | |||
505 | $isAssoc = !\array_is_list($values); |
||
506 | foreach ($values as $key => $value) { |
||
507 | if ($isAssoc && !\in_array($key, $pk, true)) { |
||
508 | throw new \InvalidArgumentException(\sprintf('Primary key `%s` not found.', $key)); |
||
509 | } |
||
510 | |||
511 | $key = $isAssoc ? $key : $pk[$key]; |
||
512 | $prepared[$index][$key] = $value; |
||
513 | } |
||
514 | } |
||
515 | |||
516 | $this->__call('where', [static function (Select\QueryBuilder $q) use ($prepared): void { |
||
517 | foreach ($prepared as $set) { |
||
518 | $q->orWhere($set); |
||
519 | } |
||
520 | }]); |
||
521 | |||
522 | return $this; |
||
523 | } |
||
524 | } |
||
525 |