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

api-platform / core / 16551910603

27 Jul 2025 01:59PM UTC coverage: 22.096% (+0.03%) from 22.07%
16551910603

Pull #7319

github

web-flow
Merge 1f75b1983 into 02a764950
Pull Request #7319: feat(doctrine): improve http cache invalidation using the mapping

28 of 29 new or added lines in 1 file covered. (96.55%)

1 existing line in 1 file now uncovered.

11590 of 52453 relevant lines covered (22.1%)

23.56 hits per line

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

77.46
/src/Symfony/Doctrine/EventListener/PurgeHttpCacheListener.php
1
<?php
2

3
/*
4
 * This file is part of the API Platform project.
5
 *
6
 * (c) Kévin Dunglas <dunglas@gmail.com>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11

12
declare(strict_types=1);
13

14
namespace ApiPlatform\Symfony\Doctrine\EventListener;
15

16
use ApiPlatform\HttpCache\PurgerInterface;
17
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
18
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
19
use ApiPlatform\Metadata\GetCollection;
20
use ApiPlatform\Metadata\IriConverterInterface;
21
use ApiPlatform\Metadata\ResourceClassResolverInterface;
22
use ApiPlatform\Metadata\UrlGeneratorInterface;
23
use ApiPlatform\Metadata\Util\ClassInfoTrait;
24
use Doctrine\ORM\EntityManagerInterface;
25
use Doctrine\ORM\Event\OnFlushEventArgs;
26
use Doctrine\ORM\Event\PreUpdateEventArgs;
27
use Doctrine\ORM\Mapping\AssociationMapping;
28
use Doctrine\ORM\PersistentCollection;
29
use Symfony\Component\ObjectMapper\Attribute\Map;
30
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
31
use Symfony\Component\PropertyAccess\PropertyAccess;
32
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
33

34
/**
35
 * Purges responses containing modified entities from the proxy cache.
36
 *
37
 * @author Kévin Dunglas <dunglas@gmail.com>
38
 */
39
final class PurgeHttpCacheListener
40
{
41
    use ClassInfoTrait;
42
    private readonly PropertyAccessorInterface $propertyAccessor;
43
    private array $tags = [];
44

45
    public function __construct(private readonly PurgerInterface $purger,
46
                                private readonly IriConverterInterface $iriConverter,
47
                                private readonly ResourceClassResolverInterface $resourceClassResolver,
48
                                ?PropertyAccessorInterface $propertyAccessor = null,
49
                                private readonly ?ObjectMapperInterface $objectMapper=null)
50
    {
51
        $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor();
254✔
52
    }
53

54
    /**
55
     * Collects tags from the previous and the current version of the updated entities to purge related documents.
56
     */
57
    public function preUpdate(PreUpdateEventArgs $eventArgs): void
58
    {
59
        $object = $eventArgs->getObject();
×
60
        $this->gatherResourceAndItemTags($object, true);
×
61

62
        $changeSet = $eventArgs->getEntityChangeSet();
×
63
        // @phpstan-ignore-next-line
64
        $objectManager = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager();
×
65
        $associationMappings = $objectManager->getClassMetadata(\get_class($eventArgs->getObject()))->getAssociationMappings();
×
66

67
        foreach ($changeSet as $key => $value) {
×
68
            if (!isset($associationMappings[$key])) {
×
69
                continue;
×
70
            }
71

72
            $this->addTagsFor($value[0]);
×
73
            $this->addTagsFor($value[1]);
×
74
        }
75
    }
76

77
    /**
78
     * Collects tags from inserted and deleted entities, including relations.
79
     */
80
    public function onFlush(OnFlushEventArgs $eventArgs): void
81
    {
82
        // @phpstan-ignore-next-line
83
        $em = method_exists($eventArgs, 'getObjectManager') ? $eventArgs->getObjectManager() : $eventArgs->getEntityManager();
254✔
84
        $uow = $em->getUnitOfWork();
254✔
85

86
        foreach ($uow->getScheduledEntityInsertions() as $entity) {
254✔
87
            $this->gatherResourceAndItemTags($entity, false);
254✔
88
            $this->gatherRelationTags($em, $entity);
254✔
89
        }
90

91
        foreach ($uow->getScheduledEntityUpdates() as $entity) {
254✔
92
            $this->gatherResourceAndItemTags($entity, true);
×
93
            $this->gatherRelationTags($em, $entity);
×
94
        }
95

96
        foreach ($uow->getScheduledEntityDeletions() as $entity) {
254✔
97
            $this->gatherResourceAndItemTags($entity, true);
×
98
            $this->gatherRelationTags($em, $entity);
×
99
        }
100
    }
101

102
    /**
103
     * Purges tags collected during this request, and clears the tag list.
104
     */
105
    public function postFlush(): void
106
    {
107
        if (empty($this->tags)) {
254✔
108
            return;
6✔
109
        }
110

111
        $this->purger->purge(array_values($this->tags));
248✔
112

113
        $this->tags = [];
248✔
114
    }
115

116
    private function gatherResourceAndItemTags(object $entity, bool $purgeItem): void
117
    {
118
        $resources = $this->getResourcesForEntity($entity);
254✔
119

120
        foreach($resources as $resource) {
254✔
121
            try {
122
                $iri = $this->iriConverter->getIriFromResource($resource, UrlGeneratorInterface::ABS_PATH, new GetCollection());
248✔
123
                $this->tags[$iri] = $iri;
248✔
124

125
                if ($purgeItem) {
248✔
126
                    $this->addTagForItem($entity);
248✔
127
                }
128
            } catch (OperationNotFoundException|InvalidArgumentException) {
4✔
129
            }
130
        }
131
    }
132

133
    private function gatherRelationTags(EntityManagerInterface $em, object $entity): void
134
    {
135
        $associationMappings = $em->getClassMetadata($entity::class)->getAssociationMappings();
254✔
136

137
        /** @var array|AssociationMapping $associationMapping according to the version of doctrine orm */
138
        foreach ($associationMappings as $property => $associationMapping) {
254✔
139
            if ($associationMapping instanceof AssociationMapping && ($associationMapping->targetEntity ?? null) && !$this->resourceClassResolver->isResourceClass($associationMapping->targetEntity)) {
46✔
140
                return;
22✔
141
            }
142
            if (!$this->propertyAccessor->isReadable($entity, $property)) {
30✔
143
                return;
6✔
144
            }
145

146
            if (
147
                \is_array($associationMapping)
30✔
148
                && \array_key_exists('targetEntity', $associationMapping)
30✔
149
                && !$this->resourceClassResolver->isResourceClass($associationMapping['targetEntity'])
30✔
150
                && (
151
                    !$this->objectMapper ||
30✔
152
                    !(new \ReflectionClass($associationMapping['targetEntity']))->getAttributes(Map::class)
30✔
153
                )
154
            ) {
UNCOV
155
                return;
×
156
            }
157

158
            $this->addTagsFor($this->propertyAccessor->getValue($entity, $property));
30✔
159

160
        }
161
    }
162

163
    private function addTagsFor(mixed $value): void
164
    {
165
        if (!$value || \is_scalar($value)) {
30✔
166
            return;
6✔
167
        }
168

169
        if (!is_iterable($value)) {
30✔
170
            $this->addTagForItem($value);
18✔
171

172
            return;
18✔
173
        }
174

175
        if ($value instanceof PersistentCollection) {
16✔
176
            $value = clone $value;
16✔
177
        }
178

179
        foreach ($value as $v) {
16✔
180
            $this->addTagForItem($v);
4✔
181
        }
182
    }
183

184
    private function addTagForItem(mixed $value): void
185
    {
186
        $resources = $this->getResourcesForEntity($value);
18✔
187

188
        foreach($resources as $resource) {
18✔
189
            try {
190
                $iri = $this->iriConverter->getIriFromResource($resource);
18✔
191
                $this->tags[$iri] = $iri;
2✔
192
            } catch (OperationNotFoundException|InvalidArgumentException) {
16✔
193
            }
194
        }
195
    }
196

197
    private function getResourcesForEntity(object $entity): array
198
    {
199
        $resources = [];
254✔
200

201
        if (!$this->resourceClassResolver->isResourceClass(($class= $this->getObjectClass($entity)))) {
254✔
202
            //is the entity mapped to resource(s)?
203
            if (!$this->objectMapper) {
10✔
NEW
204
                return [];
×
205
            }
206

207
            $mapAttributes = (new \ReflectionClass($class))->getAttributes(Map::class);
10✔
208

209
            if (!$mapAttributes) {
10✔
210
                return [];
6✔
211
            }
212

213
            //loop over all mappings to fetch all resources mapped to this entity
214
            foreach($mapAttributes as $mapAttribute) {
4✔
215
                $resources[] = $this->objectMapper->map($entity, $mapAttribute->newInstance()->target);;
4✔
216
            }
217
        }
218
        else{
219
            $resources[] = $entity;
244✔
220
        }
221

222
        return $resources;
248✔
223
    }
224
}
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