• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nextras / orm / 17019780057

17 Aug 2025 10:11AM UTC coverage: 91.652% (+0.009%) from 91.643%
17019780057

Pull #763

github

web-flow
Merge 74a960232 into 787d648e2
Pull Request #763: collection: fix repeated queryBuilder construction

28 of 29 new or added lines in 1 file covered. (96.55%)

2 existing lines in 1 file now uncovered.

4139 of 4516 relevant lines covered (91.65%)

4.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.52
/src/Collection/DbalCollection.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Orm\Collection;
4

5

6
use Iterator;
7
use Nextras\Dbal\IConnection;
8
use Nextras\Dbal\Platforms\Data\Fqn;
9
use Nextras\Dbal\Platforms\MySqlPlatform;
10
use Nextras\Dbal\Platforms\SqlServerPlatform;
11
use Nextras\Dbal\QueryBuilder\QueryBuilder;
12
use Nextras\Orm\Collection\Expression\ExpressionContext;
13
use Nextras\Orm\Collection\Functions\Result\DbalExpressionResult;
14
use Nextras\Orm\Collection\Helpers\DbalQueryBuilderHelper;
15
use Nextras\Orm\Collection\Helpers\FetchPairsHelper;
16
use Nextras\Orm\Entity\IEntity;
17
use Nextras\Orm\Exception\InvalidStateException;
18
use Nextras\Orm\Exception\MemberAccessException;
19
use Nextras\Orm\Exception\NoResultException;
20
use Nextras\Orm\Mapper\Dbal\DbalMapper;
21
use Nextras\Orm\Mapper\IRelationshipMapper;
22
use function count;
23
use function is_array;
24

25

26
/**
27
 * @template E of IEntity
28
 * @implements ICollection<E>
29
 */
30
class DbalCollection implements ICollection
31
{
32
        /** @var list<callable(\Traversable<E> $entities): void> */
33
        public array $onEntityFetch = [];
34

35
        protected IRelationshipMapper|null $relationshipMapper = null;
36
        protected IEntity|null $relationshipParent = null;
37

38
        /** @var Iterator<E>|null */
39
        protected Iterator|null $fetchIterator = null;
40

41
        /** @var array<mixed> FindBy expressions for deferred processing */
42
        protected array $filtering = [];
43

44
        /** @var array<array{DbalExpressionResult, string}> OrderBy expression result & sorting direction */
45
        protected array $ordering = [];
46

47
        /** @var array{int|null, int|null}|null */
48
        protected array|null $limitBy = null;
49

50
        protected DbalQueryBuilderHelper|null $helper = null;
51

52
        /** @var list<E>|null */
53
        protected ?array $result = null;
54
        protected ?int $resultCount = null;
55
        protected bool $entityFetchEventTriggered = false;
56

57
        protected QueryBuilder|null $queryBuilderCache = null;
58

59

60
        /**
61
         * @param DbalMapper<E> $mapper
62
         */
63
        public function __construct(
5✔
64
                protected readonly DbalMapper $mapper,
65
                protected readonly IConnection $connection,
66
                protected QueryBuilder $queryBuilder,
67
        )
68
        {
69
        }
5✔
70

71

72
        public function getBy(array $conds): ?IEntity
73
        {
74
                return $this->findBy($conds)->fetch();
5✔
75
        }
76

77

78
        public function getByChecked(array $conds): IEntity
79
        {
80
                return $this->findBy($conds)->fetchChecked();
5✔
81
        }
82

83

84
        public function getById($id): ?IEntity
85
        {
86
                return $this->getBy(['id' => $id]);
5✔
87
        }
88

89

90
        public function getByIdChecked($id): IEntity
91
        {
92
                $entity = $this->getById($id);
5✔
93
                if ($entity === null) {
5✔
94
                        throw new NoResultException();
5✔
95
                }
96
                return $entity;
5✔
97
        }
98

99

100
        public function findBy(array $conds): ICollection
101
        {
102
                $collection = clone $this;
5✔
103
                $collection->filtering[] = $conds;
5✔
104
                return $collection;
5✔
105
        }
106

107

108
        public function orderBy($expression, string $direction = ICollection::ASC): ICollection
109
        {
110
                $collection = clone $this;
5✔
111
                $helper = $collection->getHelper();
5✔
112
                if (is_array($expression) && !isset($expression[0])) {
5✔
113
                        /** @var array<string, string> $expression */
114
                        $expression = $expression; // no-op for PHPStan
5✔
115

116
                        foreach ($expression as $subExpression => $subDirection) {
5✔
117
                                $collection->ordering[] = [
5✔
118
                                        $helper->processExpression($collection->queryBuilder, $subExpression, null),
5✔
119
                                        $subDirection,
5✔
120
                                ];
121
                        }
122
                } else {
123
                        $collection->ordering[] = [
5✔
124
                                $helper->processExpression($collection->queryBuilder, $expression, null),
5✔
125
                                $direction,
5✔
126
                        ];
127
                }
128
                return $collection;
5✔
129
        }
130

131

132
        public function resetOrderBy(): ICollection
133
        {
134
                $collection = clone $this;
5✔
135
                $collection->ordering = [];
5✔
136
                // reset default ordering from mapper
137
                $collection->queryBuilder->orderBy(null);
5✔
138
                return $collection;
5✔
139
        }
140

141

142
        public function limitBy(int $limit, int|null $offset = null): ICollection
143
        {
144
                $collection = clone $this;
5✔
145
                $collection->limitBy = [$limit, $offset];
5✔
146
                return $collection;
5✔
147
        }
148

149

150
        public function fetch(): ?IEntity
151
        {
152
                if ($this->fetchIterator === null) {
5✔
153
                        $this->fetchIterator = $this->getIterator();
5✔
154
                }
155

156
                if ($this->fetchIterator->valid()) {
5✔
157
                        $current = $this->fetchIterator->current();
5✔
158
                        $this->fetchIterator->next();
5✔
159
                        return $current;
5✔
160
                }
161

162
                return null;
5✔
163
        }
164

165

166
        public function fetchChecked(): IEntity
167
        {
168
                $entity = $this->fetch();
5✔
169
                if ($entity === null) {
5✔
170
                        throw new NoResultException();
5✔
171
                }
172
                return $entity;
5✔
173
        }
174

175

176
        public function fetchAll(): array
177
        {
178
                return iterator_to_array($this->getIterator(), preserve_keys: false);
5✔
179
        }
180

181

182
        public function fetchPairs(string|null $key = null, string|null $value = null): array
183
        {
184
                return FetchPairsHelper::process($this->getIterator(), $key, $value);
5✔
185
        }
186

187

188
        /**
189
         * @param mixed[] $args
190
         * @return never
191
         * @throws MemberAccessException
192
         */
193
        public function __call(string $name, array $args)
194
        {
UNCOV
195
                $class = get_class($this);
×
196
                throw new MemberAccessException("Call to undefined method $class::$name().");
×
197
        }
198

199

200
        /**
201
         * @return Iterator<int, E>
202
         */
203
        public function getIterator(): Iterator
204
        {
205
                if ($this->relationshipParent !== null && $this->relationshipMapper !== null) {
5✔
206
                        /** @var Iterator<E> $entityIterator */
207
                        $entityIterator = $this->relationshipMapper->getIterator($this->relationshipParent, $this);
5✔
208
                } else {
209
                        if ($this->result === null) {
5✔
210
                                $this->execute();
5✔
211
                        }
212

213
                        assert(is_array($this->result));
214
                        /** @var Iterator<E> $entityIterator */
215
                        $entityIterator = new EntityIterator($this->result);
5✔
216
                }
217

218
                if (!$this->entityFetchEventTriggered) {
5✔
219
                        foreach ($this->onEntityFetch as $entityFetchCallback) {
5✔
220
                                $entityFetchCallback($entityIterator);
5✔
221
                        }
222
                        $entityIterator->rewind();
5✔
223
                        $this->entityFetchEventTriggered = true;
5✔
224
                }
225

226
                return $entityIterator;
5✔
227
        }
228

229

230
        public function count(): int
231
        {
232
                return iterator_count($this->getIterator());
5✔
233
        }
234

235

236
        public function countStored(): int
237
        {
238
                if ($this->relationshipParent !== null && $this->relationshipMapper !== null) {
5✔
239
                        return $this->relationshipMapper->getIteratorCount($this->relationshipParent, $this);
5✔
240
                }
241

242
                return $this->getIteratorCount();
5✔
243
        }
244

245

246
        public function toMemoryCollection(): MemoryCollection
247
        {
248
                $collection = clone $this;
5✔
249
                $entities = $collection->fetchAll();
5✔
250
                return new ArrayCollection($entities, $this->mapper->getRepository());
5✔
251
        }
252

253

254
        public function setRelationshipMapper(IRelationshipMapper|null $mapper): ICollection
255
        {
256
                $this->relationshipMapper = $mapper;
5✔
257
                return $this;
5✔
258
        }
259

260

261
        public function getRelationshipMapper(): ?IRelationshipMapper
262
        {
263
                return $this->relationshipMapper;
5✔
264
        }
265

266

267
        public function setRelationshipParent(IEntity $parent): ICollection
268
        {
269
                $collection = clone $this;
5✔
270
                $collection->relationshipParent = $parent;
5✔
271
                return $collection;
5✔
272
        }
273

274

275
        public function subscribeOnEntityFetch(callable $callback): void
276
        {
277
                $this->onEntityFetch[] = $callback;
5✔
278
        }
5✔
279

280

281
        public function __clone()
282
        {
283
                // clone is needed for resetOrderBy()
284
                $this->queryBuilder = clone $this->queryBuilder;
5✔
285
                $this->queryBuilderCache = null;
5✔
286
                $this->result = null;
5✔
287
                $this->resultCount = null;
5✔
288
                $this->fetchIterator = null;
5✔
289
                $this->entityFetchEventTriggered = false;
5✔
290
        }
5✔
291

292

293
        /**
294
         * @internal
295
         */
296
        public function getQueryBuilder(): QueryBuilder
297
        {
298
                if ($this->queryBuilderCache !== null) {
5✔
299
                        return $this->queryBuilderCache;
5✔
300
                }
301

302
                $joins = [];
5✔
303
                $groupBy = [];
5✔
304
                $queryBuilder = clone $this->queryBuilder;
5✔
305
                $helper = $this->getHelper();
5✔
306

307
                $filtering = $this->filtering;
5✔
308
                if (count($filtering) > 0) {
5✔
309
                        array_unshift($filtering, ICollection::AND);
5✔
310
                        $expression = $helper->processExpression(
5✔
311
                                builder: $queryBuilder,
312
                                expression: $filtering,
313
                                aggregator: null,
5✔
314
                        );
315
                        $finalContext = $expression->havingExpression === null
5✔
316
                                ? ExpressionContext::FilterAnd
5✔
317
                                : ExpressionContext::FilterAndWithHavingClause;
5✔
318
                        $expression = $expression->collect($finalContext);
5✔
319
                        $joins = $expression->joins;
5✔
320
                        $groupBy = $expression->groupBy;
5✔
321
                        if ($expression->expression !== null && $expression->args !== []) {
5✔
322
                                $queryBuilder->andWhere($expression->expression, ...$expression->args);
5✔
323
                        }
324
                        if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
5✔
325
                                $queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
5✔
326
                        }
327
                        if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
5✔
328
                                $this->applyGroupByWithSameNamedColumnsWorkaround($queryBuilder, $groupBy);
5✔
329
                        }
330
                }
331

332
                foreach ($this->ordering as [$expression, $direction]) {
5✔
333
                        $joins = array_merge($joins, $expression->joins);
5✔
334
                        $groupBy = array_merge($groupBy, $expression->groupBy);
5✔
335
                        $orderingExpression = $helper->processOrderDirection($expression, $direction);
5✔
336
                        $queryBuilder->addOrderBy('%ex', $orderingExpression);
5✔
337
                }
338

339
                if ($this->limitBy !== null) {
5✔
340
                        $queryBuilder->limitBy($this->limitBy[0], $this->limitBy[1]);
5✔
341
                }
342

343
                $mergedJoins = $helper->mergeJoins('%and', $joins);
5✔
344
                foreach ($mergedJoins as $join) {
5✔
345
                        $join->applyJoin($queryBuilder);
5✔
346
                }
347

348
                if (count($groupBy) > 0) {
5✔
349
                        foreach ($this->ordering as [$expression]) {
5✔
350
                                $groupBy = array_merge($groupBy, $expression->columns);
5✔
351
                        }
352
                }
353

354
                if (count($groupBy) > 0) {
5✔
355
                        $unique = [];
5✔
356
                        foreach ($groupBy as $groupByFqn) {
5✔
357
                                $unique[$groupByFqn->getUnescaped()] = $groupByFqn;
5✔
358
                        }
359
                        $queryBuilder->groupBy('%column[]', array_values($unique));
5✔
360
                }
361

362
                $this->queryBuilderCache = $queryBuilder;
5✔
363
                return $queryBuilder;
5✔
364
        }
365

366

367
        protected function getIteratorCount(): int
368
        {
369
                if ($this->resultCount !== null) {
5✔
NEW
370
                        return $this->resultCount;
×
371
                }
372

373
                $builder = clone $this->getQueryBuilder();
5✔
374

375
                if ($this->connection->getPlatform()->getName() === SqlServerPlatform::NAME) {
5✔
376
                        if (!$builder->hasLimitOffsetClause()) {
5✔
377
                                $builder->orderBy(null);
5✔
378
                        }
379
                } else {
380
                        $builder->orderBy(null);
5✔
381
                }
382

383
                $select = $builder->getClause('select')[0];
5✔
384
                if (is_array($select) && count($select) === 1 && $select[0] === "%table.*") {
5✔
385
                        $builder->select(null);
5✔
386
                        foreach ($this->mapper->getConventions()->getStoragePrimaryKey() as $column) {
5✔
387
                                $builder->addSelect('%table.%column', $builder->getFromAlias(), $column);
5✔
388
                        }
389
                }
390
                $sql = 'SELECT COUNT(*) AS count FROM (' . $builder->getQuerySql() . ') temp';
5✔
391
                $args = $builder->getQueryParameters();
5✔
392

393
                $this->resultCount = $this->connection->queryArgs($sql, $args)->fetchField()
5✔
UNCOV
394
                        ?? throw new InvalidStateException("Unable to fetch collection count.");
×
395
                return $this->resultCount;
5✔
396
        }
397

398

399
        protected function execute(): void
400
        {
401
                $result = $this->connection->queryByQueryBuilder($this->getQueryBuilder());
5✔
402

403
                $this->result = [];
5✔
404
                while (($data = $result->fetch()) !== null) {
5✔
405
                        $entity = $this->mapper->hydrateEntity($data->toArray());
5✔
406
                        if ($entity === null) continue;
5✔
407
                        $this->result[] = $entity;
5✔
408
                }
409
        }
5✔
410

411

412
        protected function getHelper(): DbalQueryBuilderHelper
413
        {
414
                if ($this->helper === null) {
5✔
415
                        $repository = $this->mapper->getRepository();
5✔
416
                        $this->helper = new DbalQueryBuilderHelper($repository);
5✔
417
                }
418

419
                return $this->helper;
5✔
420
        }
421

422

423
        /**
424
         * Apply workaround for MySQL that is not able to properly resolve columns when there are more same-named
425
         * columns in the GROUP BY clause, even though they are properly referenced to their tables. Orm workarounds
426
         * this by adding them to the SELECT clause and renames them not to conflict anywhere.
427
         *
428
         * @param list<Fqn> $groupBy
429
         */
430
        private function applyGroupByWithSameNamedColumnsWorkaround(QueryBuilder $queryBuilder, array $groupBy): void
431
        {
432
                $map = [];
5✔
433
                foreach ($groupBy as $fqn) {
5✔
434
                        if (!isset($map[$fqn->name])) {
5✔
435
                                $map[$fqn->name] = [$fqn];
5✔
436
                        } else {
437
                                $map[$fqn->name][] = $fqn;
5✔
438
                        }
439
                }
440
                $i = 0;
5✔
441
                foreach ($map as $fqns) {
5✔
442
                        if (count($fqns) > 1) {
5✔
443
                                foreach ($fqns as $fqn) {
5✔
444
                                        $queryBuilder->addSelect("%column AS __nextras_fix_" . $i++, $fqn); // @phpstan-ignore-line
5✔
445
                                }
446
                        }
447
                }
448
        }
5✔
449
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc