Passed
Pull Request — master (#8)
by Alex
03:52
created

php$2 ➔ getSaveAssociationsData()   A

Complexity

Conditions 1

Size

Total Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 41
rs 9.264
c 0
b 0
f 0
cc 1
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\Persistence\Mapping\ClassMetadataFactory;
16
use PHPUnit\Framework\MockObject\MockObject;
17
use PHPUnit\Framework\TestCase;
18
use Psr\Log\LoggerInterface;
19
20
/**
21
 * @author  Alex Patterson <[email protected]>
22
 * @package ArpTest\DoctrineEntityRepository\Persistence
23
 */
24
final class CascadeSaveServiceTest extends TestCase
25
{
26
    /**
27
     * @var EntityManagerInterface&MockObject
28
     */
29
    private $entityManager;
30
31
    /**
32
     * @var LoggerInterface&MockObject
33
     */
34
    private $logger;
35
36
    /**
37
     * @var array<string|int, mixed>
38
     */
39
    private array $options = [];
40
41
    /**
42
     * @var array<int|string, mixed>
43
     */
44
    private array $collectionOptions = [];
45
46
    /**
47
     * Prepare the test case dependencies.
48
     */
49
    public function setUp(): void
50
    {
51
        $this->logger = $this->getMockForAbstractClass(LoggerInterface::class);
52
53
        $this->entityManager = $this->getMockForAbstractClass(EntityManagerInterface::class);
54
    }
55
56
    /**
57
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
58
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
59
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
60
     *
61
     * @throws PersistenceException
62
     * @throws EntityRepositoryException
63
     */
64
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityNameIsInvalid(): void
65
    {
66
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
67
68
        $entityName = EntityInterface::class;
69
70
        /** @var EntityInterface $entity */
71
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
72
73
        /** @var ClassMetadataFactory&MockObject $metadataFactory */
74
        $metadataFactory = $this->createMock(ClassMetadataFactory::class);
75
76
        $this->entityManager->expects($this->once())
77
            ->method('getMetadataFactory')
78
            ->willReturn($metadataFactory);
79
80
        $metadataFactory->expects($this->once())
81
            ->method('hasMetadataFor')
82
            ->with($entityName)
83
            ->willReturn(false);
84
85
        $errorMessage = sprintf('The target repository class \'%s\' could not be found', $entityName);
86
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
87
88
        $this->expectException(PersistenceException::class);
89
        $this->expectExceptionMessage($errorMessage);
90
91
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
92
    }
93
94
    /**
95
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
96
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
97
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
98
     *
99
     * @throws PersistenceException
100
     * @throws EntityRepositoryException
101
     */
102
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityRepositoryCannotBeLoaded(): void
103
    {
104
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
105
106
        $entityName = EntityInterface::class;
107
108
        /** @var EntityInterface $entity */
109
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
110
111
        $exceptionMessage = 'This is a test exception message';
112
        $exception = new \Exception($exceptionMessage, 123);
113
114
        if (!class_exists($entityName, true)) {
115
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
116
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
117
118
            $this->entityManager->expects($this->once())
119
                ->method('getMetadataFactory')
120
                ->willReturn($metadataFactory);
121
122
            $metadataFactory->expects($this->once())
123
                ->method('hasMetadataFor')
124
                ->with($entityName)
125
                ->willReturn(true);
126
        }
127
128
        $this->entityManager->expects($this->once())
129
            ->method('getRepository')
130
            ->with($entityName)
131
            ->willThrowException($exception);
132
133
        $errorMessage = sprintf(
134
            'An error occurred while attempting to load the repository for entity class \'%s\' : %s',
135
            $entityName,
136
            $exceptionMessage
137
        );
138
139
        $this->logger->expects($this->once())
140
            ->method('error')
141
            ->with($errorMessage, compact('exception'));
142
143
        $this->expectException(PersistenceException::class);
144
        $this->expectExceptionMessage($errorMessage);
145
        $this->expectExceptionCode($exception->getCode());
146
147
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
148
    }
149
150
    /**
151
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
152
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
153
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
154
     *
155
     * @throws PersistenceException
156
     * @throws EntityRepositoryException
157
     */
158
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityRepositoryIsInvalid(): void
159
    {
160
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
161
162
        $entityName = EntityInterface::class;
163
164
        /** @var EntityInterface $entity */
165
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
166
167
        $entityRepository = new \stdClass();
168
169
        if (!class_exists($entityName, true)) {
170
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
171
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
172
173
            $this->entityManager->expects($this->once())
174
                ->method('getMetadataFactory')
175
                ->willReturn($metadataFactory);
176
177
            $metadataFactory->expects($this->once())
178
                ->method('hasMetadataFor')
179
                ->with($entityName)
180
                ->willReturn(true);
181
        }
182
183
        $this->entityManager->expects($this->once())
184
            ->method('getRepository')
185
            ->with($entityName)
186
            ->willReturn($entityRepository);
187
188
        $errorMessage = sprintf(
189
            'The entity repository must be an object of type \'%s\'; \'%s\' returned in \'%s::%s\'',
190
            EntityRepositoryInterface::class,
191
            get_class($entityRepository),
192
            CascadeSaveService::class,
193
            'getTargetRepository'
194
        );
195
196
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
197
198
        $this->expectException(PersistenceException::class);
199
        $this->expectExceptionMessage($errorMessage);
200
201
        $cascadeService->saveAssociation($this->entityManager, $entityName, $entity);
202
    }
203
204
    /**
205
     * Assert that an PersistenceException is thrown if an invalid entity or collection value is
206
     * passed to the saveAssociation() method.
207
     *
208
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
209
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
210
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
211
     *
212
     * @throws PersistenceException
213
     * @throws EntityRepositoryException
214
     */
215
    public function testSaveAssociationWillThrowAPersistenceExceptionIfTheTargetEntityOrCollectionIsInvalid(): void
216
    {
217
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
218
219
        $targetEntityName = EntityInterface::class;
220
221
        /** @var EntityInterface|\stdClass $entityOrCollection */
222
        $entityOrCollection = new \stdClass();
223
224
        $errorMessage = sprintf(
225
            'Unable to cascade save target entity \'%s\': The entity or collection is of an invalid type \'%s\'',
226
            $targetEntityName,
227
            \stdClass::class
228
        );
229
230
        $this->logger->expects($this->once())->method('error')->with($errorMessage);
231
232
        $this->expectException(PersistenceException::class);
233
        $this->expectExceptionMessage($errorMessage);
234
235
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection);
236
    }
237
238
    /**
239
     * @param array<string|int, mixed> $options
240
     *
241
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
242
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
243
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
244
     *
245
     * @throws EntityRepositoryException
246
     * @throws PersistenceException
247
     */
248
    public function testSaveAssociationWillSaveEntity(array $options = []): void
249
    {
250
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
251
252
        $targetEntityName = EntityInterface::class;
253
254
        /** @var EntityInterface&MockObject $entityOrCollection */
255
        $entityOrCollection = $this->getMockForAbstractClass(EntityInterface::class);
256
257
        /** @var EntityRepositoryInterface&MockObject $entityRepository */
258
        $entityRepository = $this->getMockForAbstractClass(EntityRepositoryInterface::class);
259
260
        if (!class_exists($targetEntityName, true)) {
261
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
262
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
263
264
            $this->entityManager->expects($this->once())
265
                ->method('getMetadataFactory')
266
                ->willReturn($metadataFactory);
267
268
            $metadataFactory->expects($this->once())
269
                ->method('hasMetadataFor')
270
                ->with($targetEntityName)
271
                ->willReturn(true);
272
        }
273
274
        $this->entityManager->expects($this->once())
275
            ->method('getRepository')
276
            ->with($targetEntityName)
277
            ->willReturn($entityRepository);
278
279
        $entityRepository->expects($this->once())
280
            ->method('save')
281
            ->with($entityOrCollection, $options);
282
283
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection, $options);
284
    }
285
286
    /**
287
     * @param array<int|string, mixed> $options
288
     *
289
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
290
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociation
291
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getTargetRepository
292
     *
293
     * @throws EntityRepositoryException
294
     * @throws PersistenceException
295
     */
296
    public function testSaveAssociationWillSaveEntityCollection(array $options = []): void
297
    {
298
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
299
300
        $targetEntityName = EntityInterface::class;
301
302
        /** @var EntityInterface&MockObject $entityOrCollection */
303
        $entityOrCollection = [
304
            $this->getMockForAbstractClass(EntityInterface::class),
305
            $this->getMockForAbstractClass(EntityInterface::class),
306
            $this->getMockForAbstractClass(EntityInterface::class),
307
        ];
308
309
        if (!class_exists($targetEntityName, true)) {
310
            /** @var ClassMetadataFactory&MockObject $metadataFactory */
311
            $metadataFactory = $this->createMock(ClassMetadataFactory::class);
312
313
            $this->entityManager->expects($this->once())
314
                ->method('getMetadataFactory')
315
                ->willReturn($metadataFactory);
316
317
            $metadataFactory->expects($this->once())
318
                ->method('hasMetadataFor')
319
                ->with($targetEntityName)
320
                ->willReturn(true);
321
        }
322
323
        /** @var EntityRepositoryInterface&MockObject $entityRepository */
324
        $entityRepository = $this->getMockForAbstractClass(EntityRepositoryInterface::class);
325
326
        $this->entityManager->expects($this->once())
327
            ->method('getRepository')
328
            ->with($targetEntityName)
329
            ->willReturn($entityRepository);
330
331
        $entityRepository->expects($this->once())
332
            ->method('saveCollection')
333
            ->with($entityOrCollection, $options);
334
335
        $cascadeService->saveAssociation($this->entityManager, $targetEntityName, $entityOrCollection, $options);
336
    }
337
338
    /**
339
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::__construct
340
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
341
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::getClassMetadata
342
     *
343
     * @throws EntityRepositoryException
344
     * @throws PersistenceException
345
     */
346
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheEntityMetadataCannotBeLoaded(): void
347
    {
348
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
349
350
        $entityName = EntityInterface::class;
351
352
        /** @var EntityInterface&MockObject $entity */
353
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
354
355
        $exceptionMessage = 'This is a test exception message';
356
        $exception = new \Exception($exceptionMessage, 123);
357
358
        $errorMessage = $errorMessage = sprintf(
0 ignored issues
show
Unused Code introduced by
The assignment to $errorMessage is dead and can be removed.
Loading history...
359
            'The entity metadata mapping for class \'%s\' could not be loaded: %s',
360
            $entityName,
361
            $exceptionMessage
362
        );
363
364
        $this->entityManager->expects($this->once())
365
            ->method('getClassMetadata')
366
            ->with($entityName)
367
            ->willThrowException($exception);
368
369
        $this->logger->expects($this->once())
370
            ->method('error')
371
            ->with($errorMessage);
372
373
        $this->expectException(PersistenceException::class);
374
        $this->expectExceptionMessage($errorMessage);
375
        $this->expectExceptionCode(123);
376
377
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
378
    }
379
380
    /**
381
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
382
     * @covers \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::resolveTargetEntityOrCollection
383
     *
384
     * @throws EntityRepositoryException
385
     * @throws PersistenceException
386
     */
387
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheTargetEntityMethodDoesNotExist(): void
388
    {
389
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
390
391
        $entityName = EntityInterface::class;
392
393
        /** @var EntityInterface&MockObject $entity */
394
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
395
396
        /** @var ClassMetadata&MockObject $classMetadata */
397
        $classMetadata = $this->createMock(ClassMetadata::class);
398
399
        /** @var ClassMetadata&MockObject $targetMetadata */
400
        $targetMetadata = $this->createMock(ClassMetadata::class);
401
402
        $mapping = [
403
            'targetEntity'     => EntityInterface::class,
404
            'fieldName'        => 'test',
405
            'type'             => 'string',
406
            'isCascadePersist' => true,
407
        ];
408
409
        $mappings = [$mapping];
410
411
        $this->entityManager->expects($this->exactly(2))
412
            ->method('getClassMetadata')
413
            ->withConsecutive(
414
                [$entityName],
415
                [$mapping['targetEntity']]
416
            )
417
            ->willReturnOnConsecutiveCalls(
418
                $classMetadata,
419
                $targetMetadata
420
            );
421
422
        $classMetadata->expects($this->once())
423
            ->method('getAssociationMappings')
424
            ->willReturn($mappings);
425
426
        $this->logger->expects($this->exactly(2))
427
            ->method('info')
428
            ->withConsecutive(
429
                [
430
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
431
                ],
432
                [
433
                    sprintf(
434
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
435
                        $entityName,
436
                        $mapping['fieldName'],
437
                        $mapping['targetEntity']
438
                    ),
439
                ]
440
            );
441
442
        $classMetadata->expects($this->once())->method('getName')->willReturn($entityName);
443
        $targetMetadata->expects($this->once())->method('getName')->willReturn($mapping['targetEntity']);
444
445
        $methodName = 'get' . ucfirst($mapping['fieldName']);
446
447
        $errorMessage = sprintf(
448
            'Failed to find required entity method \'%s::%s\'. The method is required for cascade operations '
449
            . 'of field \'%s\' of target entity \'%s\'',
450
            $entityName,
451
            $methodName,
452
            $mapping['fieldName'],
453
            $mapping['targetEntity']
454
        );
455
456
        $this->logger->expects($this->once())
457
            ->method('error')
458
            ->with($errorMessage);
459
460
        $this->expectException(PersistenceException::class);
461
        $this->expectExceptionMessage($errorMessage);
462
463
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
464
    }
465
466
    /**
467
     * Assert that calls to saveAssociations() will raise a PersistenceException if the provided entity method call
468
     * throws an exception
469
     *
470
     * @throws EntityRepositoryException
471
     * @throws PersistenceException
472
     */
473
    public function testSaveAssociationsWillThrowAPersistenceExceptionIfTheTargetEntityCannotBeLoaded(): void
474
    {
475
        $cascadeService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
476
477
        $entityName = EntityInterface::class;
478
479
        $exceptionMessage = 'This is a test exception message';
480
        $exception = new \Exception($exceptionMessage);
481
482
        /** @var EntityInterface&MockObject $entity */
483
        $entity = new class($exception) implements EntityInterface {
484
            use EntityTrait;
485
486
            public \Throwable $exception;
487
488
            public function __construct(\Throwable $exception)
489
            {
490
                $this->exception = $exception;
491
            }
492
493
            public function getFoo(): string
494
            {
495
                throw $this->exception;
496
            }
497
        };
498
499
        /**
500
         * @var ClassMetadata&MockObject $classMetadata
501
         * @var ClassMetadata&MockObject $targetMetadata
502
         */
503
        $classMetadata = $this->createMock(ClassMetadata::class);
504
        $targetMetadata = $this->createMock(ClassMetadata::class);
505
506
        $mapping = [
507
            'targetEntity'     => EntityInterface::class,
508
            'fieldName'        => 'foo',
509
            'type'             => 'string',
510
            'isCascadePersist' => true,
511
        ];
512
513
        $mappings = [$mapping];
514
515
        $this->entityManager->expects($this->exactly(2))
516
            ->method('getClassMetadata')
517
            ->withConsecutive(
518
                [$entityName],
519
                [$mapping['targetEntity']]
520
            )
521
            ->willReturnOnConsecutiveCalls(
522
                $classMetadata,
523
                $targetMetadata
524
            );
525
526
        $classMetadata->expects($this->once())
527
            ->method('getAssociationMappings')
528
            ->willReturn($mappings);
529
530
        $this->logger->expects($this->exactly(2))
531
            ->method('info')
532
            ->withConsecutive(
533
                [
534
                    sprintf(
535
                        'Processing cascade save operations for for entity class \'%s\'',
536
                        $entityName
537
                    ),
538
                ],
539
                [
540
                    sprintf(
541
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
542
                        $entityName,
543
                        $mapping['fieldName'],
544
                        $mapping['targetEntity']
545
                    ),
546
                ]
547
            );
548
549
        $classMetadata->expects($this->once())->method('getName')->willReturn($entityName);
550
        $targetMetadata->expects($this->once())->method('getName')->willReturn($mapping['targetEntity']);
551
552
        $methodName = 'get' . ucfirst($mapping['fieldName']);
553
554
        $errorMessage = sprintf(
555
            'The call to resolve entity of type \'%s\' from method call \'%s::%s\' failed: %s',
556
            $mapping['targetEntity'],
557
            $entityName,
558
            $methodName,
559
            $exceptionMessage
560
        );
561
562
        $this->logger->expects($this->once())
563
            ->method('error')
564
            ->with($errorMessage);
565
566
        $this->expectException(PersistenceException::class);
567
        $this->expectExceptionMessage($errorMessage);
568
569
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
570
    }
571
572
    /**
573
     * Assert that calls to saveAssociations() when mapping data contains associations that are either incorrectly
574
     * configured (missing required keys) or are not cascade persist.
575
     *
576
     * @param array<int|string, mixed> $mappingData The association mapping data for a single field to test
577
     *
578
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
579
     *
580
     * @dataProvider getSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingDataData
581
     *
582
     * @throws EntityRepositoryException
583
     * @throws PersistenceException
584
     */
585
    public function testSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingData(array $mappingData): void
586
    {
587
        /** @var CascadeSaveService&MockObject $cascadeService */
588
        $cascadeService = $this->getMockBuilder(CascadeSaveService::class)
589
            ->setConstructorArgs([$this->logger, $this->options, $this->collectionOptions])
590
            ->onlyMethods(['saveAssociation'])
591
            ->getMock();
592
593
        $entityName = EntityInterface::class;
594
595
        /** @var EntityInterface&MockObject $entity */
596
        $entity = $this->getMockForAbstractClass(EntityInterface::class);
597
598
        /** @var ClassMetadata&MockObject $classMetadata */
599
        $classMetadata = $this->createMock(ClassMetadata::class);
600
601
        $this->entityManager->expects($this->once())
602
            ->method('getClassMetadata')
603
            ->with($entityName)
604
            ->willReturn($classMetadata);
605
606
        $classMetadata->expects($this->once())
607
            ->method('getAssociationMappings')
608
            ->willReturn([$mappingData]);
609
610
        $cascadeService->saveAssociations($this->entityManager, $entityName, $entity);
611
    }
612
613
    /**
614
     * @return array<mixed>
615
     */
616
    public function getSaveAssociationsWillSkipAssociationsWithNonCascadePersistMappingDataData(): array
617
    {
618
        return [
619
            [
620
                [
621
622
                ],
623
            ],
624
625
            [
626
                [
627
                    'targetEntity' => EntityInterface::class,
628
                ],
629
            ],
630
631
            [
632
                [
633
                    'targetEntity' => EntityInterface::class,
634
                    'fieldName'    => 'foo',
635
                ],
636
            ],
637
638
            [
639
                [
640
                    'targetEntity' => EntityInterface::class,
641
                    'fieldName'    => 'foo',
642
                    'type'         => 1,
643
                ],
644
            ],
645
646
            [
647
                [
648
                    'targetEntity'     => EntityInterface::class,
649
                    'fieldName'        => 'foo',
650
                    'type'             => 1,
651
                    'isCascadePersist' => false,
652
                ],
653
            ],
654
        ];
655
    }
656
657
    /**
658
     * @param array<int|string, mixed> $mappingData
659
     * @param mixed                    $returnValue
660
     *
661
     * @dataProvider getSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalidData
662
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
663
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::isValidAssociation
664
     *
665
     * @throws EntityRepositoryException
666
     * @throws PersistenceException
667
     */
668
    public function testSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalid(
669
        array $mappingData,
670
        $returnValue
671
    ): void {
672
        $cascadeSaveService = new CascadeSaveService($this->logger, $this->options, $this->collectionOptions);
673
674
        $entityName = EntityInterface::class;
675
        $fieldName = 'foo';
676
        $targetEntityName = $mappingData['targetEntity'] = $mappingData['targetEntity'] ?? EntityInterface::class;
677
        $mappingData['fieldName'] = 'foo';
678
679
        /** @var EntityInterface&MockObject $entity */
680
        $entity = new class($returnValue) implements EntityInterface {
681
            use EntityTrait;
682
683
            /**
684
             * @var mixed
685
             */
686
            private $returnValue;
687
688
            /**
689
             * @param mixed $returnValue
690
             */
691
            public function __construct($returnValue)
692
            {
693
                $this->returnValue = $returnValue;
694
            }
695
696
            /**
697
             * @return mixed
698
             */
699
            public function getFoo()
700
            {
701
                return $this->returnValue;
702
            }
703
        };
704
705
        /** @var ClassMetadata&MockObject $metadata */
706
        $metadata = $this->createMock(ClassMetadata::class);
707
708
        /** @var ClassMetadata&MockObject $targetMetadata */
709
        $targetMetadata = $this->createMock(ClassMetadata::class);
710
711
        $this->entityManager->expects($this->exactly(2))
712
            ->method('getClassMetadata')
713
            ->withConsecutive(
714
                [$entityName],
715
                [$targetEntityName]
716
            )->willReturnOnConsecutiveCalls(
717
                $metadata,
718
                $targetMetadata
719
            );
720
721
        $mappings = [
722
            $mappingData,
723
        ];
724
725
        $metadata->expects($this->once())
726
            ->method('getAssociationMappings')
727
            ->willReturn($mappings);
728
729
        $this->logger->expects($this->exactly(2))
730
            ->method('info')
731
            ->withConsecutive(
732
                [
733
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
734
                ],
735
                [
736
                    sprintf(
737
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
738
                        $entityName,
739
                        $fieldName,
740
                        $targetEntityName
741
                    ),
742
                ]
743
            );
744
745
        $errorMessage = sprintf('The entity field \'%s::%s\' value could not be resolved', $entityName, $fieldName);
746
747
        $this->logger->expects($this->once())
748
            ->method('error')
749
            ->with($errorMessage);
750
751
        $this->expectException(PersistenceException::class);
752
        $this->expectExceptionMessage($errorMessage);
753
754
        $cascadeSaveService->saveAssociations($this->entityManager, $entityName, $entity);
755
    }
756
757
    /**
758
     * @return array<mixed>
759
     */
760
    public function getSaveAssociationsThrowPersistenceExceptionIfTheAssocValueIsInvalidData(): array
761
    {
762
        return [
763
            // The return value of the assoc is NULL but the field mapping does not allow NULL should raise an error.
764
            [
765
                [
766
                    'type'             => ClassMetadata::MANY_TO_ONE,
767
                    'isCascadePersist' => true,
768
                    'joinColumns'      => [
769
                        [
770
                            'nullable' => false,
771
                        ],
772
                    ],
773
                ],
774
                null,
775
            ],
776
777
            // The return value of the assoc is not a EntityInterface or iterable collection
778
            [
779
                [
780
                    'type'             => ClassMetadata::MANY_TO_ONE,
781
                    'isCascadePersist' => true,
782
                    'joinColumns'      => [
783
                        [
784
                            'nullable' => false,
785
                        ],
786
                    ],
787
                ],
788
                new \stdClass(),
789
            ],
790
791
        ];
792
    }
793
794
    /**
795
     * @param array<mixed> $mappingData
796
     * @param mixed        $returnValue
797
     *
798
     * @dataProvider getSaveAssociationsData
799
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::saveAssociations
800
     * @covers       \Arp\DoctrineEntityRepository\Persistence\CascadeSaveService::resolveTargetEntityOrCollection
801
     *
802
     * @throws EntityRepositoryException
803
     * @throws PersistenceException
804
     */
805
    public function testSaveAssociations(array $mappingData, $returnValue): void
806
    {
807
        $this->options = [
808
            'foo' => 1,
809
        ];
810
811
        $this->collectionOptions = [
812
            'bar' => 2,
813
        ];
814
815
        $saveOptions = ($returnValue instanceof EntityInterface)
816
            ? $this->options
817
            : $this->collectionOptions;
818
819
        /** @var CascadeSaveService&MockObject $cascadeSaveService */
820
        $cascadeSaveService = $this->getMockBuilder(CascadeSaveService::class)
821
            ->setConstructorArgs([$this->logger, $this->options, $this->collectionOptions])
822
            ->onlyMethods(['saveAssociation'])
823
            ->getMock();
824
825
        $entityName = EntityInterface::class;
826
        $fieldName = 'foo';
827
        $targetEntityName = $mappingData['targetEntity'] = $mappingData['targetEntity'] ?? EntityInterface::class;
828
        $mappingData['fieldName'] = 'foo';
829
830
        /** @var EntityInterface&MockObject $entity */
831
        $entity = new class($returnValue) implements EntityInterface {
832
            use EntityTrait;
833
834
            /**
835
             * @var mixed
836
             */
837
            private $returnValue;
838
839
            /**
840
             * @param mixed $returnValue
841
             */
842
            public function __construct($returnValue)
843
            {
844
                $this->returnValue = $returnValue;
845
            }
846
847
            /**
848
             * @return mixed
849
             */
850
            public function getFoo()
851
            {
852
                return $this->returnValue;
853
            }
854
        };
855
856
        /** @var ClassMetadata&MockObject $metadata */
857
        $metadata = $this->createMock(ClassMetadata::class);
858
859
        /** @var ClassMetadata&MockObject $targetMetadata */
860
        $targetMetadata = $this->createMock(ClassMetadata::class);
861
862
        $this->entityManager->expects($this->exactly(2))
863
            ->method('getClassMetadata')
864
            ->withConsecutive(
865
                [$entityName],
866
                [$targetEntityName]
867
            )->willReturnOnConsecutiveCalls(
868
                $metadata,
869
                $targetMetadata
870
            );
871
872
        $mappings = [
873
            $mappingData,
874
        ];
875
876
        $metadata->expects($this->once())
877
            ->method('getAssociationMappings')
878
            ->willReturn($mappings);
879
880
        $this->logger->expects($this->exactly(3))
881
            ->method('info')
882
            ->withConsecutive(
883
                [
884
                    sprintf('Processing cascade save operations for for entity class \'%s\'', $entityName),
885
                ],
886
                [
887
                    sprintf(
888
                        'The entity field \'%s::%s\' is configured for cascade operations for target entity \'%s\'',
889
                        $entityName,
890
                        $fieldName,
891
                        $targetEntityName
892
                    ),
893
                ],
894
                [
895
                    sprintf('Performing cascading save operations for field \'%s::%s\'', $entityName, $fieldName),
896
                ]
897
            );
898
899
        $cascadeSaveService->expects($this->once())
900
            ->method('saveAssociation')
901
            ->with($this->entityManager, $targetEntityName, $returnValue, $saveOptions);
902
903
        $cascadeSaveService->saveAssociations($this->entityManager, $entityName, $entity);
904
    }
905
906
    /**
907
     * @return array<mixed>
908
     */
909
    public function getSaveAssociationsData(): array
910
    {
911
        /** @var EntityInterface[]&MockObject[] $collection */
912
        $collection = [
913
            $this->getMockForAbstractClass(EntityInterface::class),
914
            $this->getMockForAbstractClass(EntityInterface::class),
915
            $this->getMockForAbstractClass(EntityInterface::class),
916
        ];
917
        $iteratorCollection = new \ArrayIterator($collection);
918
919
        return [
920
            // Save a single entity association
921
            [
922
                [
923
                    'type'             => ClassMetadata::MANY_TO_ONE,
924
                    'isCascadePersist' => true,
925
                    'joinColumns'      => [
926
                        [
927
                            'nullable' => false,
928
                        ],
929
                    ],
930
                ],
931
                $this->getMockForAbstractClass(EntityInterface::class),
932
            ],
933
934
            // Save a collection association
935
            [
936
                [
937
                    'type'             => ClassMetadata::ONE_TO_MANY,
938
                    'isCascadePersist' => true,
939
                ],
940
                $collection,
941
            ],
942
943
            // Save an iterable collection association
944
            [
945
                [
946
                    'type'             => ClassMetadata::MANY_TO_MANY,
947
                    'isCascadePersist' => true,
948
                ],
949
                $iteratorCollection,
950
            ],
951
        ];
952
    }
953
}
954