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

nextras / orm / 27441222970

12 Jun 2026 08:29PM UTC coverage: 92.051% (-0.1%) from 92.162%
27441222970

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%)

4 existing lines in 2 files now uncovered.

4273 of 4642 relevant lines covered (92.05%)

5.41 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(
5✔
65
                protected readonly DbalMapper $mapper,
1✔
66
                protected readonly IConnection $connection,
1✔
67
                protected QueryBuilder $queryBuilder,
1✔
68
        )
69
        {
70
        }
6✔
71

72

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

78

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

84

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

90

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

100

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

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

126

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

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

143

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

154

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

162

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

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

175
                return null;
6✔
176
        }
177

178

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

188

189
        public function fetchAll(): array
190
        {
191
                return iterator_to_array($this->getIterator(), preserve_keys: false);
6✔
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);
6✔
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) {
6✔
219
                        /** @var Iterator<E> $entityIterator */
220
                        $entityIterator = $this->relationshipMapper->getIterator($this->relationshipParent, $this);
6✔
221
                } else {
222
                        if ($this->result === null) {
6✔
223
                                $this->execute();
6✔
224
                        }
225

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

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

239
                return $entityIterator;
6✔
240
        }
241

242

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

248

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

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

258

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

266

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

273

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

279

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

287

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

293

294
        public function __clone()
295
        {
296
                $this->queryBuilder = clone $this->queryBuilder;
6✔
297
                $this->result = null;
6✔
298
                $this->resultCount = null;
6✔
299
                $this->fetchIterator = null;
6✔
300
                $this->entityFetchEventTriggered = false;
6✔
301
        }
6✔
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;
6✔
314
                $this->resultCount = null;
6✔
315
                $this->fetchIterator = null;
6✔
316
                $this->entityFetchEventTriggered = false;
6✔
317
                return $this->queryBuilder;
6✔
318
        }
319

320

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

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

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

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

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

352

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

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

365

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

373
                return $this->helper;
6✔
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);
6✔
383
                $this->applyExpressionJoins($expressionResult);
6✔
384

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

389
                $orderByExpression = $this->getHelper()->processOrderDirection($expressionResult, $direction);
6✔
390
                $this->queryBuilder->addOrderBy('%ex', $orderByExpression);
6✔
391
        }
6✔
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) {
6✔
404
                        $target[$fqn->getUnescaped()] ??= $fqn;
6✔
405
                }
406
                return $target;
6✔
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;
6✔
420
                if (count($groupBy) > 0) {
6✔
421
                        $groupBy += $this->orderColumns;
6✔
422
                }
423

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

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

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

436

437
        private function applyExpressionJoins(DbalExpressionResult $expression): void
438
        {
439
                $mergedJoins = $this->getHelper()->mergeJoins('%and', $expression->joins);
6✔
440
                foreach ($mergedJoins as $join) {
6✔
441
                        $join->applyJoin($this->queryBuilder);
6✔
442
                }
443
        }
6✔
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 = [];
6✔
456
                foreach ($groupBy as $fqn) {
6✔
457
                        $map[$fqn->name][$fqn->getUnescaped()] = $fqn;
6✔
458
                }
459

460
                foreach ($map as $fqns) {
6✔
461
                        if (count($fqns) > 1) {
6✔
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
        }
6✔
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