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

nextras / orm / 22264745434

21 Feb 2026 09:31PM UTC coverage: 92.11%. First build
22264745434

Pull #793

github

web-flow
Merge c2ae59b3a into 96b3f3a9a
Pull Request #793: relationship: implement removeOrphan cascade relationship [closes #205]

160 of 167 new or added lines in 4 files covered. (95.81%)

4273 of 4639 relevant lines covered (92.11%)

5.41 hits per line

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

95.95
/src/Repository/PersistenceHelper.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Orm\Repository;
4

5

6
use Nextras\Orm\Entity\IEntity;
7
use Nextras\Orm\Entity\Reflection\PropertyMetadata;
8
use Nextras\Orm\Entity\Reflection\PropertyRelationshipMetadata;
9
use Nextras\Orm\Entity\Reflection\PropertyRelationshipMetadata as Relationship;
10
use Nextras\Orm\Exception\InvalidStateException;
11
use Nextras\Orm\Model\IModel;
12
use Nextras\Orm\Relationships\HasMany;
13
use Nextras\Orm\Relationships\HasOne;
14
use Nextras\Orm\Relationships\IRelationshipCollection;
15
use Nextras\Orm\Relationships\IRelationshipContainer;
16
use Nextras\Orm\Relationships\ManyHasMany;
17
use function array_filter;
18
use function assert;
19

20

21
class PersistenceHelper
22
{
23
        /** @var array<int, IRelationshipCollection<IEntity>|IRelationshipContainer<IEntity>> */
24
        protected static array $inputQueue = [];
25

26
        /** @var array<int, IEntity|IRelationshipCollection<IEntity>|IRelationshipContainer<IEntity>|true> */
27
        protected static array $outputPersistQueue = [];
28

29
        /** @var array<int, IEntity|true> */
30
        protected static array $outputRemoveQueue = [];
31

32

33
        /**
34
         * @see https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
35
         * @return array{
36
         *     array<int, IEntity|IRelationshipCollection<IEntity>|IRelationshipContainer<IEntity>>,
37
         *     array<int, IEntity>,
38
         * }
39
         */
40
        public static function getCascadeQueue(
41
                IEntity $entity,
42
                PersistenceMode $mode,
43
                IModel $model,
44
                bool $withCascade,
45
        ): array
46
        {
47
                try {
48
                        self::visitEntity($entity, $mode, $model, $withCascade);
6✔
49

50
                        while (count(self::$inputQueue) > 0) {
6✔
51
                                $value = array_shift(self::$inputQueue);
6✔
52
                                self::visitRelationship($value, $mode, $model);
6✔
53
                        }
54

55
                        return [
56
                                array_filter(self::$outputPersistQueue, fn ($val) => assert($val !== true)),
6✔
57
                                array_filter(self::$outputRemoveQueue, fn ($val) => assert($val !== true)),
6✔
58
                        ];
59
                } finally {
60
                        self::$inputQueue = [];
6✔
61
                        self::$outputPersistQueue = [];
6✔
62
                        self::$outputRemoveQueue = [];
6✔
63
                }
64
        }
65

66

67
        protected static function visitEntity(
68
                IEntity $entity,
69
                PersistenceMode $mode,
70
                IModel $model,
71
                bool $withCascade = true,
72
        ): void
73
        {
74
                $entityId = spl_object_id($entity);
6✔
75
                $checkedQueue = match ($mode) {
6✔
76
                        PersistenceMode::Persist => self::$outputPersistQueue,
6✔
77
                        PersistenceMode::Remove => self::$outputRemoveQueue,
6✔
78
                };
79
                if (isset($checkedQueue[$entityId])) {
6✔
80
                        if ($checkedQueue[$entityId] === true) {
6✔
81
                                $cycle = [];
6✔
82
                                $bt = debug_backtrace();
6✔
83
                                foreach ($bt as $item) {
6✔
84
                                        if ($item['function'] === 'getCascadeQueue') {
6✔
85
                                                break;
6✔
86
                                        } elseif ($item['function'] === 'enqueueRelationship' && isset($item['args'])) {
6✔
87
                                                $cycle[] = get_class($item['args'][0]) . '::$' . $item['args'][2]->name;
6✔
88
                                        }
89
                                }
90
                                $cycle = array_reverse($cycle);
6✔
91
                                throw new InvalidStateException('Persist cycle detected in ' . implode(' - ', $cycle) . '. Use manual two-phase persist.');
6✔
92
                        }
93
                        return;
6✔
94
                }
95
                if ($mode === PersistenceMode::Persist && isset(self::$outputRemoveQueue[$entityId])) {
6✔
96
                        // this entity is already processed && scheduled for removal
97
                        return;
6✔
98
                }
99

100
                $repository = $model->getRepositoryForEntity($entity);
6✔
101
                $repository->attach($entity);
6✔
102
                if ($mode === PersistenceMode::Persist) {
6✔
103
                        $repository->onBeforePersist($entity);
6✔
104
                } else {
105
                        $repository->onBeforeRemove($entity);
6✔
106
                }
107

108
                if ($mode === PersistenceMode::Remove) {
6✔
109
                        self::$outputRemoveQueue[$entityId] = true;
6✔
110
                        foreach ($entity->getMetadata()->getProperties() as $propertyMeta) {
6✔
111
                                if ($propertyMeta->relationship !== null) {
6✔
112
                                        self::enqueueRelationship(
6✔
113
                                                entity: $entity,
6✔
114
                                                mode: $mode,
115
                                                propertyMeta: $propertyMeta,
116
                                                relationshipMeta: $propertyMeta->relationship,
6✔
117
                                                model: $model,
118
                                                nullOnly: !$withCascade,
6✔
119
                                        );
120
                                }
121
                        }
122
                        unset(self::$outputPersistQueue[$entityId]);
6✔
123
                        unset(self::$outputRemoveQueue[$entityId]); // re-enqueue
6✔
124
                        self::$outputRemoveQueue[$entityId] = $entity;
6✔
125
                } elseif ($withCascade) {
6✔
126
                        self::$outputPersistQueue[$entityId] = true;
6✔
127
                        foreach ($entity->getMetadata()->getProperties() as $propertyMeta) {
6✔
128
                                if ($propertyMeta->relationship !== null) {
6✔
129
                                        self::enqueueRelationship(
6✔
130
                                                entity: $entity,
6✔
131
                                                mode: $mode,
132
                                                propertyMeta: $propertyMeta,
133
                                                relationshipMeta: $propertyMeta->relationship,
6✔
134
                                                model: $model,
135
                                        );
136
                                }
137
                        }
138
                        unset(self::$outputPersistQueue[$entityId]); // re-enqueue
6✔
139
                        self::$outputPersistQueue[$entityId] = $entity;
6✔
140
                } else {
141
                        self::$outputPersistQueue[$entityId] = $entity;
6✔
142
                }
143
        }
6✔
144

145

146
        /**
147
         * @param IRelationshipCollection<IEntity>|IRelationshipContainer<IEntity> $rel
148
         */
149
        protected static function visitRelationship(
150
                IRelationshipContainer|IRelationshipCollection $rel,
151
                PersistenceMode $mode,
152
                IModel $model,
153
        ): void
154
        {
155
                foreach ($rel->getEntitiesForPersistence() as $entity) {
6✔
156
                        self::visitEntity($entity, $mode, $model);
6✔
157
                }
158

159
                self::$outputPersistQueue[spl_object_id($rel)] = $rel;
6✔
160
        }
6✔
161

162

163
        protected static function enqueueRelationship(
164
                IEntity $entity,
165
                PersistenceMode $mode,
166
                PropertyMetadata $propertyMeta,
167
                PropertyRelationshipMetadata $relationshipMeta,
168
                IModel $model,
169
                bool $nullOnly = false,
170
        ): void
171
        {
172
                if ($mode === PersistenceMode::Persist) {
6✔
173
                        if ($relationshipMeta->cascade['persist'] !== true) {
6✔
NEW
174
                                return;
×
175
                        }
176

177
                        $isPersisted = $entity->isPersisted();
6✔
178
                        $relationship = $entity->getRawProperty($propertyMeta->name);
6✔
179
                        if ($relationship === null || (!($relationship instanceof IRelationshipCollection || $relationship instanceof IRelationshipContainer) && $isPersisted)) {
6✔
180
                                // 1. relationship is not initialized at all
181
                                // 2. relationship has a scalar value and the entity is persisted -> no change
182
                                return;
6✔
183
                        }
184

185
                        assert($relationship instanceof IRelationshipCollection || $relationship instanceof IRelationshipContainer);
186
                        if (!$relationship->isLoaded() && $isPersisted) {
6✔
187
                                return;
6✔
188
                        }
189

190
                        if ($relationship instanceof IRelationshipCollection) {
6✔
191
                                $toRemoveFromRelationship = $relationship->getEntitiesForRemoval();
6✔
192
                                if (count($toRemoveFromRelationship) > 0) {
6✔
193
                                        if ($relationshipMeta->cascade['removeOrphan'] !== true) {
6✔
NEW
194
                                                throw new InvalidStateException("Orphan removal needed " . get_class($entity) . $entity->getValue('id'));
×
195
                                        }
196
                                        foreach ($toRemoveFromRelationship as $subEntity) {
6✔
197
                                                self::visitEntity($subEntity, PersistenceMode::Remove, $model);
6✔
198
                                        }
199
                                }
200
                        }
201

202
                        if ($relationship instanceof IRelationshipContainer) {
6✔
203
                                $immediateEntity = $relationship->getImmediateEntityForPersistence();
6✔
204
                                if ($immediateEntity !== null) {
6✔
205
                                        self::visitEntity($immediateEntity, $mode, $model);
6✔
206
                                }
207
                        }
208

209
                        self::$inputQueue[] = $relationship;
6✔
210
                } else {
211
                        if ($relationshipMeta->cascade['remove'] !== true) {
6✔
212
                                self::nullRelationship($entity, $propertyMeta, $relationshipMeta, $model);
6✔
213
                                return;
6✔
214
                        }
215

216
                        if ($nullOnly) {
6✔
NEW
217
                                return;
×
218
                        }
219

220
                        $rawValue = $entity->getRawValue($propertyMeta->name);
6✔
221
                        if ($rawValue === null && $propertyMeta->isNullable) {
6✔
222
                                return;
6✔
223
                        }
224

225
                        $relationship = $entity->getProperty($propertyMeta->name);
6✔
226
                        if ($relationship instanceof IRelationshipContainer) {
6✔
227
                                $canSkip = !$relationship->isLoaded() && !$relationshipMeta->isMain;
6✔
228
                                if ($canSkip) {
6✔
NEW
229
                                        return;
×
230
                                }
231
                                $value = $relationship->getEntity();
6✔
232
                                if ($value !== null) {
6✔
233
                                        if ($relationshipMeta->type === Relationship::ONE_HAS_ONE && !$relationshipMeta->isMain) {
6✔
234
                                                self::visitEntity($value, $mode, $model);
6✔
235
                                        } else {
236
                                                self::$inputQueue[] = $relationship;
6✔
237
                                        }
238
                                }
239
                        } elseif ($relationship instanceof IRelationshipCollection) {
6✔
240
                                foreach ($relationship->getIterator() as $subValue) {
6✔
241
                                        self::visitEntity($subValue, $mode, $model);
6✔
242
                                }
243
                                self::$outputPersistQueue[spl_object_id($relationship)] = $relationship;
6✔
244
                        }
245
                }
246
        }
6✔
247

248

249
        protected static function nullRelationship(
250
                IEntity $entity,
251
                PropertyMetadata $propertyMeta,
252
                PropertyRelationshipMetadata $relationshipMeta,
253
                IModel $model,
254
        ): void
255
        {
256
                $type = $relationshipMeta->type;
6✔
257
                $name = $propertyMeta->name;
6✔
258

259
                $reverseRepository = $model->getRepository($relationshipMeta->repository);
6✔
260
                $reversePropertyMeta = $relationshipMeta->property !== null
6✔
261
                        ? $reverseRepository->getEntityMetadata($relationshipMeta->entity)
6✔
262
                                ->getProperty($relationshipMeta->property)
6✔
NEW
263
                        : null;
×
264

265
                if ($type === Relationship::MANY_HAS_MANY) {
6✔
266
                        /** @var ManyHasMany<IEntity> $property */
267
                        $property = $entity->getProperty($name);
6✔
268
                        assert($property instanceof ManyHasMany);
269
                        self::$outputPersistQueue[spl_object_id($property)] = $property;
6✔
270
                        if ($reversePropertyMeta !== null) {
6✔
271
                                foreach ($property as $reverseEntity) {
6✔
272
                                        /** @var ManyHasMany<IEntity> $reverseRelationship */
273
                                        $reverseRelationship = $reverseEntity->getProperty($reversePropertyMeta->name);
6✔
274
                                        self::$outputPersistQueue[spl_object_id($reverseRelationship)] = $reverseRelationship;
6✔
275
                                }
276
                        }
277
                        $property->set([]);
6✔
278
                } elseif ($type === Relationship::MANY_HAS_ONE || $type === Relationship::ONE_HAS_ONE) {
6✔
279
                        $property = $entity->getProperty($name);
6✔
280
                        assert($property instanceof HasOne);
281
                        $canSkip = (!$property->isLoaded() && !$relationshipMeta->isMain) || $property->getRawValue() === null;
6✔
282
                        if ($canSkip) {
6✔
283
                                return;
6✔
284
                        }
285
                        if ($reversePropertyMeta !== null) {
6✔
286
                                $reverseEntity = $property->getEntity();
6✔
287
                                if ($reverseEntity === null || isset(self::$outputRemoveQueue[spl_object_id($reverseEntity)])) {
6✔
288
                                        // The reverse side is also being removed, do not set null to this relationship.
289
                                        return;
6✔
290
                                }
291
                                /** @var HasOne<IEntity> $reverseRelationship */
292
                                $reverseRelationship = $reverseEntity->getProperty($reversePropertyMeta->name);
6✔
293
                                self::$outputPersistQueue[spl_object_id($reverseRelationship)] = $reverseRelationship;
6✔
294
                                self::$outputPersistQueue[spl_object_id($reverseEntity)] = $reverseEntity;
6✔
295
                        }
296
                        $property->set(null, allowNull: true);
6✔
297
                } else {
298
                        // $type === Relationship::ONE_HAS_MANY
299
                        if ($reversePropertyMeta === null) {
6✔
NEW
300
                                return;
×
301
                        }
302

303
                        $value = $entity->getValue($name);
6✔
304
                        if ($value instanceof HasMany && $value->count() === 0) {
6✔
305
                                return;
6✔
306
                        }
307

308
                        if ($reversePropertyMeta->isNullable) {
6✔
309
                                $property = $entity->getProperty($name);
6✔
310
                                assert($property instanceof IRelationshipCollection);
311
                                foreach ($property as $subValue) {
6✔
312
                                        assert($subValue instanceof IEntity);
313
                                        if (!isset(self::$outputRemoveQueue[spl_object_id($subValue)])) {
6✔
314
                                                self::$outputPersistQueue[spl_object_id($subValue)] = $subValue;
6✔
315
                                        }
316
                                }
317
                                $property->set([]);
6✔
318
                        } else {
319
                                $entityClass = get_class($entity);
6✔
320
                                $reverseEntityClass = $relationshipMeta->entity;
6✔
321
                                $primaryValue = $entity->getValue('id');
6✔
322
                                $primaryValue = is_array($primaryValue) ? '[' . implode(', ', $primaryValue) . ']' : $primaryValue;
6✔
323
                                throw new InvalidStateException(
6✔
324
                                        "Cannot remove {$entityClass}::\$id={$primaryValue} because {$reverseEntityClass}::\${$reversePropertyMeta->name} cannot be a null.",
6✔
325
                                );
326
                        }
327
                }
328
        }
6✔
329
}
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