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

nextras / orm / 12612856313

04 Jan 2025 06:40PM UTC coverage: 92.071% (+0.02%) from 92.053%
12612856313

Pull #717

github

web-flow
Merge c17f47bec into f56f253de
Pull Request #717: Fix OneHasOne relationship when loading from non-main side

27 of 27 new or added lines in 4 files covered. (100.0%)

5 existing lines in 1 file now uncovered.

4064 of 4414 relevant lines covered (92.07%)

4.59 hits per line

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

92.5
/src/Relationships/HasOne.php
1
<?php declare(strict_types = 1);
2

3
namespace Nextras\Orm\Relationships;
4

5

6
use Nette\SmartObject;
7
use Nextras\Orm\Collection\ICollection;
8
use Nextras\Orm\Entity\IEntity;
9
use Nextras\Orm\Entity\Reflection\PropertyMetadata;
10
use Nextras\Orm\Entity\Reflection\PropertyRelationshipMetadata;
11
use Nextras\Orm\Exception\InvalidArgumentException;
12
use Nextras\Orm\Exception\InvalidStateException;
13
use Nextras\Orm\Exception\NullValueException;
14
use Nextras\Orm\Repository\IRepository;
15
use function _PHPStan_c875e8309\React\Promise\all;
16
use function assert;
17

18

19
/**
20
 * @template E of IEntity
21
 * @implements IRelationshipContainer<E>
22
 */
23
abstract class HasOne implements IRelationshipContainer
24
{
25
        use SmartObject;
26

27

28
        /** @var E|null */
29
        protected ?IEntity $parent = null;
30

31
        /** @var ICollection<E>|null */
32
        protected ?ICollection $collection = null;
33

34
        /**
35
         * Denotes if the value is validated, i.e. if the value is pk of valid entity or if the value is not null when it is
36
         * disallowed.
37
         *
38
         * By default, relationship is validated because no initial value has been set yet.
39
         * The first setRawValue will change that to false (with exception on null, which won't be validated later).
40
         */
41
        protected bool $isValueValidated = true;
42

43
        /**
44
         * Denotes if the value is present. Value is not present when this relationship side
45
         * is not the main one and the reverse side was not yet asked to get the initial value.
46
         * After setting this value in runtime, the value is always present.
47
         *
48
         * If value is not present and is worked with, it is fetched via {@see fetchValue()}.
49
         */
50
        protected bool $isValuePresent = true;
51

52
        /** @var E|string|int|null */
53
        protected mixed $value = null;
54

55
        /** @var list<E> */
56
        protected array $tracked = [];
57

58
        /** @var IRepository<E>|null */
59
        protected ?IRepository $targetRepository = null;
60

61
        protected bool $updatingReverseRelationship = false;
62
        protected bool $isModified = false;
63

64
        protected PropertyRelationshipMetadata $metadataRelationship;
65

66

67
        public function __construct(
5✔
68
                protected readonly PropertyMetadata $metadata,
69
        )
70
        {
71
                assert($metadata->relationship !== null);
72
                $this->metadataRelationship = $metadata->relationship;
5✔
73
        }
5✔
74

75

76
        public function onEntityAttach(IEntity $entity): void
77
        {
78
                $this->parent = $entity;
5✔
79
        }
5✔
80

81

82
        public function onEntityRepositoryAttach(IEntity $entity): void
83
        {
84
                if (!$this->isValueValidated) {
5✔
85
                        $this->getEntity();
5✔
86
                        if ($this->value instanceof IEntity) {
5✔
87
                                $this->attachIfPossible($this->value);
5✔
88
                        }
89
                }
90
        }
5✔
91

92

93
        public function convertToRawValue($value)
94
        {
95
                if ($value instanceof IEntity) {
5✔
96
                        return $value->getValue('id');
5✔
97
                }
98
                return $value;
5✔
99
        }
100

101

102
        public function setRawValue($value): void
103
        {
104
                $this->value = $value;
5✔
105
                $this->isValueValidated = $value === null;
5✔
106
                $this->isValuePresent = true;
5✔
107
        }
5✔
108

109

110
        public function getRawValue()
111
        {
112
                return $this->getPrimaryValue();
5✔
113
        }
114

115

116
        public function setInjectedValue($value): bool
117
        {
118
                return $this->set($value);
5✔
119
        }
120

121

122
        public function &getInjectedValue()
123
        {
124
                $value = $this->getEntity();
5✔
125
                return $value;
5✔
126
        }
127

128

129
        public function hasInjectedValue(): bool
130
        {
131
                return $this->value !== null;
5✔
132
        }
133

134

135
        public function isLoaded(): bool
136
        {
137
                return $this->value instanceof IEntity;
5✔
138
        }
139

140

141
        /**
142
         * Sets the relationship value to passed entity.
143
         *
144
         * Returns true if the setter has modified property value.
145
         * @param E|int|string|null $value Accepts also a primary key value.
146
         * @param bool $allowNull Allows setting null when the property type does not allow it.
147
         *                        This flag is used for any of the relationship sides.
148
         *                        The invalid entity has to be either removed or its property has to be reset with a proper value.
149
         */
150
        public function set($value, bool $allowNull = false): bool
151
        {
152
                if ($this->updatingReverseRelationship) {
5✔
153
                        return false;
5✔
154
                }
155

156
                if ($this->parent?->isAttached() === true || $value === null) {
5✔
157
                        $entity = $this->createEntity($value, allowNull: $allowNull);
5✔
158
                } else {
159
                        $entity = $value;
5✔
160
                }
161

162
                if ($entity instanceof IEntity || $entity === null) {
5✔
163
                        $isChanged = $this->isChanged($entity);
5✔
164
                        if ($isChanged) {
5✔
165
                                $this->modify();
5✔
166
                                $oldEntity = $this->getValue(allowPreloadContainer: false);
5✔
167
                                if ($oldEntity !== null) {
5✔
168
                                        $this->tracked[] = $oldEntity;
5✔
169
                                }
170
                                $this->updateRelationship($oldEntity, $entity, $allowNull);
5✔
171
                        } else {
172
                                $this->initReverseRelationship($entity);
5✔
173
                        }
174
                } else {
175
                        $this->modify();
5✔
176
                        $isChanged = true;
5✔
177
                }
178

179
                $this->value = $entity;
5✔
180
                $this->isValueValidated = $entity === null || $entity instanceof IEntity;
5✔
181
                $this->isValuePresent = true;
5✔
182
                return $isChanged;
5✔
183
        }
184

185

186
        public function getEntity(): ?IEntity
187
        {
188
                $value = $this->getValue();
5✔
189

190
                if ($value === null && !$this->metadata->isNullable) {
5✔
191
                        assert($this->parent !== null);
192
                        throw new NullValueException($this->metadata);
5✔
193
                }
194

195
                return $value;
5✔
196
        }
197

198

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

204

205
        /**
206
         * @return mixed|null
207
         */
208
        protected function getPrimaryValue(): mixed
209
        {
210
                if ($this->value instanceof IEntity) {
5✔
211
                        if ($this->value->hasValue('id')) {
5✔
212
                                return $this->value->getValue('id');
5✔
213
                        } else {
UNCOV
214
                                return null;
×
215
                        }
216
                } else {
217
                        return $this->value;
5✔
218
                }
219
        }
220

221

222
        /**
223
         * @return E|null
224
         */
225
        protected function getValue(bool $allowPreloadContainer = true): ?IEntity
226
        {
227
                if ((!$this->isValueValidated && ($this->value !== null || $this->metadata->isNullable)) || !$this->isValuePresent) {
5✔
228
                        $this->initValue($allowPreloadContainer);
5✔
229
                }
230

231
                assert($this->value instanceof IEntity || $this->value === null);
232
                return $this->value;
5✔
233
        }
234

235

236
        protected function initValue(bool $allowPreloadContainer = true): void
237
        {
238
                if ($this->parent === null) {
5✔
UNCOV
239
                        throw new InvalidStateException('Relationship is not attached to a parent entity.');
×
240
                }
241

242
                if (!$this->isValuePresent || $allowPreloadContainer) {
5✔
243
                        // load the value using relationship mapper to utilize preload container to avoid validation if the
244
                        // relationship's entity is actually present in the database;
245
                        $this->set($this->fetchValue());
5✔
246

247
                } else {
248
                        $this->set($this->value);
5✔
249
                }
250
        }
5✔
251

252

253
        /**
254
         * @return E|null
255
         */
256
        protected function fetchValue(): ?IEntity
257
        {
258
                $collection = $this->getCollection();
5✔
259
                return iterator_to_array($collection->getIterator())[0] ?? null;
5✔
260
        }
261

262

263
        /**
264
         * @return IRepository<E>
265
         */
266
        protected function getTargetRepository(): IRepository
267
        {
268
                if ($this->targetRepository === null) {
5✔
269
                        /** @var IRepository<E> $targetRepository */
270
                        $targetRepository = $this->getParentEntity()
5✔
271
                                ->getRepository()
5✔
272
                                ->getModel()
5✔
273
                                ->getRepository($this->metadataRelationship->repository);
5✔
274
                        $this->targetRepository = $targetRepository;
5✔
275
                }
276

277
                return $this->targetRepository;
5✔
278
        }
279

280

281
        /**
282
         * @return ICollection<E>
283
         */
284
        protected function getCollection(): ICollection
285
        {
286
                $this->collection ??= $this->createCollection();
5✔
287
                return $this->collection;
5✔
288
        }
289

290

291
        /**
292
         * @return E
293
         */
294
        protected function getParentEntity(): IEntity
295
        {
296
                return $this->parent ?? throw new InvalidStateException('Relationship is not attached to a parent entity.');
5✔
297
        }
298

299

300
        /**
301
         * @param E|string|int|null $entity
302
         * @return E|null
303
         */
304
        protected function createEntity($entity, bool $allowNull): ?IEntity
305
        {
306
                if ($entity instanceof IEntity) {
5✔
307
                        $this->attachIfPossible($entity);
5✔
308
                        return $entity;
5✔
309

310
                } elseif ($entity === null) {
5✔
311
                        if (!$this->metadata->isNullable && !$allowNull) {
5✔
312
                                throw new NullValueException($this->metadata);
5✔
313
                        }
314
                        return null;
5✔
315

316
                } elseif (is_scalar($entity)) {
5✔
317
                        return $this->getTargetRepository()->getByIdChecked($entity);
5✔
318

319
                } else {
UNCOV
320
                        throw new InvalidArgumentException('Value is not a valid entity representation.');
×
321
                }
322
        }
323

324

325
        protected function attachIfPossible(IEntity $entity): void
326
        {
327
                if ($this->parent === null) return;
5✔
328

329
                if ($this->parent->isAttached() && !$entity->isAttached()) {
5✔
330
                        $model = $this->parent->getRepository()->getModel();
5✔
331
                        $repository = $model->getRepository($this->metadataRelationship->repository);
5✔
332
                        $repository->attach($entity);
5✔
333

334
                } elseif ($entity->isAttached() && !$this->parent->isAttached()) {
5✔
335
                        $model = $entity->getRepository()->getModel();
×
336
                        $repository = $model->getRepositoryForEntity($this->parent);
×
UNCOV
337
                        $repository->attach($this->parent);
×
338
                }
339
        }
5✔
340

341

342
        protected function isChanged(?IEntity $newValue): bool
343
        {
344
                if ($this->value instanceof IEntity && $newValue instanceof IEntity) {
5✔
345
                        return $this->value !== $newValue;
5✔
346

347
                } elseif ($this->value instanceof IEntity) {
5✔
348
                        // value is an entity
349
                        // newValue is null
350
                        return true;
5✔
351

352
                } else if (!$this->isValuePresent) {
5✔
353
                        // before initial load, we cannot detect changes
354
                        return false;
5✔
355

356
                } elseif ($newValue instanceof IEntity && $newValue->isPersisted()) {
5✔
357
                        // value is persisted entity or null
358
                        // newValue is persisted entity
359
                        $oldValueId = $this->getPrimaryValue();
5✔
360
                        $newValueId = $newValue->getValue('id');
5✔
361
                        if ($oldValueId !== null && gettype($oldValueId) !== gettype($newValueId)) {
5✔
362
                                throw new InvalidStateException(
×
363
                                        'The primary value types (' . gettype($oldValueId) . ', ' . gettype($newValueId)
×
UNCOV
364
                                        . ') are not equal, possible misconfiguration in entity definition.',
×
365
                                );
366
                        }
367
                        return $oldValueId !== $newValueId;
5✔
368

369
                } else {
370
                        // value is persisted entity or null
371
                        // newValue is null
372
                        return $this->getPrimaryValue() !== $newValue;
5✔
373
                }
374
        }
375

376

377
        public function getEntitiesForPersistence(): array
378
        {
379
                $entity = $this->getEntity();
5✔
380
                $isImmediate = $this->isImmediateEntityForPersistence($entity);
5✔
381

382
                if ($isImmediate || $entity === null) {
5✔
383
                        return $this->tracked;
5✔
384
                } else {
385
                        return $this->tracked + [$entity];
5✔
386
                }
387
        }
388

389

390
        public function getImmediateEntityForPersistence(): ?IEntity
391
        {
392
                $entity = $this->getEntity();
5✔
393
                if ($this->isImmediateEntityForPersistence($entity)) {
5✔
394
                        return $entity;
5✔
395
                } else {
396
                        return null;
5✔
397
                }
398
        }
399

400

401
        public function doPersist(): void
402
        {
403
                $this->tracked = [];
5✔
404
        }
5✔
405

406

407
        abstract protected function isImmediateEntityForPersistence(?IEntity $entity): bool;
408

409

410
        /**
411
         * Creates relationship collection.
412
         * @return ICollection<E>
413
         */
414
        abstract protected function createCollection(): ICollection;
415

416

417
        /**
418
         * Sets relationship (and entity) as modified.
419
         */
420
        abstract protected function modify(): void;
421

422

423
        /**
424
         * Updates relationship on the other side.
425
         */
426
        abstract protected function updateRelationship(?IEntity $oldEntity, ?IEntity $newEntity, bool $allowNull): void;
427

428

429
        abstract protected function initReverseRelationship(?IEntity $currentEntity): void;
430
}
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