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

nextras / orm / 27441222947

12 Jun 2026 08:29PM UTC coverage: 91.915% (-0.2%) from 92.162%
27441222947

Pull #810

github

web-flow
Merge 191dbb3fe into 7a20304be
Pull Request #810: Fix DbalCollection::getQueryBuilder()

61 of 66 new or added lines in 3 files covered. (92.42%)

6 existing lines in 3 files now uncovered.

4263 of 4638 relevant lines covered (91.91%)

1.81 hits per line

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

94.38
/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<string, Fqn> GROUP BY columns contributed by findBy() filtering. */
42
        private array $filterGroupByColumns = [];
43

44
        /** @var array<string, Fqn> GROUP BY columns contributed by orderBy() expressions. */
45
        private array $orderGroupByColumns = [];
46

47
        /** @var array<string, Fqn> Columns referenced by orderBy() expressions; required in GROUP BY once grouping is active. */
48
        private array $orderColumns = [];
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
        /** @var array<string, true> */
58
        private array $mysqlGroupByWorkaroundApplied = [];
59

60

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

72

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

78

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

84

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

90

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

100

101
        public function findBy(array $conds): ICollection
102
        {
103
                $collection = clone $this;
2✔
104
                $expression = $collection->getHelper()->processExpression(
2✔
105
                        builder: $collection->queryBuilder,
2✔
106
                        expression: $conds,
107
                        aggregator: null,
2✔
108
                );
109
                $finalContext = $expression->havingExpression === null
2✔
110
                        ? ExpressionContext::FilterAnd
2✔
111
                        : ExpressionContext::FilterAndWithHavingClause;
2✔
112
                $expression = $expression->collect($finalContext);
2✔
113

114
                $collection->applyExpressionJoins($expression);
2✔
115
                if ($expression->expression !== null && $expression->args !== []) {
2✔
116
                        $collection->queryBuilder->andWhere($expression->expression, ...$expression->args);
2✔
117
                }
118
                if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
2✔
119
                        $collection->queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
2✔
120
                }
121
                $collection->filterGroupByColumns = $collection->mergeColumns($collection->filterGroupByColumns, $expression->groupBy);
2✔
122
                $collection->rebuildGroupBy();
2✔
123
                return $collection;
2✔
124
        }
125

126

127
        public function orderBy($expression, string $direction = ICollection::ASC): ICollection
128
        {
129
                $collection = clone $this;
2✔
130
                if (is_array($expression) && !isset($expression[0])) {
2✔
131
                        /** @var array<string, string> $expression */
132
                        $expression = $expression; // no-op for PHPStan
2✔
133

134
                        foreach ($expression as $subExpression => $subDirection) {
2✔
135
                                $collection->addOrderByExpression($subExpression, $subDirection);
2✔
136
                        }
137
                } else {
138
                        $collection->addOrderByExpression($expression, $direction);
2✔
139
                }
140
                return $collection;
2✔
141
        }
142

143

144
        public function resetOrderBy(): ICollection
145
        {
146
                $collection = clone $this;
2✔
147
                $collection->orderGroupByColumns = [];
2✔
148
                $collection->orderColumns = [];
2✔
149
                $collection->queryBuilder->orderBy(null);
2✔
150
                $collection->rebuildGroupBy();
2✔
151
                return $collection;
2✔
152
        }
153

154

155
        public function limitBy(int $limit, int|null $offset = null): ICollection
156
        {
157
                $collection = clone $this;
2✔
158
                $collection->queryBuilder->limitBy($limit, $offset);
2✔
159
                return $collection;
2✔
160
        }
161

162

163
        public function fetch(): ?IEntity
164
        {
165
                if ($this->fetchIterator === null) {
2✔
166
                        $this->fetchIterator = $this->getIterator();
2✔
167
                }
168

169
                if ($this->fetchIterator->valid()) {
2✔
170
                        $current = $this->fetchIterator->current();
2✔
171
                        $this->fetchIterator->next();
2✔
172
                        return $current;
2✔
173
                }
174

175
                return null;
2✔
176
        }
177

178

179
        public function fetchChecked(): IEntity
180
        {
181
                $entity = $this->fetch();
2✔
182
                if ($entity === null) {
2✔
183
                        throw new NoResultException();
2✔
184
                }
185
                return $entity;
2✔
186
        }
187

188

189
        public function fetchAll(): array
190
        {
191
                return iterator_to_array($this->getIterator(), preserve_keys: false);
2✔
192
        }
193

194

195
        public function fetchPairs(string|null $key = null, string|null $value = null): array
196
        {
197
                return FetchPairsHelper::process($this->getIterator(), $key, $value);
2✔
198
        }
199

200

201
        /**
202
         * @param mixed[] $args
203
         * @return never
204
         * @throws MemberAccessException
205
         */
206
        public function __call(string $name, array $args)
207
        {
208
                $class = get_class($this);
×
209
                throw new MemberAccessException("Call to undefined method $class::$name().");
×
210
        }
211

212

213
        /**
214
         * @return Iterator<int, E>
215
         */
216
        public function getIterator(): Iterator
217
        {
218
                if ($this->relationshipParent !== null && $this->relationshipMapper !== null) {
2✔
219
                        /** @var Iterator<E> $entityIterator */
220
                        $entityIterator = $this->relationshipMapper->getIterator($this->relationshipParent, $this);
2✔
221
                } else {
222
                        if ($this->result === null) {
2✔
223
                                $this->execute();
2✔
224
                        }
225

226
                        assert(is_array($this->result));
227
                        /** @var Iterator<E> $entityIterator */
228
                        $entityIterator = new EntityIterator($this->result);
2✔
229
                }
230

231
                if (!$this->entityFetchEventTriggered) {
2✔
232
                        foreach ($this->onEntityFetch as $entityFetchCallback) {
2✔
233
                                $entityFetchCallback($entityIterator);
2✔
234
                        }
235
                        $entityIterator->rewind();
2✔
236
                        $this->entityFetchEventTriggered = true;
2✔
237
                }
238

239
                return $entityIterator;
2✔
240
        }
241

242

243
        public function count(): int
244
        {
245
                return iterator_count($this->getIterator());
2✔
246
        }
247

248

249
        public function countStored(): int
250
        {
251
                if ($this->relationshipParent !== null && $this->relationshipMapper !== null) {
2✔
252
                        return $this->relationshipMapper->getIteratorCount($this->relationshipParent, $this);
2✔
253
                }
254

255
                return $this->getIteratorCount();
2✔
256
        }
257

258

259
        public function toMemoryCollection(): MemoryCollection
260
        {
261
                $collection = clone $this;
2✔
262
                $entities = $collection->fetchAll();
2✔
263
                return new ArrayCollection($entities, $this->mapper->getRepository());
2✔
264
        }
265

266

267
        public function setRelationshipMapper(IRelationshipMapper|null $mapper): ICollection
268
        {
269
                $this->relationshipMapper = $mapper;
2✔
270
                return $this;
2✔
271
        }
272

273

274
        public function getRelationshipMapper(): ?IRelationshipMapper
275
        {
276
                return $this->relationshipMapper;
2✔
277
        }
278

279

280
        public function setRelationshipParent(IEntity $parent): ICollection
281
        {
282
                $collection = clone $this;
2✔
283
                $collection->relationshipParent = $parent;
2✔
284
                return $collection;
2✔
285
        }
286

287

288
        public function subscribeOnEntityFetch(callable $callback): void
289
        {
290
                $this->onEntityFetch[] = $callback;
2✔
291
        }
2✔
292

293

294
        public function __clone()
295
        {
296
                $this->queryBuilder = clone $this->queryBuilder;
2✔
297
                $this->result = null;
2✔
298
                $this->resultCount = null;
2✔
299
                $this->fetchIterator = null;
2✔
300
                $this->entityFetchEventTriggered = false;
2✔
301
        }
2✔
302

303

304
        /**
305
         * Returns the live, mutable query builder backing this collection.
306
         *
307
         * Callers may further mutate the returned builder, therefore any already fetched result is discarded so that the
308
         * next fetch reflects the changes. Internal callers intentionally read {@see $queryBuilder} directly to avoid
309
         * invalidating an in-progress iteration.
310
         */
311
        public function getQueryBuilder(): QueryBuilder
312
        {
313
                $this->result = null;
2✔
314
                $this->resultCount = null;
2✔
315
                $this->fetchIterator = null;
2✔
316
                $this->entityFetchEventTriggered = false;
2✔
317
                return $this->queryBuilder;
2✔
318
        }
319

320

321
        protected function getIteratorCount(): int
322
        {
323
                if ($this->resultCount !== null) {
2✔
324
                        return $this->resultCount;
×
325
                }
326

327
                $builder = clone $this->queryBuilder;
2✔
328

329
                if ($this->connection->getPlatform()->getName() === SqlServerPlatform::NAME) {
2✔
330
                        if (!$builder->hasLimitOffsetClause()) {
2✔
331
                                $builder->orderBy(null);
2✔
332
                        }
333
                } else {
334
                        $builder->orderBy(null);
2✔
335
                }
336

337
                $select = $builder->getClause('select')[0];
2✔
338
                if (is_array($select) && count($select) === 1 && $select[0] === "%table.*") {
2✔
339
                        $builder->select(null);
2✔
340
                        foreach ($this->mapper->getConventions()->getStoragePrimaryKey() as $column) {
2✔
341
                                $builder->addSelect('%table.%column', $builder->getFromAlias(), $column);
2✔
342
                        }
343
                }
344
                $sql = 'SELECT COUNT(*) AS count FROM (' . $builder->getQuerySql() . ') temp';
2✔
345
                $args = $builder->getQueryParameters();
2✔
346

347
                $this->resultCount = $this->connection->queryArgs($sql, $args)->fetchField()
2✔
348
                        ?? throw new InvalidStateException("Unable to fetch collection count.");
×
349
                return $this->resultCount;
2✔
350
        }
351

352

353
        protected function execute(): void
354
        {
355
                $result = $this->connection->queryByQueryBuilder($this->queryBuilder);
2✔
356

357
                $this->result = [];
2✔
358
                while (($data = $result->fetch()) !== null) {
2✔
359
                        $entity = $this->mapper->hydrateEntity($data->toArray());
2✔
360
                        if ($entity === null) continue;
2✔
361
                        $this->result[] = $entity;
2✔
362
                }
363
        }
2✔
364

365

366
        protected function getHelper(): DbalQueryBuilderHelper
367
        {
368
                if ($this->helper === null) {
2✔
369
                        $repository = $this->mapper->getRepository();
2✔
370
                        $this->helper = new DbalQueryBuilderHelper($repository);
2✔
371
                }
372

373
                return $this->helper;
2✔
374
        }
375

376

377
        /**
378
         * @param array<mixed>|string $expression
379
         */
380
        private function addOrderByExpression(string|array $expression, string $direction): void
381
        {
382
                $expressionResult = $this->getHelper()->processExpression($this->queryBuilder, $expression, null);
2✔
383
                $this->applyExpressionJoins($expressionResult);
2✔
384

385
                $this->orderGroupByColumns = $this->mergeColumns($this->orderGroupByColumns, $expressionResult->groupBy);
2✔
386
                $this->orderColumns = $this->mergeColumns($this->orderColumns, $expressionResult->columns);
2✔
387
                $this->rebuildGroupBy();
2✔
388

389
                $orderByExpression = $this->getHelper()->processOrderDirection($expressionResult, $direction);
2✔
390
                $this->queryBuilder->addOrderBy('%ex', $orderByExpression);
2✔
391
        }
2✔
392

393

394
        /**
395
         * Merges the given columns into a keyed map (deduplicated by their unescaped Fqn), preserving order.
396
         *
397
         * @param array<string, Fqn> $target
398
         * @param list<Fqn> $columns
399
         * @return array<string, Fqn>
400
         */
401
        private function mergeColumns(array $target, array $columns): array
402
        {
403
                foreach ($columns as $fqn) {
2✔
404
                        $target[$fqn->getUnescaped()] ??= $fqn;
2✔
405
                }
406
                return $target;
2✔
407
        }
408

409

410
        /**
411
         * Rebuilds the whole GROUP BY clause from the tracked filtering and ordering columns.
412
         *
413
         * The clause is recomputed instead of mutated incrementally so that it does not depend on the order in which
414
         * findBy()/orderBy() were called, and so that resetOrderBy() can drop the ordering-induced columns again.
415
         * Order-by columns are added only when grouping is otherwise active, matching the SQL grouping requirements.
416
         */
417
        private function rebuildGroupBy(): void
418
        {
419
                $groupBy = $this->filterGroupByColumns + $this->orderGroupByColumns;
2✔
420
                if (count($groupBy) > 0) {
2✔
421
                        $groupBy += $this->orderColumns;
2✔
422
                }
423

424
                if (count($groupBy) === 0) {
2✔
425
                        $this->queryBuilder->groupBy(null);
2✔
426
                        return;
2✔
427
                }
428

429
                $this->queryBuilder->groupBy('%column[]', array_values($groupBy));
2✔
430

431
                if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
2✔
432
                        $this->applyGroupByWithSameNamedColumnsWorkaround($this->queryBuilder, array_values($groupBy));
2✔
433
                }
434
        }
2✔
435

436

437
        private function applyExpressionJoins(DbalExpressionResult $expression): void
438
        {
439
                $mergedJoins = $this->getHelper()->mergeJoins('%and', $expression->joins);
2✔
440
                foreach ($mergedJoins as $join) {
2✔
441
                        $join->applyJoin($this->queryBuilder);
2✔
442
                }
443
        }
2✔
444

445

446
        /**
447
         * Apply workaround for MySQL that is not able to properly resolve columns when there are more same-named
448
         * columns in the GROUP BY clause, even though they are properly referenced to their tables. Orm workarounds
449
         * this by adding them to the SELECT clause and renames them not to conflict anywhere.
450
         *
451
         * @param list<Fqn> $groupBy
452
         */
453
        private function applyGroupByWithSameNamedColumnsWorkaround(QueryBuilder $queryBuilder, array $groupBy): void
454
        {
455
                $map = [];
2✔
456
                foreach ($groupBy as $fqn) {
2✔
457
                        $map[$fqn->name][$fqn->getUnescaped()] = $fqn;
2✔
458
                }
459

460
                foreach ($map as $fqns) {
2✔
461
                        if (count($fqns) > 1) {
2✔
NEW
462
                                foreach ($fqns as $key => $fqn) {
×
NEW
463
                                        if (isset($this->mysqlGroupByWorkaroundApplied[$key])) {
×
NEW
464
                                                continue;
×
465
                                        }
466

NEW
467
                                        $queryBuilder->addSelect("%column AS __nextras_fix_" . count($this->mysqlGroupByWorkaroundApplied), $fqn); // @phpstan-ignore-line
×
NEW
468
                                        $this->mysqlGroupByWorkaroundApplied[$key] = true;
×
469
                                }
470
                        }
471
                }
472
        }
2✔
473
}
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