Passed
Push — trunk ( 6d115e...2fe92c )
by Christian
11:00 queued 15s
created

UnusedMediaPurger   A

Complexity

Total Complexity 25

Size/Duplication

Total Lines 214
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 85
dl 0
loc 214
rs 10
c 1
b 0
f 0
wmc 25

8 Methods

Rating   Name   Duplication   Size   Complexity  
A getUnusedMediaIds() 0 31 4
A filterOutNewMedia() 0 16 2
B createFilterForNotUsedMedia() 0 41 8
A deleteNotUsedMedia() 0 22 2
A __construct() 0 4 1
A getNotUsedMedia() 0 34 4
A isInsideTopLevelDomain() 0 11 3
A dispatchEvent() 0 6 1
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Content\Media;
4
5
use Shopware\Core\Content\Media\Event\UnusedMediaSearchEvent;
6
use Shopware\Core\Defaults;
7
use Shopware\Core\Framework\Context;
8
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\Common\RepositoryIterator;
9
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
10
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
11
use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField;
12
use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField;
13
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField;
14
use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField;
15
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
16
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter;
17
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
18
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\RangeFilter;
19
use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;
20
use Shopware\Core\Framework\Log\Package;
21
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
22
23
/**
24
 * @final
25
 */
26
#[Package('content')]
27
class UnusedMediaPurger
28
{
29
    private const VALID_ASSOCIATIONS = [
30
        ManyToManyAssociationField::class,
31
        OneToManyAssociationField::class,
32
        OneToOneAssociationField::class,
33
    ];
34
35
    /**
36
     * @internal
37
     */
38
    public function __construct(
39
        private readonly EntityRepository $mediaRepo,
40
        private readonly EventDispatcherInterface $eventDispatcher,
41
    ) {
42
    }
43
44
    /**
45
     * @internal This method is used only by the media:delete-unused command and is subject to change
46
     */
47
    public function getNotUsedMedia(?int $limit = 50, ?int $offset = null, ?int $gracePeriodDays = null, ?string $folderEntity = null): \Generator
48
    {
49
        $limit ??= 50;
50
        $gracePeriodDays ??= 0;
51
52
        $context = Context::createDefaultContext();
53
54
        $criteria = $this->createFilterForNotUsedMedia($folderEntity);
55
        $criteria->addSorting(new FieldSorting('media.createdAt', FieldSorting::ASCENDING));
56
        $criteria->setLimit($limit);
57
58
        //if we provided an offset, then just grab that batch based on the limit
59
        if ($offset !== null) {
60
            $criteria->setOffset($offset);
61
62
            /** @var array<string> $ids */
63
            $ids = $this->mediaRepo->searchIds($criteria, $context)->getIds();
64
            $ids = $this->filterOutNewMedia($ids, $gracePeriodDays);
65
            $ids = $this->dispatchEvent($ids);
66
67
            return yield array_values($this->mediaRepo->search(new Criteria($ids), $context)->getElements());
68
        }
69
70
        //otherwise, we need iterate over the entire result set in batches
71
        $iterator = new RepositoryIterator($this->mediaRepo, $context, $criteria);
72
        while (($ids = $iterator->fetchIds()) !== null) {
73
            $ids = $this->filterOutNewMedia($ids, $gracePeriodDays);
74
            $unusedIds = $this->dispatchEvent($ids);
75
76
            if (empty($unusedIds)) {
77
                continue;
78
            }
79
80
            yield array_values($this->mediaRepo->search(new Criteria($unusedIds), $context)->getElements());
81
        }
82
    }
83
84
    public function deleteNotUsedMedia(
85
        ?int $limit = 50,
86
        ?int $offset = null,
87
        ?int $gracePeriodDays = null,
88
        ?string $folderEntity = null,
89
    ): int {
90
        $limit ??= 50;
91
        $gracePeriodDays ??= 0;
92
        $deletedTotal = 0;
93
94
        foreach ($this->getUnusedMediaIds($limit, $offset, $folderEntity) as $idBatch) {
95
            $idBatch = $this->filterOutNewMedia($idBatch, $gracePeriodDays);
96
97
            $this->mediaRepo->delete(
98
                array_map(static fn ($id) => ['id' => $id], $idBatch),
99
                Context::createDefaultContext()
100
            );
101
102
            $deletedTotal += \count($idBatch);
103
        }
104
105
        return $deletedTotal;
106
    }
107
108
    /**
109
     * @param array<string> $mediaIds
110
     *
111
     * @return array<string>
112
     */
113
    private function filterOutNewMedia(array $mediaIds, int $gracePeriodDays): array
114
    {
115
        if ($gracePeriodDays === 0) {
116
            return $mediaIds;
117
        }
118
119
        $threeDaysAgo = (new \DateTime())->sub(new \DateInterval(sprintf('P%dD', $gracePeriodDays)));
120
        $rangeFilter = new RangeFilter('uploadedAt', ['lt' => $threeDaysAgo->format(Defaults::STORAGE_DATE_TIME_FORMAT)]);
121
122
        $criteria = new Criteria($mediaIds);
123
        $criteria->addFilter($rangeFilter);
124
125
        /** @var array<string> $ids */
126
        $ids = $this->mediaRepo->searchIds($criteria, Context::createDefaultContext())->getIds();
127
128
        return $ids;
129
    }
130
131
    /**
132
     * @return \Generator<int, array<string>>
133
     */
134
    private function getUnusedMediaIds(int $limit, ?int $offset = null, ?string $folderEntity = null): \Generator
135
    {
136
        $context = Context::createDefaultContext();
137
138
        $criteria = $this->createFilterForNotUsedMedia($folderEntity);
139
        $criteria->addSorting(new FieldSorting('id', FieldSorting::ASCENDING));
140
        $criteria->setLimit($limit);
141
        $criteria->setOffset(0);
142
143
        //if we provided an offset, then just grab that batch based on the limit
144
        if ($offset !== null) {
145
            $criteria->setOffset($offset);
146
147
            /** @var array<string> $ids */
148
            $ids = $this->mediaRepo->searchIds($criteria, $context)->getIds();
149
150
            return yield $this->dispatchEvent($ids);
151
        }
152
153
        //in order to iterate all records whilst deleting them, we must adjust the offset for each batch
154
        //using the amount of deleted records in the previous batch
155
        //eg: we start from offset 0. we search for 50, and delete 3 of them. Now we start from offset 47.
156
        while (!empty($ids = $this->mediaRepo->searchIds($criteria, $context)->getIds())) {
157
            /** @var array<string> $ids */
158
            $unusedIds = $this->dispatchEvent($ids);
159
160
            if (!empty($unusedIds)) {
161
                yield $unusedIds;
162
            }
163
164
            $criteria->setOffset(($criteria->getOffset() + $limit) - \count($unusedIds));
165
        }
166
    }
167
168
    /**
169
     * @param array<string> $ids
170
     *
171
     * @return array<string>
172
     */
173
    private function dispatchEvent(array $ids): array
174
    {
175
        $event = new UnusedMediaSearchEvent(array_values($ids));
176
        $this->eventDispatcher->dispatch($event);
177
178
        return $event->getUnusedIds();
179
    }
180
181
    /**
182
     * Here we attempt to exclude entity associations that are extending the behaviour of the media entity rather than
183
     * referencing media. For example, `MediaThumbnailDefinition` adds thumbnail support, whereas `ProductMediaDefinition`
184
     * adds support for images to products.
185
     */
186
    private function isInsideTopLevelDomain(string $domain, EntityDefinition $definition): bool
187
    {
188
        if ($definition->getParentDefinition() === null) {
189
            return false;
190
        }
191
192
        if ($definition->getParentDefinition()->getEntityName() === $domain) {
193
            return true;
194
        }
195
196
        return $this->isInsideTopLevelDomain($domain, $definition->getParentDefinition());
197
    }
198
199
    private function createFilterForNotUsedMedia(?string $folderEntity = null): Criteria
200
    {
201
        $criteria = new Criteria();
202
203
        foreach ($this->mediaRepo->getDefinition()->getFields() as $field) {
204
            if (!$field instanceof AssociationField) {
205
                continue;
206
            }
207
208
            if (!\in_array($field::class, self::VALID_ASSOCIATIONS, true)) {
209
                continue;
210
            }
211
212
            $definition = $field->getReferenceDefinition();
213
214
            if ($field instanceof ManyToManyAssociationField) {
215
                $definition = $field->getToManyReferenceDefinition();
216
            }
217
218
            if ($this->isInsideTopLevelDomain(MediaDefinition::ENTITY_NAME, $definition)) {
219
                continue;
220
            }
221
222
            $fkey = $definition->getFields()->getByStorageName($field->getReferenceField());
223
224
            if ($fkey === null) {
225
                continue;
226
            }
227
228
            $criteria->addFilter(
229
                new EqualsFilter(sprintf('media.%s.%s', $field->getPropertyName(), $fkey->getPropertyName()), null)
230
            );
231
        }
232
233
        if ($folderEntity) {
234
            $criteria->addFilter(
235
                new EqualsAnyFilter('media.mediaFolder.defaultFolder.entity', [strtolower($folderEntity)])
236
            );
237
        }
238
239
        return $criteria;
240
    }
241
}
242