Skip to content

Commit 84f8a4f

Browse files
add ChainDirectoryNamer directory namer
1 parent ee47bc4 commit 84f8a4f

5 files changed

Lines changed: 209 additions & 0 deletions

File tree

config/namer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
44

55
use Vich\UploaderBundle\Naming\Base64Namer;
6+
use Vich\UploaderBundle\Naming\ChainDirectoryNamer;
67
use Vich\UploaderBundle\Naming\ConfigurableDirectoryNamer;
78
use Vich\UploaderBundle\Naming\CurrentDateTimeDirectoryNamer;
89
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;
@@ -60,6 +61,8 @@
6061

6162
$services->set(ConfigurableDirectoryNamer::class);
6263

64+
$services->set(ChainDirectoryNamer::class);
65+
6366
$services->set(SmartUniqueNamer::class)
6467
->args([
6568
service(Transliterator::class),
@@ -75,6 +78,7 @@
7578
$services->alias('vich_uploader.namer_directory_property', PropertyDirectoryNamer::class);
7679
$services->alias('vich_uploader.namer_directory_current_date_time', CurrentDateTimeDirectoryNamer::class);
7780
$services->alias('vich_uploader.namer_directory_configurable', ConfigurableDirectoryNamer::class);
81+
$services->alias('vich_uploader.namer_directory_chain', ChainDirectoryNamer::class);
7882
$services->alias('vich_uploader.namer_smart_unique', SmartUniqueNamer::class);
7983

8084
// Transliterator service

docs/namers.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ At the moment there are several available namers:
154154
* `Vich\UploaderBundle\Naming\PropertyDirectoryNamer`
155155
* `Vich\UploaderBundle\Naming\CurrentDateTimeDirectoryNamer`
156156
* `Vich\UploaderBundle\Naming\ConfigurableDirectoryNamer`
157+
* `Vich\UploaderBundle\Naming\ChainDirectoryNamer`
157158

158159
**SubdirDirectoryNamer** creates subdirs depending on the file name, i.e. `abcdef.jpg` will be
159160
stored in a folder `ab`. It is also possible to configure how many chars use per directory name and
@@ -242,6 +243,40 @@ vich_uploader:
242243
directory_path: 'folder/subfolder/subsubfolder'
243244
```
244245

246+
**ChainDirectoryNamer** allows you to chain multiple directory namers together, concatenating their
247+
results with a configurable separator. This is useful when you need to combine multiple naming
248+
strategies, for example organizing files by date and then by a property value.
249+
250+
To use it, specify the service for the `directory_namer` configuration option and configure
251+
the `namers` option with a list of directory namers to chain:
252+
253+
``` yaml
254+
vich_uploader:
255+
# ...
256+
mappings:
257+
products:
258+
upload_destination: products
259+
directory_namer:
260+
service: Vich\UploaderBundle\Naming\ChainDirectoryNamer
261+
options:
262+
namers:
263+
- service: Vich\UploaderBundle\Naming\CurrentDateTimeDirectoryNamer
264+
options:
265+
date_time_format: 'Y/m'
266+
date_time_property: createdAt
267+
- service: Vich\UploaderBundle\Naming\PropertyDirectoryNamer
268+
options:
269+
property: category.slug
270+
separator: '/' # optional, defaults to '/'
271+
```
272+
273+
This configuration will create directories like `2024/01/electronics` for a product in the
274+
"electronics" category uploaded in January 2024.
275+
276+
> [!NOTE]
277+
> Empty directory names returned by any namer in the chain are automatically filtered out.
278+
> For example, if one namer returns an empty string, it won't add an extra separator to the path.
279+
245280
If no directory namer is configured for a mapping, the bundle will simply use
246281
the `upload_destination` configuration option.
247282

src/Mapping/PropertyMappingResolver.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Vich\UploaderBundle\Mapping;
66

77
use Vich\UploaderBundle\Exception\MappingNotFoundException;
8+
use Vich\UploaderBundle\Naming\ChainDirectoryNamer;
89
use Vich\UploaderBundle\Naming\ConfigurableInterface;
910
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;
1011
use Vich\UploaderBundle\Naming\NamerInterface;
@@ -72,6 +73,20 @@ public function resolve(object|array $obj, string $fieldName, array $mappingData
7273
$namerConfig = $config['directory_namer'];
7374
$namer = $this->getDirectoryNamer($mappingData['mapping'], $namerConfig['service']);
7475

76+
// Handle ChainDirectoryNamer specially - resolve nested namers
77+
if ($namer instanceof ChainDirectoryNamer && !empty($namerConfig['options']['namers'])) {
78+
$chainedNamers = [];
79+
foreach ($namerConfig['options']['namers'] as $nestedConfig) {
80+
$nestedNamer = $this->getDirectoryNamer($mappingData['mapping'], $nestedConfig['service']);
81+
if (!empty($nestedConfig['options']) && $nestedNamer instanceof ConfigurableInterface) {
82+
$nestedNamer->configure($nestedConfig['options']);
83+
}
84+
$chainedNamers[] = $nestedNamer;
85+
}
86+
$namer->setNamers($chainedNamers);
87+
}
88+
89+
// Configure the namer itself (e.g., separator option for ChainDirectoryNamer)
7590
if (!empty($namerConfig['options'])) {
7691
if (!$namer instanceof ConfigurableInterface) {
7792
throw new \LogicException(\sprintf('Namer %s can not receive options as it does not implement ConfigurableInterface.', $namerConfig['service']));

src/Naming/ChainDirectoryNamer.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
namespace Vich\UploaderBundle\Naming;
4+
5+
use Vich\UploaderBundle\Mapping\PropertyMapping;
6+
7+
/**
8+
* Directory namer that chains multiple directory namers together.
9+
*
10+
* @author Guillaume Sainthillier <guillaume@silarhi.fr>
11+
*/
12+
final class ChainDirectoryNamer implements DirectoryNamerInterface, ConfigurableInterface
13+
{
14+
/** @var array<DirectoryNamerInterface> */
15+
private array $namers = [];
16+
17+
private string $separator = '/';
18+
19+
/**
20+
* @param array<DirectoryNamerInterface> $namers
21+
*/
22+
public function setNamers(array $namers): void
23+
{
24+
$this->namers = $namers;
25+
}
26+
27+
/**
28+
* @param array $options Options for this namer. The following options are accepted:
29+
* - separator: the separator between directory names (default: '/')
30+
*/
31+
public function configure(array $options): void
32+
{
33+
if (isset($options['separator'])) {
34+
$this->separator = (string) $options['separator'];
35+
}
36+
}
37+
38+
public function directoryName(object|array $object, PropertyMapping $mapping): string
39+
{
40+
$directories = [];
41+
foreach ($this->namers as $namer) {
42+
$directory = $namer->directoryName($object, $mapping);
43+
if ('' !== $directory) {
44+
$directories[] = $directory;
45+
}
46+
}
47+
48+
return \implode($this->separator, $directories);
49+
}
50+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace Vich\UploaderBundle\Tests\Naming;
4+
5+
use PHPUnit\Framework\Attributes\DataProvider;
6+
use Vich\UploaderBundle\Naming\ChainDirectoryNamer;
7+
use Vich\UploaderBundle\Naming\DirectoryNamerInterface;
8+
use Vich\UploaderBundle\Tests\DummyEntity;
9+
use Vich\UploaderBundle\Tests\TestCase;
10+
11+
/**
12+
* @author Guillaume Loulier
13+
*/
14+
final class ChainDirectoryNamerTest extends TestCase
15+
{
16+
public static function chainDataProvider(): array
17+
{
18+
return [
19+
'two namers' => [['dir1', 'dir2'], '/', 'dir1/dir2'],
20+
'three namers' => [['a', 'b', 'c'], '/', 'a/b/c'],
21+
'custom separator' => [['a', 'b'], '-', 'a-b'],
22+
'with empty values' => [['a', '', 'c'], '/', 'a/c'],
23+
'single namer' => [['only'], '/', 'only'],
24+
'no namers' => [[], '/', ''],
25+
];
26+
}
27+
28+
#[DataProvider('chainDataProvider')]
29+
public function testDirectoryNameChainsNamers(array $namerResults, string $separator, string $expected): void
30+
{
31+
$entity = new DummyEntity();
32+
$mapping = $this->getPropertyMappingMock();
33+
34+
$namers = [];
35+
foreach ($namerResults as $result) {
36+
$namer = $this->createMock(DirectoryNamerInterface::class);
37+
$namer->expects(self::once())
38+
->method('directoryName')
39+
->with($entity, $mapping)
40+
->willReturn($result);
41+
$namers[] = $namer;
42+
}
43+
44+
$chainNamer = new ChainDirectoryNamer();
45+
$chainNamer->setNamers($namers);
46+
$chainNamer->configure(['separator' => $separator]);
47+
48+
self::assertSame($expected, $chainNamer->directoryName($entity, $mapping));
49+
}
50+
51+
public function testDefaultSeparatorIsSlash(): void
52+
{
53+
$entity = new DummyEntity();
54+
$mapping = $this->getPropertyMappingMock();
55+
56+
$namer1 = $this->createMock(DirectoryNamerInterface::class);
57+
$namer1->method('directoryName')->willReturn('a');
58+
59+
$namer2 = $this->createMock(DirectoryNamerInterface::class);
60+
$namer2->method('directoryName')->willReturn('b');
61+
62+
$chainNamer = new ChainDirectoryNamer();
63+
$chainNamer->setNamers([$namer1, $namer2]);
64+
65+
self::assertSame('a/b', $chainNamer->directoryName($entity, $mapping));
66+
}
67+
68+
public function testConfigureWithoutSeparatorKeepsDefault(): void
69+
{
70+
$entity = new DummyEntity();
71+
$mapping = $this->getPropertyMappingMock();
72+
73+
$namer1 = $this->createMock(DirectoryNamerInterface::class);
74+
$namer1->method('directoryName')->willReturn('a');
75+
76+
$namer2 = $this->createMock(DirectoryNamerInterface::class);
77+
$namer2->method('directoryName')->willReturn('b');
78+
79+
$chainNamer = new ChainDirectoryNamer();
80+
$chainNamer->setNamers([$namer1, $namer2]);
81+
$chainNamer->configure([]); // Empty options should not change the default separator
82+
83+
self::assertSame('a/b', $chainNamer->directoryName($entity, $mapping));
84+
}
85+
86+
public function testEmptyStringFromNamerIsFiltered(): void
87+
{
88+
$entity = new DummyEntity();
89+
$mapping = $this->getPropertyMappingMock();
90+
91+
$namer1 = $this->createMock(DirectoryNamerInterface::class);
92+
$namer1->method('directoryName')->willReturn('start');
93+
94+
$namer2 = $this->createMock(DirectoryNamerInterface::class);
95+
$namer2->method('directoryName')->willReturn('');
96+
97+
$namer3 = $this->createMock(DirectoryNamerInterface::class);
98+
$namer3->method('directoryName')->willReturn('end');
99+
100+
$chainNamer = new ChainDirectoryNamer();
101+
$chainNamer->setNamers([$namer1, $namer2, $namer3]);
102+
103+
self::assertSame('start/end', $chainNamer->directoryName($entity, $mapping));
104+
}
105+
}

0 commit comments

Comments
 (0)