Add flexible support for multiple database variations in JdbcChatMemory

Add SQL Server dialect support with new SqlServerChatMemoryDialect class

Create schema-sqlserver.sql script for SQL Server table creation

Refactor database schema initialization with new JdbcChatMemorySchemaInitializer

Standardize table names across databases to SPRING_AI_CHAT_MEMORY

Implement ChatMemoryDialect abstraction for database-specific operations

Add dialect implementations for PostgreSQL, MySQL/MariaDB, HSQLDB

Update configuration properties with more flexible schema initialization options

Enhance documentation with detailed information about dialect support and configuration

Add integration tests for HSQLDB and SQL Server

Signed-off-by: Mark Pollack <247466+markpollack@users.noreply.github.com>
This commit is contained in:
Mark Pollack
2025-05-08 16:39:21 -04:00
committed by Ilayaperumal Gopinathan
parent 6ae1c13aee
commit 08958af670
25 changed files with 913 additions and 195 deletions

View File

@@ -78,6 +78,23 @@
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mssqlserver</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>

View File

@@ -1,56 +0,0 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer;
import org.springframework.boot.jdbc.init.PlatformPlaceholderDatabaseDriverResolver;
import org.springframework.boot.sql.init.DatabaseInitializationMode;
import org.springframework.boot.sql.init.DatabaseInitializationSettings;
/**
* Performs database initialization for the JDBC Chat Memory Repository.
*
* @since 1.0.0
*/
class JdbcChatMemoryDataSourceScriptDatabaseInitializer extends DataSourceScriptDatabaseInitializer {
private static final String SCHEMA_LOCATION = "classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql";
JdbcChatMemoryDataSourceScriptDatabaseInitializer(DataSource dataSource) {
super(dataSource, getSettings(dataSource));
}
static DatabaseInitializationSettings getSettings(DataSource dataSource) {
var settings = new DatabaseInitializationSettings();
settings.setSchemaLocations(resolveSchemaLocations(dataSource));
settings.setMode(DatabaseInitializationMode.ALWAYS);
settings.setContinueOnError(true);
return settings;
}
static List<String> resolveSchemaLocations(DataSource dataSource) {
var platformResolver = new PlatformPlaceholderDatabaseDriverResolver();
return platformResolver.resolveAll(dataSource, SCHEMA_LOCATION);
}
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Jonathan Leijendekker
* @author Thomas Vitale
* @since 1.0.0
*/
@ConfigurationProperties(JdbcChatMemoryProperties.CONFIG_PREFIX)
public class JdbcChatMemoryProperties {
public static final String CONFIG_PREFIX = "spring.ai.chat.memory.repository.jdbc";
/**
* Whether to initialize the schema on startup.
*/
private boolean initializeSchema = true;
public boolean isInitializeSchema() {
return this.initializeSchema;
}
public void setInitializeSchema(boolean initializeSchema) {
this.initializeSchema = initializeSchema;
}
}

View File

@@ -14,19 +14,19 @@
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import javax.sql.DataSource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.memory.jdbc.JdbcChatMemoryDialect;
import org.springframework.ai.chat.memory.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -39,24 +39,23 @@ import org.springframework.jdbc.core.JdbcTemplate;
*/
@AutoConfiguration(after = JdbcTemplateAutoConfiguration.class, before = ChatMemoryAutoConfiguration.class)
@ConditionalOnClass({ JdbcChatMemoryRepository.class, DataSource.class, JdbcTemplate.class })
@EnableConfigurationProperties(JdbcChatMemoryProperties.class)
public class JdbcChatMemoryAutoConfiguration {
@EnableConfigurationProperties(JdbcChatMemoryRepositoryProperties.class)
public class JdbcChatMemoryRepositoryAutoConfiguration {
private static final Logger logger = LoggerFactory.getLogger(JdbcChatMemoryAutoConfiguration.class);
private static final Logger logger = LoggerFactory.getLogger(JdbcChatMemoryRepositoryAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean
JdbcChatMemoryRepository chatMemoryRepository(JdbcTemplate jdbcTemplate) {
return JdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate).build();
JdbcChatMemoryRepository chatMemoryRepository(JdbcTemplate jdbcTemplate, DataSource dataSource) {
JdbcChatMemoryDialect dialect = JdbcChatMemoryDialect.from(dataSource);
return JdbcChatMemoryRepository.builder().jdbcTemplate(jdbcTemplate).dialect(dialect).build();
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = JdbcChatMemoryProperties.CONFIG_PREFIX, name = "initialize-schema",
havingValue = "true", matchIfMissing = true)
JdbcChatMemoryDataSourceScriptDatabaseInitializer jdbcChatMemoryScriptDatabaseInitializer(DataSource dataSource) {
logger.debug("Initializing schema for JdbcChatMemoryRepository");
return new JdbcChatMemoryDataSourceScriptDatabaseInitializer(dataSource);
JdbcChatMemoryRepositorySchemaInitializer jdbcChatMemoryScriptDatabaseInitializer(DataSource dataSource,
JdbcChatMemoryRepositoryProperties properties) {
return new JdbcChatMemoryRepositorySchemaInitializer(dataSource, properties);
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author Jonathan Leijendekker
* @author Thomas Vitale
* @since 1.0.0
*/
@ConfigurationProperties(JdbcChatMemoryRepositoryProperties.CONFIG_PREFIX)
public class JdbcChatMemoryRepositoryProperties {
public static final String CONFIG_PREFIX = "spring.ai.chat.memory.repository.jdbc";
/**
* Whether to initialize the schema on startup. Values: embedded, always, never.
* Default is embedded.
*/
private DatabaseInitializationMode initializeSchema = DatabaseInitializationMode.EMBEDDED;
/**
* Locations of schema (DDL) scripts. Supports comma-separated list. Default is
* classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql
*/
private String schema = "classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql";
public DatabaseInitializationMode getInitializeSchema() {
return this.initializeSchema;
}
public void setInitializeSchema(DatabaseInitializationMode initializeSchema) {
this.initializeSchema = initializeSchema;
}
public String getSchema() {
return this.schema;
}
public void setSchema(String schema) {
this.schema = schema;
}
public enum DatabaseInitializationMode {
/**
* Always initialize the database.
*/
ALWAYS,
/**
* Only initialize an embedded database.
*/
EMBEDDED,
/**
* Never initialize the database.
*/
NEVER
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import java.util.List;
import javax.sql.DataSource;
import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer;
import org.springframework.boot.jdbc.init.PlatformPlaceholderDatabaseDriverResolver;
import org.springframework.boot.sql.init.DatabaseInitializationMode;
import org.springframework.boot.sql.init.DatabaseInitializationSettings;
import org.springframework.util.StringUtils;
/**
* Performs database initialization for the JDBC Chat Memory Repository.
*
* @author Mark Pollack
* @since 1.0.0
*/
class JdbcChatMemoryRepositorySchemaInitializer extends DataSourceScriptDatabaseInitializer {
private static final String DEFAULT_SCHEMA_LOCATION = "classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql";
JdbcChatMemoryRepositorySchemaInitializer(DataSource dataSource, JdbcChatMemoryRepositoryProperties properties) {
super(dataSource, getSettings(dataSource, properties));
}
static DatabaseInitializationSettings getSettings(DataSource dataSource,
JdbcChatMemoryRepositoryProperties properties) {
var settings = new DatabaseInitializationSettings();
// Determine schema locations
String schemaProp = properties.getSchema();
List<String> schemaLocations;
PlatformPlaceholderDatabaseDriverResolver resolver = new PlatformPlaceholderDatabaseDriverResolver();
try {
String url = dataSource.getConnection().getMetaData().getURL().toLowerCase();
if (url.contains("hsqldb")) {
schemaLocations = List.of("classpath:org/springframework/ai/chat/memory/jdbc/schema-hsqldb.sql");
}
else if (StringUtils.hasText(schemaProp)) {
schemaLocations = resolver.resolveAll(dataSource, schemaProp);
}
else {
schemaLocations = resolver.resolveAll(dataSource, DEFAULT_SCHEMA_LOCATION);
}
}
catch (Exception e) {
// fallback to default
if (StringUtils.hasText(schemaProp)) {
schemaLocations = resolver.resolveAll(dataSource, schemaProp);
}
else {
schemaLocations = resolver.resolveAll(dataSource, DEFAULT_SCHEMA_LOCATION);
}
}
settings.setSchemaLocations(schemaLocations);
// Determine initialization mode
JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode init = properties.getInitializeSchema();
DatabaseInitializationMode mode;
if (JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode.ALWAYS.equals(init)) {
mode = DatabaseInitializationMode.ALWAYS;
}
else if (JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode.NEVER.equals(init)) {
mode = DatabaseInitializationMode.NEVER;
}
else {
// embedded or default
mode = DatabaseInitializationMode.EMBEDDED;
}
settings.setMode(mode);
settings.setContinueOnError(true);
return settings;
}
}

View File

@@ -13,4 +13,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
org.springframework.ai.model.chat.memory.jdbc.autoconfigure.JdbcChatMemoryAutoConfiguration
org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryAutoConfiguration

View File

@@ -0,0 +1,190 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.junit4.SpringRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
@RunWith(SpringRunner.class)
@SpringBootTest(classes = JdbcChatMemoryHsqldbAutoConfigurationIT.TestConfig.class,
properties = { "spring.datasource.url=jdbc:hsqldb:mem:chat_memory_auto_configuration_test;DB_CLOSE_DELAY=-1",
"spring.datasource.username=sa", "spring.datasource.password=",
"spring.datasource.driver-class-name=org.hsqldb.jdbcDriver",
"spring.ai.chat.memory.repository.jdbc.initialize-schema=always", "spring.sql.init.mode=always",
"spring.jpa.hibernate.ddl-auto=none", "spring.jpa.defer-datasource-initialization=true",
"spring.sql.init.continue-on-error=true", "spring.sql.init.schema-locations=classpath:schema.sql",
"logging.level.org.springframework.jdbc=DEBUG",
"logging.level.org.springframework.boot.sql.init=DEBUG" })
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@ImportAutoConfiguration({ org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration.class,
JdbcChatMemoryRepositoryAutoConfiguration.class,
org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration.class,
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class,
SqlInitializationAutoConfiguration.class })
public class JdbcChatMemoryHsqldbAutoConfigurationIT {
@Autowired
private ApplicationContext context;
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* can't get the automatic loading of the schema with boot to work.
*/
@Before
public void setUp() {
// Explicitly initialize the schema
try {
System.out.println("Explicitly initializing schema...");
// Debug: Print current schemas and tables
try {
List<String> schemas = jdbcTemplate.queryForList("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA",
String.class);
System.out.println("Available schemas: " + schemas);
List<String> tables = jdbcTemplate
.queryForList("SELECT TABLE_SCHEMA, TABLE_NAME FROM INFORMATION_SCHEMA.TABLES", String.class);
System.out.println("Available tables: " + tables);
}
catch (Exception e) {
System.out.println("Error listing schemas/tables: " + e.getMessage());
}
// Try a more direct approach with explicit SQL statements
try {
// Drop the table first if it exists to avoid any conflicts
jdbcTemplate.execute("DROP TABLE SPRING_AI_CHAT_MEMORY IF EXISTS");
System.out.println("Dropped existing table if it existed");
// Create the table with a simplified schema
jdbcTemplate.execute("CREATE TABLE SPRING_AI_CHAT_MEMORY (" + "conversation_id VARCHAR(36) NOT NULL, "
+ "content LONGVARCHAR NOT NULL, " + "type VARCHAR(10) NOT NULL, "
+ "timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL)");
System.out.println("Created table with simplified schema");
// Create index
jdbcTemplate.execute(
"CREATE INDEX SPRING_AI_CHAT_MEMORY_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, timestamp DESC)");
System.out.println("Created index");
// Verify table was created
boolean tableExists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SPRING_AI_CHAT_MEMORY'",
Integer.class) > 0;
System.out.println("Table SPRING_AI_CHAT_MEMORY exists after creation: " + tableExists);
}
catch (Exception e) {
System.out.println("Error during direct table creation: " + e.getMessage());
e.printStackTrace();
}
System.out.println("Schema initialization completed");
}
catch (Exception e) {
System.out.println("Error during explicit schema initialization: " + e.getMessage());
e.printStackTrace();
}
}
@Test
public void useAutoConfiguredChatMemoryWithJdbc() {
// Check that the custom schema initializer is present
assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue();
// Debug: List all schema-hsqldb.sql resources on the classpath
try {
java.util.Enumeration<java.net.URL> resources = Thread.currentThread()
.getContextClassLoader()
.getResources("org/springframework/ai/chat/memory/jdbc/schema-hsqldb.sql");
System.out.println("--- schema-hsqldb.sql resources found on classpath ---");
while (resources.hasMoreElements()) {
System.out.println(resources.nextElement());
}
System.out.println("------------------------------------------------------");
}
catch (Exception e) {
e.printStackTrace();
}
// Verify the table exists by executing a direct query
try {
boolean tableExists = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'SPRING_AI_CHAT_MEMORY'",
Integer.class) > 0;
System.out.println("Table SPRING_AI_CHAT_MEMORY exists: " + tableExists);
assertThat(tableExists).isTrue();
}
catch (Exception e) {
System.out.println("Error checking table: " + e.getMessage());
e.printStackTrace();
fail("Failed to check if table exists: " + e.getMessage());
}
// Now test the ChatMemory functionality
assertThat(context.getBean(org.springframework.ai.chat.memory.ChatMemory.class)).isNotNull();
assertThat(context.getBean(org.springframework.ai.chat.memory.jdbc.JdbcChatMemoryRepository.class)).isNotNull();
var chatMemory = context.getBean(org.springframework.ai.chat.memory.ChatMemory.class);
var conversationId = java.util.UUID.randomUUID().toString();
var userMessage = new UserMessage("Message from the user");
chatMemory.add(conversationId, userMessage);
assertThat(chatMemory.get(conversationId)).hasSize(1);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage));
var assistantMessage = new AssistantMessage("Message from the assistant");
chatMemory.add(conversationId, List.of(assistantMessage));
assertThat(chatMemory.get(conversationId)).hasSize(2);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage));
chatMemory.clear(conversationId);
assertThat(chatMemory.get(conversationId)).isEmpty();
var multipleMessages = List.<Message>of(new UserMessage("Message from the user 1"),
new AssistantMessage("Message from the assistant 1"));
chatMemory.add(conversationId, multipleMessages);
assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size());
assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages);
}
@SpringBootConfiguration
static class TestConfig {
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import java.util.List;
import java.util.UUID;
@@ -42,27 +42,29 @@ import static org.assertj.core.api.Assertions.assertThat;
class JdbcChatMemoryPostgresqlAutoConfigurationIT {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JdbcChatMemoryAutoConfiguration.class,
.withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class))
.withPropertyValues("spring.datasource.url=jdbc:tc:postgresql:17:///");
@Test
void jdbcChatMemoryScriptDatabaseInitializer_shouldBeLoaded() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.jdbc.initialize-schema=true")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue());
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=true")
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue());
}
@Test
void jdbcChatMemoryScriptDatabaseInitializer_shouldNotBeLoaded() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=false")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isFalse());
void jdbcChatMemoryScriptDatabaseInitializer_shouldNotRunSchemaInit() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=never")
.run(context -> {
assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue();
// Optionally, check that the schema is not initialized (could check table
// absence if needed)
});
}
@Test
void initializeSchemaEnabledWithProperty() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=true")
void initializeSchemaEmbeddedDefault() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=embedded")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue());
}
@@ -94,38 +96,40 @@ class JdbcChatMemoryPostgresqlAutoConfigurationIT {
@Test
void useAutoConfiguredChatMemoryWithJdbc() {
this.contextRunner.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class)).run(context -> {
assertThat(context).hasSingleBean(ChatMemory.class);
assertThat(context).hasSingleBean(JdbcChatMemoryRepository.class);
this.contextRunner.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class))
.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always")
.run(context -> {
assertThat(context).hasSingleBean(ChatMemory.class);
assertThat(context).hasSingleBean(JdbcChatMemoryRepository.class);
var chatMemory = context.getBean(ChatMemory.class);
var conversationId = UUID.randomUUID().toString();
var userMessage = new UserMessage("Message from the user");
var chatMemory = context.getBean(ChatMemory.class);
var conversationId = UUID.randomUUID().toString();
var userMessage = new UserMessage("Message from the user");
chatMemory.add(conversationId, userMessage);
chatMemory.add(conversationId, userMessage);
assertThat(chatMemory.get(conversationId)).hasSize(1);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage));
assertThat(chatMemory.get(conversationId)).hasSize(1);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage));
var assistantMessage = new AssistantMessage("Message from the assistant");
var assistantMessage = new AssistantMessage("Message from the assistant");
chatMemory.add(conversationId, List.of(assistantMessage));
chatMemory.add(conversationId, List.of(assistantMessage));
assertThat(chatMemory.get(conversationId)).hasSize(2);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage));
assertThat(chatMemory.get(conversationId)).hasSize(2);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage));
chatMemory.clear(conversationId);
chatMemory.clear(conversationId);
assertThat(chatMemory.get(conversationId)).isEmpty();
assertThat(chatMemory.get(conversationId)).isEmpty();
var multipleMessages = List.<Message>of(new UserMessage("Message from the user 1"),
new AssistantMessage("Message from the assistant 1"));
var multipleMessages = List.<Message>of(new UserMessage("Message from the user 1"),
new AssistantMessage("Message from the assistant 1"));
chatMemory.add(conversationId, multipleMessages);
chatMemory.add(conversationId, multipleMessages);
assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size());
assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages);
});
assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size());
assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages);
});
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import org.junit.jupiter.api.Test;
@@ -23,21 +23,21 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Jonathan Leijendekker
*/
class JdbcChatMemoryPropertiesTests {
class JdbcChatMemoryRepositoryPropertiesTests {
@Test
void defaultValues() {
var props = new JdbcChatMemoryProperties();
assertThat(props.isInitializeSchema()).isTrue();
var props = new JdbcChatMemoryRepositoryProperties();
assertThat(props.getInitializeSchema())
.isEqualTo(JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode.EMBEDDED);
}
@Test
void customValues() {
var props = new JdbcChatMemoryProperties();
props.setInitializeSchema(false);
assertThat(props.isInitializeSchema()).isFalse();
var props = new JdbcChatMemoryRepositoryProperties();
props.setInitializeSchema(JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode.NEVER);
assertThat(props.getInitializeSchema())
.isEqualTo(JdbcChatMemoryRepositoryProperties.DatabaseInitializationMode.NEVER);
}
}

View File

@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.springframework.ai.model.chat.memory.jdbc.autoconfigure;
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import javax.sql.DataSource;
@@ -35,7 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* @author Jonathan Leijendekker
*/
@Testcontainers
class JdbcChatMemoryDataSourceScriptDatabaseInitializerPostgresqlTests {
class JdbcChatMemoryRepositorySchemaInitializerPostgresqlTests {
static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName.parse("postgres:17");
@@ -47,7 +47,7 @@ class JdbcChatMemoryDataSourceScriptDatabaseInitializerPostgresqlTests {
.withPassword("postgres");
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JdbcChatMemoryAutoConfiguration.class,
.withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class))
.withPropertyValues(String.format("spring.datasource.url=%s", postgresContainer.getJdbcUrl()),
String.format("spring.datasource.username=%s", postgresContainer.getUsername()),
@@ -57,7 +57,9 @@ class JdbcChatMemoryDataSourceScriptDatabaseInitializerPostgresqlTests {
void getSettings_shouldHaveSchemaLocations() {
this.contextRunner.run(context -> {
var dataSource = context.getBean(DataSource.class);
var settings = JdbcChatMemoryDataSourceScriptDatabaseInitializer.getSettings(dataSource);
// Use new signature: requires JdbcChatMemoryRepositoryProperties
var settings = JdbcChatMemoryRepositorySchemaInitializer.getSettings(dataSource,
new JdbcChatMemoryRepositoryProperties());
assertThat(settings.getSchemaLocations())
.containsOnly("classpath:org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql");

View File

@@ -0,0 +1,110 @@
/*
* Integration test for SQL Server using Testcontainers, following the same structure as the PostgreSQL test.
*/
package org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.testcontainers.containers.MSSQLServerContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class JdbcChatMemorySqlServerAutoConfigurationIT {
static final DockerImageName DEFAULT_IMAGE_NAME = DockerImageName
.parse("mcr.microsoft.com/mssql/server:2022-latest");
@Container
@SuppressWarnings("resource")
static MSSQLServerContainer<?> mssqlContainer = new MSSQLServerContainer<>(DEFAULT_IMAGE_NAME).acceptLicense()
.withEnv("MSSQL_DATABASE", "chat_memory_auto_configuration_test")
.withPassword("Strong!NotR34LLyPassword")
.withUrlParam("loginTimeout", "60") // Give more time for the login
.withUrlParam("connectRetryCount", "10") // Retry 10 times
.withUrlParam("connectRetryInterval", "10")
.withStartupTimeout(Duration.ofSeconds(60));
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JdbcChatMemoryRepositoryAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class, DataSourceAutoConfiguration.class))
.withPropertyValues(String.format("spring.datasource.url=%s", mssqlContainer.getJdbcUrl()),
String.format("spring.datasource.username=%s", mssqlContainer.getUsername()),
String.format("spring.datasource.password=%s", mssqlContainer.getPassword()));
@Test
void jdbcChatMemoryScriptDatabaseInitializer_shouldBeLoaded() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue());
}
@Test
void jdbcChatMemoryScriptDatabaseInitializer_shouldNotRunSchemaInit() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=never")
.run(context -> {
assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue();
});
}
@Test
void initializeSchemaEmbeddedDefault() {
this.contextRunner.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=embedded")
.run(context -> assertThat(context.containsBean("jdbcChatMemoryScriptDatabaseInitializer")).isTrue());
}
@Test
void useAutoConfiguredChatMemoryWithJdbc() {
this.contextRunner.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class))
.withPropertyValues("spring.ai.chat.memory.repository.jdbc.initialize-schema=always")
.run(context -> {
assertThat(context).hasSingleBean(ChatMemory.class);
assertThat(context).hasSingleBean(JdbcChatMemoryRepository.class);
var chatMemory = context.getBean(ChatMemory.class);
var conversationId = UUID.randomUUID().toString();
var userMessage = new UserMessage("Message from the user");
chatMemory.add(conversationId, userMessage);
assertThat(chatMemory.get(conversationId)).hasSize(1);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage));
var assistantMessage = new AssistantMessage("Message from the assistant");
chatMemory.add(conversationId, List.of(assistantMessage));
assertThat(chatMemory.get(conversationId)).hasSize(2);
assertThat(chatMemory.get(conversationId)).isEqualTo(List.of(userMessage, assistantMessage));
chatMemory.clear(conversationId);
assertThat(chatMemory.get(conversationId)).isEmpty();
var multipleMessages = List.<Message>of(new UserMessage("Message from the user 1"),
new AssistantMessage("Message from the assistant 1"));
chatMemory.add(conversationId, multipleMessages);
assertThat(chatMemory.get(conversationId)).hasSize(multipleMessages.size());
assertThat(chatMemory.get(conversationId)).isEqualTo(multipleMessages);
});
}
}

View File

@@ -0,0 +1,12 @@
-- Test-specific schema initialization for HSQLDB
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content LONGVARCHAR NOT NULL,
type VARCHAR(10) NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, timestamp DESC);
-- Add constraint if it doesn't exist
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'));

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.memory.jdbc;
/**
* HSQLDB-specific SQL dialect for chat memory repository.
*/
public class HsqldbChatMemoryDialect implements JdbcChatMemoryDialect {
@Override
public String getSelectMessagesSql() {
return "SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY timestamp ASC";
}
@Override
public String getInsertMessageSql() {
return "INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, timestamp) VALUES (?, ?, ?, ?)";
}
@Override
public String getDeleteMessagesSql() {
return "DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?";
}
@Override
public String getSelectConversationIdsSql() {
return "SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY";
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.memory.jdbc;
import javax.sql.DataSource;
/**
* Abstraction for database-specific SQL for chat memory repository.
*/
public interface JdbcChatMemoryDialect {
/**
* Returns the SQL to fetch messages for a conversation, ordered by timestamp, with
* limit.
*/
String getSelectMessagesSql();
/**
* Returns the SQL to insert a message.
*/
String getInsertMessageSql();
/**
* Returns the SQL to fetch conversation IDs.
*/
String getSelectConversationIdsSql();
/**
* Returns the SQL to delete all messages for a conversation.
*/
String getDeleteMessagesSql();
/**
* Optionally, dialect can provide more advanced SQL as needed.
*/
/**
* Detects the dialect from the DataSource or JDBC URL.
*/
static JdbcChatMemoryDialect from(DataSource dataSource) {
// Simple detection (could be improved)
try {
String url = dataSource.getConnection().getMetaData().getURL().toLowerCase();
if (url.contains("postgresql"))
return new PostgresChatMemoryDialect();
if (url.contains("mysql"))
return new MysqlChatMemoryDialect();
if (url.contains("mariadb"))
return new MysqlChatMemoryDialect();
if (url.contains("sqlserver"))
return new SqlServerChatMemoryDialect();
if (url.contains("hsqldb"))
return new HsqldbChatMemoryDialect();
// Add more as needed
}
catch (Exception ignored) {
}
return new PostgresChatMemoryDialect(); // default
}
}

View File

@@ -44,34 +44,25 @@ import org.springframework.util.Assert;
* @author Jonathan Leijendekker
* @author Thomas Vitale
* @author Linar Abzaltdinov
* @author Mark Pollack
* @since 1.0.0
*/
public class JdbcChatMemoryRepository implements ChatMemoryRepository {
private static final String QUERY_GET_IDS = """
SELECT DISTINCT conversation_id FROM ai_chat_memory
""";
private static final String QUERY_ADD = """
INSERT INTO ai_chat_memory (conversation_id, content, type, "timestamp") VALUES (?, ?, ?, ?)
""";
private static final String QUERY_GET = """
SELECT content, type FROM ai_chat_memory WHERE conversation_id = ? ORDER BY "timestamp"
""";
private static final String QUERY_CLEAR = "DELETE FROM ai_chat_memory WHERE conversation_id = ?";
private final JdbcTemplate jdbcTemplate;
private JdbcChatMemoryRepository(JdbcTemplate jdbcTemplate) {
private final JdbcChatMemoryDialect dialect;
private JdbcChatMemoryRepository(JdbcTemplate jdbcTemplate, JdbcChatMemoryDialect dialect) {
Assert.notNull(jdbcTemplate, "jdbcTemplate cannot be null");
Assert.notNull(dialect, "dialect cannot be null");
this.jdbcTemplate = jdbcTemplate;
this.dialect = dialect;
}
@Override
public List<String> findConversationIds() {
List<String> conversationIds = this.jdbcTemplate.query(QUERY_GET_IDS, rs -> {
List<String> conversationIds = this.jdbcTemplate.query(dialect.getSelectConversationIdsSql(), rs -> {
var ids = new ArrayList<String>();
while (rs.next()) {
ids.add(rs.getString(1));
@@ -84,7 +75,7 @@ public class JdbcChatMemoryRepository implements ChatMemoryRepository {
@Override
public List<Message> findByConversationId(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
return this.jdbcTemplate.query(QUERY_GET, new MessageRowMapper(), conversationId);
return this.jdbcTemplate.query(dialect.getSelectMessagesSql(), new MessageRowMapper(), conversationId);
}
@Override
@@ -93,13 +84,14 @@ public class JdbcChatMemoryRepository implements ChatMemoryRepository {
Assert.notNull(messages, "messages cannot be null");
Assert.noNullElements(messages, "messages cannot contain null elements");
this.deleteByConversationId(conversationId);
this.jdbcTemplate.batchUpdate(QUERY_ADD, new AddBatchPreparedStatement(conversationId, messages));
this.jdbcTemplate.batchUpdate(dialect.getInsertMessageSql(),
new AddBatchPreparedStatement(conversationId, messages));
}
@Override
public void deleteByConversationId(String conversationId) {
Assert.hasText(conversationId, "conversationId cannot be null or empty");
this.jdbcTemplate.update(QUERY_CLEAR, conversationId);
this.jdbcTemplate.update(dialect.getDeleteMessagesSql(), conversationId);
}
private record AddBatchPreparedStatement(String conversationId, List<Message> messages,
@@ -154,6 +146,8 @@ public class JdbcChatMemoryRepository implements ChatMemoryRepository {
private JdbcTemplate jdbcTemplate;
private JdbcChatMemoryDialect dialect;
private Builder() {
}
@@ -162,8 +156,15 @@ public class JdbcChatMemoryRepository implements ChatMemoryRepository {
return this;
}
public Builder dialect(JdbcChatMemoryDialect dialect) {
this.dialect = dialect;
return this;
}
public JdbcChatMemoryRepository build() {
return new JdbcChatMemoryRepository(this.jdbcTemplate);
if (this.dialect == null)
throw new IllegalStateException("Dialect must be set");
return new JdbcChatMemoryRepository(this.jdbcTemplate, this.dialect);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.memory.jdbc;
/**
* Dialect for MySQL.
*
* @author Mark Pollack
* @since 1.0.0
*/
public class MysqlChatMemoryDialect implements JdbcChatMemoryDialect {
@Override
public String getSelectMessagesSql() {
return "SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY `timestamp` DESC LIMIT ?";
}
@Override
public String getInsertMessageSql() {
return "INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, `timestamp`) VALUES (?, ?, ?, ?)";
}
@Override
public String getSelectConversationIdsSql() {
return "SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY";
}
@Override
public String getDeleteMessagesSql() {
return "DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?";
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.memory.jdbc;
/**
* Dialect for Postgres.
*
* @author Mark Pollack
* @since 1.0.0
*/
public class PostgresChatMemoryDialect implements JdbcChatMemoryDialect {
@Override
public String getSelectMessagesSql() {
return "SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY \"timestamp\"";
}
@Override
public String getInsertMessageSql() {
return "INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, \"timestamp\") VALUES (?, ?, ?, ?)";
}
@Override
public String getSelectConversationIdsSql() {
return "SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY";
}
@Override
public String getDeleteMessagesSql() {
return "DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?";
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2024-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.chat.memory.jdbc;
/**
* Dialect for SQL Server.
*
* @author Mark Pollack
* @since 1.0.0
*/
public class SqlServerChatMemoryDialect implements JdbcChatMemoryDialect {
@Override
public String getSelectMessagesSql() {
return "SELECT content, type FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY [timestamp] ASC";
}
@Override
public String getInsertMessageSql() {
return "INSERT INTO SPRING_AI_CHAT_MEMORY (conversation_id, content, type, [timestamp]) VALUES (?, ?, ?, ?)";
}
@Override
public String getSelectConversationIdsSql() {
return "SELECT DISTINCT conversation_id FROM SPRING_AI_CHAT_MEMORY";
}
@Override
public String getDeleteMessagesSql() {
return "DELETE FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ?";
}
}

View File

@@ -34,9 +34,7 @@ class JdbcChatMemoryRepositoryRuntimeHints implements RuntimeHintsRegistrar {
hints.reflection()
.registerType(DataSource.class, hint -> hint.withMembers(MemberCategory.INVOKE_DECLARED_METHODS));
hints.resources()
.registerPattern("org/springframework/ai/chat/memory/jdbc/schema-mariadb.sql")
.registerPattern("org/springframework/ai/chat/memory/jdbc/schema-postgresql.sql");
hints.resources().registerPattern("org/springframework/ai/chat/memory/jdbc/schema-*.sql");
}
}

View File

@@ -0,0 +1,10 @@
CREATE TABLE SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content LONGVARCHAR NOT NULL,
type VARCHAR(10) NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, timestamp DESC);
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'));

View File

@@ -1,10 +1,10 @@
CREATE TABLE IF NOT EXISTS ai_chat_memory (
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(10) NOT NULL,
`timestamp` TIMESTAMP NOT NULL,
CONSTRAINT type_check CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
);
CREATE INDEX IF NOT EXISTS ai_chat_memory_conversation_id_timestamp_idx
ON ai_chat_memory(conversation_id, `timestamp`);
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX
ON SPRING_AI_CHAT_MEMORY(conversation_id, `timestamp`);

View File

@@ -1,9 +1,9 @@
CREATE TABLE IF NOT EXISTS ai_chat_memory (
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')),
"timestamp" TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS ai_chat_memory_conversation_id_timestamp_idx
ON ai_chat_memory(conversation_id, "timestamp");
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX
ON SPRING_AI_CHAT_MEMORY(conversation_id, "timestamp");

View File

@@ -0,0 +1,9 @@
CREATE TABLE SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content NVARCHAR(MAX) NOT NULL,
type VARCHAR(10) NOT NULL,
[timestamp] DATETIME2 NOT NULL DEFAULT SYSDATETIME(),
CONSTRAINT TYPE_CHECK CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL'))
);
CREATE INDEX SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, [timestamp] DESC);

View File

@@ -5,7 +5,7 @@ Large language models (LLMs) are stateless, meaning they do not retain informati
The `ChatMemory` abstraction allows you to implement various types of memory to support different use cases. The underlying storage of the messages is handled by the `ChatMemoryRepository`, whose sole responsibility is to store and retrieve messages. It's up to the `ChatMemory` implementation to decide which messages to keep and when to remove them. Examples of strategies could include keeping the last N messages, keeping messages for a certain time period, or keeping messages up to a certain token limit.
Before choosing a memory type, it's important to understand the difference between chat memory and chat history.
Before choosing a memory type, it's essential to understand the difference between chat memory and chat history.
* *Chat Memory*. The information that a large-language model retains and uses to maintain contextual awareness throughout a conversation.
* *Chat History*. The entire conversation history, including all messages exchanged between the user and the model.
@@ -66,7 +66,7 @@ ChatMemoryRepository repository = new InMemoryChatMemoryRepository();
=== JdbcChatMemoryRepository
`JdbcChatMemoryRepository` is a built-in implementation that uses JDBC to store messages in a relational database. It is suitable for applications that require persistent storage of chat memory.
`JdbcChatMemoryRepository` is a built-in implementation that uses JDBC to store messages in a relational database. It supports multiple databases out-of-the-box and is suitable for applications that require persistent storage of chat memory.
First, add the following dependency to your project:
@@ -105,12 +105,13 @@ ChatMemory chatMemory = MessageWindowChatMemory.builder()
.build();
----
If you'd rather create the `JdbcChatMemoryRepository` manually, you can do so by providing a `JdbcTemplate` instance:
If you'd rather create the `JdbcChatMemoryRepository` manually, you can do so by providing a `JdbcTemplate` instance and optionally a custom `JdbcChatMemoryDialect`:
[source,java]
----
ChatMemoryRepository chatMemoryRepository = JdbcChatMemoryRepository.builder()
.jdbcTemplate(jdbcTemplate)
.dialect(new PostgresChatMemoryDialect()) // Optional: custom dialect, auto-detected by default
.build();
ChatMemory chatMemory = MessageWindowChatMemory.builder()
@@ -119,22 +120,57 @@ ChatMemory chatMemory = MessageWindowChatMemory.builder()
.build();
----
==== Supported Databases and Dialect Abstraction
Spring AI supports multiple relational databases via a dialect abstraction. The following databases are supported out-of-the-box:
- PostgreSQL
- MySQL / MariaDB
- SQL Server
- HSQLDB
The correct dialect is auto-detected from the JDBC URL. You can extend support for other databases by implementing the `JdbcChatMemoryDialect` interface.
==== Configuration Properties
[cols="2,5,1",stripes=even]
|===
|Property | Description | Default Value
| `spring.ai.chat.memory.repository.jdbc.initialize-schema` | Whether to initialize the schema on startup. | `true`
| `spring.ai.chat.memory.repository.jdbc.initialize-schema` | Controls when to initialize the schema. Values: `embedded` (default), `always`, `never`. | `embedded`
| `spring.ai.chat.memory.repository.jdbc.schema` | Location of the schema script to use for initialization. Supports `classpath:` URLs and platform placeholders. | `classpath:org/springframework/ai/chat/memory/jdbc/schema-@@platform@@.sql`
|===
==== Schema Initialization
The auto-configuration will automatically create the `ai_chat_memory` table using the JDBC driver. Currently, only PostgreSQL and MariaDB are supported.
The auto-configuration will automatically create the `SPRING_AI_CHAT_MEMORY` table on startup, using a vendor-specific SQL script for your database. By default, schema initialization runs only for embedded databases (H2, HSQL, Derby, etc.).
You can disable the schema initialization by setting the property `spring.ai.chat.memory.repository.jdbc.initialize-schema` to `false`.
You can control schema initialization using the `spring.ai.chat.memory.repository.jdbc.initialize-schema` property:
If your project uses a tool like Flyway or Liquibase to manage your database schemas, you can disable the schema initialization and refer to link:https://github.com/spring-projects/spring-ai/tree/main/memory/spring-ai-model-chat-memory-jdbc/src/main/resources/org/springframework/ai/chat/memory/jdbc[these SQL scripts] for configuring those tools to create the `ai_chat_memory` table.
[source,properties]
----
spring.ai.chat.memory.repository.jdbc.initialize-schema=embedded # Only for embedded DBs (default)
spring.ai.chat.memory.repository.jdbc.initialize-schema=always # Always initialize
spring.ai.chat.memory.repository.jdbc.initialize-schema=never # Never initialize (useful with Flyway/Liquibase)
----
To override the schema script location, use:
[source,properties]
----
spring.ai.chat.memory.repository.jdbc.schema=classpath:/custom/path/schema-mysql.sql
----
==== Extending Dialects
To add support for a new database, implement the `JdbcChatMemoryDialect` interface and provide SQL for selecting, inserting, and deleting messages. You can then pass your custom dialect to the repository builder.
[source,java]
----
ChatMemoryRepository chatMemoryRepository = JdbcChatMemoryRepository.builder()
.jdbcTemplate(jdbcTemplate)
.dialect(new MyCustomDbDialect())
.build();
----
=== Neo4j ChatMemoryRepository