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

nextras / orm / 38393301854

13 Oct 2026 11:52AM UTC coverage: 92.012% (+0.07%) from 91.945%
38393301854

Pull #834

github

web-flow
Merge cf4c11396 into f8795a678
Pull Request #834: perf: faster reads (relationship cache key memo, hydration & identity map speedups)

75 of 76 new or added lines in 11 files covered. (98.68%)

4400 of 4782 relevant lines covered (92.01%)

5.4 hits per line

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

94.62
/src/Repository/IdentityMap.php
1
<?php declare(strict_types = 1);
2

3
/**
4
 * This file is part of the Nextras\Orm library.
5
 * This file was inspired by PetrP's ORM library https://github.com/PetrP/Orm/.
6
 * @license    MIT
7
 * @link       https://github.com/nextras/orm
8
 */
9

10
namespace Nextras\Orm\Repository;
11

12

13
use DateTimeImmutable;
14
use Nextras\Orm\Entity\IEntity;
15
use Nextras\Orm\Entity\IEntityHasPreloadContainer;
16
use Nextras\Orm\Exception\InvalidArgumentException;
17
use ReflectionClass;
18

19

20
/**
21
 * @template E of IEntity
22
 */
23
class IdentityMap
24
{
25
        /** @var array<int|string, \WeakReference<E>|false> */
26
        private array $entities = [];
27

28
        /** @var array<int|string, bool> */
29
        private array $entitiesForRefresh = [];
30

31
        /** @var array<class-string<E>, ReflectionClass<E>> */
32
        private array $entityReflections = [];
33

34
        /** @var array<class-string<E>, bool> whether entity's persisted id is equal to the raw hydrated "id" value */
35
        private array $hasRawId = [];
36

37

38
        /**
39
         * @param IRepository<E> $repository
40
         */
41
        public function __construct(
5 ✔
42
                private readonly IRepository $repository,
1 ✔
43
        )
44
        {
45
        }
6 ✔
46

47

48
        /**
49
         * @param array|int|mixed $id
50
         */
51
        public function hasById($id): bool
52
        {
53
                $idHash = $this->getIdHash($id);
×
54
                return isset($this->entities[$idHash]) && ($this->entities[$idHash] === false || $this->entities[$idHash]->get() !== null);
×
55
        }
56

57

58
        /**
59
         * @param array|int|mixed $id
60
         * @return E|null|false
61
         */
62
        public function getById($id)
63
        {
64
                $idHash = $this->getIdHash($id);
6 ✔
65
                if (!isset($this->entities[$idHash]) || $this->entities[$idHash] === false || isset($this->entitiesForRefresh[$idHash])) {
6 ✔
66
                        return null;
6 ✔
67
                }
68

69
                $entity = $this->entities[$idHash]->get();
6 ✔
70
                if ($entity instanceof IEntityHasPreloadContainer) {
6 ✔
71
                        $entity->setPreloadContainer(null);
6 ✔
72
                }
73
                return $entity;
6 ✔
74
        }
75

76

77
        /**
78
         * @param E $entity
79
         */
80
        public function add(IEntity $entity): void
81
        {
82
                $id = $this->getIdHash($entity->getPersistedId());
6 ✔
83
                $this->entities[$id] = \WeakReference::create($entity);
6 ✔
84
        }
6 ✔
85

86

87
        /**
88
         * @param array|int|mixed $id
89
         */
90
        public function remove($id): void
91
        {
92
                $idHash = $this->getIdHash($id);
6 ✔
93
                $this->entities[$idHash] = false;
6 ✔
94
                unset($this->entitiesForRefresh[$idHash]);
6 ✔
95
        }
6 ✔
96

97

98
        /**
99
         * @param array<string, mixed> $data
100
         * @return E|null
101
         */
102
        public function create(array $data): ?IEntity
103
        {
104
                // fast path: the row is already mapped, avoid creating (and immediately detaching) a throw-away entity
105
                $rawId = $this->getRawIdHash($data);
6 ✔
106
                if ($rawId !== null && isset($this->entities[$rawId]) && !isset($this->entitiesForRefresh[$rawId])) {
6 ✔
107
                        if ($this->entities[$rawId] === false) {
6 ✔
NEW
108
                                return null;
×
109
                        }
110
                        $existingEntity = $this->entities[$rawId]->get();
6 ✔
111
                        if ($existingEntity !== null) {
6 ✔
112
                                return $existingEntity;
6 ✔
113
                        }
114
                        // the entity was garbage collected, continue with the regular path
115
                }
116

117
                $entity = $this->createEntity($data);
6 ✔
118
                $id = $this->getIdHash($entity->getPersistedId());
6 ✔
119

120
                if (isset($this->entities[$id])) {
6 ✔
121
                        if ($this->entities[$id] === false) {
6 ✔
122
                                $this->repository->detach($entity);
×
123
                                return null;
×
124
                        }
125
                        $existingEntity = $this->entities[$id]->get();
6 ✔
126
                        if ($existingEntity === null) {
6 ✔
127
                                // The entity was garbage collected, use the new one
128
                                $this->entities[$id] = \WeakReference::create($entity);
6 ✔
129
                                return $entity;
6 ✔
130
                        }
131
                        $this->repository->detach($entity);
6 ✔
132
                        if (isset($this->entitiesForRefresh[$id])) {
6 ✔
133
                                // entity can be detached because of delete try
134
                                $this->repository->attach($existingEntity);
6 ✔
135
                                $existingEntity->onRefresh($data);
6 ✔
136
                                unset($this->entitiesForRefresh[$id]);
6 ✔
137
                        }
138
                        return $existingEntity;
6 ✔
139
                }
140

141
                $this->entities[$id] = \WeakReference::create($entity);
6 ✔
142
                return $entity;
6 ✔
143
        }
144

145

146
        /**
147
         * @return list<E>
148
         */
149
        public function getAll(): array
150
        {
151
                $all = [];
6 ✔
152
                foreach ($this->entities as $entity) {
6 ✔
153
                        if ($entity !== false && $entity->get() !== null) {
6 ✔
154
                                $all[] = $entity->get();
6 ✔
155
                        }
156
                }
157
                return $all;
6 ✔
158
        }
159

160

161
        public function check(IEntity $entity): void
162
        {
163
                if (!in_array(get_class($entity), ($this->repository)::getEntityClassNames(), true)) {
6 ✔
164
                        throw new InvalidArgumentException("Entity '" . get_class($entity) . "' is not accepted by '" . get_class($this->repository) . "' repository.");
6 ✔
165
                }
166
        }
6 ✔
167

168

169
        public function destroyAllEntities(): void
170
        {
171
                foreach ($this->entities as $entity) {
6 ✔
172
                        if ($entity !== false && $entity->get() !== null) {
6 ✔
173
                                $this->repository->detach($entity->get());
6 ✔
174
                                $entity->get()->onFree();
6 ✔
175
                        }
176
                }
177

178
                $this->entities = [];
6 ✔
179
        }
6 ✔
180

181

182
        public function markForRefresh(IEntity $entity): void
183
        {
184
                $id = $this->getIdHash($entity->getPersistedId());
6 ✔
185
                $this->entitiesForRefresh[$id] = true;
6 ✔
186
        }
6 ✔
187

188

189
        public function isMarkedForRefresh(IEntity $entity): bool
190
        {
191
                $id = $this->getIdHash($entity->getPersistedId());
6 ✔
192
                return isset($this->entitiesForRefresh[$id]);
6 ✔
193
        }
194

195

196
        /**
197
         * @param array<string, mixed> $data
198
         * @return E
199
         */
200
        protected function createEntity(array $data): IEntity
201
        {
202
                $entityClass = $this->repository->getEntityClassName($data);
6 ✔
203
                $this->entityReflections[$entityClass] ??= new ReflectionClass($entityClass);
6 ✔
204
                $entity = $this->entityReflections[$entityClass]->newInstanceWithoutConstructor();
6 ✔
205
                $this->repository->attach($entity);
6 ✔
206
                $entity->onLoad($data);
6 ✔
207
                return $entity;
6 ✔
208
        }
209

210

211
        /**
212
         * Returns id hash computed directly from the hydration data, or null if the persisted id may differ from the raw
213
         * value (composite/proxied primary key, property wrapper or getter, non-scalar value).
214
         * @param array<string, mixed> $data
215
         */
216
        private function getRawIdHash(array $data): ?string
217
        {
218
                $rawId = $data['id'] ?? null;
6 ✔
219
                if (!is_int($rawId) && !is_string($rawId)) {
6 ✔
220
                        return null;
6 ✔
221
                }
222

223
                $entityClass = $this->repository->getEntityClassName($data);
6 ✔
224
                if (!isset($this->hasRawId[$entityClass])) {
6 ✔
225
                        $metadata = $this->repository->getEntityMetadata($entityClass);
6 ✔
226
                        $property = $metadata->hasProperty('id') ? $metadata->getProperty('id') : null;
6 ✔
227
                        $this->hasRawId[$entityClass] = $property !== null
6 ✔
228
                                && !$property->isVirtual
6 ✔
229
                                && $property->wrapper === null
6 ✔
230
                                && $property->hasGetter === null;
6 ✔
231
                }
232

233
                return $this->hasRawId[$entityClass] ? (string) $rawId : null;
6 ✔
234
        }
235

236

237
        /**
238
         * @param mixed $id
239
         */
240
        protected function getIdHash($id): string
241
        {
242
                if (!is_array($id)) {
6 ✔
243
                        return $id instanceof DateTimeImmutable
6 ✔
244
                                ? $id->format('c.u')
6 ✔
245
                                : (string) $id;
6 ✔
246
                }
247

248
                return implode(
6 ✔
249
                        ',',
4 ✔
250
                        array_map(
6 ✔
251
                                function ($id): string {
6 ✔
252
                                        return $id instanceof DateTimeImmutable
6 ✔
253
                                                ? $id->format('c.u')
6 ✔
254
                                                : (string) $id;
6 ✔
255
                                },
6 ✔
256
                                $id,
257
                        ),
258
                );
259
        }
260
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc