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

nextras / orm / 15606347441

12 Jun 2025 09:05AM UTC coverage: 91.652% (+0.002%) from 91.65%
15606347441

Pull #750

github

web-flow
Merge 8bbb20e7b into fce95e93c
Pull Request #750: Duplicate column count

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

1 existing line in 1 file now uncovered.

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

98.68
/src/Mapper/Dbal/RelationshipMapperOneHasMany.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Orm\Mapper\Dbal;
4

5

6
use Iterator;
7
use Nextras\Dbal\IConnection;
8
use Nextras\Dbal\QueryBuilder\QueryBuilder;
9
use Nextras\Dbal\Result\Row;
10
use Nextras\Orm\Collection\DbalCollection;
11
use Nextras\Orm\Collection\ICollection;
12
use Nextras\Orm\Collection\MultiEntityIterator;
13
use Nextras\Orm\Entity\IEntity;
14
use Nextras\Orm\Entity\IEntityHasPreloadContainer;
15
use Nextras\Orm\Entity\Reflection\PropertyMetadata;
16
use Nextras\Orm\Entity\Reflection\PropertyRelationshipMetadata;
17
use Nextras\Orm\Exception\InvalidStateException;
18
use Nextras\Orm\Mapper\IRelationshipMapper;
19
use function array_merge;
20
use function array_unique;
21
use function array_unshift;
22
use function assert;
23
use function count;
24
use function implode;
25
use function json_encode;
26
use function md5;
27
use function sort;
28

29

30
class RelationshipMapperOneHasMany implements IRelationshipMapper
31
{
32
        protected PropertyRelationshipMetadata $metadataRelationship;
33
        protected string $joinStorageKey;
34

35
        /** @var array<string, MultiEntityIterator> */
36
        protected array $cacheEntityIterators;
37

38
        /** @var array<string, array<int>> */
39
        protected array $cacheCounts;
40

41

42
        /**
43
         * @param DbalMapper<IEntity> $targetMapper
44
         */
45
        public function __construct(
5✔
46
                protected readonly  IConnection $connection,
47
                protected readonly DbalMapper $targetMapper,
48
                protected readonly PropertyMetadata $metadata,
49
        )
50
        {
51
                assert($metadata->relationship !== null);
52
                assert($metadata->relationship->property !== null);
53
                $this->metadataRelationship = $metadata->relationship;
5✔
54
                $this->joinStorageKey = $targetMapper->getConventions()
5✔
55
                        ->convertEntityToStorageKey($metadata->relationship->property);
5✔
56
        }
5✔
57

58

59
        public function clearCache(): void
60
        {
61
                $this->cacheEntityIterators = [];
5✔
62
                $this->cacheCounts = [];
5✔
63
        }
5✔
64

65

66
        // ==== ITERATOR ===================================================================================================
67

68

69
        /**
70
         * @param ICollection<IEntity> $collection
71
         */
72
        public function getIterator(IEntity $parent, ICollection $collection): Iterator
73
        {
74
                assert($collection instanceof DbalCollection);
75
                $iterator = clone $this->execute($collection, $parent);
5✔
76
                $iterator->setDataIndex($parent->getValue('id'));
5✔
77
                return $iterator;
5✔
78
        }
79

80

81
        /**
82
         * @param DbalCollection<IEntity> $collection
83
         */
84
        protected function execute(DbalCollection $collection, IEntity $parent): MultiEntityIterator
85
        {
86
                $preloadContainer = $parent instanceof IEntityHasPreloadContainer ? $parent->getPreloadContainer() : null;
5✔
87
                $values = $preloadContainer !== null ? $preloadContainer->getPreloadValues('id') : [$parent->getValue('id')];
5✔
88
                $builder = $collection->getQueryBuilder();
5✔
89

90
                $cacheKey = $this->calculateCacheKey($builder, $values);
5✔
91
                $data = &$this->cacheEntityIterators[$cacheKey];
5✔
92

93
                if ($data !== null) {
5✔
94
                        return $data;
5✔
95
                }
96

97
                $builder = $collection->getQueryBuilder();
5✔
98
                if ($builder->hasLimitOffsetClause() && count($values) > 1) {
5✔
99
                        $data = $this->fetchByTwoPassStrategy($builder, $values);
5✔
100
                } else {
101
                        $data = $this->fetchByOnePassStrategy($builder, $values);
5✔
102
                }
103

104
                return $data;
5✔
105
        }
106

107

108
        /**
109
         * @param list<mixed> $values
110
         */
111
        protected function fetchByOnePassStrategy(QueryBuilder $builder, array $values): MultiEntityIterator
112
        {
113
                $builder = clone $builder;
5✔
114
                $builder->andWhere('%column IN %any', "{$builder->getFromAlias()}.{$this->joinStorageKey}", $values);
5✔
115

116
                $result = $this->connection->queryByQueryBuilder($builder);
5✔
117
                $entities = [];
5✔
118

119
                $property = $this->metadataRelationship->property;
5✔
120
                assert($property !== null);
121

122
                while (($data = $result->fetch()) !== null) {
5✔
123
                        $entity = $this->targetMapper->hydrateEntity($data->toArray());
5✔
124
                        if ($entity !== null) { // entity may have been deleted
5✔
125
                                $entities[$entity->getRawValue($property)][] = $entity;
5✔
126
                        }
127
                }
128

129
                return new MultiEntityIterator($entities);
5✔
130
        }
131

132

133
        /**
134
         * @param list<mixed> $values
135
         */
136
        protected function fetchByTwoPassStrategy(QueryBuilder $builder, array $values): MultiEntityIterator
137
        {
138
                $builder = clone $builder;
5✔
139
                $targetPrimaryKey = array_map(function ($key): string {
5✔
140
                        return $this->targetMapper->getConventions()->convertEntityToStorageKey($key);
5✔
141
                }, $this->metadataRelationship->entityMetadata->getPrimaryKey());
5✔
142
                $isComposite = count($targetPrimaryKey) !== 1;
5✔
143

144
                foreach (array_unique(array_merge($targetPrimaryKey, [$this->joinStorageKey])) as $key) {
5✔
145
                        $builder->addSelect("%column", "{$builder->getFromAlias()}.$key");
5✔
146
                }
147

148
                $result = $this->processMultiResult($builder, $values);
5✔
149

150
                $map = $ids = [];
5✔
151
                if ($isComposite) {
5✔
152
                        foreach ($result as $row) {
5✔
153
                                $id = [];
5✔
154
                                foreach ($targetPrimaryKey as $key) {
5✔
155
                                        $id["{$builder->getFromAlias()}.$key"] = $row->{$key};
5✔
156
                                }
157

158
                                $ids[] = $id;
5✔
159
                                $map[$row->{$this->joinStorageKey}][] = implode(',', $id);
5✔
160
                        }
161

162
                } else {
163
                        $targetPrimaryKey = $targetPrimaryKey[0];
5✔
164
                        foreach ($result as $row) {
5✔
165
                                $ids[] = $row->{$targetPrimaryKey};
5✔
166
                                $map[$row->{$this->joinStorageKey}][] = $row->{$targetPrimaryKey};
5✔
167
                        }
168
                }
169

170
                if (count($ids) === 0) {
5✔
171
                        return new MultiEntityIterator([]);
×
172
                }
173

174
                sort($values); // make ids sorted deterministically
5✔
175
                if ($isComposite) {
5✔
176
                        $builder = $this->targetMapper->builder();
5✔
177
                        $builder->andWhere('%multiOr', $ids);
5✔
178

179
                        $entitiesResult = [];
5✔
180
                        $collection = $this->targetMapper->toCollection($builder);
5✔
181
                        foreach ($collection as $entity) {
5✔
182
                                $entitiesResult[implode(',', $entity->getValue('id'))] = $entity;
5✔
183
                        }
184
                } else {
185
                        $entitiesResult = $this->targetMapper->findAll()->findBy(['id' => $ids])->fetchPairs('id', null);
5✔
186
                }
187

188
                $entities = [];
5✔
189
                foreach ($map as $joiningStorageKey => $primaryValues) {
5✔
190
                        foreach ($primaryValues as $primaryValue) {
5✔
191
                                $entity = $entitiesResult[$primaryValue];
5✔
192
                                $entities[$entity->getRawValue($this->metadataRelationship->property)][] = $entity;
5✔
193
                        }
194
                }
195

196
                return new MultiEntityIterator($entities);
5✔
197
        }
198

199

200
        // ==== ITERATOR COUNT =============================================================================================
201

202
        public function getIteratorCount(IEntity $parent, ICollection $collection): int
203
        {
204
                assert($collection instanceof DbalCollection);
205
                $counts = $this->executeCounts($collection, $parent);
5✔
206
                $id = $parent->getValue('id');
5✔
207
                return $counts[$id] ?? 0;
5✔
208
        }
209

210

211
        /**
212
         * @param DbalCollection<IEntity> $collection
213
         * @return array<int|string, int>
214
         */
215
        protected function executeCounts(DbalCollection $collection, IEntity $parent): array
216
        {
217
                $preloadContainer = $parent instanceof IEntityHasPreloadContainer ? $parent->getPreloadContainer() : null;
5✔
218
                $values = $preloadContainer !== null ? $preloadContainer->getPreloadValues('id') : [$parent->getValue('id')];
5✔
219
                $builder = $collection->getQueryBuilder();
5✔
220

221
                $cacheKey = $this->calculateCacheKey($builder, $values);
5✔
222
                $data = &$this->cacheCounts[$cacheKey];
5✔
223

224
                if ($data !== null) {
5✔
225
                        return $data;
5✔
226
                }
227

228
                /** @noinspection PhpUnnecessaryLocalVariableInspection */
229
                $data = $this->fetchCounts($builder, $values);
5✔
230
                return $data;
5✔
231
        }
232

233

234
        /**
235
         * @param list<mixed> $values
236
         * @return array<int|string, int>
237
         */
238
        private function fetchCounts(QueryBuilder $builder, array $values): array
239
        {
240
                $sourceTable = $builder->getFromAlias();
5✔
241

242
                $builder = clone $builder;
5✔
243

244
                if ($builder->hasLimitOffsetClause()) {
5✔
245
                        $builder->select('%column', "{$sourceTable}.{$this->joinStorageKey}");
5✔
246
                        $result = $this->processMultiCountResult($builder, $values);
5✔
247

248
                } else {
249
                        $targetStoragePrimaryKeys = $this->targetMapper->getConventions()->getStoragePrimaryKey();
5✔
250
                        $targetColumn = null;
5✔
251
                        foreach ($targetStoragePrimaryKeys as $targetStoragePrimaryKey) {
5✔
252
                                if ($targetStoragePrimaryKey === $this->joinStorageKey) {
5✔
253
                                        continue;
5✔
254
                                }
255
                                $targetColumn = "$sourceTable.$targetStoragePrimaryKey";
5✔
256
                                break;
5✔
257
                        }
258

259
                        if ($targetColumn === null) {
5✔
UNCOV
260
                                throw new InvalidStateException('Unable to detect column for count query.');
×
261
                        }
262

263
                        $builder->select('%column', "{$sourceTable}.{$this->joinStorageKey}");
5✔
264
                        $builder->addSelect('%column AS [count]', $targetColumn);
5✔
265
                        $builder->andWhere('%column IN %any', "{$sourceTable}.{$this->joinStorageKey}", $values);
5✔
266
                        $builder->orderBy(null);
5✔
267

268
                        $boxingBuilder = $this->connection->createQueryBuilder();
5✔
269
                        $boxingBuilder->select('%column, COUNT(DISTINCT [count]) as [count]', $this->joinStorageKey);
5✔
270
                        $boxingBuilder->groupBy('%column', $this->joinStorageKey);
5✔
271

272
                        $args = $builder->getQueryParameters();
5✔
273
                        array_unshift($args, $builder->getQuerySql());
5✔
274
                        $boxingBuilder->from('(%ex)', 'temp', $args);
5✔
275

276
                        $result = $this->connection->queryByQueryBuilder($boxingBuilder);
5✔
277
                }
278

279
                $counts = [];
5✔
280
                foreach ($result as $row) {
5✔
281
                        $counts[$row->{$this->joinStorageKey}] = $row->count;
5✔
282
                }
283
                return $counts;
5✔
284
        }
285

286

287
        /**
288
         * @param list<mixed> $values
289
         * @return iterable<Row>
290
         */
291
        protected function processMultiResult(QueryBuilder $builder, array $values): iterable
292
        {
293
                if ($this->connection->getPlatform()->getName() === 'mssql') {
5✔
294
                        $result = [];
5✔
295
                        foreach ($values as $primaryValue) {
5✔
296
                                $builderPart = clone $builder;
5✔
297
                                $builderPart->andWhere("%column = %any", $this->joinStorageKey, $primaryValue);
5✔
298
                                $result = array_merge($this->connection->queryByQueryBuilder($builderPart)->fetchAll(), $result);
5✔
299
                        }
300
                        return $result;
5✔
301

302
                } else {
303
                        $sqls = $args = [];
5✔
304
                        foreach ($values as $primaryValue) {
5✔
305
                                $builderPart = clone $builder;
5✔
306
                                $builderPart->andWhere("%column = %any", $this->joinStorageKey, $primaryValue);
5✔
307
                                $sqls[] = $builderPart->getQuerySql();
5✔
308
                                $args = array_merge($args, $builderPart->getQueryParameters());
5✔
309
                        }
310

311
                        $query = '(' . implode(') UNION ALL (', $sqls) . ')';
5✔
312
                        return $this->connection->queryArgs($query, $args);
5✔
313
                }
314
        }
315

316

317
        /**
318
         * @param list<mixed> $values
319
         * @return iterable<Row>
320
         */
321
        protected function processMultiCountResult(QueryBuilder $builder, array $values): iterable
322
        {
323
                $sourceTable = $builder->getFromAlias();
5✔
324

325
                if ($this->connection->getPlatform()->getName() === 'mssql') {
5✔
326
                        $result = [];
5✔
327
                        foreach ($values as $value) {
5✔
328
                                $builderPart = clone $builder;
5✔
329
                                $builderPart->andWhere('%column = %any', "{$sourceTable}.{$this->joinStorageKey}", $value);
5✔
330
                                $result = array_merge($this->connection->queryArgs(
5✔
331
                                        "SELECT %any AS %column, COUNT(*) AS [count] FROM (" . $builderPart->getQuerySql() . ') [temp]',
5✔
332
                                        array_merge([$value, $this->joinStorageKey], $builderPart->getQueryParameters())
5✔
333
                                )->fetchAll(), $result);
5✔
334
                        }
335
                        return $result;
5✔
336

337
                } else {
338
                        $sqls = [];
5✔
339
                        $args = [];
5✔
340
                        $builder->orderBy(null);
5✔
341
                        foreach ($values as $value) {
5✔
342
                                $builderPart = clone $builder;
5✔
343
                                $builderPart->andWhere('%column = %any', "{$sourceTable}.{$this->joinStorageKey}", $value);
5✔
344
                                $sqls[] = "SELECT %any AS %column, COUNT(*) AS [count] FROM (" . $builderPart->getQuerySql() . ') [temp]';
5✔
345
                                $args[] = $value;
5✔
346
                                $args[] = $this->joinStorageKey;
5✔
347
                                $args = array_merge($args, $builderPart->getQueryParameters());
5✔
348
                        }
349

350
                        $sql = '(' . implode(') UNION ALL (', $sqls) . ')';
5✔
351
                        $result = $this->connection->queryArgs($sql, $args);
5✔
352
                        return $result;
5✔
353
                }
354
        }
355

356

357
        /**
358
         * @param list<mixed> $values
359
         */
360
        protected function calculateCacheKey(QueryBuilder $builder, array $values): string
361
        {
362
                return md5($builder->getQuerySql() . json_encode($builder->getQueryParameters()) . json_encode($values));
5✔
363
        }
364
}
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