Passed
Push — trunk ( 9ee228...8c0a35 )
by Christian
12:21 queued 14s
created

UnusedMediaPurger::getUnusedMediaIds()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 31
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 14
nc 4
nop 3
dl 0
loc 31
rs 9.7998
c 1
b 0
f 0
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
     * @return \Generator<array<MediaEntity>>
48
     */
49
    public function getNotUsedMedia(?int $limit = 50, ?int $offset = null, ?int $gracePeriodDays = null, ?string $folderEntity = null): \Generator
50
    {
51
        $limit ??= 50;
52
        $gracePeriodDays ??= 0;
53
54
        $context = Context::createDefaultContext();
55
56
        $criteria = $this->createFilterForNotUsedMedia($folderEntity);
57
        $criteria->addSorting(new FieldSorting('media.createdAt', FieldSorting::ASCENDING));
58
        $criteria->setLimit($limit);
59
60
        // if we provided an offset, then just grab that batch based on the limit
61
        if ($offset !== null) {
62
            $criteria->setOffset($offset);
63
64
            /** @var array<string> $ids */
65
            $ids = $this->mediaRepo->searchIds($criteria, $context)->getIds();
66
            $ids = $this->filterOutNewMedia($ids, $gracePeriodDays);
67
            $ids = $this->dispatchEvent($ids);
68
69
            return yield $this->searchMedia($ids, $context);
70
        }
71
72
        // otherwise, we need iterate over the entire result set in batches
73
        $iterator = new RepositoryIterator($this->mediaRepo, $context, $criteria);
74
        while (($ids = $iterator->fetchIds()) !== null) {
75
            $ids = $this->filterOutNewMedia($ids, $gracePeriodDays);
76
            $unusedIds = $this->dispatchEvent($ids);
77
78
            if (empty($unusedIds)) {
79
                continue;
80
            }
81
82
            yield $this->searchMedia($unusedIds, $context);
83
        }
84
    }
85
86
    public function deleteNotUsedMedia(
87
        ?int $limit = 50,
88
        ?int $offset = null,
89
        ?int $gracePeriodDays = null,
90
        ?string $folderEntity = null,
91
    ): int {
92
        $limit ??= 50;
93
        $gracePeriodDays ??= 0;
94
        $deletedTotal = 0;
95
96
        foreach ($this->getUnusedMediaIds($limit, $offset, $folderEntity) as $idBatch) {
97
            $idBatch = $this->filterOutNewMedia($idBatch, $gracePeriodDays);
98
99
            $this->mediaRepo->delete(
100
                array_map(static fn ($id) => ['id' => $id], $idBatch),
101
                Context::createDefaultContext()
102
            );
103
104
            $deletedTotal += \count($idBatch);
105
        }
106
107
        return $deletedTotal;
108
    }
109
110
    /**
111
     * @param array<string> $ids
112
     *
113
     * @return array<MediaEntity>
114
     */
115
    public function searchMedia(array $ids, Context $context): array
116
    {
117
        /** @var array<MediaEntity> $media */
118
        $media = $this->mediaRepo->search(new Criteria($ids), $context)->getElements();
119
120
        return array_values($media);
121
    }
122
123
    /**
124
     * @param array<string> $mediaIds
125
     *
126
     * @return array<string>
127
     */
128
    private function filterOutNewMedia(array $mediaIds, int $gracePeriodDays): array
129
    {
130
        if ($gracePeriodDays === 0) {
131
            return $mediaIds;
132
        }
133
134
        $threeDaysAgo = (new \DateTime())->sub(new \DateInterval(sprintf('P%dD', $gracePeriodDays)));
135
        $rangeFilter = new RangeFilter('uploadedAt', ['lt' => $threeDaysAgo->format(Defaults::STORAGE_DATE_TIME_FORMAT)]);
136
137
        $criteria = new Criteria($mediaIds);
138
        $criteria->addFilter($rangeFilter);
139
140
        /** @var array<string> $ids */
141
        $ids = $this->mediaRepo->searchIds($criteria, Context::createDefaultContext())->getIds();
142
143
        return $ids;
144
    }
145
146
    /**
147
     * @return \Generator<int, array<string>>
148
     */
149
    private function getUnusedMediaIds(int $limit, ?int $offset = null, ?string $folderEntity = null): \Generator
150
    {
151
        $context = Context::createDefaultContext();
152
153
        $criteria = $this->createFilterForNotUsedMedia($folderEntity);
154
        $criteria->addSorting(new FieldSorting('id', FieldSorting::ASCENDING));
155
        $criteria->setLimit($limit);
156
        $criteria->setOffset(0);
157
158
        // if we provided an offset, then just grab that batch based on the limit
159
        if ($offset !== null) {
160
            $criteria->setOffset($offset);
161
162
            /** @var array<string> $ids */
163
            $ids = $this->mediaRepo->searchIds($criteria, $context)->getIds();
164
165
            return yield $this->dispatchEvent($ids);
166
        }
167
168
        // in order to iterate all records whilst deleting them, we must adjust the offset for each batch
169
        // using the amount of deleted records in the previous batch
170
        // eg: we start from offset 0. we search for 50, and delete 3 of them. Now we start from offset 47.
171
        while (!empty($ids = $this->mediaRepo->searchIds($criteria, $context)->getIds())) {
172
            /** @var array<string> $ids */
173
            $unusedIds = $this->dispatchEvent($ids);
174
175
            if (!empty($unusedIds)) {
176
                yield $unusedIds;
177
            }
178
179
            $criteria->setOffset(($criteria->getOffset() + $limit) - \count($unusedIds));
180
        }
181
    }
182
183
    /**
184
     * @param array<string> $ids
185
     *
186
     * @return array<string>
187
     */
188
    private function dispatchEvent(array $ids): array
189
    {
190
        $event = new UnusedMediaSearchEvent(array_values($ids));
191
        $this->eventDispatcher->dispatch($event);
192
193
        return $event->getUnusedIds();
194
    }
195
196
    /**
197
     * Here we attempt to exclude entity associations that are extending the behaviour of the media entity rather than
198
     * referencing media. For example, `MediaThumbnailDefinition` adds thumbnail support, whereas `ProductMediaDefinition`
199
     * adds support for images to products.
200
     */
201
    private function isInsideTopLevelDomain(string $domain, EntityDefinition $definition): bool
202
    {
203
        if ($definition->getParentDefinition() === null) {
204
            return false;
205
        }
206
207
        if ($definition->getParentDefinition()->getEntityName() === $domain) {
208
            return true;
209
        }
210
211
        return $this->isInsideTopLevelDomain($domain, $definition->getParentDefinition());
212
    }
213
214
    private function createFilterForNotUsedMedia(?string $folderEntity = null): Criteria
215
    {
216
        $criteria = new Criteria();
217
218
        foreach ($this->mediaRepo->getDefinition()->getFields() as $field) {
219
            if (!$field instanceof AssociationField) {
220
                continue;
221
            }
222
223
            if (!\in_array($field::class, self::VALID_ASSOCIATIONS, true)) {
224
                continue;
225
            }
226
227
            $definition = $field->getReferenceDefinition();
228
229
            if ($field instanceof ManyToManyAssociationField) {
230
                $definition = $field->getToManyReferenceDefinition();
231
            }
232
233
            if ($this->isInsideTopLevelDomain(MediaDefinition::ENTITY_NAME, $definition)) {
234
                continue;
235
            }
236
237
            $fkey = $definition->getFields()->getByStorageName($field->getReferenceField());
238
239
            if ($fkey === null) {
240
                continue;
241
            }
242
243
            $criteria->addFilter(
244
                new EqualsFilter(sprintf('media.%s.%s', $field->getPropertyName(), $fkey->getPropertyName()), null)
245
            );
246
        }
247
248
        if ($folderEntity) {
249
            $criteria->addFilter(
250
                new EqualsAnyFilter('media.mediaFolder.defaultFolder.entity', [strtolower($folderEntity)])
251
            );
252
        }
253
254
        return $criteria;
255
    }
256
}
257