From a8a3ad4b333683d5b3607d7079522dfc1b844902 Mon Sep 17 00:00:00 2001 From: Guillaume Loulier Date: Mon, 17 Nov 2025 16:24:12 +0100 Subject: [PATCH] feat(chat): Doctrine Dbal message store --- docs/components/chat.rst | 4 + .../chat/persistent-chat-doctrine-dbal.php | 40 ++++ examples/commands/message-stores.php | 6 + src/ai-bundle/config/options.php | 15 ++ src/ai-bundle/src/AiBundle.php | 21 ++ .../DependencyInjection/AiBundleTest.php | 69 +++++++ src/chat/composer.json | 1 + .../Doctrine/DoctrineDbalMessageStore.php | 148 ++++++++++++++ .../Doctrine/DoctrineDbalMessageStoreTest.php | 182 ++++++++++++++++++ 9 files changed, 486 insertions(+) create mode 100644 examples/chat/persistent-chat-doctrine-dbal.php create mode 100644 src/chat/src/Bridge/Doctrine/DoctrineDbalMessageStore.php create mode 100644 src/chat/tests/Bridge/Doctrine/DoctrineDbalMessageStoreTest.php diff --git a/docs/components/chat.rst b/docs/components/chat.rst index 7a2c6edb3..839355ada 100644 --- a/docs/components/chat.rst +++ b/docs/components/chat.rst @@ -34,6 +34,7 @@ with a ``Symfony\AI\Agent\AgentInterface`` and a ``Symfony\AI\Chat\MessageStoreI You can find more advanced usage in combination with an Agent using the store for long-term context: * `External services storage with Cache`_ +* `Long-term context with Doctrine DBAL`_ * `Current session context storage with HttpFoundation session`_ * `Current process context storage with InMemory`_ * `Long-term context with Meilisearch`_ @@ -45,6 +46,7 @@ Supported Message stores ------------------------ * `Cache`_ +* `Doctrine DBAL`_ * `HttpFoundation session`_ * `InMemory`_ * `Meilisearch`_ @@ -127,6 +129,7 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store: $ php bin/console ai:message-store:drop symfonycon .. _`External services storage with Cache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cache.php +.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php .. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php .. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php .. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php @@ -134,6 +137,7 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store: .. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php .. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php .. _`Cache`: https://symfony.com/doc/current/components/cache.html +.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html .. _`InMemory`: https://www.php.net/manual/en/language.types.array.php .. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/ .. _`Meilisearch`: https://www.meilisearch.com/ diff --git a/examples/chat/persistent-chat-doctrine-dbal.php b/examples/chat/persistent-chat-doctrine-dbal.php new file mode 100644 index 000000000..4826724a2 --- /dev/null +++ b/examples/chat/persistent-chat-doctrine-dbal.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Doctrine\DBAL\DriverManager; +use Symfony\AI\Agent\Agent; +use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore; +use Symfony\AI\Chat\Chat; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + +$store = new DoctrineDbalMessageStore('symfony', $connection); +$store->setup(); + +$agent = new Agent($platform, 'gpt-4o-mini'); +$chat = new Chat($agent, $store); + +$messages = new MessageBag( + Message::forSystem('You are a helpful assistant. You only answer with short sentences.'), +); + +$chat->initiate($messages); +$chat->submit(Message::ofUser('My name is Christopher.')); +$message = $chat->submit(Message::ofUser('What is my name?')); + +echo $message->getContent().\PHP_EOL; diff --git a/examples/commands/message-stores.php b/examples/commands/message-stores.php index c9fce7f2b..5241a3b93 100644 --- a/examples/commands/message-stores.php +++ b/examples/commands/message-stores.php @@ -11,6 +11,8 @@ require_once dirname(__DIR__).'/bootstrap.php'; +use Doctrine\DBAL\DriverManager; +use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore; use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; use Symfony\AI\Chat\Bridge\Local\CacheStore; use Symfony\AI\Chat\Bridge\Local\InMemoryStore; @@ -37,6 +39,10 @@ $factories = [ 'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'), + 'doctrine' => static fn (): DoctrineDbalMessageStore => new DoctrineDbalMessageStore( + 'symfony', + DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]), + ), 'meilisearch' => static fn (): MeilisearchMessageStore => new MeilisearchMessageStore( http_client(), env('MEILISEARCH_HOST'), diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 77787af9d..d4ac49d2a 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -806,6 +806,21 @@ ->end() ->end() ->end() + ->arrayNode('doctrine') + ->children() + ->arrayNode('dbal') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->stringNode('connection')->cannotBeEmpty()->end() + ->stringNode('table_name') + ->info('The name of the message store will be used if the table_name is not set') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() ->arrayNode('meilisearch') ->useAttributeAsKey('name') ->arrayPrototype() diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 35f1bd3c2..64c5d3a32 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -36,6 +36,7 @@ use Symfony\AI\AiBundle\Profiler\TraceablePlatform; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool; +use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore; use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore; use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore; use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore; @@ -1535,6 +1536,26 @@ private function processMessageStoreConfig(string $type, array $messageStores, C } } + if ('doctrine' === $type) { + foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) { + $definition = new Definition(DoctrineDbalMessageStore::class); + $definition + ->setLazy(true) + ->setArguments([ + $dbalMessageStore['connection'], + $dbalMessageStore['table_name'] ?? $name, + new Reference(\sprintf('doctrine.dbal.%s_connection', $dbalMessageStore['connection'])), + new Reference('serializer'), + ]) + ->addTag('proxy', ['interface' => MessageStoreInterface::class]) + ->addTag('ai.message_store'); + + $container->setDefinition('ai.message_store.'.$type.'.dbal.'.$name, $definition); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name); + $container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name); + } + } + if ('meilisearch' === $type) { foreach ($messageStores as $name => $messageStore) { $definition = new Definition(MeilisearchMessageStore::class); diff --git a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php index 0d7bed4e2..e25e5e6eb 100644 --- a/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php +++ b/src/ai-bundle/tests/DependencyInjection/AiBundleTest.php @@ -2942,6 +2942,67 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl() $this->assertTrue($cacheMessageStoreDefinition->hasTag('ai.message_store')); } + public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'doctrine' => [ + 'dbal' => [ + 'default' => [ + 'connection' => 'default', + ], + ], + ], + ], + ], + ]); + + $doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default'); + + $this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0)); + $this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1)); + $this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2)); + $this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2)); + $this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3)); + $this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3)); + + $this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy')); + $this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy')); + $this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store')); + } + + public function testDoctrineDbalMessageStoreWithCustomTableNameCanBeConfiguredWithCustomKey() + { + $container = $this->buildContainer([ + 'ai' => [ + 'message_store' => [ + 'doctrine' => [ + 'dbal' => [ + 'default' => [ + 'connection' => 'default', + 'table_name' => 'foo', + ], + ], + ], + ], + ], + ]); + + $doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default'); + + $this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0)); + $this->assertSame('foo', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1)); + $this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2)); + $this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2)); + $this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3)); + $this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3)); + + $this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy')); + $this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy')); + $this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store')); + } + public function testMeilisearchMessageStoreIsConfigured() { $container = $this->buildContainer([ @@ -3597,6 +3658,14 @@ private function getFullConfig(): array 'key' => 'foo', ], ], + 'doctrine' => [ + 'dbal' => [ + 'default' => [ + 'connection' => 'default', + 'table_name' => 'foo', + ], + ], + ], 'memory' => [ 'my_memory_message_store' => [ 'identifier' => '_memory', diff --git a/src/chat/composer.json b/src/chat/composer.json index cb3a73c52..a5524c821 100644 --- a/src/chat/composer.json +++ b/src/chat/composer.json @@ -26,6 +26,7 @@ }, "require-dev": { "ext-redis": "*", + "doctrine/dbal": "^3.3 || ^4.0", "phpstan/phpstan": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.13", diff --git a/src/chat/src/Bridge/Doctrine/DoctrineDbalMessageStore.php b/src/chat/src/Bridge/Doctrine/DoctrineDbalMessageStore.php new file mode 100644 index 000000000..6a2ef80fb --- /dev/null +++ b/src/chat/src/Bridge/Doctrine/DoctrineDbalMessageStore.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Bridge\Doctrine; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Connection as DBALConnection; +use Doctrine\DBAL\Platforms\OraclePlatform; +use Doctrine\DBAL\Result; +use Doctrine\DBAL\Schema\Name\Identifier; +use Doctrine\DBAL\Schema\Name\UnqualifiedName; +use Doctrine\DBAL\Schema\PrimaryKeyConstraint; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Types; +use Symfony\AI\Chat\Exception\InvalidArgumentException; +use Symfony\AI\Chat\ManagedStoreInterface; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Chat\MessageStoreInterface; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Message\MessageInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Guillaume Loulier + */ +final class DoctrineDbalMessageStore implements ManagedStoreInterface, MessageStoreInterface +{ + public function __construct( + private readonly string $tableName, + private readonly DBALConnection $dbalConnection, + private readonly SerializerInterface $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]), + ) { + } + + public function setup(array $options = []): void + { + if ([] !== $options) { + throw new InvalidArgumentException('No supported options.'); + } + + $schema = $this->dbalConnection->createSchemaManager()->introspectSchema(); + + if ($schema->hasTable($this->tableName)) { + return; + } + + $this->addTableToSchema($schema); + } + + public function drop(): void + { + $schema = $this->dbalConnection->createSchemaManager()->introspectSchema(); + + if (!$schema->hasTable($this->tableName)) { + return; + } + + $queryBuilder = $this->dbalConnection->createQueryBuilder() + ->delete($this->tableName); + + $this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery( + $queryBuilder->getSQL(), + )); + } + + public function save(MessageBag $messages): void + { + $queryBuilder = $this->dbalConnection->createQueryBuilder() + ->insert($this->tableName) + ->values([ + 'messages' => '?', + ]); + + $this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery( + $queryBuilder->getSQL(), + [ + $this->serializer->serialize($messages->getMessages(), 'json'), + ], + $queryBuilder->getParameterTypes(), + )); + } + + public function load(): MessageBag + { + $queryBuilder = $this->dbalConnection->createQueryBuilder() + ->select('messages') + ->from($this->tableName) + ; + + $result = $this->dbalConnection->transactional(static fn (Connection $connection): Result => $connection->executeQuery( + $queryBuilder->getSQL(), + )); + + $messages = array_map( + fn (array $payload): array => $this->serializer->deserialize($payload['messages'], MessageInterface::class.'[]', 'json'), + $result->fetchAllAssociative(), + ); + + return new MessageBag(...array_merge(...$messages)); + } + + private function addTableToSchema(Schema $schema): void + { + $table = $schema->createTable($this->tableName); + $table->addOption('_symfony_ai_chat_table_name', $this->tableName); + $idColumn = $table->addColumn('id', Types::BIGINT) + ->setAutoincrement(true) + ->setNotnull(true); + $table->addColumn('messages', Types::TEXT) + ->setNotnull(true); + if (class_exists(PrimaryKeyConstraint::class)) { + $table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [ + new UnqualifiedName(Identifier::unquoted('id')), + ], true)); + } else { + $table->setPrimaryKey(['id']); + } + + // We need to create a sequence for Oracle and set the id column to get the correct nextval + if ($this->dbalConnection->getDatabasePlatform() instanceof OraclePlatform) { + $serverVersion = $this->dbalConnection->executeQuery("SELECT version FROM product_component_version WHERE product LIKE 'Oracle Database%'")->fetchOne(); + if (version_compare($serverVersion, '12.1.0', '>=')) { + $idColumn->setAutoincrement(false); // disable the creation of SEQUENCE and TRIGGER + $idColumn->setDefault($this->tableName.'_seq.nextval'); + + $schema->createSequence($this->tableName.'_seq'); + } + } + + foreach ($schema->toSql($this->dbalConnection->getDatabasePlatform()) as $sql) { + $this->dbalConnection->executeQuery($sql); + } + } +} diff --git a/src/chat/tests/Bridge/Doctrine/DoctrineDbalMessageStoreTest.php b/src/chat/tests/Bridge/Doctrine/DoctrineDbalMessageStoreTest.php new file mode 100644 index 000000000..b6b9c01cf --- /dev/null +++ b/src/chat/tests/Bridge/Doctrine/DoctrineDbalMessageStoreTest.php @@ -0,0 +1,182 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Chat\Tests\Bridge\Doctrine; + +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Query\QueryBuilder; +use Doctrine\DBAL\Result; +use Doctrine\DBAL\Schema\AbstractSchemaManager; +use Doctrine\DBAL\Schema\Column; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Schema\Table; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore; +use Symfony\AI\Chat\Exception\InvalidArgumentException; +use Symfony\AI\Chat\MessageNormalizer; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Serializer; + +final class DoctrineDbalMessageStoreTest extends TestCase +{ + public function testMessageStoreTableCannotBeSetupWithExtraOptions() + { + $connection = $this->createMock(Connection::class); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No supported options.'); + $this->expectExceptionCode(0); + $messageStore->setup([ + 'foo' => 'bar', + ]); + } + + public function testMessageStoreTableCannotBeSetupIfItAlreadyExist() + { + $schema = $this->createMock(Schema::class); + $schema->expects($this->once())->method('hasTable')->willReturn(true); + + $sqliteSchemaManager = $this->createMock(AbstractSchemaManager::class); + $sqliteSchemaManager->expects($this->once())->method('introspectSchema')->willReturn($schema); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createSchemaManager')->willReturn($sqliteSchemaManager); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + $messageStore->setup(); + } + + public function testMessageStoreTableCanBeSetup() + { + $platform = $this->createMock(AbstractPlatform::class); + + $column = $this->createMock(Column::class); + $column->expects($this->once())->method('setAutoincrement')->willReturnSelf(); + + $table = $this->createMock(Table::class); + $table->expects($this->once())->method('addOption') + ->with('_symfony_ai_chat_table_name', 'foo') + ->willReturnSelf(); + $table->expects($this->exactly(2))->method('addColumn')->willReturn($column); + + $schema = $this->createMock(Schema::class); + $schema->expects($this->once())->method('hasTable')->willReturn(false); + $schema->expects($this->once())->method('createTable')->with('foo')->willReturn($table); + $schema->expects($this->once())->method('toSql')->with($platform)->willReturn([]); + + $sqliteSchemaManager = $this->createMock(AbstractSchemaManager::class); + $sqliteSchemaManager->expects($this->once())->method('introspectSchema')->willReturn($schema); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createSchemaManager')->willReturn($sqliteSchemaManager); + $connection->expects($this->exactly(2))->method('getDatabasePlatform')->willReturn($platform); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + $messageStore->setup(); + } + + public function testMessageStoreTableCannotBeDroppedIfTableDoesNotExist() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects($this->never())->method('delete'); + + $schema = $this->createMock(Schema::class); + $schema->expects($this->once())->method('hasTable')->willReturn(false); + + $sqliteSchemaManager = $this->createMock(AbstractSchemaManager::class); + $sqliteSchemaManager->expects($this->once())->method('introspectSchema')->willReturn($schema); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createSchemaManager')->willReturn($sqliteSchemaManager); + $connection->expects($this->never())->method('createQueryBuilder'); + $connection->expects($this->never())->method('transactional'); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + $messageStore->drop(); + } + + public function testMessageStoreTableCanBeDropped() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects($this->once())->method('delete')->with('foo'); + + $schema = $this->createMock(Schema::class); + $schema->expects($this->once())->method('hasTable')->willReturn(true); + + $sqliteSchemaManager = $this->createMock(AbstractSchemaManager::class); + $sqliteSchemaManager->expects($this->once())->method('introspectSchema')->willReturn($schema); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createSchemaManager')->willReturn($sqliteSchemaManager); + $connection->expects($this->once())->method('createQueryBuilder')->willReturn($queryBuilder); + $connection->expects($this->once())->method('transactional'); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + $messageStore->drop(); + } + + public function testMessageBagCanBeSaved() + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects($this->once())->method('insert')->with('foo')->willReturnSelf(); + $queryBuilder->expects($this->once())->method('values')->with([ + 'messages' => '?', + ])->willReturnSelf(); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createQueryBuilder')->willReturn($queryBuilder); + $connection->expects($this->once())->method('transactional'); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection); + $messageStore->save(new MessageBag( + Message::ofUser('Hello world'), + )); + } + + public function testMessageBagCanBeLoaded() + { + $serializer = new Serializer([ + new ArrayDenormalizer(), + new MessageNormalizer(), + ], [new JsonEncoder()]); + + $messageBag = new MessageBag( + Message::ofUser('Hello world'), + ); + + $result = $this->createMock(Result::class); + $result->expects($this->once())->method('fetchAllAssociative')->willReturn([ + [ + 'messages' => $serializer->serialize($messageBag->getMessages(), 'json'), + ], + ]); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects($this->once())->method('select')->with('messages')->willReturnSelf(); + $queryBuilder->expects($this->once())->method('from')->with('foo')->willReturnSelf(); + + $connection = $this->createMock(Connection::class); + $connection->expects($this->once())->method('createQueryBuilder')->willReturn($queryBuilder); + $connection->expects($this->once())->method('transactional')->willReturn($result); + + $messageStore = new DoctrineDbalMessageStore('foo', $connection, $serializer); + + $messages = $messageStore->load(); + + $this->assertCount(1, $messages); + } +}