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 @@ true + + io.agentscope + agentscope-extensions-skill-mysql-repository + compile + true + + io.modelcontextprotocol.sdk diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index b9310c9e9..c30c70779 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -296,6 +296,13 @@ agentscope-chat-completions-web-starter ${project.version} + + + + io.agentscope + agentscope-extensions-skill-mysql-repository + ${project.version} + diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml new file mode 100644 index 000000000..bbaac0431 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml @@ -0,0 +1,49 @@ + + + + + 4.0.0 + + io.agentscope + agentscope-extensions + ${revision} + ../pom.xml + + + agentscope-extensions-skill-mysql-repository + AgentScope Java - Extensions - Skill MySQL + AgentScope Extensions - MySQL Skill Storage + + + + io.agentscope + agentscope-core + true + provided + + + + + com.mysql + mysql-connector-j + + + + + \ No newline at end of file diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java new file mode 100644 index 000000000..887bded25 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java @@ -0,0 +1,1133 @@ +/* + * 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 io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.repository.AgentSkillRepository; +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.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import javax.sql.DataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MySQL database-based implementation of AgentSkillRepository. + * + *

+ * 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 resources = new HashMap<>(); + try (PreparedStatement stmt = conn.prepareStatement(selectResourcesSql)) { + stmt.setLong(1, skillId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String path = rs.getString("resource_path"); + String content = rs.getString("resource_content"); + resources.put(path, content); + } + } + } + + return new AgentSkill(name, description, skillContent, resources, source); + + } catch (SQLException e) { + throw new RuntimeException("Failed to load skill: " + name, e); + } + } + + @Override + public List getAllSkillNames() { + String selectSql = + "SELECT name FROM " + getFullTableName(skillsTableName) + " ORDER BY name"; + + List skillNames = new ArrayList<>(); + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(selectSql); + ResultSet rs = stmt.executeQuery()) { + + while (rs.next()) { + skillNames.add(rs.getString("name")); + } + + } catch (SQLException e) { + throw new RuntimeException("Failed to list skill names", e); + } + + return skillNames; + } + + @Override + public List getAllSkills() { + String selectAllSkillsSql = + "SELECT skill_id, name, description, skill_content, source FROM " + + getFullTableName(skillsTableName) + + " ORDER BY name"; + + String selectAllResourcesSql = + "SELECT skill_id, resource_path, resource_content FROM " + + getFullTableName(resourcesTableName); + + try (Connection conn = dataSource.getConnection()) { + // Load all skills in one query, use skill_id as key for mapping resources + Map skillBuilders = new HashMap<>(); + + try (PreparedStatement stmt = conn.prepareStatement(selectAllSkillsSql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long skillId = rs.getLong("skill_id"); + String name = rs.getString("name"); + String description = rs.getString("description"); + String skillContent = rs.getString("skill_content"); + String source = rs.getString("source"); + + AgentSkill.Builder builder = + AgentSkill.builder() + .name(name) + .description(description) + .skillContent(skillContent) + .source(source); + skillBuilders.put(skillId, builder); + } + } + + // Load all resources in one query and map them to skills using skill_id + try (PreparedStatement stmt = conn.prepareStatement(selectAllResourcesSql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long skillId = rs.getLong("skill_id"); + String resourcePath = rs.getString("resource_path"); + String resourceContent = rs.getString("resource_content"); + + AgentSkill.Builder builder = skillBuilders.get(skillId); + if (builder != null) { + builder.addResource(resourcePath, resourceContent); + } else { + logger.warn( + "Found orphaned resource for non-existent skill_id: {}", skillId); + } + } + } + + // Build all skills + List skills = new ArrayList<>(skillBuilders.size()); + for (AgentSkill.Builder builder : skillBuilders.values()) { + try { + skills.add(builder.build()); + } catch (Exception e) { + logger.warn("Failed to build skill: {}", e.getMessage(), e); + } + } + + return skills; + + } catch (SQLException e) { + throw new RuntimeException("Failed to load all skills", e); + } + } + + @Override + public boolean save(List skills, boolean force) { + if (skills == null || skills.isEmpty()) { + return false; + } + + if (!writeable) { + logger.warn("Cannot save skills: repository is read-only"); + return false; + } + + try (Connection conn = dataSource.getConnection()) { + // Pre-check: validate all skill names and resource paths before transaction + for (AgentSkill skill : skills) { + validateSkillName(skill.getName()); + + // Validate resource paths before transaction to avoid unnecessary rollback + Map resources = skill.getResources(); + if (resources != null && !resources.isEmpty()) { + for (String path : resources.keySet()) { + validateResourcePath(path); + } + } + } + + // Pre-check: if force=false, check all skills for existence before starting + // transaction + if (!force) { + List existingSkills = new ArrayList<>(); + for (AgentSkill skill : skills) { + if (skillExistsInternal(conn, skill.getName())) { + existingSkills.add(skill.getName()); + } + } + if (!existingSkills.isEmpty()) { + String conflictingSkills = String.join(", ", existingSkills); + throw new IllegalStateException( + "Cannot save skills: the following skills already exist and" + + " force=false: " + + conflictingSkills + + ". Use force=true to overwrite existing skills."); + } + } + + // Use transaction for atomic operations + conn.setAutoCommit(false); + + try { + for (AgentSkill skill : skills) { + String skillName = skill.getName(); + + // Check if skill exists (for force=true case) + boolean exists = skillExistsInternal(conn, skillName); + + if (exists) { + // Delete existing skill and its resources + deleteSkillInternal(conn, skillName); + logger.debug("Deleted existing skill for overwrite: {}", skillName); + } + + // Insert skill and get generated skill_id + long skillId = insertSkill(conn, skill); + + // Insert resources using skill_id + insertResources(conn, skillId, skill.getResources()); + + logger.info("Successfully saved skill: {} (skill_id={})", skillName, skillId); + } + + conn.commit(); + return true; + + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + restoreAutoCommit(conn); + } + + } catch (SQLException e) { + logger.error("Failed to save skills", e); + throw new RuntimeException("Failed to save skills", e); + } + } + + /** + * Insert a skill into the database and return the generated skill_id. + * + * @param conn the database connection + * @param skill the skill to insert + * @return the generated skill_id + * @throws SQLException if insertion fails + */ + private long insertSkill(Connection conn, AgentSkill skill) throws SQLException { + String insertSql = + "INSERT INTO " + + getFullTableName(skillsTableName) + + " (name, description, skill_content, source) VALUES (?, ?, ?, ?)"; + + try (PreparedStatement stmt = + conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { + stmt.setString(1, skill.getName()); + stmt.setString(2, skill.getDescription()); + stmt.setString(3, skill.getSkillContent()); + stmt.setString(4, skill.getSource()); + stmt.executeUpdate(); + + try (ResultSet generatedKeys = stmt.getGeneratedKeys()) { + if (generatedKeys.next()) { + return generatedKeys.getLong(1); + } else { + throw new SQLException( + "Failed to get generated skill_id for skill: " + skill.getName()); + } + } + } + } + + /** + * Insert resources for a skill using batch processing. + * + *

+ * 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) + throws SQLException { + if (resources == null || resources.isEmpty()) { + logger.debug("No resources to insert for skill_id: {}", skillId); + return; + } + + // Note: Resource paths are validated in save() before transaction starts + + String insertSql = + "INSERT INTO " + + getFullTableName(resourcesTableName) + + " (skill_id, resource_path, resource_content) VALUES (?, ?, ?)"; + + // Use batch processing for better performance + try (PreparedStatement stmt = conn.prepareStatement(insertSql)) { + for (Map.Entry entry : resources.entrySet()) { + String path = entry.getKey(); + String content = entry.getValue(); + + stmt.setLong(1, skillId); + stmt.setString(2, path); + stmt.setString(3, content); + + stmt.addBatch(); + } + + // Execute all inserts in one batch + int[] results = stmt.executeBatch(); + + // Count successful insertions + int insertedCount = 0; + for (int i = 0; i < results.length; i++) { + if (results[i] > 0 || results[i] == Statement.SUCCESS_NO_INFO) { + insertedCount++; + } else if (results[i] == Statement.EXECUTE_FAILED) { + logger.error("Failed to insert resource at batch index {}", i); + } + } + + logger.debug( + "Batch inserted {} resources for skill_id '{}' (total: {})", + insertedCount, + skillId, + resources.size()); + + if (insertedCount != resources.size()) { + throw new SQLException( + "Failed to insert all resources for skill_id '" + + skillId + + "'. Expected: " + + resources.size() + + ", Inserted: " + + insertedCount); + } + } + } + + @Override + public boolean delete(String skillName) { + if (!writeable) { + logger.warn("Cannot delete skill: repository is read-only"); + return false; + } + + validateSkillName(skillName); + + try (Connection conn = dataSource.getConnection()) { + if (!skillExistsInternal(conn, skillName)) { + logger.warn("Skill does not exist: {}", skillName); + return false; + } + + conn.setAutoCommit(false); + try { + deleteSkillInternal(conn, skillName); + conn.commit(); + logger.info("Successfully deleted skill: {}", skillName); + return true; + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + restoreAutoCommit(conn); + } + + } catch (SQLException e) { + logger.error("Failed to delete skill: {}", skillName, e); + throw new RuntimeException("Failed to delete skill: " + skillName, e); + } + } + + /** + * Delete a skill and its resources from the database. + * + *

+ * 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: + * + *

{@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. + * + *

+ * 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: + *

+ */ +@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 names = repo.getAllSkillNames(); + + assertEquals(2, names.size()); + assertEquals("skill1", names.get(0)); + assertEquals("skill2", names.get(1)); + } + + @Test + @DisplayName("Should return empty list when no skills exist") + void testGetAllSkillNamesEmpty() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + List names = repo.getAllSkillNames(); + + assertTrue(names.isEmpty()); + } + + @Test + @DisplayName("Should save skill successfully") + void testSaveSkill() throws SQLException { + when(mockStatement.executeUpdate()).thenReturn(1); + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); // skill doesn't exist + + AgentSkill skill = + new AgentSkill("new-skill", "Description", "Content", Map.of(), "test"); + + boolean saved = repo.save(List.of(skill), false); + + assertTrue(saved); + verify(mockStatement, atLeast(1)).executeUpdate(); + } + + @Test + @DisplayName("Should save skill with resources") + void testSaveSkillWithResources() throws SQLException { + // Mock executeUpdate for skill insertion + when(mockStatement.executeUpdate()).thenReturn(1); + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); // skill doesn't exist + // Mock executeBatch for resource batch insertion + when(mockStatement.executeBatch()).thenReturn(new int[] {1, 1}); + + Map resources = + Map.of( + "file1.txt", "content1", + "file2.txt", "content2"); + + AgentSkill skill = + new AgentSkill("skill-with-resources", "Desc", "Content", resources, "test"); + + boolean saved = repo.save(List.of(skill), false); + + assertTrue(saved); + // Verify executeUpdate was called for skill insert + verify(mockStatement, atLeast(1)).executeUpdate(); + // Verify executeBatch was called for resource inserts + verify(mockStatement, atLeast(1)).executeBatch(); + } + + @Test + @DisplayName("Should throw exception when skill exists and force=false") + void testSaveSkillExistsNoForce() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); // skill exists + + AgentSkill skill = + new AgentSkill("existing-skill", "Description", "Content", Map.of(), "test"); + + // Pre-check now throws IllegalStateException instead of returning false + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> repo.save(List.of(skill), false)); + + assertTrue(exception.getMessage().contains("existing-skill")); + assertTrue(exception.getMessage().contains("force=false")); + } + + @Test + @DisplayName("Should overwrite skill when force=true") + void testSaveSkillWithForce() throws SQLException { + when(mockStatement.executeUpdate()).thenReturn(1); + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true, false); // skill exists, then deleted + + AgentSkill skill = + new AgentSkill( + "existing-skill", "New Description", "New Content", Map.of(), "test"); + + boolean saved = repo.save(List.of(skill), true); + + assertTrue(saved); + } + + @Test + @DisplayName("Should return false when saving null list") + void testSaveNullList() { + boolean saved = repo.save(null, false); + assertFalse(saved); + } + + @Test + @DisplayName("Should return false when saving empty list") + void testSaveEmptyList() { + boolean saved = repo.save(List.of(), false); + assertFalse(saved); + } + + @Test + @DisplayName("Should delete skill successfully") + void testDeleteSkill() throws SQLException { + when(mockStatement.executeUpdate()).thenReturn(1); + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); // skill exists + + boolean deleted = repo.delete("test-skill"); + + assertTrue(deleted); + } + + @Test + @DisplayName("Should return false when deleting non-existent skill") + void testDeleteNonExistentSkill() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); // skill doesn't exist + + boolean deleted = repo.delete("non-existent"); + + assertFalse(deleted); + } + + @Test + @DisplayName("Should check skill exists correctly") + void testSkillExists() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(true); + + assertTrue(repo.skillExists("existing-skill")); + } + + @Test + @DisplayName("Should return false for non-existent skill") + void testSkillNotExists() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + assertFalse(repo.skillExists("non-existent")); + } + + @Test + @DisplayName("Should return false for null skill name in exists") + void testSkillExistsWithNullName() { + assertFalse(repo.skillExists(null)); + } + + @Test + @DisplayName("Should return false for empty skill name in exists") + void testSkillExistsWithEmptyName() { + assertFalse(repo.skillExists("")); + } + } + + // ==================== Read-Only Mode Tests ==================== + + @Nested + @DisplayName("Read-Only Mode Tests") + class ReadOnlyModeTests { + + @Test + @DisplayName("Should not save when repository is read-only") + void testSaveWhenReadOnly() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder(mockDataSource) + .databaseName("db") + .skillsTableName("skills") + .resourcesTableName("resources") + .createIfNotExist(true) + .writeable(false) + .build(); + + AgentSkill skill = new AgentSkill("test", "desc", "content", Map.of(), "test"); + boolean saved = repo.save(List.of(skill), false); + + assertFalse(saved); + } + + @Test + @DisplayName("Should not delete when repository is read-only") + void testDeleteWhenReadOnly() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder(mockDataSource) + .databaseName("db") + .skillsTableName("skills") + .resourcesTableName("resources") + .createIfNotExist(true) + .writeable(false) + .build(); + + boolean deleted = repo.delete("test-skill"); + + assertFalse(deleted); + } + + @Test + @DisplayName("Should toggle writeable flag") + void testSetWriteable() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); + + assertTrue(repo.isWriteable()); + + repo.setWriteable(false); + assertFalse(repo.isWriteable()); + + repo.setWriteable(true); + assertTrue(repo.isWriteable()); + } + } + + // ==================== Repository Info Tests ==================== + + @Nested + @DisplayName("Repository Info Tests") + class RepositoryInfoTests { + + @Test + @DisplayName("Should return correct repository info") + void testGetRepositoryInfo() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); + + AgentSkillRepositoryInfo info = repo.getRepositoryInfo(); + + assertNotNull(info); + assertEquals("mysql", info.getType()); + assertEquals("agentscope.agentscope_skills", info.getLocation()); + assertTrue(info.isWritable()); + } + + @Test + @DisplayName("Should return correct source") + void testGetSource() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); + + String source = repo.getSource(); + + assertEquals("mysql_agentscope_agentscope_skills", source); + } + + @Test + @DisplayName("Should return correct source with custom names") + void testGetSourceWithCustomNames() 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(); + + String source = repo.getSource(); + + assertEquals("mysql_custom_db_custom_skills", source); + } + } + + // ==================== Close and Cleanup Tests ==================== + + @Nested + @DisplayName("Close and Cleanup Tests") + class CloseAndCleanupTests { + + @Test + @DisplayName("Should close without error") + void testClose() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); + repo.close(); + + // Should not throw exception + assertEquals(mockDataSource, repo.getDataSource()); + } + + @Test + @DisplayName("Should clear all skills") + void testClearAllSkills() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + when(mockStatement.executeUpdate()).thenReturn(5); + + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); + int deleted = repo.clearAllSkills(); + + assertEquals(5, deleted); + } + } +} diff --git a/agentscope-extensions/pom.xml b/agentscope-extensions/pom.xml index d23257c12..7cbddb50c 100644 --- a/agentscope-extensions/pom.xml +++ b/agentscope-extensions/pom.xml @@ -52,5 +52,6 @@ agentscope-extensions-higress agentscope-extensions-kotlin agentscope-extensions-nacos + agentscope-extensions-skill-mysql-repository diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index 0bd4aac74..9f520c3b1 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -260,7 +260,25 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` -#### MySQL Database Storage (not yet implemented) +#### MySQL Database Storage + +```java +// 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(); + +repo.save(List.of(skill), false); +AgentSkill loaded = repo.getSkill("data_analysis"); +``` #### Git Repository (not yet implemented) diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index 1031f79c7..c4b76efd2 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -257,7 +257,25 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` -#### MySQL数据库存储 (暂未实现) +#### MySQL数据库存储 + +```java +// 使用简单构造函数(使用默认数据库/表名) +DataSource dataSource = createDataSource(); +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); + +// 使用Builder进行自定义配置 +MysqlSkillRepository repo = MysqlSkillRepository.builder(dataSource) + .databaseName("my_database") + .skillsTableName("my_skills") + .resourcesTableName("my_resources") + .createIfNotExist(true) + .writeable(true) + .build(); + +repo.save(List.of(skill), false); +AgentSkill loaded = repo.getSkill("data_analysis"); +``` #### Git仓库 (暂未实现)