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

nextras / orm / 18797011147

25 Oct 2025 02:42AM UTC coverage: 91.58% (+0.01%) from 91.569%
18797011147

Pull #774

github

web-flow
Merge acf47b9cc into 54524bb40
Pull Request #774: Apply LIMIT in getBy* methods

5 of 5 new or added lines in 1 file covered. (100.0%)

4 existing lines in 1 file now uncovered.

4155 of 4537 relevant lines covered (91.58%)

4.57 hits per line

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

97.6
/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\FeatureToggle;
21
use Nextras\Orm\Mapper\Dbal\DbalMapper;
22
use Nextras\Orm\Mapper\IRelationshipMapper;
23
use function count;
24
use function is_array;
25

26

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

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

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

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

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

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

51
        protected DbalQueryBuilderHelper|null $helper = null;
52

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

58
        protected QueryBuilder|null $queryBuilderCache = null;
59

60

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

72

73
        public function getBy(array $conds): ?IEntity
74
        {
75
                $collection = $this->findBy($conds);
5✔
76
                if (FeatureToggle::$limitInGetBy && $this->connection->getPlatform()->getName() !== SqlServerPlatform::NAME) {
5✔
77
                        $collection = $collection->limitBy(1);
5✔
78
                }
79

80
                return $collection->fetch();
5✔
81
        }
82

83

84
        public function getByChecked(array $conds): IEntity
85
        {
86
                $collection = $this->findBy($conds);
5✔
87
                if (FeatureToggle::$limitInGetBy && $this->connection->getPlatform()->getName() !== SqlServerPlatform::NAME) {
5✔
88
                        $collection = $collection->limitBy(1);
5✔
89
                }
90

91
                return $collection->fetchChecked();
5✔
92
        }
93

94

95
        public function getById($id): ?IEntity
96
        {
97
                return $this->getBy(['id' => $id]);
5✔
98
        }
99

100

101
        public function getByIdChecked($id): IEntity
102
        {
103
                $entity = $this->getById($id);
5✔
104
                if ($entity === null) {
5✔
105
                        throw new NoResultException();
5✔
106
                }
107
                return $entity;
5✔
108
        }
109

110

111
        public function findBy(array $conds): ICollection
112
        {
113
                $collection = clone $this;
5✔
114
                $collection->filtering[] = $conds;
5✔
115
                return $collection;
5✔
116
        }
117

118

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

127
                        foreach ($expression as $subExpression => $subDirection) {
5✔
128
                                $collection->ordering[] = [
5✔
129
                                        $helper->processExpression($collection->queryBuilder, $subExpression, null),
5✔
130
                                        $subDirection,
5✔
131
                                ];
132
                        }
133
                } else {
134
                        $collection->ordering[] = [
5✔
135
                                $helper->processExpression($collection->queryBuilder, $expression, null),
5✔
136
                                $direction,
5✔
137
                        ];
138
                }
139
                return $collection;
5✔
140
        }
141

142

143
        public function resetOrderBy(): ICollection
144
        {
145
                $collection = clone $this;
5✔
146
                $collection->ordering = [];
5✔
147
                // reset default ordering from mapper
148
                $collection->queryBuilder = clone $collection->queryBuilder;
5✔
149
                $collection->queryBuilder->orderBy(null);
5✔
150
                return $collection;
5✔
151
        }
152

153

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

161

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

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

174
                return null;
5✔
175
        }
176

177

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

187

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

193

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

199

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

211

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

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

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

238
                return $entityIterator;
5✔
239
        }
240

241

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

247

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

254
                return $this->getIteratorCount();
5✔
255
        }
256

257

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

265

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

272

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

278

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

286

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

292

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

302

303
        /**
304
         * @internal
305
         */
306
        public function getQueryBuilder(): QueryBuilder
307
        {
308
                if ($this->queryBuilderCache !== null) {
5✔
309
                        return $this->queryBuilderCache;
5✔
310
                }
311

312
                $joins = [];
5✔
313
                $groupBy = [];
5✔
314
                $queryBuilder = clone $this->queryBuilder;
5✔
315
                $helper = $this->getHelper();
5✔
316

317
                $filtering = $this->filtering;
5✔
318
                if (count($filtering) > 0) {
5✔
319
                        array_unshift($filtering, ICollection::AND);
5✔
320
                        $expression = $helper->processExpression(
5✔
321
                                builder: $queryBuilder,
322
                                expression: $filtering,
323
                                aggregator: null,
5✔
324
                        );
325
                        $finalContext = $expression->havingExpression === null
5✔
326
                                ? ExpressionContext::FilterAnd
5✔
327
                                : ExpressionContext::FilterAndWithHavingClause;
5✔
328
                        $expression = $expression->collect($finalContext);
5✔
329
                        $joins = $expression->joins;
5✔
330
                        $groupBy = $expression->groupBy;
5✔
331
                        if ($expression->expression !== null && $expression->args !== []) {
5✔
332
                                $queryBuilder->andWhere($expression->expression, ...$expression->args);
5✔
333
                        }
334
                        if ($expression->havingExpression !== null && $expression->havingArgs !== []) {
5✔
335
                                $queryBuilder->andHaving($expression->havingExpression, ...$expression->havingArgs);
5✔
336
                        }
337
                        if ($this->mapper->getDatabasePlatform()->getName() === MySqlPlatform::NAME) {
5✔
338
                                $this->applyGroupByWithSameNamedColumnsWorkaround($queryBuilder, $groupBy);
5✔
339
                        }
340
                }
341

342
                foreach ($this->ordering as [$expression, $direction]) {
5✔
343
                        $joins = array_merge($joins, $expression->joins);
5✔
344
                        $groupBy = array_merge($groupBy, $expression->groupBy);
5✔
345
                        $orderingExpression = $helper->processOrderDirection($expression, $direction);
5✔
346
                        $queryBuilder->addOrderBy('%ex', $orderingExpression);
5✔
347
                }
348

349
                if ($this->limitBy !== null) {
5✔
350
                        $queryBuilder->limitBy($this->limitBy[0], $this->limitBy[1]);
5✔
351
                }
352

353
                $mergedJoins = $helper->mergeJoins('%and', $joins);
5✔
354
                foreach ($mergedJoins as $join) {
5✔
355
                        $join->applyJoin($queryBuilder);
5✔
356
                }
357

358
                if (count($groupBy) > 0) {
5✔
359
                        foreach ($this->ordering as [$expression]) {
5✔
360
                                $groupBy = array_merge($groupBy, $expression->columns);
5✔
361
                        }
362
                }
363

364
                if (count($groupBy) > 0) {
5✔
365
                        $unique = [];
5✔
366
                        foreach ($groupBy as $groupByFqn) {
5✔
367
                                $unique[$groupByFqn->getUnescaped()] = $groupByFqn;
5✔
368
                        }
369
                        $queryBuilder->groupBy('%column[]', array_values($unique));
5✔
370
                }
371

372
                $this->queryBuilderCache = $queryBuilder;
5✔
373
                return $queryBuilder;
5✔
374
        }
375

376

377
        protected function getIteratorCount(): int
378
        {
379
                if ($this->resultCount !== null) {
5✔
UNCOV
380
                        return $this->resultCount;
×
381
                }
382

383
                $builder = clone $this->getQueryBuilder();
5✔
384

385
                if ($this->connection->getPlatform()->getName() === SqlServerPlatform::NAME) {
5✔
386
                        if (!$builder->hasLimitOffsetClause()) {
5✔
387
                                $builder->orderBy(null);
5✔
388
                        }
389
                } else {
390
                        $builder->orderBy(null);
5✔
391
                }
392

393
                $select = $builder->getClause('select')[0];
5✔
394
                if (is_array($select) && count($select) === 1 && $select[0] === "%table.*") {
5✔
395
                        $builder->select(null);
5✔
396
                        foreach ($this->mapper->getConventions()->getStoragePrimaryKey() as $column) {
5✔
397
                                $builder->addSelect('%table.%column', $builder->getFromAlias(), $column);
5✔
398
                        }
399
                }
400
                $sql = 'SELECT COUNT(*) AS count FROM (' . $builder->getQuerySql() . ') temp';
5✔
401
                $args = $builder->getQueryParameters();
5✔
402

403
                $this->resultCount = $this->connection->queryArgs($sql, $args)->fetchField()
5✔
UNCOV
404
                        ?? throw new InvalidStateException("Unable to fetch collection count.");
×
405
                return $this->resultCount;
5✔
406
        }
407

408

409
        protected function execute(): void
410
        {
411
                $result = $this->connection->queryByQueryBuilder($this->getQueryBuilder());
5✔
412

413
                $this->result = [];
5✔
414
                while (($data = $result->fetch()) !== null) {
5✔
415
                        $entity = $this->mapper->hydrateEntity($data->toArray());
5✔
416
                        if ($entity === null) continue;
5✔
417
                        $this->result[] = $entity;
5✔
418
                }
419
        }
5✔
420

421

422
        protected function getHelper(): DbalQueryBuilderHelper
423
        {
424
                if ($this->helper === null) {
5✔
425
                        $repository = $this->mapper->getRepository();
5✔
426
                        $this->helper = new DbalQueryBuilderHelper($repository);
5✔
427
                }
428

429
                return $this->helper;
5✔
430
        }
431

432

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