diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml
index 64d678f32..cafe4c844 100644
--- a/agentscope-distribution/agentscope-all/pom.xml
+++ b/agentscope-distribution/agentscope-all/pom.xml
@@ -193,6 +193,13 @@
+ * This implementation stores skills in MySQL database tables with the following + * structure: + * + *
+ * Table Schema (auto-created if createIfNotExist=true): + * + *
+ * CREATE TABLE IF NOT EXISTS agentscope_skills ( + * skill_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + * name VARCHAR(255) NOT NULL UNIQUE, + * description TEXT NOT NULL, + * skill_content LONGTEXT NOT NULL, + * source VARCHAR(255) NOT NULL, + * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + * updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + * ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + * + * CREATE TABLE IF NOT EXISTS agentscope_skill_resources ( + * skill_id BIGINT NOT NULL, + * resource_path VARCHAR(500) NOT NULL, + * resource_content LONGTEXT NOT NULL, + * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + * updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + * PRIMARY KEY (skill_id, resource_path), + * FOREIGN KEY (skill_id) REFERENCES agentscope_skills(skill_id) ON DELETE CASCADE + * ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + *+ * + *
+ * Features: + * + *
+ * Example usage: + * + *
{@code
+ * // Using simple constructor with default database/table names
+ * DataSource dataSource = createDataSource();
+ * MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true);
+ *
+ * // Using Builder for custom configuration
+ * MysqlSkillRepository repo = MysqlSkillRepository.builder(dataSource)
+ * .databaseName("my_database")
+ * .skillsTableName("my_skills")
+ * .resourcesTableName("my_resources")
+ * .createIfNotExist(true)
+ * .writeable(true)
+ * .build();
+ *
+ * // Save a skill
+ * AgentSkill skill = new AgentSkill("my-skill", "Description", "Content", resources);
+ * repo.save(List.of(skill), false);
+ *
+ * // Get a skill
+ * AgentSkill loaded = repo.getSkill("my-skill");
+ * }
+ */
+public class MysqlSkillRepository implements AgentSkillRepository {
+
+ private static final Logger logger = LoggerFactory.getLogger(MysqlSkillRepository.class);
+
+ /** Default database name for skill storage. */
+ private static final String DEFAULT_DATABASE_NAME = "agentscope";
+
+ /** Default table name for storing skills. */
+ private static final String DEFAULT_SKILLS_TABLE_NAME = "agentscope_skills";
+
+ /** Default table name for storing skill resources. */
+ private static final String DEFAULT_RESOURCES_TABLE_NAME = "agentscope_skill_resources";
+
+ /**
+ * Pattern for validating database and table names.
+ * Only allows alphanumeric characters and underscores, must start with letter
+ * or underscore.
+ * This prevents SQL injection attacks through malicious database/table names.
+ */
+ private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
+
+ /** MySQL identifier length limit. */
+ private static final int MAX_IDENTIFIER_LENGTH = 64;
+
+ /** Maximum length for skill name. */
+ private static final int MAX_SKILL_NAME_LENGTH = 255;
+
+ /** Maximum length for resource path. */
+ private static final int MAX_RESOURCE_PATH_LENGTH = 500;
+
+ private final DataSource dataSource;
+ private final String databaseName;
+ private final String skillsTableName;
+ private final String resourcesTableName;
+ private boolean writeable;
+
+ /**
+ * Create a MysqlSkillRepository with default database and table names.
+ *
+ * + * This constructor uses default database name ({@code agentscope}) and table + * names ({@code agentscope_skills} and {@code agentscope_skill_resources}). + * + * @param dataSource DataSource for database connections + * @param createIfNotExist If true, auto-create database and tables; if false, + * require existing + * @param writeable Whether the repository supports write operations + * @throws IllegalArgumentException if dataSource is null + * @throws IllegalStateException if createIfNotExist is false and + * database/tables do not exist + */ + public MysqlSkillRepository( + DataSource dataSource, boolean createIfNotExist, boolean writeable) { + this( + dataSource, + DEFAULT_DATABASE_NAME, + DEFAULT_SKILLS_TABLE_NAME, + DEFAULT_RESOURCES_TABLE_NAME, + createIfNotExist, + writeable); + } + + /** + * Create a MysqlSkillRepository with custom database name, table names, and + * options. + * + *
+ * If {@code createIfNotExist} is true, the database and tables will be created + * automatically + * if they don't exist. If false and the database or tables don't exist, an + * {@link IllegalStateException} will be thrown. + * + *
+ * This constructor is private. Use {@link #builder(DataSource)} to create instances + * with custom configuration. + * + * @param dataSource DataSource for database connections + * @param databaseName Custom database name (uses default if null or + * empty) + * @param skillsTableName Custom skills table name (uses default if null or + * empty) + * @param resourcesTableName Custom resources table name (uses default if null + * or empty) + * @param createIfNotExist If true, auto-create database and tables; if false, + * require existing + * @param writeable Whether the repository supports write operations + * @throws IllegalArgumentException if dataSource is null or identifiers are + * invalid + * @throws IllegalStateException if createIfNotExist is false and + * database/tables do not exist + */ + private MysqlSkillRepository( + DataSource dataSource, + String databaseName, + String skillsTableName, + String resourcesTableName, + boolean createIfNotExist, + boolean writeable) { + if (dataSource == null) { + throw new IllegalArgumentException("DataSource cannot be null"); + } + + this.dataSource = dataSource; + this.writeable = writeable; + + // Use defaults if null or empty, then validate + this.databaseName = + (databaseName == null || databaseName.trim().isEmpty()) + ? DEFAULT_DATABASE_NAME + : databaseName.trim(); + this.skillsTableName = + (skillsTableName == null || skillsTableName.trim().isEmpty()) + ? DEFAULT_SKILLS_TABLE_NAME + : skillsTableName.trim(); + this.resourcesTableName = + (resourcesTableName == null || resourcesTableName.trim().isEmpty()) + ? DEFAULT_RESOURCES_TABLE_NAME + : resourcesTableName.trim(); + + // Validate identifiers to prevent SQL injection + validateIdentifier(this.databaseName, "Database name"); + validateIdentifier(this.skillsTableName, "Skills table name"); + validateIdentifier(this.resourcesTableName, "Resources table name"); + + if (createIfNotExist) { + // Create database and tables if they don't exist + createDatabaseIfNotExist(); + createTablesIfNotExist(); + } else { + // Verify database and tables exist + verifyDatabaseExists(); + verifyTablesExist(); + } + + logger.info( + "MysqlSkillRepository initialized with database: {}, skills table: {}," + + " resources table: {}", + this.databaseName, + this.skillsTableName, + this.resourcesTableName); + } + + /** + * Create the database if it doesn't exist. + * + *
+ * Creates the database with UTF-8 (utf8mb4) character set and unicode collation
+ * for proper internationalization support.
+ */
+ private void createDatabaseIfNotExist() {
+ String createDatabaseSql =
+ "CREATE DATABASE IF NOT EXISTS "
+ + databaseName
+ + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement stmt = conn.prepareStatement(createDatabaseSql)) {
+ stmt.execute();
+ logger.debug("Database created or already exists: {}", databaseName);
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to create database: " + databaseName, e);
+ }
+ }
+
+ /**
+ * Create the skills and resources tables if they don't exist.
+ */
+ private void createTablesIfNotExist() {
+ // Create skills table with skill_id as primary key and name as unique
+ String createSkillsTableSql =
+ "CREATE TABLE IF NOT EXISTS "
+ + getFullTableName(skillsTableName)
+ + " (skill_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,"
+ + " name VARCHAR(255) NOT NULL UNIQUE, description TEXT NOT NULL,"
+ + " skill_content LONGTEXT NOT NULL, source VARCHAR(255) NOT NULL,"
+ + " created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP"
+ + " DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) DEFAULT"
+ + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
+
+ // Create resources table with skill_id as foreign key
+ String createResourcesTableSql =
+ "CREATE TABLE IF NOT EXISTS "
+ + getFullTableName(resourcesTableName)
+ + " (skill_id BIGINT NOT NULL, resource_path VARCHAR(500) NOT NULL,"
+ + " resource_content LONGTEXT NOT NULL, created_at TIMESTAMP DEFAULT"
+ + " CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON"
+ + " UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (skill_id, resource_path),"
+ + " FOREIGN KEY (skill_id) REFERENCES "
+ + getFullTableName(skillsTableName)
+ + "(skill_id) ON DELETE CASCADE)"
+ + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
+
+ try (Connection conn = dataSource.getConnection()) {
+ try (PreparedStatement stmt = conn.prepareStatement(createSkillsTableSql)) {
+ stmt.execute();
+ logger.debug("Skills table created or already exists: {}", skillsTableName);
+ }
+
+ try (PreparedStatement stmt = conn.prepareStatement(createResourcesTableSql)) {
+ stmt.execute();
+ logger.debug("Resources table created or already exists: {}", resourcesTableName);
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to create tables", e);
+ }
+ }
+
+ /**
+ * Verify that the database exists.
+ *
+ * @throws IllegalStateException if database does not exist
+ */
+ private void verifyDatabaseExists() {
+ String checkSql =
+ "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?";
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement stmt = conn.prepareStatement(checkSql)) {
+ stmt.setString(1, databaseName);
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (!rs.next()) {
+ throw new IllegalStateException(
+ "Database does not exist: "
+ + databaseName
+ + ". Use MysqlSkillRepository(dataSource, true) to"
+ + " auto-create.");
+ }
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to check database existence: " + databaseName, e);
+ }
+ }
+
+ /**
+ * Verify that the required tables exist.
+ *
+ * @throws IllegalStateException if any table does not exist
+ */
+ private void verifyTablesExist() {
+ verifyTableExists(skillsTableName);
+ verifyTableExists(resourcesTableName);
+ }
+
+ /**
+ * Verify that a specific table exists.
+ *
+ * @param tableName the table name to check
+ * @throws IllegalStateException if table does not exist
+ */
+ private void verifyTableExists(String tableName) {
+ String checkSql =
+ "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
+ + "WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?";
+
+ try (Connection conn = dataSource.getConnection();
+ PreparedStatement stmt = conn.prepareStatement(checkSql)) {
+ stmt.setString(1, databaseName);
+ stmt.setString(2, tableName);
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (!rs.next()) {
+ throw new IllegalStateException(
+ "Table does not exist: "
+ + databaseName
+ + "."
+ + tableName
+ + ". Use MysqlSkillRepository(dataSource, true) to"
+ + " auto-create.");
+ }
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to check table existence: " + tableName, e);
+ }
+ }
+
+ /**
+ * Get the full table name with database prefix.
+ *
+ * @param tableName the table name
+ * @return The full table name (database.table)
+ */
+ private String getFullTableName(String tableName) {
+ return databaseName + "." + tableName;
+ }
+
+ @Override
+ public AgentSkill getSkill(String name) {
+ validateSkillName(name);
+
+ String selectSkillSql =
+ "SELECT skill_id, name, description, skill_content, source FROM "
+ + getFullTableName(skillsTableName)
+ + " WHERE name = ?";
+
+ String selectResourcesSql =
+ "SELECT resource_path, resource_content FROM "
+ + getFullTableName(resourcesTableName)
+ + " WHERE skill_id = ?";
+
+ try (Connection conn = dataSource.getConnection()) {
+ // Load skill metadata
+ long skillId;
+ String description;
+ String skillContent;
+ String source;
+
+ try (PreparedStatement stmt = conn.prepareStatement(selectSkillSql)) {
+ stmt.setString(1, name);
+ try (ResultSet rs = stmt.executeQuery()) {
+ if (!rs.next()) {
+ throw new IllegalArgumentException("Skill not found: " + name);
+ }
+ skillId = rs.getLong("skill_id");
+ description = rs.getString("description");
+ skillContent = rs.getString("skill_content");
+ source = rs.getString("source");
+ }
+ }
+
+ // Load skill resources using skill_id
+ Map
+ * This method uses JDBC batch processing to insert all resources in a single
+ * network round-trip, significantly improving performance for skills with
+ * multiple resources.
+ *
+ * @param conn the database connection
+ * @param skillId the skill_id to associate resources with
+ * @param resources the resources to insert
+ * @throws SQLException if insertion fails
+ */
+ private void insertResources(Connection conn, long skillId, Map
+ * Resources are deleted automatically via ON DELETE CASCADE, but we also
+ * delete the skill by name which triggers the cascade.
+ *
+ * @param conn the database connection
+ * @param skillName the skill name to delete
+ * @throws SQLException if deletion fails
+ */
+ private void deleteSkillInternal(Connection conn, String skillName) throws SQLException {
+ // Delete skill by name - resources will be deleted via ON DELETE CASCADE
+ String deleteSkillSql =
+ "DELETE FROM " + getFullTableName(skillsTableName) + " WHERE name = ?";
+
+ try (PreparedStatement stmt = conn.prepareStatement(deleteSkillSql)) {
+ stmt.setString(1, skillName);
+ stmt.executeUpdate();
+ }
+ }
+
+ @Override
+ public boolean skillExists(String skillName) {
+ if (skillName == null || skillName.isEmpty()) {
+ return false;
+ }
+
+ try (Connection conn = dataSource.getConnection()) {
+ return skillExistsInternal(conn, skillName);
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to check skill existence: " + skillName, e);
+ }
+ }
+
+ /**
+ * Check if a skill exists using an existing connection.
+ *
+ * @param conn the database connection
+ * @param skillName the skill name to check
+ * @return true if the skill exists
+ * @throws SQLException if query fails
+ */
+ private boolean skillExistsInternal(Connection conn, String skillName) throws SQLException {
+ String checkSql =
+ "SELECT 1 FROM " + getFullTableName(skillsTableName) + " WHERE name = ? LIMIT 1";
+
+ try (PreparedStatement stmt = conn.prepareStatement(checkSql)) {
+ stmt.setString(1, skillName);
+ try (ResultSet rs = stmt.executeQuery()) {
+ return rs.next();
+ }
+ }
+ }
+
+ @Override
+ public AgentSkillRepositoryInfo getRepositoryInfo() {
+ return new AgentSkillRepositoryInfo(
+ "mysql", databaseName + "." + skillsTableName, writeable);
+ }
+
+ @Override
+ public String getSource() {
+ return "mysql_" + databaseName + "_" + skillsTableName;
+ }
+
+ @Override
+ public void setWriteable(boolean writeable) {
+ this.writeable = writeable;
+ }
+
+ @Override
+ public boolean isWriteable() {
+ return writeable;
+ }
+
+ @Override
+ public void close() {
+ // DataSource is managed externally, so we don't close it here
+ logger.debug("MysqlSkillRepository closed");
+ }
+
+ /**
+ * Get the database name used for storing skills.
+ *
+ * @return the database name
+ */
+ public String getDatabaseName() {
+ return databaseName;
+ }
+
+ /**
+ * Get the skills table name.
+ *
+ * @return the skills table name
+ */
+ public String getSkillsTableName() {
+ return skillsTableName;
+ }
+
+ /**
+ * Get the resources table name.
+ *
+ * @return the resources table name
+ */
+ public String getResourcesTableName() {
+ return resourcesTableName;
+ }
+
+ /**
+ * Get the DataSource used for database connections.
+ *
+ * @return the DataSource instance
+ */
+ public DataSource getDataSource() {
+ return dataSource;
+ }
+
+ /**
+ * Clear all skills from the database (for testing or cleanup).
+ *
+ *
+ * Resources are deleted automatically via ON DELETE CASCADE when skills are deleted.
+ *
+ * @return the number of skills deleted
+ */
+ public int clearAllSkills() {
+ // Resources will be deleted automatically via ON DELETE CASCADE
+ String deleteSkillsSql = "DELETE FROM " + getFullTableName(skillsTableName);
+
+ try (Connection conn = dataSource.getConnection()) {
+ conn.setAutoCommit(false);
+ try {
+ // Delete all skills (resources are deleted via CASCADE)
+ int deleted;
+ try (PreparedStatement stmt = conn.prepareStatement(deleteSkillsSql)) {
+ deleted = stmt.executeUpdate();
+ }
+
+ conn.commit();
+ logger.info("Cleared all skills, {} skills deleted", deleted);
+ return deleted;
+
+ } catch (Exception e) {
+ conn.rollback();
+ throw e;
+ } finally {
+ restoreAutoCommit(conn);
+ }
+
+ } catch (SQLException e) {
+ throw new RuntimeException("Failed to clear skills", e);
+ }
+ }
+
+ /**
+ * Safely restore auto-commit mode on a connection.
+ *
+ *
+ * This method catches and logs any SQLException that may occur when restoring
+ * auto-commit mode, preventing it from masking the original exception in a
+ * finally block.
+ *
+ * @param conn the connection to restore auto-commit on
+ */
+ private void restoreAutoCommit(Connection conn) {
+ try {
+ conn.setAutoCommit(true);
+ } catch (SQLException e) {
+ logger.warn("Failed to restore auto-commit mode on connection", e);
+ }
+ }
+
+ /**
+ * Validate a skill name.
+ *
+ * @param skillName the skill name to validate
+ * @throws IllegalArgumentException if the skill name is invalid
+ */
+ private void validateSkillName(String skillName) {
+ if (skillName == null || skillName.trim().isEmpty()) {
+ throw new IllegalArgumentException("Skill name cannot be null or empty");
+ }
+ if (skillName.length() > MAX_SKILL_NAME_LENGTH) {
+ throw new IllegalArgumentException(
+ "Skill name cannot exceed " + MAX_SKILL_NAME_LENGTH + " characters");
+ }
+ // Check for path traversal attempts
+ if (skillName.contains("..") || skillName.contains("/") || skillName.contains("\\")) {
+ throw new IllegalArgumentException("Skill name cannot contain path separators or '..'");
+ }
+ }
+
+ /**
+ * Validate a resource path.
+ *
+ * @param path the resource path to validate
+ * @throws IllegalArgumentException if the path is invalid
+ */
+ private void validateResourcePath(String path) {
+ if (path == null || path.trim().isEmpty()) {
+ throw new IllegalArgumentException("Resource path cannot be null or empty");
+ }
+ if (path.length() > MAX_RESOURCE_PATH_LENGTH) {
+ throw new IllegalArgumentException(
+ "Resource path cannot exceed " + MAX_RESOURCE_PATH_LENGTH + " characters");
+ }
+ }
+
+ /**
+ * Validate a database or table identifier to prevent SQL injection.
+ *
+ *
+ * This method ensures that identifiers only contain safe characters
+ * (alphanumeric and
+ * underscores) and start with a letter or underscore. This is critical for
+ * security since
+ * database and table names cannot be parameterized in prepared statements.
+ *
+ * @param identifier The identifier to validate (database name or table
+ * name)
+ * @param identifierType Description of the identifier type for error messages
+ * @throws IllegalArgumentException if the identifier is invalid or contains
+ * unsafe characters
+ */
+ private void validateIdentifier(String identifier, String identifierType) {
+ if (identifier == null || identifier.isEmpty()) {
+ throw new IllegalArgumentException(identifierType + " cannot be null or empty");
+ }
+ if (identifier.length() > MAX_IDENTIFIER_LENGTH) {
+ throw new IllegalArgumentException(
+ identifierType + " cannot exceed " + MAX_IDENTIFIER_LENGTH + " characters");
+ }
+ if (!IDENTIFIER_PATTERN.matcher(identifier).matches()) {
+ throw new IllegalArgumentException(
+ identifierType
+ + " contains invalid characters. Only alphanumeric characters and"
+ + " underscores are allowed, and it must start with a letter or"
+ + " underscore. Invalid value: "
+ + identifier);
+ }
+ }
+
+ /**
+ * Create a new Builder for MysqlSkillRepository.
+ *
+ *
+ * Example usage:
+ *
+ *
+ * This builder provides a fluent API for configuring all aspects of the repository,
+ * including database name, table names, and behavior options.
+ */
+ public static class Builder {
+
+ private final DataSource dataSource;
+ private String databaseName = DEFAULT_DATABASE_NAME;
+ private String skillsTableName = DEFAULT_SKILLS_TABLE_NAME;
+ private String resourcesTableName = DEFAULT_RESOURCES_TABLE_NAME;
+ private boolean createIfNotExist = true;
+ private boolean writeable = true;
+
+ /**
+ * Create a new Builder with the required DataSource.
+ *
+ * @param dataSource DataSource for database connections
+ * @throws IllegalArgumentException if dataSource is null
+ */
+ private Builder(DataSource dataSource) {
+ if (dataSource == null) {
+ throw new IllegalArgumentException("DataSource cannot be null");
+ }
+ this.dataSource = dataSource;
+ }
+
+ /**
+ * Set the database name for storing skills.
+ *
+ * @param databaseName the database name (default: "agentscope")
+ * @return this builder for method chaining
+ */
+ public Builder databaseName(String databaseName) {
+ this.databaseName = databaseName;
+ return this;
+ }
+
+ /**
+ * Set the skills table name.
+ *
+ * @param skillsTableName the skills table name (default: "agentscope_skills")
+ * @return this builder for method chaining
+ */
+ public Builder skillsTableName(String skillsTableName) {
+ this.skillsTableName = skillsTableName;
+ return this;
+ }
+
+ /**
+ * Set the resources table name.
+ *
+ * @param resourcesTableName the resources table name (default:
+ * "agentscope_skill_resources")
+ * @return this builder for method chaining
+ */
+ public Builder resourcesTableName(String resourcesTableName) {
+ this.resourcesTableName = resourcesTableName;
+ return this;
+ }
+
+ /**
+ * Set whether to create database and tables if they don't exist.
+ *
+ * @param createIfNotExist true to auto-create, false to require existing
+ * (default: true)
+ * @return this builder for method chaining
+ */
+ public Builder createIfNotExist(boolean createIfNotExist) {
+ this.createIfNotExist = createIfNotExist;
+ return this;
+ }
+
+ /**
+ * Set whether the repository supports write operations.
+ *
+ * @param writeable true to enable write operations, false for read-only
+ * (default: true)
+ * @return this builder for method chaining
+ */
+ public Builder writeable(boolean writeable) {
+ this.writeable = writeable;
+ return this;
+ }
+
+ /**
+ * Build the MysqlSkillRepository instance.
+ *
+ * @return a new MysqlSkillRepository instance
+ * @throws IllegalArgumentException if identifiers are invalid
+ * @throws IllegalStateException if createIfNotExist is false and
+ * database/tables do not exist
+ */
+ public MysqlSkillRepository build() {
+ return new MysqlSkillRepository(
+ dataSource,
+ databaseName,
+ skillsTableName,
+ resourcesTableName,
+ createIfNotExist,
+ writeable);
+ }
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java
new file mode 100644
index 000000000..96e33c405
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java
@@ -0,0 +1,945 @@
+/*
+ * Copyright 2024-2026 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
+ *
+ * http://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 io.agentscope.core.skill.repository.mysql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.agentscope.core.skill.AgentSkill;
+import io.agentscope.core.skill.repository.AgentSkillRepositoryInfo;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Unit tests for MysqlSkillRepository.
+ *
+ *
+ * These tests use mocked DataSource and Connection to verify the behavior of
+ * MysqlSkillRepository without requiring an actual MySQL database.
+ *
+ *
+ * Test categories:
+ * {@code
+ * MysqlSkillRepository repo = MysqlSkillRepository.builder(dataSource)
+ * .databaseName("my_database")
+ * .skillsTableName("my_skills")
+ * .resourcesTableName("my_resources")
+ * .createIfNotExist(true)
+ * .writeable(true)
+ * .build();
+ * }
+ *
+ * @param dataSource DataSource for database connections (required)
+ * @return a new Builder instance
+ * @throws IllegalArgumentException if dataSource is null
+ */
+ public static Builder builder(DataSource dataSource) {
+ return new Builder(dataSource);
+ }
+
+ /**
+ * Builder for creating MysqlSkillRepository instances with custom configuration.
+ *
+ *
+ *
+ */
+@DisplayName("MysqlSkillRepository Tests")
+public class MysqlSkillRepositoryTest {
+
+ @Mock private DataSource mockDataSource;
+
+ @Mock private Connection mockConnection;
+
+ @Mock private PreparedStatement mockStatement;
+
+ @Mock private ResultSet mockResultSet;
+
+ @Mock private ResultSet mockGeneratedKeysResultSet;
+
+ private AutoCloseable mockitoCloseable;
+
+ @BeforeEach
+ void setUp() throws SQLException {
+ mockitoCloseable = MockitoAnnotations.openMocks(this);
+ when(mockDataSource.getConnection()).thenReturn(mockConnection);
+ when(mockConnection.prepareStatement(anyString())).thenReturn(mockStatement);
+ // Also mock prepareStatement with RETURN_GENERATED_KEYS for insertSkill
+ when(mockConnection.prepareStatement(anyString(), anyInt())).thenReturn(mockStatement);
+ // Mock getGeneratedKeys for insertSkill
+ when(mockStatement.getGeneratedKeys()).thenReturn(mockGeneratedKeysResultSet);
+ when(mockGeneratedKeysResultSet.next()).thenReturn(true);
+ when(mockGeneratedKeysResultSet.getLong(1)).thenReturn(1L);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (mockitoCloseable != null) {
+ mockitoCloseable.close();
+ }
+ }
+
+ // ==================== Constructor Tests ====================
+
+ @Nested
+ @DisplayName("Constructor Tests")
+ class ConstructorTests {
+
+ @Test
+ @DisplayName("Should throw exception when DataSource is null")
+ void testConstructorWithNullDataSource() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new MysqlSkillRepository(null, true, true),
+ "DataSource cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should create repository with createIfNotExist=true")
+ void testConstructorWithCreateIfNotExistTrue() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true);
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ assertEquals(mockDataSource, repo.getDataSource());
+ assertTrue(repo.isWriteable());
+ }
+
+ @Test
+ @DisplayName("Should create repository with writeable=false")
+ void testConstructorWithWriteableFalse() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, false);
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertFalse(repo.isWriteable());
+ }
+
+ @Test
+ @DisplayName(
+ "Should throw exception when database does not exist and createIfNotExist=false")
+ void testConstructorWithDatabaseNotExist() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ when(mockResultSet.next()).thenReturn(false);
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> new MysqlSkillRepository(mockDataSource, false, true),
+ "Database does not exist");
+ }
+
+ @Test
+ @DisplayName("Should throw exception when skills table does not exist")
+ void testConstructorWithSkillsTableNotExist() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ // First call: database exists, second call: skills table not found
+ when(mockResultSet.next()).thenReturn(true, false);
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> new MysqlSkillRepository(mockDataSource, false, true),
+ "Table does not exist");
+ }
+
+ @Test
+ @DisplayName("Should throw exception when resources table does not exist")
+ void testConstructorWithResourcesTableNotExist() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ // database exists, skills table exists, resources table not found
+ when(mockResultSet.next()).thenReturn(true, true, false);
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> new MysqlSkillRepository(mockDataSource, false, true),
+ "Table does not exist");
+ }
+
+ @Test
+ @DisplayName("Should create repository when all tables exist")
+ void testConstructorWithAllTablesExist() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ // database exists, skills table exists, resources table exists
+ when(mockResultSet.next()).thenReturn(true, true, true);
+
+ MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, false, true);
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ }
+
+ @Test
+ @DisplayName("Should create repository with custom names using Builder")
+ void testConstructorWithCustomNames() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("custom_db")
+ .skillsTableName("custom_skills")
+ .resourcesTableName("custom_resources")
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals("custom_db", repo.getDatabaseName());
+ assertEquals("custom_skills", repo.getSkillsTableName());
+ assertEquals("custom_resources", repo.getResourcesTableName());
+ }
+
+ @Test
+ @DisplayName("Should use default names when null provided via Builder")
+ void testConstructorWithNullNamesUsesDefaults() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName(null)
+ .skillsTableName(null)
+ .resourcesTableName(null)
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ }
+
+ @Test
+ @DisplayName("Should use default names when empty string provided via Builder")
+ void testConstructorWithEmptyNamesUsesDefaults() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName(" ")
+ .skillsTableName(" ")
+ .resourcesTableName(" ")
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ }
+ }
+
+ // ==================== Builder Tests ====================
+
+ @Nested
+ @DisplayName("Builder Tests")
+ class BuilderTests {
+
+ @Test
+ @DisplayName("Should throw exception when builder DataSource is null")
+ void testBuilderWithNullDataSource() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> MysqlSkillRepository.builder(null),
+ "DataSource cannot be null");
+ }
+
+ @Test
+ @DisplayName("Should create repository with Builder using defaults")
+ void testBuilderWithDefaults() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo = MysqlSkillRepository.builder(mockDataSource).build();
+
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ assertTrue(repo.isWriteable());
+ assertEquals(mockDataSource, repo.getDataSource());
+ }
+
+ @Test
+ @DisplayName("Should create repository with Builder setting all options")
+ void testBuilderWithAllOptions() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("my_db")
+ .skillsTableName("my_skills")
+ .resourcesTableName("my_resources")
+ .createIfNotExist(true)
+ .writeable(false)
+ .build();
+
+ assertEquals("my_db", repo.getDatabaseName());
+ assertEquals("my_skills", repo.getSkillsTableName());
+ assertEquals("my_resources", repo.getResourcesTableName());
+ assertFalse(repo.isWriteable());
+ }
+
+ @Test
+ @DisplayName("Should support Builder method chaining")
+ void testBuilderMethodChaining() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ // Test that all builder methods return the builder for chaining
+ MysqlSkillRepository.Builder builder = MysqlSkillRepository.builder(mockDataSource);
+
+ // Each method should return the same builder instance
+ MysqlSkillRepository.Builder result1 = builder.databaseName("db");
+ MysqlSkillRepository.Builder result2 = result1.skillsTableName("skills");
+ MysqlSkillRepository.Builder result3 = result2.resourcesTableName("resources");
+ MysqlSkillRepository.Builder result4 = result3.createIfNotExist(true);
+ MysqlSkillRepository.Builder result5 = result4.writeable(true);
+
+ // All should be the same instance
+ assertEquals(builder, result1);
+ assertEquals(builder, result2);
+ assertEquals(builder, result3);
+ assertEquals(builder, result4);
+ assertEquals(builder, result5);
+
+ // Build should work after chaining
+ MysqlSkillRepository repo = result5.build();
+ assertNotNull(repo);
+ assertEquals("db", repo.getDatabaseName());
+ }
+
+ @Test
+ @DisplayName("Should create repository with Builder using only databaseName")
+ void testBuilderWithOnlyDatabaseName() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource).databaseName("custom_db").build();
+
+ assertEquals("custom_db", repo.getDatabaseName());
+ // Should use defaults for other options
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ assertTrue(repo.isWriteable());
+ }
+
+ @Test
+ @DisplayName("Should create repository with Builder using only writeable=false")
+ void testBuilderWithOnlyWriteableFalse() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource).writeable(false).build();
+
+ // Should use defaults for other options
+ assertEquals("agentscope", repo.getDatabaseName());
+ assertEquals("agentscope_skills", repo.getSkillsTableName());
+ assertEquals("agentscope_skill_resources", repo.getResourcesTableName());
+ assertFalse(repo.isWriteable());
+ }
+
+ @Test
+ @DisplayName("Should create repository with Builder using createIfNotExist=false")
+ void testBuilderWithCreateIfNotExistFalse() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ // database exists, skills table exists, resources table exists
+ when(mockResultSet.next()).thenReturn(true, true, true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource).createIfNotExist(false).build();
+
+ assertNotNull(repo);
+ assertEquals("agentscope", repo.getDatabaseName());
+ }
+
+ @Test
+ @DisplayName("Should throw exception when createIfNotExist=false and database not exist")
+ void testBuilderWithCreateIfNotExistFalseAndDatabaseNotExist() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ when(mockResultSet.next()).thenReturn(false); // database doesn't exist
+
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .createIfNotExist(false)
+ .build(),
+ "Database does not exist");
+ }
+ }
+
+ // ==================== SQL Injection Prevention Tests ====================
+
+ @Nested
+ @DisplayName("SQL Injection Prevention Tests")
+ class SqlInjectionPreventionTests {
+
+ @Test
+ @DisplayName("Should reject database name with semicolon")
+ void testRejectsDatabaseNameWithSemicolon() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("db; DROP DATABASE mysql; --")
+ .skillsTableName("skills")
+ .resourcesTableName("resources")
+ .build(),
+ "Database name contains invalid characters");
+ }
+
+ @Test
+ @DisplayName("Should reject table name with semicolon")
+ void testRejectsTableNameWithSemicolon() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("valid_db")
+ .skillsTableName("table; DROP TABLE users; --")
+ .resourcesTableName("resources")
+ .build(),
+ "Table name contains invalid characters");
+ }
+
+ @Test
+ @DisplayName("Should reject database name with space")
+ void testRejectsDatabaseNameWithSpace() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("db name")
+ .skillsTableName("skills")
+ .resourcesTableName("resources")
+ .build(),
+ "Database name contains invalid characters");
+ }
+
+ @Test
+ @DisplayName("Should reject table name with space")
+ void testRejectsTableNameWithSpace() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("valid_db")
+ .skillsTableName("table name")
+ .resourcesTableName("resources")
+ .build(),
+ "Table name contains invalid characters");
+ }
+
+ @Test
+ @DisplayName("Should reject database name starting with number")
+ void testRejectsDatabaseNameStartingWithNumber() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("123db")
+ .skillsTableName("skills")
+ .resourcesTableName("resources")
+ .build(),
+ "Database name contains invalid characters");
+ }
+
+ @Test
+ @DisplayName("Should reject database name exceeding max length")
+ void testRejectsDatabaseNameExceedingMaxLength() {
+ String longName = "a".repeat(65);
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName(longName)
+ .skillsTableName("skills")
+ .resourcesTableName("resources")
+ .build(),
+ "Database name cannot exceed 64 characters");
+ }
+
+ @Test
+ @DisplayName("Should accept valid identifiers")
+ void testAcceptsValidIdentifiers() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("my_database_123")
+ .skillsTableName("my_skills_456")
+ .resourcesTableName("my_resources_789")
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals("my_database_123", repo.getDatabaseName());
+ assertEquals("my_skills_456", repo.getSkillsTableName());
+ assertEquals("my_resources_789", repo.getResourcesTableName());
+ }
+
+ @Test
+ @DisplayName("Should accept names starting with underscore")
+ void testAcceptsNamesStartingWithUnderscore() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName("_private_db")
+ .skillsTableName("_private_skills")
+ .resourcesTableName("_private_resources")
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals("_private_db", repo.getDatabaseName());
+ assertEquals("_private_skills", repo.getSkillsTableName());
+ }
+
+ @Test
+ @DisplayName("Should accept max length names")
+ void testAcceptsMaxLengthNames() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+
+ String maxLengthName = "a".repeat(64);
+ MysqlSkillRepository repo =
+ MysqlSkillRepository.builder(mockDataSource)
+ .databaseName(maxLengthName)
+ .skillsTableName(maxLengthName)
+ .resourcesTableName(maxLengthName)
+ .createIfNotExist(true)
+ .writeable(true)
+ .build();
+
+ assertEquals(maxLengthName, repo.getDatabaseName());
+ }
+ }
+
+ // ==================== Skill Name Validation Tests ====================
+
+ @Nested
+ @DisplayName("Skill Name Validation Tests")
+ class SkillNameValidationTests {
+
+ private MysqlSkillRepository repo;
+
+ @BeforeEach
+ void setUp() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+ repo = new MysqlSkillRepository(mockDataSource, true, true);
+ }
+
+ @Test
+ @DisplayName("Should reject null skill name in getSkill")
+ void testGetSkillWithNullName() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> repo.getSkill(null),
+ "Skill name cannot be null or empty");
+ }
+
+ @Test
+ @DisplayName("Should reject empty skill name in getSkill")
+ void testGetSkillWithEmptyName() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> repo.getSkill(""),
+ "Skill name cannot be null or empty");
+ }
+
+ @Test
+ @DisplayName("Should reject skill name with path traversal")
+ void testGetSkillWithPathTraversal() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> repo.getSkill("../etc/passwd"),
+ "Skill name cannot contain path separators");
+ }
+
+ @Test
+ @DisplayName("Should reject skill name exceeding max length")
+ void testGetSkillWithExceedingMaxLength() {
+ String longName = "a".repeat(256);
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> repo.getSkill(longName),
+ "Skill name cannot exceed 255 characters");
+ }
+ }
+
+ // ==================== CRUD Operation Tests ====================
+
+ @Nested
+ @DisplayName("CRUD Operation Tests")
+ class CrudOperationTests {
+
+ private MysqlSkillRepository repo;
+
+ @BeforeEach
+ void setUp() throws SQLException {
+ when(mockStatement.execute()).thenReturn(true);
+ repo = new MysqlSkillRepository(mockDataSource, true, true);
+ }
+
+ @Test
+ @DisplayName("Should get skill successfully")
+ void testGetSkill() throws SQLException {
+ // Setup mock for skill query
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ // First query: skill exists, second query: no resources
+ when(mockResultSet.next()).thenReturn(true, false);
+ when(mockResultSet.getString("name")).thenReturn("test-skill");
+ when(mockResultSet.getString("description")).thenReturn("Test description");
+ when(mockResultSet.getString("skill_content")).thenReturn("Test content");
+ when(mockResultSet.getString("source")).thenReturn("mysql_test");
+
+ AgentSkill skill = repo.getSkill("test-skill");
+
+ assertNotNull(skill);
+ assertEquals("test-skill", skill.getName());
+ assertEquals("Test description", skill.getDescription());
+ assertEquals("Test content", skill.getSkillContent());
+ assertEquals("mysql_test", skill.getSource());
+ }
+
+ @Test
+ @DisplayName("Should throw exception when skill not found")
+ void testGetSkillNotFound() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ when(mockResultSet.next()).thenReturn(false);
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> repo.getSkill("non-existent"),
+ "Skill not found");
+ }
+
+ @Test
+ @DisplayName("Should get all skill names")
+ void testGetAllSkillNames() throws SQLException {
+ when(mockStatement.executeQuery()).thenReturn(mockResultSet);
+ when(mockResultSet.next()).thenReturn(true, true, false);
+ when(mockResultSet.getString("name")).thenReturn("skill1", "skill2");
+
+ List