Skip to content

Commit 14ca832

Browse files
committed
DynamicTableMessageHandler
1 parent 1c0b58d commit 14ca832

File tree

11 files changed

+297
-52
lines changed

11 files changed

+297
-52
lines changed

config/packages/messenger.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ framework:
3030
'PhpList\Core\Domain\Messaging\Message\PasswordResetMessage': async_email
3131
'PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage': async_email
3232
'PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage': sync
33+
'PhpList\Core\Domain\Subscription\Message\DynamicTableMessage': sync
3334

config/services/messenger.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ services:
2929
autoconfigure: true
3030
tags: [ 'messenger.message_handler' ]
3131

32+
PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler:
33+
autowire: true
34+
autoconfigure: true
35+
tags: [ 'messenger.message_handler' ]

resources/translations/messages.en.xlf

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,6 @@
342342
<source>Subscription not found for this subscriber and list.</source>
343343
<target>Subscription not found for this subscriber and list.</target>
344344
</trans-unit>
345-
<trans-unit id="xWcRIPk" resname="Attribute definition already exists">
346-
<source>Attribute definition already exists</source>
347-
<target>Attribute definition already exists</target>
348-
</trans-unit>
349345
<trans-unit id="kQejNsl" resname="Another attribute with this name already exists.">
350346
<source>Another attribute with this name already exists.</source>
351347
<target>Another attribute with this name already exists.</target>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Subscription\Message;
6+
7+
class DynamicTableMessage
8+
{
9+
private string $tableName;
10+
11+
public function __construct(string $tableName)
12+
{
13+
$this->tableName = $tableName;
14+
}
15+
16+
public function getTableName(): string
17+
{
18+
return $this->tableName;
19+
}
20+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Domain\Subscription\MessageHandler;
6+
7+
use Doctrine\DBAL\Exception\TableExistsException;
8+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
9+
use Doctrine\DBAL\Schema\Table;
10+
use InvalidArgumentException;
11+
use PhpList\Core\Domain\Subscription\Message\DynamicTableMessage;
12+
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
13+
14+
#[AsMessageHandler]
15+
class DynamicTableMessageHandler
16+
{
17+
18+
public function __construct(private readonly AbstractSchemaManager $schemaManager)
19+
{
20+
}
21+
22+
/**
23+
* @throws InvalidArgumentException
24+
*/
25+
public function __invoke(DynamicTableMessage $message): void
26+
{
27+
if ($this->schemaManager->tablesExist([$message->getTableName()])) {
28+
return;
29+
}
30+
31+
if (!preg_match('/^[A-Za-z0-9_]+$/', $message->getTableName())) {
32+
throw new InvalidArgumentException('Invalid list table name: ' . $message->getTableName());
33+
}
34+
35+
$table = new Table($message->getTableName());
36+
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
37+
$table->addColumn('name', 'string', ['length' => 255, 'notnull' => false]);
38+
$table->addColumn('listorder', 'integer', ['notnull' => false, 'default' => 0]);
39+
$table->setPrimaryKey(['id']);
40+
$table->addUniqueIndex(['name'], 'uniq_' . $message->getTableName() . '_name');
41+
42+
try {
43+
$this->schemaManager->createTable($table);
44+
} catch (TableExistsException $e) {
45+
// Table was created by a concurrent process or a previous test run.
46+
// Since this method is idempotent by contract, swallow the exception.
47+
return;
48+
}
49+
}
50+
}

src/Domain/Subscription/Service/Manager/AttributeDefinitionManager.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public function create(AttributeDefinitionDto $attributeDefinitionDto): Subscrib
3838
$existingAttribute = $this->definitionRepository->findOneByName($attributeDefinitionDto->name);
3939
if ($existingAttribute) {
4040
throw new AttributeDefinitionCreationException(
41-
message: $this->translator->trans('Attribute definition already exists'),
41+
message: $this->translator->trans('Attribute definition already exists.'),
4242
statusCode: 409
4343
);
4444
}

src/Domain/Subscription/Service/Manager/DynamicListAttrTablesManager.php

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@
44

55
namespace PhpList\Core\Domain\Subscription\Service\Manager;
66

7-
use Doctrine\DBAL\Exception\TableExistsException;
8-
use Doctrine\DBAL\Schema\AbstractSchemaManager;
9-
use Doctrine\DBAL\Schema\Table;
10-
use InvalidArgumentException;
7+
use PhpList\Core\Domain\Subscription\Message\DynamicTableMessage;
118
use PhpList\Core\Domain\Subscription\Model\AttributeTypeEnum;
129
use PhpList\Core\Domain\Subscription\Repository\SubscriberAttributeDefinitionRepository;
10+
use Symfony\Component\Messenger\MessageBusInterface;
1311
use function Symfony\Component\String\u;
1412

1513
class DynamicListAttrTablesManager
@@ -18,7 +16,7 @@ class DynamicListAttrTablesManager
1816

1917
public function __construct(
2018
private readonly SubscriberAttributeDefinitionRepository $definitionRepository,
21-
private readonly AbstractSchemaManager $schemaManager,
19+
private readonly MessageBusInterface $messageBus,
2220
string $dbPrefix = 'phplist_',
2321
string $dynamicListTablePrefix = 'listattr_',
2422
) {
@@ -47,34 +45,10 @@ public function resolveTableName(string $name, ?AttributeTypeEnum $type): ?strin
4745
return $candidate;
4846
}
4947

50-
/**
51-
* @throws InvalidArgumentException
52-
*/
5348
public function createOptionsTableIfNotExists(string $listTable): void
5449
{
5550
$fullTableName = $this->prefix . $listTable;
5651

57-
if ($this->schemaManager->tablesExist([$fullTableName])) {
58-
return;
59-
}
60-
61-
if (!preg_match('/^[A-Za-z0-9_]+$/', $listTable)) {
62-
throw new InvalidArgumentException('Invalid list table name: ' . $listTable);
63-
}
64-
65-
$table = new Table($fullTableName);
66-
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
67-
$table->addColumn('name', 'string', ['length' => 255, 'notnull' => false]);
68-
$table->addColumn('listorder', 'integer', ['notnull' => false, 'default' => 0]);
69-
$table->setPrimaryKey(['id']);
70-
$table->addUniqueIndex(['name'], 'uniq_' . $fullTableName . '_name');
71-
72-
try {
73-
$this->schemaManager->createTable($table);
74-
} catch (TableExistsException $e) {
75-
// Table was created by a concurrent process or a previous test run.
76-
// Since this method is idempotent by contract, swallow the exception.
77-
return;
78-
}
52+
$this->messageBus->dispatch(new DynamicTableMessage($fullTableName));
7953
}
8054
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Tests\Support\DBAL;
6+
7+
use Doctrine\DBAL\Driver\Exception as DriverException;
8+
use Exception;
9+
use Throwable;
10+
11+
/**
12+
* Lightweight test double for Doctrine DBAL driver exceptions.
13+
* Allows constructing DBAL higher-level exceptions (e.g., TableExistsException)
14+
* without relying on a real driver implementation.
15+
*/
16+
class FakeDriverException extends Exception implements DriverException
17+
{
18+
private string|null $sqlState;
19+
20+
public function __construct(
21+
string $message = '',
22+
?string $sqlState = null,
23+
int $code = 0,
24+
?Throwable $previous = null
25+
) {
26+
parent::__construct($message, $code, $previous);
27+
$this->sqlState = $sqlState;
28+
}
29+
30+
public function getSQLState(): ?string
31+
{
32+
return $this->sqlState;
33+
}
34+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpList\Core\Tests\Unit\Domain\Subscription\MessageHandler;
6+
7+
use Doctrine\DBAL\Exception\TableExistsException;
8+
use Doctrine\DBAL\Schema\AbstractSchemaManager;
9+
use Doctrine\DBAL\Schema\Table;
10+
use InvalidArgumentException;
11+
use PhpList\Core\Domain\Subscription\Message\DynamicTableMessage;
12+
use PhpList\Core\Domain\Subscription\MessageHandler\DynamicTableMessageHandler;
13+
use PHPUnit\Framework\MockObject\MockObject;
14+
use PHPUnit\Framework\TestCase;
15+
use PhpList\Core\Tests\Support\DBAL\FakeDriverException;
16+
17+
class DynamicTableMessageHandlerTest extends TestCase
18+
{
19+
private AbstractSchemaManager&MockObject $schemaManager;
20+
21+
protected function setUp(): void
22+
{
23+
$this->schemaManager = $this->createMock(AbstractSchemaManager::class);
24+
}
25+
26+
public function testInvokeCreatesTableWhenNotExists(): void
27+
{
28+
$tableName = 'phplist_listattr_sizes';
29+
$message = new DynamicTableMessage($tableName);
30+
31+
$capturedTable = null;
32+
33+
$this->schemaManager
34+
->expects($this->once())
35+
->method('tablesExist')
36+
->with([$tableName])
37+
->willReturn(false);
38+
39+
$this->schemaManager
40+
->expects($this->once())
41+
->method('createTable')
42+
->with($this->callback(function (Table $table) use (&$capturedTable, $tableName) {
43+
$capturedTable = $table;
44+
// Basic checks
45+
$this->assertSame($tableName, $table->getName());
46+
$this->assertTrue($table->hasColumn('id'));
47+
$this->assertTrue($table->hasColumn('name'));
48+
$this->assertTrue($table->hasColumn('listorder'));
49+
50+
// id column
51+
$idCol = $table->getColumn('id');
52+
$this->assertSame('integer', $idCol->getType()->getName());
53+
$this->assertTrue($idCol->getAutoincrement());
54+
$this->assertTrue($idCol->getNotnull());
55+
56+
// name column
57+
$nameCol = $table->getColumn('name');
58+
$this->assertSame('string', $nameCol->getType()->getName());
59+
$this->assertSame(255, $nameCol->getLength());
60+
$this->assertFalse($nameCol->getNotnull());
61+
62+
// listorder column
63+
$orderCol = $table->getColumn('listorder');
64+
$this->assertSame('integer', $orderCol->getType()->getName());
65+
$this->assertFalse($orderCol->getNotnull());
66+
$this->assertSame(0, $orderCol->getDefault());
67+
68+
// Primary key
69+
$this->assertSame(['id'], $table->getPrimaryKey()?->getColumns());
70+
71+
// Unique index on name
72+
$indexName = 'uniq_' . $tableName . '_name';
73+
$this->assertTrue($table->hasIndex($indexName));
74+
$idx = $table->getIndex($indexName);
75+
$this->assertTrue($idx->isUnique());
76+
$this->assertSame(['name'], $idx->getColumns());
77+
78+
return true;
79+
}))
80+
->willReturnCallback(function (Table $table) {
81+
// no-op; we just want the assertions in the callback
82+
});
83+
84+
$handler = new DynamicTableMessageHandler($this->schemaManager);
85+
$handler($message);
86+
87+
$this->assertInstanceOf(Table::class, $capturedTable);
88+
}
89+
90+
public function testInvokeDoesNothingWhenTableAlreadyExists(): void
91+
{
92+
$tableName = 'phplist_listattr_sizes';
93+
$message = new DynamicTableMessage($tableName);
94+
95+
$this->schemaManager
96+
->expects($this->once())
97+
->method('tablesExist')
98+
->with([$tableName])
99+
->willReturn(true);
100+
101+
$this->schemaManager
102+
->expects($this->never())
103+
->method('createTable');
104+
105+
$handler = new DynamicTableMessageHandler($this->schemaManager);
106+
$handler($message);
107+
// reached without creating a table
108+
$this->assertTrue(true);
109+
}
110+
111+
public function testInvokeThrowsForInvalidTableName(): void
112+
{
113+
$invalidName = 'invalid-name!';
114+
$message = new DynamicTableMessage($invalidName);
115+
116+
// tablesExist is consulted before validating the name
117+
$this->schemaManager
118+
->expects($this->once())
119+
->method('tablesExist')
120+
->with([$invalidName])
121+
->willReturn(false);
122+
123+
$handler = new DynamicTableMessageHandler($this->schemaManager);
124+
125+
$this->expectException(InvalidArgumentException::class);
126+
$this->expectExceptionMessage('Invalid list table name');
127+
$handler($message);
128+
}
129+
130+
public function testInvokeSwallowsTableExistsRace(): void
131+
{
132+
$tableName = 'phplist_listattr_colors';
133+
$message = new DynamicTableMessage($tableName);
134+
135+
$this->schemaManager
136+
->expects($this->once())
137+
->method('tablesExist')
138+
->with([$tableName])
139+
->willReturn(false);
140+
141+
$this->schemaManager
142+
->expects($this->once())
143+
->method('createTable')
144+
->willThrowException(new TableExistsException(
145+
new FakeDriverException('already exists', '42P07'),
146+
null
147+
));
148+
149+
$handler = new DynamicTableMessageHandler($this->schemaManager);
150+
151+
// Should not throw despite the TableExistsException
152+
$handler($message);
153+
$this->assertTrue(true);
154+
}
155+
}

tests/Unit/Domain/Subscription/Service/Manager/AttributeDefinitionManagerTest.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace PhpList\Core\Tests\Unit\Domain\Subscription\Service\Manager;
66

7-
use Doctrine\DBAL\Schema\AbstractSchemaManager;
87
use PhpList\Core\Domain\Subscription\Exception\AttributeDefinitionCreationException;
98
use PhpList\Core\Domain\Subscription\Model\AttributeTypeEnum;
109
use PhpList\Core\Domain\Subscription\Model\Dto\AttributeDefinitionDto;
@@ -15,6 +14,8 @@
1514
use PhpList\Core\Domain\Subscription\Service\Manager\DynamicListAttrTablesManager;
1615
use PhpList\Core\Domain\Subscription\Validator\AttributeTypeValidator;
1716
use PHPUnit\Framework\TestCase;
17+
use Symfony\Component\Messenger\Envelope;
18+
use Symfony\Component\Messenger\MessageBusInterface;
1819
use Symfony\Component\Translation\Translator;
1920

2021
class AttributeDefinitionManagerTest extends TestCase
@@ -23,13 +24,13 @@ public function testCreateAttributeDefinition(): void
2324
{
2425
$repository = $this->createMock(SubscriberAttributeDefinitionRepository::class);
2526
$validator = $this->createMock(AttributeTypeValidator::class);
26-
$schema = $this->getMockBuilder(AbstractSchemaManager::class)
27-
->disableOriginalConstructor()
28-
->onlyMethods(['tablesExist', 'createTable'])
29-
->getMockForAbstractClass();
27+
$bus = $this->createMock(MessageBusInterface::class);
28+
$bus->method('dispatch')->willReturnCallback(function ($message) {
29+
return new Envelope($message);
30+
});
3031
$dynamicTablesManager = new DynamicListAttrTablesManager(
3132
definitionRepository: $this->createMock(SubscriberAttributeDefinitionRepository::class),
32-
schemaManager: $schema,
33+
messageBus: $bus,
3334
);
3435
$manager = new AttributeDefinitionManager(
3536
definitionRepository: $repository,

0 commit comments

Comments
 (0)