Skip to content

Commit 8e30b35

Browse files
committed
feat(chat): Doctrine Dbal message store
1 parent f9da9c0 commit 8e30b35

File tree

9 files changed

+487
-0
lines changed

9 files changed

+487
-0
lines changed

docs/components/chat.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ with a ``Symfony\AI\Agent\AgentInterface`` and a ``Symfony\AI\Chat\MessageStoreI
3434
You can find more advanced usage in combination with an Agent using the store for long-term context:
3535

3636
* `External services storage with Cache`_
37+
* `Long-term context with Doctrine DBAL`_
3738
* `Current session context storage with HttpFoundation session`_
3839
* `Current process context storage with InMemory`_
3940
* `Long-term context with Meilisearch`_
@@ -44,6 +45,7 @@ Supported Message stores
4445
------------------------
4546

4647
* `Cache`_
48+
* `Doctrine DBAL`_
4749
* `HttpFoundation session`_
4850
* `InMemory`_
4951
* `Meilisearch`_
@@ -125,12 +127,14 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
125127
$ php bin/console ai:message-store:drop symfonycon
126128
127129
.. _`External services storage with Cache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cache.php
130+
.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php
128131
.. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
129132
.. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php
130133
.. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php
131134
.. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php
132135
.. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php
133136
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
137+
.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html
134138
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
135139
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
136140
.. _`Meilisearch`: https://www.meilisearch.com/
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Doctrine\DBAL\DriverManager;
13+
use Symfony\AI\Agent\Agent;
14+
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
15+
use Symfony\AI\Chat\Chat;
16+
use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory;
17+
use Symfony\AI\Platform\Message\Message;
18+
use Symfony\AI\Platform\Message\MessageBag;
19+
20+
require_once dirname(__DIR__).'/bootstrap.php';
21+
22+
$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client());
23+
24+
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
25+
26+
$store = new DoctrineDbalMessageStore('symfony', $connection);
27+
$store->setup();
28+
29+
$agent = new Agent($platform, 'gpt-4o-mini');
30+
$chat = new Chat($agent, $store);
31+
32+
$messages = new MessageBag(
33+
Message::forSystem('You are a helpful assistant. You only answer with short sentences.'),
34+
);
35+
36+
$chat->initiate($messages);
37+
$chat->submit(Message::ofUser('My name is Christopher.'));
38+
$message = $chat->submit(Message::ofUser('What is my name?'));
39+
40+
echo $message->getContent().\PHP_EOL;

examples/commands/message-stores.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111

1212
require_once dirname(__DIR__).'/bootstrap.php';
1313

14+
use Doctrine\DBAL\DriverManager;
15+
use Doctrine\DBAL\Tools\DsnParser;
16+
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
1417
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
1518
use Symfony\AI\Chat\Bridge\Local\CacheStore;
1619
use Symfony\AI\Chat\Bridge\Local\InMemoryStore;
@@ -36,6 +39,10 @@
3639

3740
$factories = [
3841
'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'),
42+
'doctrine' => static fn (): DoctrineDbalMessageStore => new DoctrineDbalMessageStore(
43+
'symfony',
44+
DriverManager::getConnection((new DsnParser())->parse('pdo-sqlite:///:memory:')),
45+
),
3946
'meilisearch' => static fn (): MeilisearchMessageStore => new MeilisearchMessageStore(
4047
http_client(),
4148
env('MEILISEARCH_HOST'),

src/ai-bundle/config/options.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,21 @@
786786
->end()
787787
->end()
788788
->end()
789+
->arrayNode('doctrine')
790+
->children()
791+
->arrayNode('dbal')
792+
->useAttributeAsKey('name')
793+
->arrayPrototype()
794+
->children()
795+
->stringNode('connection')->cannotBeEmpty()->end()
796+
->stringNode('table_name')
797+
->info('The name of the message store will be used if the table_name is not set')
798+
->end()
799+
->end()
800+
->end()
801+
->end()
802+
->end()
803+
->end()
789804
->arrayNode('meilisearch')
790805
->useAttributeAsKey('name')
791806
->arrayPrototype()

src/ai-bundle/src/AiBundle.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
use Symfony\AI\AiBundle\Profiler\TraceablePlatform;
3737
use Symfony\AI\AiBundle\Profiler\TraceableToolbox;
3838
use Symfony\AI\AiBundle\Security\Attribute\IsGrantedTool;
39+
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
3940
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
4041
use Symfony\AI\Chat\Bridge\Local\CacheStore as CacheMessageStore;
4142
use Symfony\AI\Chat\Bridge\Meilisearch\MessageStore as MeilisearchMessageStore;
@@ -1496,6 +1497,26 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
14961497
}
14971498
}
14981499

1500+
if ('doctrine' === $type) {
1501+
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
1502+
$definition = new Definition(DoctrineDbalMessageStore::class);
1503+
$definition
1504+
->setLazy(true)
1505+
->setArguments([
1506+
$dbalMessageStore['connection'],
1507+
$dbalMessageStore['table_name'] ?? $name,
1508+
new Reference(\sprintf('doctrine.dbal.%s_connection', $dbalMessageStore['connection'])),
1509+
new Reference('serializer'),
1510+
])
1511+
->addTag('proxy', ['interface' => MessageStoreInterface::class])
1512+
->addTag('ai.message_store');
1513+
1514+
$container->setDefinition('ai.message_store.'.$type.'.dbal.'.$name, $definition);
1515+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
1516+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
1517+
}
1518+
}
1519+
14991520
if ('meilisearch' === $type) {
15001521
foreach ($messageStores as $name => $messageStore) {
15011522
$definition = new Definition(MeilisearchMessageStore::class);

src/ai-bundle/tests/DependencyInjection/AiBundleTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2902,6 +2902,67 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
29022902
$this->assertTrue($cacheMessageStoreDefinition->hasTag('ai.message_store'));
29032903
}
29042904

2905+
public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
2906+
{
2907+
$container = $this->buildContainer([
2908+
'ai' => [
2909+
'message_store' => [
2910+
'doctrine' => [
2911+
'dbal' => [
2912+
'default' => [
2913+
'connection' => 'default',
2914+
],
2915+
],
2916+
],
2917+
],
2918+
],
2919+
]);
2920+
2921+
$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');
2922+
2923+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
2924+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
2925+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2926+
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2927+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2928+
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2929+
2930+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
2931+
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
2932+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
2933+
}
2934+
2935+
public function testDoctrineDbalMessageStoreWithCustomTableNameCanBeConfiguredWithCustomKey()
2936+
{
2937+
$container = $this->buildContainer([
2938+
'ai' => [
2939+
'message_store' => [
2940+
'doctrine' => [
2941+
'dbal' => [
2942+
'default' => [
2943+
'connection' => 'default',
2944+
'table_name' => 'foo',
2945+
],
2946+
],
2947+
],
2948+
],
2949+
],
2950+
]);
2951+
2952+
$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');
2953+
2954+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
2955+
$this->assertSame('foo', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
2956+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2957+
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2958+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2959+
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2960+
2961+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
2962+
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
2963+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
2964+
}
2965+
29052966
public function testMeilisearchMessageStoreIsConfigured()
29062967
{
29072968
$container = $this->buildContainer([
@@ -3407,6 +3468,14 @@ private function getFullConfig(): array
34073468
'key' => 'foo',
34083469
],
34093470
],
3471+
'doctrine' => [
3472+
'dbal' => [
3473+
'default' => [
3474+
'connection' => 'default',
3475+
'table_name' => 'foo',
3476+
],
3477+
],
3478+
],
34103479
'memory' => [
34113480
'my_memory_message_store' => [
34123481
'identifier' => '_memory',

src/chat/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
},
2727
"require-dev": {
2828
"ext-redis": "*",
29+
"doctrine/dbal": "^3.3 || ^4.0",
2930
"phpstan/phpstan": "^2.0",
3031
"phpstan/phpstan-strict-rules": "^2.0",
3132
"phpunit/phpunit": "^11.5.13",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Chat\Bridge\Doctrine;
13+
14+
use Doctrine\DBAL\Connection;
15+
use Doctrine\DBAL\Connection as DBALConnection;
16+
use Doctrine\DBAL\Platforms\OraclePlatform;
17+
use Doctrine\DBAL\Result;
18+
use Doctrine\DBAL\Schema\Name\Identifier;
19+
use Doctrine\DBAL\Schema\Name\UnqualifiedName;
20+
use Doctrine\DBAL\Schema\PrimaryKeyConstraint;
21+
use Doctrine\DBAL\Schema\Schema;
22+
use Doctrine\DBAL\Types\Types;
23+
use Symfony\AI\Chat\Exception\InvalidArgumentException;
24+
use Symfony\AI\Chat\ManagedStoreInterface;
25+
use Symfony\AI\Chat\MessageNormalizer;
26+
use Symfony\AI\Chat\MessageStoreInterface;
27+
use Symfony\AI\Platform\Message\MessageBag;
28+
use Symfony\AI\Platform\Message\MessageInterface;
29+
use Symfony\Component\Serializer\Encoder\JsonEncoder;
30+
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
31+
use Symfony\Component\Serializer\Serializer;
32+
use Symfony\Component\Serializer\SerializerInterface;
33+
34+
/**
35+
* @author Guillaume Loulier <[email protected]>
36+
*/
37+
final class DoctrineDbalMessageStore implements ManagedStoreInterface, MessageStoreInterface
38+
{
39+
public function __construct(
40+
private readonly string $tableName,
41+
private readonly DBALConnection $dbalConnection,
42+
private readonly SerializerInterface $serializer = new Serializer([
43+
new ArrayDenormalizer(),
44+
new MessageNormalizer(),
45+
], [new JsonEncoder()]),
46+
) {
47+
}
48+
49+
public function setup(array $options = []): void
50+
{
51+
if ([] !== $options) {
52+
throw new InvalidArgumentException('No supported options.');
53+
}
54+
55+
$schema = $this->dbalConnection->createSchemaManager()->introspectSchema();
56+
57+
if ($schema->hasTable($this->tableName)) {
58+
return;
59+
}
60+
61+
$this->addTableToSchema($schema);
62+
}
63+
64+
public function drop(): void
65+
{
66+
$schema = $this->dbalConnection->createSchemaManager()->introspectSchema();
67+
68+
if (!$schema->hasTable($this->tableName)) {
69+
return;
70+
}
71+
72+
$queryBuilder = $this->dbalConnection->createQueryBuilder()
73+
->delete($this->tableName);
74+
75+
$this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery(
76+
$queryBuilder->getSQL(),
77+
));
78+
}
79+
80+
public function save(MessageBag $messages): void
81+
{
82+
$queryBuilder = $this->dbalConnection->createQueryBuilder()
83+
->insert($this->tableName)
84+
->values([
85+
'messages' => '?',
86+
]);
87+
88+
$this->dbalConnection->transactional(fn (Connection $connection): Result => $connection->executeQuery(
89+
$queryBuilder->getSQL(),
90+
[
91+
$this->serializer->serialize($messages->getMessages(), 'json'),
92+
],
93+
$queryBuilder->getParameterTypes(),
94+
));
95+
}
96+
97+
public function load(): MessageBag
98+
{
99+
$queryBuilder = $this->dbalConnection->createQueryBuilder()
100+
->select('messages')
101+
->from($this->tableName)
102+
;
103+
104+
$result = $this->dbalConnection->transactional(static fn (Connection $connection): Result => $connection->executeQuery(
105+
$queryBuilder->getSQL(),
106+
));
107+
108+
$messages = array_map(
109+
fn (array $payload): array => $this->serializer->deserialize($payload['messages'], MessageInterface::class.'[]', 'json'),
110+
$result->fetchAllAssociative(),
111+
);
112+
113+
return new MessageBag(...array_merge(...$messages));
114+
}
115+
116+
private function addTableToSchema(Schema $schema): void
117+
{
118+
$table = $schema->createTable($this->tableName);
119+
$table->addOption('_symfony_ai_chat_table_name', $this->tableName);
120+
$idColumn = $table->addColumn('id', Types::BIGINT)
121+
->setAutoincrement(true)
122+
->setNotnull(true);
123+
$table->addColumn('messages', Types::TEXT)
124+
->setNotnull(true);
125+
if (class_exists(PrimaryKeyConstraint::class)) {
126+
$table->addPrimaryKeyConstraint(new PrimaryKeyConstraint(null, [
127+
new UnqualifiedName(Identifier::unquoted('id')),
128+
], true));
129+
} else {
130+
$table->setPrimaryKey(['id']);
131+
}
132+
133+
// We need to create a sequence for Oracle and set the id column to get the correct nextval
134+
if ($this->dbalConnection->getDatabasePlatform() instanceof OraclePlatform) {
135+
$serverVersion = $this->dbalConnection->executeQuery("SELECT version FROM product_component_version WHERE product LIKE 'Oracle Database%'")->fetchOne();
136+
if (version_compare($serverVersion, '12.1.0', '>=')) {
137+
$idColumn->setAutoincrement(false); // disable the creation of SEQUENCE and TRIGGER
138+
$idColumn->setDefault($this->tableName.'_seq.nextval');
139+
140+
$schema->createSequence($this->tableName.'_seq');
141+
}
142+
}
143+
144+
foreach ($schema->toSql($this->dbalConnection->getDatabasePlatform()) as $sql) {
145+
$this->dbalConnection->executeQuery($sql);
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)