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

nextras / orm / 24420992226

14 Apr 2026 08:21PM UTC coverage: 92.053% (-0.1%) from 92.162%
24420992226

Pull #810

github

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

60 of 65 new or added lines in 3 files covered. (92.31%)

8 existing lines in 3 files now uncovered.

4274 of 4643 relevant lines covered (92.05%)

5.41 hits per line

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

94.41
/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> */
42
        protected array $groupByColumns = [];
43

44
        protected DbalQueryBuilderHelper|null $helper = null;
45

46
        /** @var list<E>|null */
47
        protected ?array $result = null;
48
        protected ?int $resultCount = null;
49
        protected bool $entityFetchEventTriggered = false;
50

51
        /** @var array<string, true> */
52
        private array $mysqlGroupByWorkaroundApplied = [];
53

54

55
        /**
56
         * @param DbalMapper<E> $mapper
57
         */
58
        public function __construct(
5✔
59
                protected readonly DbalMapper $mapper,
1✔
60
                protected readonly IConnection $connection,
1✔
61
                protected QueryBuilder $queryBuilder,
1✔
62
        )
63
        {
64
        }
6✔
65

66

67
        public function getBy(array $conds): ?IEntity
68
        {
69
                return $this->findBy($conds)->fetch();
6✔
70
        }
71

72

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

78

79
        public function getById($id): ?IEntity
80
        {
81
                return $this->getBy(['id' => $id]);
6✔
82
        }
83

84

85
        public function getByIdChecked($id): IEntity
86
        {
87
                $entity = $this->getById($id);
6✔
88
                if ($entity === null) {
6✔
89
                        throw new NoResultException();
6✔
90
                }
91
                return $entity;
6✔
92
        }
93

94

95
        public function findBy(array $conds): ICollection
96
        {
97
                $collection = clone $this;
6✔
98
                $expression = $collection->getHelper()->processExpression(
6✔
99
                        builder: $collection->queryBuilder,
6✔
100
                        expression: $conds,
101
                        aggregator: null,
6✔
102
                );
103
                $finalContext = $expression->havingExpression === null
6✔
104
                        ? ExpressionContext::FilterAnd
6✔
105
                        : ExpressionContext::FilterAndWithHavingClause;
6✔
106
                $expression = $expression->collect($finalContext);
6✔
107

108
                $collection->applyExpressionJoins($expression);
6✔
109
                if ($expression->expression !== null && $expression->args !== []) {
6✔
110
                        $collection->queryBuilder->andWhere($expression->expression, ...$expression->args);
6✔
111
                }
112
                if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
6✔
113
                        $collection->queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
6✔
114
                }
115
                $collection->applyGroupByColumns($expression->groupBy);
6✔
116
                return $collection;
6✔
117
        }
118

119

120
        public function orderBy($expression, string $direction = ICollection::ASC): ICollection
121
        {
122
                $collection = clone $this;
6✔
123
                if (is_array($expression) && !isset($expression[0])) {
6✔
124
                        /** @var array<string, string> $expression */
125
                        $expression = $expression; // no-op for PHPStan
6✔
126

127
                        foreach ($expression as $subExpression => $subDirection) {
6✔
128
                                $collection->addOrderByExpression($subExpression, $subDirection);
6✔
129
                        }
130
                } else {
131
                        $collection->addOrderByExpression($expression, $direction);
6✔
132
                }
133
                return $collection;
6✔
134
        }
135

136

137
        public function resetOrderBy(): ICollection
138
        {
139
                $collection = clone $this;
6✔
140
                $collection->queryBuilder->orderBy(null);
6✔
141
                return $collection;
6✔
142
        }
143

144

145
        public function limitBy(int $limit, int|null $offset = null): ICollection
146
        {
147
                $collection = clone $this;
6✔
148
                $collection->queryBuilder->limitBy($limit, $offset);
6✔
149
                return $collection;
6✔
150
        }
151

152

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

159
                if ($this->fetchIterator->valid()) {
6✔
160
                        $current = $this->fetchIterator->current();
6✔
161
                        $this->fetchIterator->next();
6✔
162
                        return $current;
6✔
163
                }
164

165
                return null;
6✔
166
        }
167

168

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

178

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

184

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

190

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

202

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

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

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

229
                return $entityIterator;
6✔
230
        }
231

232

233
        public function count(): int
234
        {
235
                return iterator_count($this->getIterator());
6✔
236
        }
237

238

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

245
                return $this->getIteratorCount();
6✔
246
        }
247

248

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

256

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

263

264
        public function getRelationshipMapper(): ?IRelationshipMapper
265
        {
266
                return $this->relationshipMapper;
6✔
267
        }
268

269

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

277

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

283

284
        public function __clone()
285
        {
286
                $this->queryBuilder = clone $this->queryBuilder;
6✔
287
                $this->result = null;
6✔
288
                $this->resultCount = null;
6✔
289
                $this->fetchIterator = null;
6✔
290
                $this->entityFetchEventTriggered = false;
6✔
291
        }
6✔
292

293

294
        public function getQueryBuilder(): QueryBuilder
295
        {
296
                $this->result = null;
6✔
297
                $this->resultCount = null;
6✔
298
                $this->fetchIterator = null;
6✔
299
                $this->entityFetchEventTriggered = false;
6✔
300
                return $this->queryBuilder;
6✔
301
        }
302

303

304
        protected function getIteratorCount(): int
305
        {
306
                if ($this->resultCount !== null) {
6✔
307
                        return $this->resultCount;
×
308
                }
309

310
                $builder = clone $this->getQueryBuilder();
6✔
311

312
                if ($this->connection->getPlatform()->getName() === SqlServerPlatform::NAME) {
6✔
313
                        if (!$builder->hasLimitOffsetClause()) {
6✔
314
                                $builder->orderBy(null);
6✔
315
                        }
316
                } else {
317
                        $builder->orderBy(null);
6✔
318
                }
319

320
                $select = $builder->getClause('select')[0];
6✔
321
                if (is_array($select) && count($select) === 1 && $select[0] === "%table.*") {
6✔
322
                        $builder->select(null);
6✔
323
                        foreach ($this->mapper->getConventions()->getStoragePrimaryKey() as $column) {
6✔
324
                                $builder->addSelect('%table.%column', $builder->getFromAlias(), $column);
6✔
325
                        }
326
                }
327
                $sql = 'SELECT COUNT(*) AS count FROM (' . $builder->getQuerySql() . ') temp';
6✔
328
                $args = $builder->getQueryParameters();
6✔
329

330
                $this->resultCount = $this->connection->queryArgs($sql, $args)->fetchField()
6✔
UNCOV
331
                        ?? throw new InvalidStateException("Unable to fetch collection count.");
×
332
                return $this->resultCount;
6✔
333
        }
334

335

336
        protected function execute(): void
337
        {
338
                $result = $this->connection->queryByQueryBuilder($this->getQueryBuilder());
6✔
339

340
                $this->result = [];
6✔
341
                while (($data = $result->fetch()) !== null) {
6✔
342
                        $entity = $this->mapper->hydrateEntity($data->toArray());
6✔
343
                        if ($entity === null) continue;
6✔
344
                        $this->result[] = $entity;
6✔
345
                }
346
        }
6✔
347

348

349
        protected function getHelper(): DbalQueryBuilderHelper
350
        {
351
                if ($this->helper === null) {
6✔
352
                        $repository = $this->mapper->getRepository();
6✔
353
                        $this->helper = new DbalQueryBuilderHelper($repository);
6✔
354
                }
355

356
                return $this->helper;
6✔
357
        }
358

359

360
        /**
361
         * @param list<Fqn> $groupBy
362
         */
363
        private function applyGroupByColumns(array $groupBy): void
364
        {
365
                if (count($groupBy) === 0) {
6✔
366
                        return;
6✔
367
                }
368

369
                $newGroupBy = [];
6✔
370
                foreach ($groupBy as $groupByFqn) {
6✔
371
                        $key = $groupByFqn->getUnescaped();
6✔
372
                        if (isset($this->groupByColumns[$key])) {
6✔
373
                                continue;
6✔
374
                        }
375

376
                        $this->groupByColumns[$key] = $groupByFqn;
6✔
377
                        $newGroupBy[] = $groupByFqn;
6✔
378
                }
379

380
                if (count($newGroupBy) === 0) {
6✔
381
                        return;
6✔
382
                }
383

384
                if ($this->queryBuilder->getClause('group')[0] === null) {
6✔
385
                        $this->queryBuilder->groupBy('%column[]', $newGroupBy);
6✔
386
                } else {
387
                        foreach ($newGroupBy as $groupByFqn) {
6✔
388
                                $this->queryBuilder->addGroupBy('%column', $groupByFqn);
6✔
389
                        }
390
                }
391

392
                if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
6✔
393
                        $this->applyGroupByWithSameNamedColumnsWorkaround($this->queryBuilder, array_values($this->groupByColumns));
6✔
394
                }
395
        }
6✔
396

397
        /**
398
         * @param array<mixed>|string $expression
399
         */
400
        private function addOrderByExpression(string|array $expression, string $direction): void
401
        {
402
                $expressionResult = $this->getHelper()->processExpression($this->queryBuilder, $expression, null);
6✔
403
                $this->applyExpressionJoins($expressionResult);
6✔
404
                $this->applyGroupByColumns($expressionResult->groupBy);
6✔
405

406
                $orderByExpression = $this->getHelper()->processOrderDirection($expressionResult, $direction);
6✔
407
                $this->queryBuilder->addOrderBy('%ex', $orderByExpression);
6✔
408

409
                if ($this->queryBuilder->getClause('group')[0] !== null) {
6✔
410
                        $this->applyGroupByColumns($expressionResult->columns);
6✔
411
                }
412
        }
6✔
413

414

415
        private function applyExpressionJoins(DbalExpressionResult $expression): void
416
        {
417
                $mergedJoins = $this->getHelper()->mergeJoins('%and', $expression->joins);
6✔
418
                foreach ($mergedJoins as $join) {
6✔
419
                        $join->applyJoin($this->queryBuilder);
6✔
420
                }
421
        }
6✔
422

423

424
        /**
425
         * Apply workaround for MySQL that is not able to properly resolve columns when there are more same-named
426
         * columns in the GROUP BY clause, even though they are properly referenced to their tables. Orm workarounds
427
         * this by adding them to the SELECT clause and renames them not to conflict anywhere.
428
         *
429
         * @param list<Fqn> $groupBy
430
         */
431
        private function applyGroupByWithSameNamedColumnsWorkaround(QueryBuilder $queryBuilder, array $groupBy): void
432
        {
433
                $map = [];
6✔
434
                foreach ($groupBy as $fqn) {
6✔
435
                        $map[$fqn->name][$fqn->getUnescaped()] = $fqn;
6✔
436
                }
437

438
                foreach ($map as $fqns) {
6✔
439
                        if (count($fqns) > 1) {
6✔
NEW
440
                                foreach ($fqns as $key => $fqn) {
×
NEW
441
                                        if (isset($this->mysqlGroupByWorkaroundApplied[$key])) {
×
NEW
442
                                                continue;
×
443
                                        }
444

NEW
445
                                        $queryBuilder->addSelect("%column AS __nextras_fix_" . count($this->mysqlGroupByWorkaroundApplied), $fqn); // @phpstan-ignore-line
×
NEW
UNCOV
446
                                        $this->mysqlGroupByWorkaroundApplied[$key] = true;
×
447
                                }
448
                        }
449
                }
450
        }
6✔
451
}
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