A
last analyzed

Complexity

Total Complexity 2

Size/Duplication

Total Lines 13
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 2
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ArpTest\DoctrineEntityRepository\Persistence;
6
7
use Arp\DoctrineEntityRepository\EntityRepositoryInterface;
8
use Arp\DoctrineEntityRepository\Exception\EntityRepositoryException;
9
use Arp\DoctrineEntityRepository\Persistence\CascadeSaveService;
10
use Arp\DoctrineEntityRepository\Persistence\Exception\PersistenceException;
11
use Arp\Entity\EntityInterface;
12
use Arp\Entity\EntityTrait;
13
use Doctrine\ORM\EntityManagerInterface;
14
use Doctrine\ORM\Mapping\ClassMetadata;
15
use Doctrine\ORM\Mapping\ClassMetadataFactory;
16
use PHPUnit\Framework\MockObject\MockObject;
17
use PHPUnit\Framework\TestCase;
18
use Psr\Log\LoggerInterface;
19
20
/**
21
 * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService
22
 *
23
 * @author  Alex Patterson <[email protected]>
24
 * @package ArpTest\DoctrineEntityRepository\Persistence
25
 */
26
final class CascadeSaveServiceTest extends TestCase
27
{
28
    /**
29
     * @var EntityManagerInterface&MockObject
30
     */
31
    private $entityManager;
32
33
    /**
34
     * @var LoggerInterface&MockObject
35
     */
36
    private $logger;
37
38
    /**
39
     * @var array<string|int, mixed>
40
     */
41
    private array $options = [];
42
43
    /**
44
     * @var array<int|string, mixed>
45
     */
46
    private array $collectionOptions = [];
47
48
    /**
49
     * Prepare the test case dependencies.
50
     */
51
    public function setUp(): void
52
    {
53
        $this->logger = $this->getMockForAbstractClass(LoggerInterface::class);
54
55
        $this->entityManager = $this->getMockForAbstractClass(EntityManagerInterface::class);
56
    }
57
58
    /**
59
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
60
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
61
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
62
     *
63
     * @throws PersistenceException
64
     * @throws EntityRepositoryException
65
     */
66
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityNameIsInvalid(): void
67
    {
68
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
69
70
        $entityName = EntityInterface::class;
71
72
        /** @var EntityInterface $entity */
73
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
74
75
        /** @var ClassMetadataFactory&MockObject $metadataFactory */
76
        $metadataFactory = $this->createMock(ClassMetadataFactory::class);
77
78
        $this->entityManager->expects($this->once())
79
            ->method('getMetadataFactory')
80
            ->willReturn($metadataFactory);
81
82
        $metadataFactory->expects($this->once())
83
            ->method('hasMetadataFor')
84
            ->with($entityName)
85
            ->willReturn(false);
86
87
        $errorMessage = sprintf('The target repository class \'%s\' could not be found', $entityName);
88
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
89
90
        $this->expectException(PersistenceException::class);
91
        $this->expectExceptionMessage($errorMessage);
92
93
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
94
    }
95
96
    /**
97
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
98
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
99
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
100
     *
101
     * @throws PersistenceException
102
     * @throws EntityRepositoryException
103
     */
104
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityRepositoryCannotBeLoaded(): void
105
    {
106
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
107
108
        $entityName = EntityInterface::class;
109
110
        /** @var EntityInterface $entity */
111
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
112
113
        $exceptionMessage = 'This is a test exception message';
114
        $exception = new \Exception($exceptionMessage, 123);
115
116
        if (!class_exists($entityName, true)) {
117
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
118
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
119
120
            $this->entityManager->expects($this->once())
121
                ->method('getMetadataFactory')
122
                ->willReturn($metadataFactory);
123
124
            $metadataFactory->expects($this->once())
125
                ->method('hasMetadataFor')
126
                ->with($entityName)
127
                ->willReturn(true);
128
        }
129
130
        $this->entityManager->expects($this->once())
131
            ->method('getRepository')
132
            ->with($entityName)
133
            ->willThrowException($exception);
134
135
        $errorMessage = sprintf(
136
            'An error occurred while attempting to load the repository for entity class \'%s\' : %s',
137
            $entityName,
138
            $exceptionMessage
139
        );
140
141
        $this->logger->expects($this->once())
142
            ->method('error')
143
            ->with($errorMessage, compact('exception'));
144
145
        $this->expectException(PersistenceException::class);
146
        $this->expectExceptionMessage($errorMessage);
147
        $this->expectExceptionCode($exception->getCode());
148
149
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
150
    }
151
152
    /**
153
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
154
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
155
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
156
     *
157
     * @throws PersistenceException
158
     * @throws EntityRepositoryException
159
     */
160
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityRepositoryIsInvalid(): void
161
    {
162
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
163
164
        $entityName = EntityInterface::class;
165
166
        /** @var EntityInterface $entity */
167
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
168
169
        $entityRepository = new \stdClass();
170
171
        if (!class_exists($entityName, true)) {
172
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
173
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
174
175
            $this->entityManager->expects($this->once())
176
                ->method('getMetadataFactory')
177
                ->willReturn($metadataFactory);
178
179
            $metadataFactory->expects($this->once())
180
                ->method('hasMetadataFor')
181
                ->with($entityName)
182
                ->willReturn(true);
183
        }
184
185
        $this->entityManager->expects($this->once())
186
            ->method('getRepository')
187
            ->with($entityName)
188
            ->willReturn($entityRepository);
189
190
        $errorMessage = sprintf(
191
            'The entity repository must be an object of type \'%s\'; \'%s\' returned in \'%s::%s\'',
192
            EntityRepositoryInterface::class,
193
            get_class($entityRepository),
194
            CascadeSaveService::class,
195
            'getTargetRepository'
196
        );
197
198
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
199
200
        $this->expectException(PersistenceException::class);
201
        $this->expectExceptionMessage($errorMessage);
202
203
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
204
    }
205
206
    /**
207
     * Assert that an PersistenceException is thrown if an invalid entity or collection value is
208
     * passed to the saveAssociation() method.
209
     *
210
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
211
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
212
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
213
     *
214
     * @throws PersistenceException
215
     * @throws EntityRepositoryException
216
     */
217
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityOrCollectionIsInvalid(): void
218
    {
219
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
220
221
        $targetEntityName = EntityInterface::class;
222
223
        /** @var EntityInterface|\stdClass $entityOrCollection */
224
        $entityOrCollection = new \stdClass();
225
226
        $errorMessage = sprintf(
227
            'Unable to cascade save target entity \'%s\': The entity or collection is of an invalid type \'%s\'',
228
            $targetEntityName,
229
            \stdClass::class
230
        );
231
232
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
233
234
        $this->expectException(PersistenceException::class);
235
        $this->expectExceptionMessage($errorMessage);
236
237
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection);
238
    }
239
240
    /**
241
     * @param array<string|int, mixed> $options
242
     *
243
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
244
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
245
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
246
     *
247
     * @throws EntityRepositoryException
248
     * @throws PersistenceException
249
     */
250
    public function testSaveAssociationWillSaveEntity(array $options = []): void
251
    {
252
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
253
254
        $targetEntityName = EntityInterface::class;
255
256
        /** @var EntityInterface&MockObject $entityOrCollection */
257
        $entityOrCollection = $this->getMockForAbstractClass(EntityInterface::class);
258
259
        /** @var EntityRepositoryInterface&MockObject $entityRepository */
260
        $entityRepository = $this->getMockForAbstractClass(EntityRepositoryInterface::class);
261
262
        if (!class_exists($targetEntityName, true)) {
263
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
264
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
265
266
            $this->entityManager->expects($this->once())
267
                ->method('getMetadataFactory')
268
                ->willReturn($metadataFactory);
269
270
            $metadataFactory->expects($this->once())
271
                ->method('hasMetadataFor')
272
                ->with($targetEntityName)
273
                ->willReturn(true);
274
        }
275
276
        $this->entityManager->expects($this->once())
277
            ->method('getRepository')
278
            ->with($targetEntityName)
279
            ->willReturn($entityRepository);
280
281
        $entityRepository->expects($this->once())
282
            ->method('save')
283
            ->with($entityOrCollection, $options);
284
285
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection, $options);
286
    }
287
288
    /**
289
     * @param array<int|string, mixed> $options
290
     *
291
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
292
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
293
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
294
     *
295
     * @throws EntityRepositoryException
296
     * @throws PersistenceException
297
     */
298
    public function testSaveAssociationWillSaveEntityCollection(array $options = []): void
299
    {
300
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
301
302
        $targetEntityName = EntityInterface::class;
303
304
        /** @var EntityInterface&MockObject $entityOrCollection */
305
        $entityOrCollection = [
306
            $this->getMockForAbstractClass(EntityInterface::class),
307
            $this->getMockForAbstractClass(EntityInterface::class),
308
            $this->getMockForAbstractClass(EntityInterface::class),
309
        ];
310
311
        if (!class_exists($targetEntityName, true)) {
312
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
313
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
314
315
            $this->entityManager->expects($this->once())
316
                ->method('getMetadataFactory')
317
                ->willReturn($metadataFactory);
318
319
            $metadataFactory->expects($this->once())
320
                ->method('hasMetadataFor')
321
                ->with($targetEntityName)
322
                ->willReturn(true);
323
        }
324
325
        /** @var EntityRepositoryInterface&MockObject $entityRepository */
326
        $entityRepository = $this->getMockForAbstractClass(EntityRepositoryInterface::class);
327
328
        $this->entityManager->expects($this->once())
329
            ->method('getRepository')
330
            ->with($targetEntityName)
331
            ->willReturn($entityRepository);
332
333
        $entityRepository->expects($this->once())
334
            ->method('saveCollection')
335
            ->with($entityOrCollection, $options);
336
337
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection, $options);
338
    }
339
340
    /**
341
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
342
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
343
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getClassMetadata
344
     *
345
     * @throws EntityRepositoryException
346
     * @throws PersistenceException
347
     */
348
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheEntityMetadataCannotBeLoaded(): void
349
    {
350
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
351
352
        $entityName = EntityInterface::class;
353
354
        /** @var EntityInterface&MockObject $entity */
355
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
356
357
        $exceptionMessage = 'This is a test exception message';
358
        $exception = new \Exception($exceptionMessage, 123);
359
360
        $errorMessage = sprintf(
361
            'The entity metadata mapping for class \'%s\' could not be loaded: %s',
362
            $entityName,
363
            $exceptionMessage
364
        );
365
366
        $this->entityManager->expects($this->once())
367
            ->method('getClassMetadata')
368
            ->with($entityName)
369
            ->willThrowException($exception);
370
371
        $this->logger->expects($this->once())
372
            ->method('error')
373
            ->with($errorMessage);
374
375
        $this->expectException(PersistenceException::class);
376
        $this->expectExceptionMessage($errorMessage);
377
        $this->expectExceptionCode(123);
378
379
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
380
    }
381
382
    /**
383
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
384
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::resolveTargetEntityOrCollection
385
     *
386
     * @throws EntityRepositoryException
387
     * @throws PersistenceException
388
     */
389
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheTargetEntityMethodDoesNotExist(): void
390
    {
391
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
392
393
        $entityName = EntityInterface::class;
394
395
        /** @var EntityInterface&MockObject $entity */
396
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
397
398
        /** @var ClassMetadata<EntityInterface>&MockObject $classMetadata */
399
        $classMetadata = $this->createMock(ClassMetadata::class);
400
401
        /** @var ClassMetadata<EntityInterface>&MockObject $targetMetadata */
402
        $targetMetadata = $this->createMock(ClassMetadata::class);
403
404
        $mapping = [
405
            'targetEntity'     => EntityInterface::class,
406
            'fieldName'        => 'test',
407
            'type'             => 'string',
408
            'isCascadePersist' => true,
409
        ];
410
411
        $mappings = [$mapping];
412
413
        $this->entityManager->expects($this->exactly(2))
414
            ->method('getClassMetadata')
415
            ->withConsecutive(
416
                [$entityName],
417
                [$mapping['targetEntity']]
418
            )
419
            ->willReturnOnConsecutiveCalls(
420
                $classMetadata,
421
                $targetMetadata
422
            );
423
424
        $classMetadata->expects($this->once())
0 ignored issues
show
Bug introduced by
The method expects() does not exist on Doctrine\ORM\Mapping\ClassMetadata. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

424
        $classMetadata->/** @scrutinizer ignore-call */ 
425
                        expects($this->once())

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
425
            ->method('getAssociationMappings')
426
            ->willReturn($mappings);
427
428
        $this->logger->expects($this->exactly(2))
429
            ->method('info')
430
            ->withConsecutive(
431
                [
432
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
433
                ],
434
                [
435
                    sprintf(
436
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
437
                        $entityName,
438
                        $mapping['fieldName'],
439
                        $mapping['targetEntity']
440
                    ),
441
                ]
442
            );
443
444
        $classMetadata->expects($this->once())->method('getName')->willReturn($entityName);
445
        $targetMetadata->expects($this->once())->method('getName')->willReturn($mapping['targetEntity']);
446
447
        $methodName = 'get' . ucfirst($mapping['fieldName']);
448
449
        $errorMessage = sprintf(
450
            'Failed to find required entity method \'%s::%s\'. The method is required for cascade operations '
451
            . 'of field \'%s\' of target entity \'%s\'',
452
            $entityName,
453
            $methodName,
454
            $mapping['fieldName'],
455
            $mapping['targetEntity']
456
        );
457
458
        $this->logger->expects($this->once())
459
            ->method('error')
460
            ->with($errorMessage);
461
462
        $this->expectException(PersistenceException::class);
463
        $this->expectExceptionMessage($errorMessage);
464
465
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
466
    }
467
468
    /**
469
     * Assert that calls to saveAssociations() will raise a PersistenceException if the provided entity method call
470
     * throws an exception
471
     *
472
     * @throws EntityRepositoryException
473
     * @throws PersistenceException
474
     */
475
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheTargetEntityCannotBeLoaded(): void
476
    {
477
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
478
479
        $entityName = EntityInterface::class;
480
481
        $exceptionMessage = 'This is a test exception message';
482
        $exception = new \Exception($exceptionMessage);
483
484
        /** @var EntityInterface&MockObject $entity */
485
        $entity = new class ($exception) implements EntityInterface {
486
            use EntityTrait;
487
488
            public \Throwable $exception;
489
490
            public function __construct(\Throwable $exception)
491
            {
492
                $this->exception = $exception;
493
            }
494
495
            public function getFoo(): string
496
            {
497
                throw $this->exception;
498
            }
499
        };
500
501
        /**
502
         * @var ClassMetadata<EntityInterface>&MockObject $classMetadata
503
         */
504
        $classMetadata = $this->createMock(ClassMetadata::class);
505
506
        /** @var ClassMetadata<EntityInterface>&MockObject $targetMetadata */
507
        $targetMetadata = $this->createMock(ClassMetadata::class);
508
509
        $mapping = [
510
            'targetEntity'     => EntityInterface::class,
511
            'fieldName'        => 'foo',
512
            'type'             => 'string',
513
            'isCascadePersist' => true,
514
        ];
515
516
        $mappings = [$mapping];
517
518
        $this->entityManager->expects($this->exactly(2))
519
            ->method('getClassMetadata')
520
            ->withConsecutive(
521
                [$entityName],
522
                [$mapping['targetEntity']]
523
            )
524
            ->willReturnOnConsecutiveCalls(
525
                $classMetadata,
526
                $targetMetadata
527
            );
528
529
        $classMetadata->expects($this->once())
530
            ->method('getAssociationMappings')
531
            ->willReturn($mappings);
532
533
        $this->logger->expects($this->exactly(2))
534
            ->method('info')
535
            ->withConsecutive(
536
                [
537
                    sprintf(
538
                        'Processing cascade save operations for for entity class \'%s\'',
539
                        $entityName
540
                    ),
541
                ],
542
                [
543
                    sprintf(
544
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
545
                        $entityName,
546
                        $mapping['fieldName'],
547
                        $mapping['targetEntity']
548
                    ),
549
                ]
550
            );
551
552
        $classMetadata->expects($this->once())->method('getName')->willReturn($entityName);
553
        $targetMetadata->expects($this->once())->method('getName')->willReturn($mapping['targetEntity']);
554
555
        $methodName = 'get' . ucfirst($mapping['fieldName']);
556
557
        $errorMessage = sprintf(
558
            'The call to resolve entity of type \'%s\' from method call \'%s::%s\' failed: %s',
559
            $mapping['targetEntity'],
560
            $entityName,
561
            $methodName,
562
            $exceptionMessage
563
        );
564
565
        $this->logger->expects($this->once())
566
            ->method('error')
567
            ->with($errorMessage);
568
569
        $this->expectException(PersistenceException::class);
570
        $this->expectExceptionMessage($errorMessage);
571
572
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
573
    }
574
575
    /**
576
     * Assert that calls to saveAssociations() when mapping data contains associations that are either incorrectly
577
     * configured (missing required keys) or are not cascade persist.
578
     *
579
     * @param array<int|string, mixed> $mappingData The association mapping data for a single field to test
580
     *
581
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
582
     *
583
     * @dataProvider getSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingDataData
584
     *
585
     * @throws EntityRepositoryException
586
     * @throws PersistenceException
587
     */
588
    public function testSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingData(array $mappingData): void
589
    {
590
        /** @var CascadeSaveService&MockObject $cascadeService */
591
        $cascadeService = $this->getMockBuilder(CascadeSaveService::class)
592
            ->setConstructorArgs([$this->logger, $this->options, $this->collectionOptions])
593
            ->onlyMethods(['saveAssociation'])
594
            ->getMock();
595
596
        $entityName = EntityInterface::class;
597
598
        /** @var EntityInterface&MockObject $entity */
599
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
600
601
        /** @var ClassMetadata<EntityInterface>&MockObject $classMetadata */
602
        $classMetadata = $this->createMock(ClassMetadata::class);
603
604
        $this->entityManager->expects($this->once())
605
            ->method('getClassMetadata')
606
            ->with($entityName)
607
            ->willReturn($classMetadata);
608
609
        $classMetadata->expects($this->once())
610
            ->method('getAssociationMappings')
611
            ->willReturn([$mappingData]);
612
613
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
614
    }
615
616
    /**
617
     * @return array<mixed>
618
     */
619
    public function getSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingDataData(): array
620
    {
621
        return [
622
            [
623
                [
624
625
                ],
626
            ],
627
628
            [
629
                [
630
                    'targetEntity' => EntityInterface::class,
631
                ],
632
            ],
633
634
            [
635
                [
636
                    'targetEntity' => EntityInterface::class,
637
                    'fieldName'    => 'foo',
638
                ],
639
            ],
640
641
            [
642
                [
643
                    'targetEntity' => EntityInterface::class,
644
                    'fieldName'    => 'foo',
645
                    'type'         => 1,
646
                ],
647
            ],
648
649
            [
650
                [
651
                    'targetEntity'     => EntityInterface::class,
652
                    'fieldName'        => 'foo',
653
                    'type'             => 1,
654
                    'isCascadePersist' => false,
655
                ],
656
            ],
657
        ];
658
    }
659
660
    /**
661
     * @param array<int|string, mixed> $mappingData
662
     * @param mixed                    $returnValue
663
     *
664
     * @dataProvider getSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalidData
665
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
666
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::isValidAssociation
667
     *
668
     * @throws EntityRepositoryException
669
     * @throws PersistenceException
670
     */
671
    public function testSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalid(
672
        array $mappingData,
673
        $returnValue
674
    ): void {
675
        $cascadeSaveService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
676
677
        $entityName = EntityInterface::class;
678
        $fieldName = 'foo';
679
        $targetEntityName = $mappingData['targetEntity'] = $mappingData['targetEntity'] ?? EntityInterface::class;
680
        $mappingData['fieldName'] = 'foo';
681
682
        /** @var EntityInterface&MockObject $entity */
683
        $entity = new class ($returnValue) implements EntityInterface {
684
            use EntityTrait;
685
686
            /**
687
             * @var mixed
688
             */
689
            private $returnValue;
690
691
            /**
692
             * @param mixed $returnValue
693
             */
694
            public function __construct($returnValue)
695
            {
696
                $this->returnValue = $returnValue;
697
            }
698
699
            /**
700
             * @return mixed
701
             */
702
            public function getFoo()
703
            {
704
                return $this->returnValue;
705
            }
706
        };
707
708
        /** @var ClassMetadata<EntityInterface>&MockObject $metadata */
709
        $metadata = $this->createMock(ClassMetadata::class);
710
711
        /** @var ClassMetadata<EntityInterface>&MockObject $targetMetadata */
712
        $targetMetadata = $this->createMock(ClassMetadata::class);
713
714
        $this->entityManager->expects($this->exactly(2))
715
            ->method('getClassMetadata')
716
            ->withConsecutive(
717
                [$entityName],
718
                [$targetEntityName]
719
            )->willReturnOnConsecutiveCalls(
720
                $metadata,
721
                $targetMetadata
722
            );
723
724
        $mappings = [
725
            $mappingData,
726
        ];
727
728
        $metadata->expects($this->once())
729
            ->method('getAssociationMappings')
730
            ->willReturn($mappings);
731
732
        $this->logger->expects($this->exactly(2))
733
            ->method('info')
734
            ->withConsecutive(
735
                [
736
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
737
                ],
738
                [
739
                    sprintf(
740
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
741
                        $entityName,
742
                        $fieldName,
743
                        $targetEntityName
744
                    ),
745
                ]
746
            );
747
748
        $errorMessage = sprintf('The entity field \'%s::%s\' value could not be resolved', $entityName, $fieldName);
749
750
        $this->logger->expects($this->once())
751
            ->method('error')
752
            ->with($errorMessage);
753
754
        $this->expectException(PersistenceException::class);
755
        $this->expectExceptionMessage($errorMessage);
756
757
        $cascadeSaveService->saveAssociations($this->entityManager, $entityName, $entity);
758
    }
759
760
    /**
761
     * @return array<mixed>
762
     */
763
    public function getSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalidData(): array
764
    {
765
        return [
766
            // The return value of the assoc is NULL but the field mapping does not allow NULL should raise an error.
767
            [
768
                [
769
                    'type'             => ClassMetadata::MANY_TO_ONE,
770
                    'isCascadePersist' => true,
771
                    'joinColumns'      => [
772
                        [
773
                            'nullable' => false,
774
                        ],
775
                    ],
776
                ],
777
                null,
778
            ],
779
780
            // The return value of the assoc is not a EntityInterface or iterable collection
781
            [
782
                [
783
                    'type'             => ClassMetadata::MANY_TO_ONE,
784
                    'isCascadePersist' => true,
785
                    'joinColumns'      => [
786
                        [
787
                            'nullable' => false,
788
                        ],
789
                    ],
790
                ],
791
                new \stdClass(),
792
            ],
793
794
        ];
795
    }
796
797
    /**
798
     * @param array<mixed> $mappingData
799
     * @param mixed        $returnValue
800
     *
801
     * @dataProvider getSaveAssociationsData
802
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
803
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::resolveTargetEntityOrCollection
804
     *
805
     * @throws EntityRepositoryException
806
     * @throws PersistenceException
807
     */
808
    public function testSaveAssociations(array $mappingData, $returnValue): void
809
    {
810
        $this->options = [
811
            'foo' => 1,
812
        ];
813
814
        $this->collectionOptions = [
815
            'bar' => 2,
816
        ];
817
818
        $saveOptions = ($returnValue instanceof EntityInterface)
819
            ? $this->options
820
            : $this->collectionOptions;
821
822
        /** @var CascadeSaveService&MockObject $cascadeSaveService */
823
        $cascadeSaveService = $this->getMockBuilder(CascadeSaveService::class)
824
            ->setConstructorArgs([$this->logger, $this->options, $this->collectionOptions])
825
            ->onlyMethods(['saveAssociation'])
826
            ->getMock();
827
828
        $entityName = EntityInterface::class;
829
        $fieldName = 'foo';
830
        $targetEntityName = $mappingData['targetEntity'] = $mappingData['targetEntity'] ?? EntityInterface::class;
831
        $mappingData['fieldName'] = 'foo';
832
833
        /** @var EntityInterface&MockObject $entity */
834
        $entity = new class ($returnValue) implements EntityInterface {
835
            use EntityTrait;
836
837
            /**
838
             * @var mixed
839
             */
840
            private $returnValue;
841
842
            /**
843
             * @param mixed $returnValue
844
             */
845
            public function __construct($returnValue)
846
            {
847
                $this->returnValue = $returnValue;
848
            }
849
850
            /**
851
             * @return mixed
852
             */
853
            public function getFoo()
854
            {
855
                return $this->returnValue;
856
            }
857
        };
858
859
        /** @var ClassMetadata<EntityInterface>&MockObject $metadata */
860
        $metadata = $this->createMock(ClassMetadata::class);
861
862
        /** @var ClassMetadata<EntityInterface>&MockObject $targetMetadata */
863
        $targetMetadata = $this->createMock(ClassMetadata::class);
864
865
        $this->entityManager->expects($this->exactly(2))
866
            ->method('getClassMetadata')
867
            ->withConsecutive(
868
                [$entityName],
869
                [$targetEntityName]
870
            )->willReturnOnConsecutiveCalls(
871
                $metadata,
872
                $targetMetadata
873
            );
874
875
        $mappings = [
876
            $mappingData,
877
        ];
878
879
        $metadata->expects($this->once())
880
            ->method('getAssociationMappings')
881
            ->willReturn($mappings);
882
883
        $this->logger->expects($this->exactly(3))
884
            ->method('info')
885
            ->withConsecutive(
886
                [
887
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
888
                ],
889
                [
890
                    sprintf(
891
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
892
                        $entityName,
893
                        $fieldName,
894
                        $targetEntityName
895
                    ),
896
                ],
897
                [
898
                    sprintf('Performing cascading save operations for field \'%s::%s\'', $entityName, $fieldName),
899
                ]
900
            );
901
902
        $cascadeSaveService->expects($this->once())
903
            ->method('saveAssociation')
904
            ->with($this->entityManager, $targetEntityName, $returnValue, $saveOptions);
905
906
        $cascadeSaveService->saveAssociations($this->entityManager, $entityName, $entity);
907
    }
908
909
    /**
910
     * @return array<mixed>
911
     */
912
    public function getSaveAssociationsData(): array
913
    {
914
        /** @var EntityInterface[]&MockObject[] $collection */
915
        $collection = [
916
            $this->getMockForAbstractClass(EntityInterface::class),
917
            $this->getMockForAbstractClass(EntityInterface::class),
918
            $this->getMockForAbstractClass(EntityInterface::class),
919
        ];
920
        $iteratorCollection = new \ArrayIterator($collection);
921
922
        return [
923
            // Save a single entity association
924
            [
925
                [
926
                    'type'             => ClassMetadata::MANY_TO_ONE,
927
                    'isCascadePersist' => true,
928
                    'joinColumns'      => [
929
                        [
930
                            'nullable' => false,
931
                        ],
932
                    ],
933
                ],
934
                $this->getMockForAbstractClass(EntityInterface::class),
935
            ],
936
937
            // Save a collection association
938
            [
939
                [
940
                    'type'             => ClassMetadata::ONE_TO_MANY,
941
                    'isCascadePersist' => true,
942
                ],
943
                $collection,
944
            ],
945
946
            // Save an iterable collection association
947
            [
948
                [
949
                    'type'             => ClassMetadata::MANY_TO_MANY,
950
                    'isCascadePersist' => true,
951
                ],
952
                $iteratorCollection,
953
            ],
954
        ];
955
    }
956
}
957