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

nextras / orm / 18633402011

19 Oct 2025 04:58PM UTC coverage: 91.569% (-0.03%) from 91.597%
18633402011

push

github

web-flow
Weak Reference in Indentity map  (#764)

* better error message when Entity is detached

* implement WeakReference in IdentityMap

closes #739

* update output for MemoryManagement test (which does not run on CI)

25 of 27 new or added lines in 3 files covered. (92.59%)

2 existing lines in 2 files now uncovered.

4149 of 4531 relevant lines covered (91.57%)

4.57 hits per line

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

90.0
/src/Repository/Repository.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 Nextras\Orm\Collection\ArrayCollection;
14
use Nextras\Orm\Collection\Functions\AvgAggregateFunction;
15
use Nextras\Orm\Collection\Functions\CollectionFunction;
16
use Nextras\Orm\Collection\Functions\CompareEqualsFunction;
17
use Nextras\Orm\Collection\Functions\CompareGreaterThanEqualsFunction;
18
use Nextras\Orm\Collection\Functions\CompareGreaterThanFunction;
19
use Nextras\Orm\Collection\Functions\CompareLikeFunction;
20
use Nextras\Orm\Collection\Functions\CompareNotEqualsFunction;
21
use Nextras\Orm\Collection\Functions\CompareSmallerThanEqualsFunction;
22
use Nextras\Orm\Collection\Functions\CompareSmallerThanFunction;
23
use Nextras\Orm\Collection\Functions\ConjunctionOperatorFunction;
24
use Nextras\Orm\Collection\Functions\CountAggregateFunction;
25
use Nextras\Orm\Collection\Functions\DisjunctionOperatorFunction;
26
use Nextras\Orm\Collection\Functions\FetchPropertyFunction;
27
use Nextras\Orm\Collection\Functions\MaxAggregateFunction;
28
use Nextras\Orm\Collection\Functions\MinAggregateFunction;
29
use Nextras\Orm\Collection\Functions\SumAggregateFunction;
30
use Nextras\Orm\Collection\Helpers\ConditionParser;
31
use Nextras\Orm\Collection\ICollection;
32
use Nextras\Orm\Entity\IEntity;
33
use Nextras\Orm\Entity\Reflection\EntityMetadata;
34
use Nextras\Orm\Exception\InvalidArgumentException;
35
use Nextras\Orm\Exception\InvalidStateException;
36
use Nextras\Orm\Exception\MemberAccessException;
37
use Nextras\Orm\Exception\NoResultException;
38
use Nextras\Orm\Exception\NotImplementedException;
39
use Nextras\Orm\Mapper\IMapper;
40
use Nextras\Orm\Model\IModel;
41
use Nextras\Orm\Model\MetadataStorage;
42
use ReflectionClass;
43
use function array_values;
44
use function count;
45
use function sort;
46

47

48
/**
49
 * @template E of IEntity
50
 * @implements IRepository<E>
51
 */
52
abstract class Repository implements IRepository
53
{
54
        /** @var array<mixed, callable(E $entity): void> */
55
        public array $onBeforePersist = [];
56

57
        /** @var array<mixed, callable(E $entity): void> */
58
        public array $onAfterPersist = [];
59

60
        /** @var array<mixed, callable(E $entity): void> */
61
        public array $onBeforeInsert = [];
62

63
        /** @var array<mixed, callable(E $entity): void> */
64
        public array $onAfterInsert = [];
65

66
        /** @var array<mixed, callable(E $entity): void> */
67
        public array $onBeforeUpdate = [];
68

69
        /** @var array<mixed, callable(E $entity): void> */
70
        public array $onAfterUpdate = [];
71

72
        /** @var array<mixed, callable(E $entity): void> */
73
        public array $onBeforeRemove = [];
74

75
        /** @var array<mixed, callable(E $entity): void> */
76
        public array $onAfterRemove = [];
77

78
        /** @var array<mixed, callable(E[] $persisted, E[] $removed): void> */
79
        public array $onFlush = [];
80

81
        /** @var class-string<E>|null */
82
        protected string|null $entityClassName = null;
83

84
        private ?IModel $model = null;
85
        private ConditionParser|null $conditionParser = null;
86

87
        /** @var IdentityMap<E> */
88
        private IdentityMap $identityMap;
89

90
        /** @var array<string, bool> */
91
        private array $proxyMethods = [];
92

93
        /** @var array{list<E>, list<E>} */
94
        private array $entitiesToFlush = [[], []];
95

96
        /** @var array<string, CollectionFunction> Collection functions cache */
97
        private array $collectionFunctions = [];
98

99

100
        /**
101
         * @param IMapper<E> $mapper
102
         */
103
        public function __construct(
5✔
104
                protected readonly IMapper $mapper,
105
                protected readonly IDependencyProvider|null $dependencyProvider = null,
106
        )
107
        {
108
                $this->mapper->setRepository($this);
5✔
109

110
                /** @var IdentityMap<E> $identityMap */
111
                $identityMap = new IdentityMap($this);
5✔
112
                $this->identityMap = $identityMap;
5✔
113

114
                $reflection = new ReflectionClass($this);
5✔
115
                preg_match_all(
5✔
116
                        '~^[ \t*]* @method[ \t]+[^\s]+[ \t]+(\w+)\(.*\).*$~um',
5✔
117
                        (string) $reflection->getDocComment(), $matches, PREG_SET_ORDER
5✔
118
                );
119
                foreach ($matches as [, $methodname]) {
5✔
120
                        $this->proxyMethods[strtolower($methodname)] = true;
5✔
121
                }
122
        }
5✔
123

124

125
        public function getModel(): IModel
126
        {
127
                if ($this->model === null) {
5✔
128
                        throw new InvalidStateException('Repository is not attached to model.');
×
129
                }
130

131
                return $this->model;
5✔
132
        }
133

134

135
        public function setModel(IModel $model): void
136
        {
137
                if ($this->model !== null && $this->model !== $model) {
5✔
138
                        throw new InvalidStateException('Repository is already attached.');
×
139
                }
140

141
                $this->model = $model;
5✔
142
        }
5✔
143

144

145
        public function getMapper(): IMapper
146
        {
147
                return $this->mapper;
5✔
148
        }
149

150

151
        public function getBy(array $conds): ?IEntity
152
        {
153
                return $this->findAll()->getBy($conds);
5✔
154
        }
155

156

157
        public function getByChecked(array $conds): IEntity
158
        {
159
                $entity = $this->getBy($conds);
5✔
160
                if ($entity === null) {
5✔
161
                        throw new NoResultException();
×
162
                }
163
                return $entity;
5✔
164
        }
165

166

167
        public function getById($id): ?IEntity
168
        {
169
                if ($id === null) {
5✔
170
                        return null;
×
171
                }
172

173
                $entity = $this->identityMap->getById($id);
5✔
174
                if ($entity === false) { // entity was removed
5✔
UNCOV
175
                        return null;
×
176
                } elseif ($entity instanceof IEntity) {
5✔
177
                        return $entity;
5✔
178
                }
179

180
                $entity = $this->findAll()->getBy(['id' => $id]);
5✔
181
                if ($entity === null) {
5✔
182
                        $this->identityMap->remove($id);
5✔
183
                }
184

185
                return $entity;
5✔
186
        }
187

188

189
        public function getByIdChecked($id): IEntity
190
        {
191
                $entity = $this->getById($id);
5✔
192
                if ($entity === null) {
5✔
193
                        throw new NoResultException();
×
194
                }
195
                return $entity;
5✔
196
        }
197

198

199
        public function findAll(): ICollection
200
        {
201
                return $this->mapper->findAll();
5✔
202
        }
203

204

205
        public function findBy(array $conds): ICollection
206
        {
207
                return $this->findAll()->findBy($conds);
5✔
208
        }
209

210

211
        public function findByIds(array $ids): ICollection
212
        {
213
                $entities = [];
5✔
214
                $missingEntities = false;
5✔
215

216
                foreach ($ids as $id) {
5✔
217
                        $entity = $this->identityMap->getById($id);
5✔
218
                        if ($entity === null || $entity === false) {
5✔
219
                                $missingEntities = true;
5✔
220
                                break;
5✔
221
                        }
222
                        $entities[] = $entity;
5✔
223
                }
224

225
                if (!$missingEntities) {
5✔
226
                        return new ArrayCollection($entities, $this);
5✔
227
                }
228

229
                return $this->findAll()->findBy(['id' => $ids]);
5✔
230
        }
231

232

233
        public function getCollectionFunction(string $name): CollectionFunction
234
        {
235
                if (!isset($this->collectionFunctions[$name])) {
5✔
236
                        $this->collectionFunctions[$name] = $this->createCollectionFunction($name);
5✔
237
                }
238
                return $this->collectionFunctions[$name];
5✔
239
        }
240

241

242
        protected function createCollectionFunction(string $name): CollectionFunction
243
        {
244
                /** @var array<class-string<CollectionFunction>, true> $knownFunctions */
245
                static $knownFunctions = [
5✔
246
                        FetchPropertyFunction::class => true,
247
                        CompareEqualsFunction::class => true,
248
                        CompareGreaterThanEqualsFunction::class => true,
249
                        CompareGreaterThanFunction::class => true,
250
                        CompareNotEqualsFunction::class => true,
251
                        CompareSmallerThanEqualsFunction::class => true,
252
                        CompareSmallerThanFunction::class => true,
253
                        CompareLikeFunction::class => true,
254
                        AvgAggregateFunction::class => true,
255
                        CountAggregateFunction::class => true,
256
                        MaxAggregateFunction::class => true,
257
                        MinAggregateFunction::class => true,
258
                        SumAggregateFunction::class => true,
259
                ];
260

261
                if ($name === FetchPropertyFunction::class) {
5✔
262
                        return new FetchPropertyFunction($this, $this->mapper, $this->getModel());
5✔
263
                } elseif ($name === ConjunctionOperatorFunction::class) {
5✔
264
                        return new ConjunctionOperatorFunction($this->getConditionParser());
5✔
265
                } elseif ($name === DisjunctionOperatorFunction::class) {
5✔
266
                        return new DisjunctionOperatorFunction($this->getConditionParser());
5✔
267
                }
268

269
                if (isset($knownFunctions[$name])) {
5✔
270
                        /** @var CollectionFunction $function */
271
                        $function = new $name();
5✔
272
                        return $function;
5✔
273
                } else {
274
                        throw new NotImplementedException('Override ' . get_class($this) . '::createCollectionFunction() to return an instance of ' . $name . ' collection function.');
×
275
                }
276
        }
277

278

279
        public function attach(IEntity $entity): void
280
        {
281
                if (!$entity->isAttached()) {
5✔
282
                        $entity->onAttach($this, MetadataStorage::get(get_class($entity)));
5✔
283
                        if ($this->dependencyProvider !== null) {
5✔
284
                                $this->dependencyProvider->injectDependencies($entity);
5✔
285
                        }
286
                }
287
        }
5✔
288

289

290
        public function detach(IEntity $entity): void
291
        {
292
                if ($entity->isAttached()) {
5✔
293
                        $entity->onDetach();
5✔
294
                }
295
        }
5✔
296

297

298
        public function hydrateEntity(array $data): ?IEntity
299
        {
300
                return $this->identityMap->create($data);
5✔
301
        }
302

303

304
        public function getEntityMetadata(string|null $entityClass = null): EntityMetadata
305
        {
306
                $classNames = static::getEntityClassNames();
5✔
307
                if ($entityClass !== null && !in_array($entityClass, $classNames, true)) {
5✔
308
                        throw new InvalidArgumentException("Class '$entityClass' is not accepted by '" . get_class($this) . "' repository.");
×
309
                }
310
                return MetadataStorage::get($entityClass ?? $classNames[0]);
5✔
311
        }
312

313

314
        public function getEntityClassName(array $data): string
315
        {
316
                if ($this->entityClassName === null) {
5✔
317
                        /** @var class-string<E> $entityClassName */
318
                        $entityClassName = static::getEntityClassNames()[0];
5✔
319
                        $this->entityClassName = $entityClassName;
5✔
320
                }
321

322
                return $this->entityClassName;
5✔
323
        }
324

325

326
        public function getConditionParser(): ConditionParser
327
        {
328
                if ($this->conditionParser === null) {
5✔
329
                        $this->conditionParser = new ConditionParser();
5✔
330
                }
331
                return $this->conditionParser;
5✔
332
        }
333

334

335
        public function persist(IEntity $entity, bool $withCascade = true): IEntity
336
        {
337
                $this->identityMap->check($entity);
5✔
338
                $this->getModel()->persist($entity, $withCascade);
5✔
339
                return $entity;
5✔
340
        }
341

342

343
        public function doPersist(IEntity $entity): void
344
        {
345
                if (!$entity->isModified()) {
5✔
346
                        return;
5✔
347
                }
348

349
                $isPersisted = $entity->isPersisted();
5✔
350
                if ($isPersisted) {
5✔
351
                        $this->onBeforeUpdate($entity);
5✔
352
                        $this->identityMap->remove($entity->getPersistedId()); // id can change in composite key
5✔
353
                } else {
354
                        $this->onBeforeInsert($entity);
5✔
355
                }
356

357
                $this->mapper->persist($entity);
5✔
358
                $this->identityMap->add($entity);
5✔
359
                $this->entitiesToFlush[0][] = $entity;
5✔
360

361
                if ($isPersisted) {
5✔
362
                        $this->onAfterUpdate($entity);
5✔
363
                } else {
364
                        $this->onAfterInsert($entity);
5✔
365
                }
366
                $this->onAfterPersist($entity);
5✔
367
        }
5✔
368

369

370
        public function remove(IEntity $entity, bool $withCascade = true): IEntity
371
        {
372
                $this->identityMap->check($entity);
5✔
373
                return $this->getModel()->remove($entity, $withCascade);
5✔
374
        }
375

376

377
        public function doRemove(IEntity $entity): void
378
        {
379
                $this->detach($entity);
5✔
380
                if (!$entity->isPersisted()) {
5✔
381
                        return;
×
382
                }
383

384
                $this->mapper->remove($entity);
5✔
385
                $this->identityMap->remove($entity->getPersistedId());
5✔
386
                $this->entitiesToFlush[1][] = $entity;
5✔
387
                $this->onAfterRemove($entity);
5✔
388
        }
5✔
389

390

391
        public function flush(): void
392
        {
393
                $this->getModel()->flush();
5✔
394
        }
5✔
395

396

397
        public function persistAndFlush(IEntity $entity, bool $withCascade = true): IEntity
398
        {
399
                $this->persist($entity, $withCascade);
5✔
400
                $this->flush();
5✔
401
                return $entity;
5✔
402
        }
403

404

405
        public function removeAndFlush(IEntity $entity, bool $withCascade = true): IEntity
406
        {
407
                $this->remove($entity, $withCascade);
5✔
408
                $this->flush();
5✔
409
                return $entity;
5✔
410
        }
411

412

413
        public function doFlush(): array
414
        {
415
                $this->mapper->flush();
5✔
416
                $this->onFlush($this->entitiesToFlush[0], $this->entitiesToFlush[1]);
5✔
417
                $entities = $this->entitiesToFlush;
5✔
418
                $this->entitiesToFlush = [[], []];
5✔
419
                return $entities;
5✔
420
        }
421

422

423
        public function doClear(): void
424
        {
425
                $this->identityMap->destroyAllEntities();
5✔
426
                $this->mapper->clearCache();
5✔
427
        }
5✔
428

429

430
        /**
431
         * @param mixed[] $args
432
         * @return mixed
433
         */
434
        public function __call(string $method, array $args)
435
        {
436
                if (isset($this->proxyMethods[strtolower($method)])) {
5✔
437
                        $callback = [$this->mapper, $method];
5✔
438
                        assert(is_callable($callback));
439
                        return call_user_func_array($callback, $args);
5✔
440
                } else {
441
                        $class = get_class($this);
×
442
                        throw new MemberAccessException("Undefined $class::$method() (proxy)method.");
×
443
                }
444
        }
445

446

447
        public function doRefreshAll(bool $allowOverwrite): void
448
        {
449
                $ids = [];
5✔
450
                $entities = $this->identityMap->getAll();
5✔
451
                foreach ($entities as $entity) {
5✔
452
                        if (!$entity->isPersisted()) {
5✔
453
                                continue;
×
454
                        } elseif (!$allowOverwrite && $entity->isModified()) {
5✔
455
                                throw new InvalidStateException('Cannot refresh modified entity, flush changes first or set $allowOverwrite flag to true.');
5✔
456
                        }
457
                        $this->identityMap->markForRefresh($entity);
5✔
458
                        $ids[] = $entity->getPersistedId();
5✔
459
                }
460
                if (count($ids) > 0) {
5✔
461
                        sort($ids); // make ids sorted deterministically
5✔
462
                        $this->findByIds($ids)->fetchAll();
5✔
463
                }
464
                foreach ($entities as $entity) {
5✔
465
                        if (!$this->identityMap->isMarkedForRefresh($entity)) {
5✔
466
                                continue;
5✔
467
                        }
468
                        $this->detach($entity);
5✔
469
                        $this->identityMap->remove($entity->getPersistedId());
5✔
470
                        $this->onAfterRemove($entity);
5✔
471
                }
472
                $this->mapper->clearCache();
5✔
473
        }
5✔
474

475

476
        public function onBeforePersist(IEntity $entity): void
477
        {
478
                $entity->onBeforePersist();
5✔
479
                foreach ($this->onBeforePersist as $handler) {
5✔
480
                        call_user_func($handler, $entity);
5✔
481
                }
482
        }
5✔
483

484

485
        public function onAfterPersist(IEntity $entity): void
486
        {
487
                $entity->onAfterPersist();
5✔
488
                foreach ($this->onAfterPersist as $handler) {
5✔
489
                        call_user_func($handler, $entity);
×
490
                }
491
        }
5✔
492

493

494
        public function onBeforeInsert(IEntity $entity): void
495
        {
496
                $entity->onBeforeInsert();
5✔
497
                foreach ($this->onBeforeInsert as $handler) {
5✔
498
                        call_user_func($handler, $entity);
×
499
                }
500
        }
5✔
501

502

503
        public function onAfterInsert(IEntity $entity): void
504
        {
505
                $entity->onAfterInsert();
5✔
506
                foreach ($this->onAfterInsert as $handler) {
5✔
507
                        call_user_func($handler, $entity);
×
508
                }
509
        }
5✔
510

511

512
        public function onBeforeUpdate(IEntity $entity): void
513
        {
514
                $entity->onBeforeUpdate();
5✔
515
                foreach ($this->onBeforeUpdate as $handler) {
5✔
516
                        call_user_func($handler, $entity);
×
517
                }
518
        }
5✔
519

520

521
        public function onAfterUpdate(IEntity $entity): void
522
        {
523
                $entity->onAfterUpdate();
5✔
524
                foreach ($this->onAfterUpdate as $handler) {
5✔
525
                        call_user_func($handler, $entity);
×
526
                }
527
        }
5✔
528

529

530
        public function onBeforeRemove(IEntity $entity): void
531
        {
532
                $entity->onBeforeRemove();
5✔
533
                foreach ($this->onBeforeRemove as $handler) {
5✔
534
                        call_user_func($handler, $entity);
×
535
                }
536
        }
5✔
537

538

539
        public function onAfterRemove(IEntity $entity): void
540
        {
541
                $entity->onAfterRemove();
5✔
542
                foreach ($this->onAfterRemove as $handler) {
5✔
543
                        call_user_func($handler, $entity);
×
544
                }
545
        }
5✔
546

547

548
        public function onFlush(array $persistedEntities, array $removedEntities): void
549
        {
550
                foreach ($this->onFlush as $handler) {
5✔
551
                        call_user_func($handler, $persistedEntities, $removedEntities);
5✔
552
                }
553
        }
5✔
554
}
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

© 2025 Coveralls, Inc