Skip to content

Commit a8a3ad4

Browse files
committed
feat(chat): Doctrine Dbal message store
1 parent 6a59f94 commit a8a3ad4

File tree

9 files changed

+486
-0
lines changed

9 files changed

+486
-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`_
@@ -45,6 +46,7 @@ Supported Message stores
4546
------------------------
4647

4748
* `Cache`_
49+
* `Doctrine DBAL`_
4850
* `HttpFoundation session`_
4951
* `InMemory`_
5052
* `Meilisearch`_
@@ -127,13 +129,15 @@ store and ``bin/console ai:message-store:drop`` to clean up the message store:
127129
$ php bin/console ai:message-store:drop symfonycon
128130
129131
.. _`External services storage with Cache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-cache.php
132+
.. _`Long-term context with Doctrine DBAL`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-doctrine-dbal.php
130133
.. _`Current session context storage with HttpFoundation session`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-session.php
131134
.. _`Current process context storage with InMemory`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat.php
132135
.. _`Long-term context with Meilisearch`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-meilisearch.php
133136
.. _`Long-term context with Pogocache`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-pogocache.php
134137
.. _`Long-term context with Redis`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-redis.php
135138
.. _`Long-term context with SurrealDb`: https://github.com/symfony/ai/blob/main/examples/chat/persistent-chat-surrealdb.php
136139
.. _`Cache`: https://symfony.com/doc/current/components/cache.html
140+
.. _`Doctrine DBAL`: https://www.doctrine-project.org/projects/dbal.html
137141
.. _`InMemory`: https://www.php.net/manual/en/language.types.array.php
138142
.. _`HttpFoundation session`: https://developers.cloudflare.com/vectorize/
139143
.. _`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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

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

14+
use Doctrine\DBAL\DriverManager;
15+
use Symfony\AI\Chat\Bridge\Doctrine\DoctrineDbalMessageStore;
1416
use Symfony\AI\Chat\Bridge\HttpFoundation\SessionStore;
1517
use Symfony\AI\Chat\Bridge\Local\CacheStore;
1618
use Symfony\AI\Chat\Bridge\Local\InMemoryStore;
@@ -37,6 +39,10 @@
3739

3840
$factories = [
3941
'cache' => static fn (): CacheStore => new CacheStore(new ArrayAdapter(), cacheKey: 'symfony'),
42+
'doctrine' => static fn (): DoctrineDbalMessageStore => new DoctrineDbalMessageStore(
43+
'symfony',
44+
DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]),
45+
),
4046
'meilisearch' => static fn (): MeilisearchMessageStore => new MeilisearchMessageStore(
4147
http_client(),
4248
env('MEILISEARCH_HOST'),

src/ai-bundle/config/options.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,21 @@
806806
->end()
807807
->end()
808808
->end()
809+
->arrayNode('doctrine')
810+
->children()
811+
->arrayNode('dbal')
812+
->useAttributeAsKey('name')
813+
->arrayPrototype()
814+
->children()
815+
->stringNode('connection')->cannotBeEmpty()->end()
816+
->stringNode('table_name')
817+
->info('The name of the message store will be used if the table_name is not set')
818+
->end()
819+
->end()
820+
->end()
821+
->end()
822+
->end()
823+
->end()
809824
->arrayNode('meilisearch')
810825
->useAttributeAsKey('name')
811826
->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;
@@ -1535,6 +1536,26 @@ private function processMessageStoreConfig(string $type, array $messageStores, C
15351536
}
15361537
}
15371538

1539+
if ('doctrine' === $type) {
1540+
foreach ($messageStores['dbal'] ?? [] as $name => $dbalMessageStore) {
1541+
$definition = new Definition(DoctrineDbalMessageStore::class);
1542+
$definition
1543+
->setLazy(true)
1544+
->setArguments([
1545+
$dbalMessageStore['connection'],
1546+
$dbalMessageStore['table_name'] ?? $name,
1547+
new Reference(\sprintf('doctrine.dbal.%s_connection', $dbalMessageStore['connection'])),
1548+
new Reference('serializer'),
1549+
])
1550+
->addTag('proxy', ['interface' => MessageStoreInterface::class])
1551+
->addTag('ai.message_store');
1552+
1553+
$container->setDefinition('ai.message_store.'.$type.'.dbal.'.$name, $definition);
1554+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $name);
1555+
$container->registerAliasForArgument('ai.message_store.'.$type.'.'.$name, MessageStoreInterface::class, $type.'_'.$name);
1556+
}
1557+
}
1558+
15381559
if ('meilisearch' === $type) {
15391560
foreach ($messageStores as $name => $messageStore) {
15401561
$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
@@ -2942,6 +2942,67 @@ public function testCacheMessageStoreCanBeConfiguredWithCustomTtl()
29422942
$this->assertTrue($cacheMessageStoreDefinition->hasTag('ai.message_store'));
29432943
}
29442944

2945+
public function testDoctrineDbalMessageStoreCanBeConfiguredWithCustomKey()
2946+
{
2947+
$container = $this->buildContainer([
2948+
'ai' => [
2949+
'message_store' => [
2950+
'doctrine' => [
2951+
'dbal' => [
2952+
'default' => [
2953+
'connection' => 'default',
2954+
],
2955+
],
2956+
],
2957+
],
2958+
],
2959+
]);
2960+
2961+
$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');
2962+
2963+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
2964+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
2965+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2966+
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2967+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2968+
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2969+
2970+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
2971+
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
2972+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
2973+
}
2974+
2975+
public function testDoctrineDbalMessageStoreWithCustomTableNameCanBeConfiguredWithCustomKey()
2976+
{
2977+
$container = $this->buildContainer([
2978+
'ai' => [
2979+
'message_store' => [
2980+
'doctrine' => [
2981+
'dbal' => [
2982+
'default' => [
2983+
'connection' => 'default',
2984+
'table_name' => 'foo',
2985+
],
2986+
],
2987+
],
2988+
],
2989+
],
2990+
]);
2991+
2992+
$doctrineDbalDefaultMessageStoreDefinition = $container->getDefinition('ai.message_store.doctrine.dbal.default');
2993+
2994+
$this->assertSame('default', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(0));
2995+
$this->assertSame('foo', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(1));
2996+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2997+
$this->assertSame('doctrine.dbal.default_connection', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(2));
2998+
$this->assertInstanceOf(Reference::class, $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
2999+
$this->assertSame('serializer', (string) $doctrineDbalDefaultMessageStoreDefinition->getArgument(3));
3000+
3001+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('proxy'));
3002+
$this->assertSame([['interface' => MessageStoreInterface::class]], $doctrineDbalDefaultMessageStoreDefinition->getTag('proxy'));
3003+
$this->assertTrue($doctrineDbalDefaultMessageStoreDefinition->hasTag('ai.message_store'));
3004+
}
3005+
29453006
public function testMeilisearchMessageStoreIsConfigured()
29463007
{
29473008
$container = $this->buildContainer([
@@ -3597,6 +3658,14 @@ private function getFullConfig(): array
35973658
'key' => 'foo',
35983659
],
35993660
],
3661+
'doctrine' => [
3662+
'dbal' => [
3663+
'default' => [
3664+
'connection' => 'default',
3665+
'table_name' => 'foo',
3666+
],
3667+
],
3668+
],
36003669
'memory' => [
36013670
'my_memory_message_store' => [
36023671
'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)