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

nextras / orm / 17019805283

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

Pull #763

github

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

33 of 35 new or added lines in 1 file covered. (94.29%)

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 = clone $collection->queryBuilder;
5✔
138
                $collection->queryBuilder->orderBy(null);
5✔
139
                return $collection;
5✔
140
        }
141

142

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

150

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

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

163
                return null;
5✔
164
        }
165

166

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

176

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

182

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

188

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

200

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

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

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

227
                return $entityIterator;
5✔
228
        }
229

230

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

236

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

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

246

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

254

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

261

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

267

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

275

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

281

282
        public function __clone()
283
        {
284
                $this->queryBuilderCache = null;
5✔
285
                $this->result = null;
5✔
286
                $this->resultCount = null;
5✔
287
                $this->fetchIterator = null;
5✔
288
                $this->entityFetchEventTriggered = false;
5✔
289
        }
5✔
290

291

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

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

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

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

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

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

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

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

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

365

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

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

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

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

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

397

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

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

410

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

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

421

422
        /**
423
         * Apply workaround for MySQL that is not able to properly resolve columns when there are more same-named
424
         * columns in the GROUP BY clause, even though they are properly referenced to their tables. Orm workarounds
425
         * this by adding them to the SELECT clause and renames them not to conflict anywhere.
426
         *
427
         * @param list<Fqn> $groupBy
428
         */
429
        private function applyGroupByWithSameNamedColumnsWorkaround(QueryBuilder $queryBuilder, array $groupBy): void
430
        {
431
                $map = [];
5✔
432
                foreach ($groupBy as $fqn) {
5✔
433
                        if (!isset($map[$fqn->name])) {
5✔
434
                                $map[$fqn->name] = [$fqn];
5✔
435
                        } else {
436
                                $map[$fqn->name][] = $fqn;
5✔
437
                        }
438
                }
439
                $i = 0;
5✔
440
                foreach ($map as $fqns) {
5✔
441
                        if (count($fqns) > 1) {
5✔
442
                                foreach ($fqns as $fqn) {
5✔
443
                                        $queryBuilder->addSelect("%column AS __nextras_fix_" . $i++, $fqn); // @phpstan-ignore-line
5✔
444
                                }
445
                        }
446
                }
447
        }
5✔
448
}
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

© 2026 Coveralls, Inc