From e333676b2e8693ce10ce68c0f4f5e5257fa4205c Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:51:59 +0800 Subject: [PATCH 1/8] feat(mysql): add MysqlSkillRepository and associated tests for skill management --- .../agentscope-extensions-skill-mysql/pom.xml | 49 + .../mysql/MysqlSkillRepository.java | 1099 +++++++++++++++++ .../mysql/MysqlSkillRepositoryTest.java | 905 ++++++++++++++ agentscope-extensions/pom.xml | 1 + 4 files changed, 2054 insertions(+) create mode 100644 agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml create mode 100644 agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java create mode 100644 agentscope-extensions/agentscope-extensions-skill-mysql/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml b/agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml new file mode 100644 index 000000000..eebfb810c --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml @@ -0,0 +1,49 @@ + + + + + 4.0.0 + + io.agentscope + agentscope-extensions + ${revision} + ../pom.xml + + + agentscope-extensions-skill-mysql + 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/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java b/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java new file mode 100644 index 000000000..4b9e20d39 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java @@ -0,0 +1,1099 @@ +/* + * 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.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 (
+ *     name VARCHAR(255) NOT NULL PRIMARY KEY,
+ *     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_name VARCHAR(255) 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_name, resource_path),
+ *     FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE
+ * ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ * 
+ * + *

+ * Features: + * + *

+ * + *

+ * Example usage: + * + *

{@code
+ * // Using constructor
+ * DataSource dataSource = createDataSource();
+ * MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true);
+ *
+ * // Using builder pattern
+ * MysqlSkillRepository repo = MysqlSkillRepository.builder()
+ *         .dataSource(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 settings. + * + *

+ * This constructor uses default database name ({@code agentscope}) and table + * names, + * and does NOT auto-create the database or tables. If the database or tables do + * not exist, + * an {@link IllegalStateException} will be thrown. + * + * @param dataSource DataSource for database connections + * @throws IllegalArgumentException if dataSource is null + * @throws IllegalStateException if database or tables do not exist + */ + public MysqlSkillRepository(DataSource dataSource) { + this(dataSource, false); + } + + /** + * Create a MysqlSkillRepository with optional auto-creation of database and + * tables. + * + *

+ * This constructor uses default database name ({@code agentscope}) and table + * names. + * 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. + * + * @param dataSource DataSource for database connections + * @param createIfNotExist If true, auto-create database and tables; if false, + * require existing + * @throws IllegalArgumentException if dataSource is null + * @throws IllegalStateException if createIfNotExist is false and + * database/tables do not exist + */ + public MysqlSkillRepository(DataSource dataSource, boolean createIfNotExist) { + this( + dataSource, + DEFAULT_DATABASE_NAME, + DEFAULT_SKILLS_TABLE_NAME, + DEFAULT_RESOURCES_TABLE_NAME, + createIfNotExist, + true); + } + + /** + * 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. + * + * @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 + */ + public 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 + String createSkillsTableSql = + "CREATE TABLE IF NOT EXISTS " + + getFullTableName(skillsTableName) + + " (name VARCHAR(255) NOT NULL PRIMARY KEY, 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 foreign key + String createResourcesTableSql = + "CREATE TABLE IF NOT EXISTS " + + getFullTableName(resourcesTableName) + + " (skill_name VARCHAR(255) 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_name, resource_path))" + + " 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 name, description, skill_content, source FROM " + + getFullTableName(skillsTableName) + + " WHERE name = ?"; + + String selectResourcesSql = + "SELECT resource_path, resource_content FROM " + + getFullTableName(resourcesTableName) + + " WHERE skill_name = ?"; + + try (Connection conn = dataSource.getConnection()) { + // Load skill metadata + 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); + } + description = rs.getString("description"); + skillContent = rs.getString("skill_content"); + source = rs.getString("source"); + } + } + + // Load skill resources + Map resources = new HashMap<>(); + try (PreparedStatement stmt = conn.prepareStatement(selectResourcesSql)) { + stmt.setString(1, name); + 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() { + List skillNames = getAllSkillNames(); + List skills = new ArrayList<>(); + + for (String name : skillNames) { + try { + skills.add(getSkill(name)); + } catch (Exception e) { + logger.warn("Failed to load skill '{}': {}", name, e.getMessage(), e); + // Continue processing other skills + } + } + + return skills; + } + + @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()) { + // Use transaction for atomic operations + conn.setAutoCommit(false); + + try { + for (AgentSkill skill : skills) { + String skillName = skill.getName(); + validateSkillName(skillName); + + // Check if skill exists + boolean exists = skillExistsInternal(conn, skillName); + + if (exists && !force) { + logger.info("Skill already exists and force=false: {}", skillName); + conn.rollback(); + return false; + } + + if (exists) { + // Delete existing skill and its resources + deleteSkillInternal(conn, skillName); + logger.debug("Deleted existing skill for overwrite: {}", skillName); + } + + // Insert skill + insertSkill(conn, skill); + + // Insert resources + insertResources(conn, skillName, skill.getResources()); + + logger.info("Successfully saved skill: {}", skillName); + } + + conn.commit(); + return true; + + } catch (Exception e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(true); + } + + } catch (SQLException e) { + logger.error("Failed to save skills", e); + throw new RuntimeException("Failed to save skills", e); + } + } + + /** + * Insert a skill into the database. + * + * @param conn the database connection + * @param skill the skill to insert + * @throws SQLException if insertion fails + */ + private void 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)) { + stmt.setString(1, skill.getName()); + stmt.setString(2, skill.getDescription()); + stmt.setString(3, skill.getSkillContent()); + stmt.setString(4, skill.getSource()); + stmt.executeUpdate(); + } + } + + /** + * Insert resources for a skill. + * + *

+ * This method inserts each resource one by one instead of using batch + * processing + * to ensure better compatibility and error handling across different database + * drivers. + * + * @param conn the database connection + * @param skillName the skill name + * @param resources the resources to insert + * @throws SQLException if insertion fails + */ + private void insertResources(Connection conn, String skillName, Map resources) + throws SQLException { + if (resources == null || resources.isEmpty()) { + logger.debug("No resources to insert for skill: {}", skillName); + return; + } + + String insertSql = + "INSERT INTO " + + getFullTableName(resourcesTableName) + + " (skill_name, resource_path, resource_content) VALUES (?, ?, ?)"; + + int insertedCount = 0; + for (Map.Entry entry : resources.entrySet()) { + String path = entry.getKey(); + String content = entry.getValue(); + + validateResourcePath(path); + + try (PreparedStatement stmt = conn.prepareStatement(insertSql)) { + stmt.setString(1, skillName); + stmt.setString(2, path); + stmt.setString(3, content); + int affected = stmt.executeUpdate(); + if (affected > 0) { + insertedCount++; + logger.debug("Inserted resource '{}' for skill '{}'", path, skillName); + } else { + logger.warn("Failed to insert resource '{}' for skill '{}'", path, skillName); + } + } + } + + logger.debug( + "Inserted {} resources for skill '{}' (total: {})", + insertedCount, + skillName, + resources.size()); + + if (insertedCount != resources.size()) { + throw new SQLException( + "Failed to insert all resources for skill '" + + skillName + + "'. 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 { + conn.setAutoCommit(true); + } + + } 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. + * + * @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 resources first (if foreign key doesn't have CASCADE) + String deleteResourcesSql = + "DELETE FROM " + getFullTableName(resourcesTableName) + " WHERE skill_name = ?"; + + try (PreparedStatement stmt = conn.prepareStatement(deleteResourcesSql)) { + stmt.setString(1, skillName); + stmt.executeUpdate(); + } + + // Delete skill + 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). + * + * @return the number of skills deleted + */ + public int clearAllSkills() { + String deleteResourcesSql = "DELETE FROM " + getFullTableName(resourcesTableName); + String deleteSkillsSql = "DELETE FROM " + getFullTableName(skillsTableName); + + try (Connection conn = dataSource.getConnection()) { + conn.setAutoCommit(false); + try { + // Delete all resources first + try (PreparedStatement stmt = conn.prepareStatement(deleteResourcesSql)) { + stmt.executeUpdate(); + } + + // Delete all skills + 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 { + conn.setAutoCommit(true); + } + + } catch (SQLException e) { + throw new RuntimeException("Failed to clear skills", 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); + } + } + + /** + * Creates a new Builder instance for constructing MysqlSkillRepository. + * + *

+ * The builder pattern provides a fluent API for configuring the repository + * with custom settings. + * + *

+ * Example usage: + * + *

{@code
+     * MysqlSkillRepository repo = MysqlSkillRepository.builder()
+     *         .dataSource(dataSource)
+     *         .databaseName("my_database")
+     *         .skillsTableName("my_skills")
+     *         .createIfNotExist(true)
+     *         .writeable(true)
+     *         .build();
+     * }
+ * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing MysqlSkillRepository instances. + * + *

+ * This builder provides a fluent API for configuring all aspects of the + * repository, + * including database connection, table names, and behavior options. + * + *

+ * Required fields: + *

+ * + *

+ * Optional fields with defaults: + *

+ * + *

+ * Example: + * + *

{@code
+     * MysqlSkillRepository repo = MysqlSkillRepository.builder()
+     *         .dataSource(dataSource)
+     *         .databaseName("custom_db")
+     *         .skillsTableName("custom_skills")
+     *         .resourcesTableName("custom_resources")
+     *         .createIfNotExist(true)
+     *         .writeable(true)
+     *         .build();
+     * }
+ */ + public static class Builder { + + private 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 = false; + private boolean writeable = true; + + /** + * Creates a new Builder instance with default values. + */ + public Builder() { + // Default constructor with default values + } + + /** + * Sets the DataSource for database connections. + * + *

+ * This is a required field and must be set before calling build(). + * + * @param dataSource the DataSource to use (must not be null) + * @return this builder for method chaining + */ + public Builder dataSource(DataSource dataSource) { + this.dataSource = dataSource; + return this; + } + + /** + * Sets the database name for storing skills. + * + *

+ * If not set, defaults to "agentscope". + * + * @param databaseName the database name (uses default if null or empty) + * @return this builder for method chaining + */ + public Builder databaseName(String databaseName) { + this.databaseName = databaseName; + return this; + } + + /** + * Sets the table name for storing skills. + * + *

+ * If not set, defaults to "agentscope_skills". + * + * @param skillsTableName the skills table name (uses default if null or empty) + * @return this builder for method chaining + */ + public Builder skillsTableName(String skillsTableName) { + this.skillsTableName = skillsTableName; + return this; + } + + /** + * Sets the table name for storing skill resources. + * + *

+ * If not set, defaults to "agentscope_skill_resources". + * + * @param resourcesTableName the resources table name (uses default if null or + * empty) + * @return this builder for method chaining + */ + public Builder resourcesTableName(String resourcesTableName) { + this.resourcesTableName = resourcesTableName; + return this; + } + + /** + * Sets whether to automatically create the database and tables if they don't + * exist. + * + *

+ * If set to true, the database and tables will be created during construction + * if they don't already exist. If false (default), an exception will be thrown + * if the database or tables are missing. + * + * @param createIfNotExist true to auto-create database and tables + * @return this builder for method chaining + */ + public Builder createIfNotExist(boolean createIfNotExist) { + this.createIfNotExist = createIfNotExist; + return this; + } + + /** + * Sets whether the repository supports write operations. + * + *

+ * If set to false, save and delete operations will be rejected. + * Defaults to true. + * + * @param writeable true to allow write operations + * @return this builder for method chaining + */ + public Builder writeable(boolean writeable) { + this.writeable = writeable; + return this; + } + + /** + * Builds and returns a new MysqlSkillRepository instance. + * + *

+ * This method validates that all required fields are set and creates + * the repository with the configured options. + * + * @return a new MysqlSkillRepository instance + * @throws IllegalArgumentException if dataSource is null + * @throws IllegalStateException if createIfNotExist is false and + * database/tables don't exist + */ + public MysqlSkillRepository build() { + return new MysqlSkillRepository( + dataSource, + databaseName, + skillsTableName, + resourcesTableName, + createIfNotExist, + writeable); + } + } +} diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java b/agentscope-extensions/agentscope-extensions-skill-mysql/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java new file mode 100644 index 000000000..ab43217d2 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-skill-mysql/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java @@ -0,0 +1,905 @@ +/* + * 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.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; + + private AutoCloseable mockitoCloseable; + + @BeforeEach + void setUp() throws SQLException { + mockitoCloseable = MockitoAnnotations.openMocks(this); + when(mockDataSource.getConnection()).thenReturn(mockConnection); + when(mockConnection.prepareStatement(anyString())).thenReturn(mockStatement); + } + + @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), + "DataSource cannot be null"); + } + + @Test + @DisplayName("Should throw exception when DataSource is null with createIfNotExist flag") + void testConstructorWithNullDataSourceAndCreateIfNotExist() { + assertThrows( + IllegalArgumentException.class, + () -> new MysqlSkillRepository(null, 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); + + 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 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), + "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), + "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), + "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); + + assertEquals("agentscope", repo.getDatabaseName()); + assertEquals("agentscope_skills", repo.getSkillsTableName()); + } + + @Test + @DisplayName("Should create repository with custom names") + void testConstructorWithCustomNames() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + new MysqlSkillRepository( + mockDataSource, + "custom_db", + "custom_skills", + "custom_resources", + true, + true); + + assertEquals("custom_db", repo.getDatabaseName()); + assertEquals("custom_skills", repo.getSkillsTableName()); + assertEquals("custom_resources", repo.getResourcesTableName()); + } + + @Test + @DisplayName("Should use default names when null provided") + void testConstructorWithNullNamesUsesDefaults() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + new MysqlSkillRepository(mockDataSource, null, null, null, true, true); + + 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") + void testConstructorWithEmptyNamesUsesDefaults() 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()); + } + } + + // ==================== 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, + () -> + new MysqlSkillRepository( + mockDataSource, + "db; DROP DATABASE mysql; --", + "skills", + "resources", + true, + true), + "Database name contains invalid characters"); + } + + @Test + @DisplayName("Should reject table name with semicolon") + void testRejectsTableNameWithSemicolon() { + assertThrows( + IllegalArgumentException.class, + () -> + new MysqlSkillRepository( + mockDataSource, + "valid_db", + "table; DROP TABLE users; --", + "resources", + true, + true), + "Table name contains invalid characters"); + } + + @Test + @DisplayName("Should reject database name with space") + void testRejectsDatabaseNameWithSpace() { + assertThrows( + IllegalArgumentException.class, + () -> + new MysqlSkillRepository( + mockDataSource, "db name", "skills", "resources", true, true), + "Database name contains invalid characters"); + } + + @Test + @DisplayName("Should reject table name with space") + void testRejectsTableNameWithSpace() { + assertThrows( + IllegalArgumentException.class, + () -> + new MysqlSkillRepository( + mockDataSource, + "valid_db", + "table name", + "resources", + true, + true), + "Table name contains invalid characters"); + } + + @Test + @DisplayName("Should reject database name starting with number") + void testRejectsDatabaseNameStartingWithNumber() { + assertThrows( + IllegalArgumentException.class, + () -> + new MysqlSkillRepository( + mockDataSource, "123db", "skills", "resources", true, true), + "Database name contains invalid characters"); + } + + @Test + @DisplayName("Should reject database name exceeding max length") + void testRejectsDatabaseNameExceedingMaxLength() { + String longName = "a".repeat(65); + assertThrows( + IllegalArgumentException.class, + () -> + new MysqlSkillRepository( + mockDataSource, longName, "skills", "resources", true, true), + "Database name cannot exceed 64 characters"); + } + + @Test + @DisplayName("Should accept valid identifiers") + void testAcceptsValidIdentifiers() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + new MysqlSkillRepository( + mockDataSource, + "my_database_123", + "my_skills_456", + "my_resources_789", + true, + true); + + 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 = + new MysqlSkillRepository( + mockDataSource, + "_private_db", + "_private_skills", + "_private_resources", + true, + true); + + 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 = + new MysqlSkillRepository( + mockDataSource, + maxLengthName, + maxLengthName, + maxLengthName, + true, + true); + + 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); + } + + @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); + } + + @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 { + when(mockStatement.executeUpdate()).thenReturn(1); + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); // skill doesn't exist + 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); + } + + @Test + @DisplayName("Should not save 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"); + + boolean saved = repo.save(List.of(skill), false); + + assertFalse(saved); + } + + @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 = + new MysqlSkillRepository( + mockDataSource, "db", "skills", "resources", true, false); + + 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 = + new MysqlSkillRepository( + mockDataSource, "db", "skills", "resources", true, false); + + 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); + + 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); + + 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); + + 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 = + new MysqlSkillRepository( + mockDataSource, + "custom_db", + "custom_skills", + "custom_resources", + true, + true); + + 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); + 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); + int deleted = repo.clearAllSkills(); + + assertEquals(5, deleted); + } + } + + // ==================== Builder Tests ==================== + + @Nested + @DisplayName("Builder Tests") + class BuilderTests { + + @Test + @DisplayName("Should create repository with builder using defaults") + void testBuilderWithDefaults() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .createIfNotExist(true) + .build(); + + assertNotNull(repo); + 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 builder using custom values") + void testBuilderWithCustomValues() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .databaseName("custom_db") + .skillsTableName("custom_skills") + .resourcesTableName("custom_resources") + .createIfNotExist(true) + .writeable(false) + .build(); + + assertNotNull(repo); + assertEquals("custom_db", repo.getDatabaseName()); + assertEquals("custom_skills", repo.getSkillsTableName()); + assertEquals("custom_resources", repo.getResourcesTableName()); + assertFalse(repo.isWriteable()); + } + + @Test + @DisplayName("Should throw exception when dataSource is null in builder") + void testBuilderWithNullDataSource() { + assertThrows( + IllegalArgumentException.class, + () -> + MysqlSkillRepository.builder() + .dataSource(null) + .createIfNotExist(true) + .build(), + "DataSource cannot be null"); + } + + @Test + @DisplayName("Should use defaults when null values provided to builder") + void testBuilderWithNullValues() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .databaseName(null) + .skillsTableName(null) + .resourcesTableName(null) + .createIfNotExist(true) + .build(); + + assertEquals("agentscope", repo.getDatabaseName()); + assertEquals("agentscope_skills", repo.getSkillsTableName()); + assertEquals("agentscope_skill_resources", repo.getResourcesTableName()); + } + + @Test + @DisplayName("Should create read-only repository with builder") + void testBuilderReadOnly() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + MysqlSkillRepository repo = + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .createIfNotExist(true) + .writeable(false) + .build(); + + assertFalse(repo.isWriteable()); + } + + @Test + @DisplayName("Should support method chaining in builder") + void testBuilderMethodChaining() throws SQLException { + when(mockStatement.execute()).thenReturn(true); + + // Verify that all methods return the same builder instance for chaining + MysqlSkillRepository.Builder builder = MysqlSkillRepository.builder(); + MysqlSkillRepository.Builder returned = + builder.dataSource(mockDataSource) + .databaseName("db") + .skillsTableName("skills") + .resourcesTableName("resources") + .createIfNotExist(true) + .writeable(true); + + // All calls should return the same builder + assertEquals(builder, returned.dataSource(mockDataSource)); + } + + @Test + @DisplayName("Should reject invalid identifiers in builder") + void testBuilderWithInvalidIdentifiers() { + assertThrows( + IllegalArgumentException.class, + () -> + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .databaseName("db; DROP TABLE users;") + .createIfNotExist(true) + .build(), + "Database name contains invalid characters"); + } + + @Test + @DisplayName( + "Should throw exception when database does not exist and createIfNotExist=false") + void testBuilderWithoutCreateAndDatabaseNotExist() throws SQLException { + when(mockStatement.executeQuery()).thenReturn(mockResultSet); + when(mockResultSet.next()).thenReturn(false); + + assertThrows( + IllegalStateException.class, + () -> + MysqlSkillRepository.builder() + .dataSource(mockDataSource) + .createIfNotExist(false) + .build(), + "Database does not exist"); + } + } +} diff --git a/agentscope-extensions/pom.xml b/agentscope-extensions/pom.xml index d23257c12..4028924df 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 From ba6258fe43612719f54cf42612b2c97079c0378d Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:28:35 +0800 Subject: [PATCH 2/8] feat(mysql): add MysqlSkillRepository and associated tests for skill management --- .../mysql/MysqlSkillRepository.java | 134 ++++++++++++++---- .../mysql/MysqlSkillRepositoryTest.java | 29 ++-- docs/en/task/agent-skill.md | 109 +++++++++++++- docs/zh/task/agent-skill.md | 109 +++++++++++++- 4 files changed, 339 insertions(+), 42 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java b/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java index 4b9e20d39..5e581cb11 100644 --- a/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java +++ b/agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java @@ -307,7 +307,10 @@ private void createTablesIfNotExist() { + " (skill_name VARCHAR(255) 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_name, resource_path))" + + " UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (skill_name, resource_path)," + + " FOREIGN KEY (skill_name) REFERENCES " + + getFullTableName(skillsTableName) + + "(name) ON DELETE CASCADE)" + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; try (Connection conn = dataSource.getConnection()) { @@ -478,19 +481,70 @@ public List getAllSkillNames() { @Override public List getAllSkills() { - List skillNames = getAllSkillNames(); - List skills = new ArrayList<>(); + String selectAllSkillsSql = + "SELECT name, description, skill_content, source FROM " + + getFullTableName(skillsTableName) + + " ORDER BY name"; - for (String name : skillNames) { - try { - skills.add(getSkill(name)); - } catch (Exception e) { - logger.warn("Failed to load skill '{}': {}", name, e.getMessage(), e); - // Continue processing other skills + String selectAllResourcesSql = + "SELECT skill_name, resource_path, resource_content FROM " + + getFullTableName(resourcesTableName); + + try (Connection conn = dataSource.getConnection()) { + // Load all skills in one query + Map skillBuilders = new HashMap<>(); + + try (PreparedStatement stmt = conn.prepareStatement(selectAllSkillsSql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + 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(name, builder); + } } - } - return skills; + // Load all resources in one query and map them to skills + try (PreparedStatement stmt = conn.prepareStatement(selectAllResourcesSql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String skillName = rs.getString("skill_name"); + String resourcePath = rs.getString("resource_path"); + String resourceContent = rs.getString("resource_content"); + + AgentSkill.Builder builder = skillBuilders.get(skillName); + if (builder != null) { + builder.addResource(resourcePath, resourceContent); + } else { + logger.warn( + "Found orphaned resource for non-existent skill: {}", skillName); + } + } + } + + // 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 @@ -505,23 +559,40 @@ public boolean save(List skills, boolean force) { } try (Connection conn = dataSource.getConnection()) { + // Pre-check: validate all skill names first + for (AgentSkill skill : skills) { + validateSkillName(skill.getName()); + } + + // 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(); - validateSkillName(skillName); - // Check if skill exists + // Check if skill exists (for force=true case) boolean exists = skillExistsInternal(conn, skillName); - if (exists && !force) { - logger.info("Skill already exists and force=false: {}", skillName); - conn.rollback(); - return false; - } - if (exists) { // Delete existing skill and its resources deleteSkillInternal(conn, skillName); @@ -579,10 +650,8 @@ private void insertSkill(Connection conn, AgentSkill skill) throws SQLException * Insert resources for a skill. * *

- * This method inserts each resource one by one instead of using batch - * processing - * to ensure better compatibility and error handling across different database - * drivers. + * This method creates a single PreparedStatement outside the loop and reuses it + * for all resources to improve performance by avoiding repeated SQL parsing. * * @param conn the database connection * @param skillName the skill name @@ -596,22 +665,28 @@ private void insertResources(Connection conn, String skillName, Map entry : resources.entrySet()) { - String path = entry.getKey(); - String content = entry.getValue(); - validateResourcePath(path); + // Create PreparedStatement once outside the loop and reuse it + try (PreparedStatement stmt = conn.prepareStatement(insertSql)) { + for (Map.Entry entry : resources.entrySet()) { + String path = entry.getKey(); + String content = entry.getValue(); - try (PreparedStatement stmt = conn.prepareStatement(insertSql)) { stmt.setString(1, skillName); stmt.setString(2, path); stmt.setString(3, content); + int affected = stmt.executeUpdate(); if (affected > 0) { insertedCount++; @@ -619,6 +694,9 @@ private void insertResources(Connection conn, String skillName, MapThese tests use mocked DataSource and Connection to verify the behavior of + *

+ * These tests use mocked DataSource and Connection to verify the behavior of * MysqlSkillRepository without requiring an actual MySQL database. * - *

Test categories: + *

+ * Test categories: *

    - *
  • Constructor tests - validate initialization and parameter handling - *
  • CRUD operation tests - verify skill save, get, delete operations - *
  • SQL injection prevention tests - ensure security validations work - *
  • Repository info tests - verify metadata reporting + *
  • Constructor tests - validate initialization and parameter handling + *
  • CRUD operation tests - verify skill save, get, delete operations + *
  • SQL injection prevention tests - ensure security validations work + *
  • Repository info tests - verify metadata reporting *
*/ @DisplayName("MysqlSkillRepository Tests") @@ -506,10 +508,11 @@ void testSaveSkill() throws SQLException { @Test @DisplayName("Should save skill with resources") void testSaveSkillWithResources() throws SQLException { + // Mock executeUpdate for both skill insertion and resource insertions + // Note: insertResources uses executeUpdate() in a loop, not batch processing when(mockStatement.executeUpdate()).thenReturn(1); when(mockStatement.executeQuery()).thenReturn(mockResultSet); when(mockResultSet.next()).thenReturn(false); // skill doesn't exist - when(mockStatement.executeBatch()).thenReturn(new int[] {1, 1}); Map resources = Map.of( @@ -522,10 +525,12 @@ void testSaveSkillWithResources() throws SQLException { boolean saved = repo.save(List.of(skill), false); assertTrue(saved); + // Verify executeUpdate was called: 1 for skill insert + 2 for resource inserts + verify(mockStatement, atLeast(3)).executeUpdate(); } @Test - @DisplayName("Should not save when skill exists and force=false") + @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 @@ -533,9 +538,13 @@ void testSaveSkillExistsNoForce() throws SQLException { AgentSkill skill = new AgentSkill("existing-skill", "Description", "Content", Map.of(), "test"); - boolean saved = repo.save(List.of(skill), false); + // Pre-check now throws IllegalStateException instead of returning false + IllegalStateException exception = + assertThrows( + IllegalStateException.class, () -> repo.save(List.of(skill), false)); - assertFalse(saved); + assertTrue(exception.getMessage().contains("existing-skill")); + assertTrue(exception.getMessage().contains("force=false")); } @Test diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index 748eefc56..11d225366 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -199,10 +199,10 @@ ReActAgent agent = ReActAgent.builder() Skills need to remain available after application restart, or be shared across different environments. Persistence storage supports: - File system storage -- Database storage (not yet implemented) +- MySQL database storage - Git repository (not yet implemented) -**Example Code**: +#### File System Storage ```java AgentSkillRepository repo = new FileSystemSkillRepository(Path.of("./skills")); @@ -210,6 +210,111 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` +#### MySQL Database Storage + +MySQL storage is suitable for production environments that require high availability, multi-instance sharing, and transactional guarantees. + +**Add Dependency** (Maven): + +```xml + + io.agentscope + agentscope-extensions-skill-mysql + ${agentscope.version} + +``` + +**Basic Usage**: + +```java +// Configure DataSource (using HikariCP as example) +HikariConfig config = new HikariConfig(); +config.setJdbcUrl("jdbc:mysql://localhost:3306/agentscope?useSSL=false&serverTimezone=UTC"); +config.setUsername("your_username"); +config.setPassword("your_password"); +DataSource dataSource = new HikariDataSource(config); + +// Create repository (auto-creates database and tables) +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); + +// Save skills +repo.save(List.of(skill), false); + +// Load skill +AgentSkill loaded = repo.getSkill("data_analysis"); + +// Get all skills +List allSkills = repo.getAllSkills(); + +// Delete skill +repo.delete("data_analysis"); +``` + +**Using Builder Pattern** (recommended for custom configuration): + +```java +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(dataSource) + .databaseName("my_database") // Custom database name, default: agentscope + .skillsTableName("my_skills") // Custom skills table name, default: agentscope_skills + .resourcesTableName("my_resources") // Custom resources table name, default: agentscope_skill_resources + .createIfNotExist(true) // Auto-create database and tables + .writeable(true) // Allow write operations + .build(); +``` + +**Read-Only Mode**: + +```java +// Create read-only repository for shared access +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(dataSource) + .createIfNotExist(false) // Require existing database/tables + .writeable(false) // Read-only mode + .build(); + +// Read operations work normally +AgentSkill skill = repo.getSkill("data_analysis"); + +// Write operations return false without throwing exception +boolean saved = repo.save(List.of(newSkill), false); // Returns false +``` + +**Force Overwrite Existing Skills**: + +```java +// When force=false, throws IllegalStateException if skill already exists +repo.save(List.of(skill), false); + +// When force=true, overwrites existing skills +repo.save(List.of(skill), true); +``` + +**Table Schema** (auto-created): + +```sql +-- Skills table +CREATE TABLE agentscope_skills ( + name VARCHAR(255) NOT NULL PRIMARY KEY, + 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; + +-- Resources table with foreign key constraint +CREATE TABLE agentscope_skill_resources ( + skill_name VARCHAR(255) 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_name, resource_path), + FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + This protection applies to all repository operations: `getSkill()`, `save()`, `delete()`, and `skillExists()`. For detailed security guidelines, please refer to [Claude Agent Skills Security Considerations](https://platform.claude.com/docs/zh-CN/agents-and-tools/agent-skills/overview#安全考虑). diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index 1b3b486e4..96abff236 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -196,10 +196,10 @@ ReActAgent agent = ReActAgent.builder() Skills 需要在应用重启后保持可用,或者在不同环境间共享。持久化存储支持: - 文件系统存储 -- 数据库存储 (暂未实现) +- MySQL 数据库存储 - Git 仓库 (暂未实现) -**示例代码**: +#### 文件系统存储 ```java AgentSkillRepository repo = new FileSystemSkillRepository(Path.of("./skills")); @@ -207,6 +207,111 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` +#### MySQL 数据库存储 + +MySQL 存储适用于需要高可用性、多实例共享和事务保证的生产环境。 + +**添加依赖** (Maven): + +```xml + + io.agentscope + agentscope-extensions-skill-mysql + ${agentscope.version} + +``` + +**基本使用**: + +```java +// 配置数据源 (以 HikariCP 为例) +HikariConfig config = new HikariConfig(); +config.setJdbcUrl("jdbc:mysql://localhost:3306/agentscope?useSSL=false&serverTimezone=UTC"); +config.setUsername("your_username"); +config.setPassword("your_password"); +DataSource dataSource = new HikariDataSource(config); + +// 创建仓库 (自动创建数据库和表) +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); + +// 保存技能 +repo.save(List.of(skill), false); + +// 加载技能 +AgentSkill loaded = repo.getSkill("data_analysis"); + +// 获取所有技能 +List allSkills = repo.getAllSkills(); + +// 删除技能 +repo.delete("data_analysis"); +``` + +**使用 Builder 模式** (推荐用于自定义配置): + +```java +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(dataSource) + .databaseName("my_database") // 自定义数据库名, 默认: agentscope + .skillsTableName("my_skills") // 自定义技能表名, 默认: agentscope_skills + .resourcesTableName("my_resources") // 自定义资源表名, 默认: agentscope_skill_resources + .createIfNotExist(true) // 自动创建数据库和表 + .writeable(true) // 允许写操作 + .build(); +``` + +**只读模式**: + +```java +// 创建只读仓库用于共享访问 +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(dataSource) + .createIfNotExist(false) // 要求数据库/表已存在 + .writeable(false) // 只读模式 + .build(); + +// 读取操作正常工作 +AgentSkill skill = repo.getSkill("data_analysis"); + +// 写操作返回 false,不会抛出异常 +boolean saved = repo.save(List.of(newSkill), false); // 返回 false +``` + +**强制覆盖已存在的技能**: + +```java +// 当 force=false 时,如果技能已存在会抛出 IllegalStateException +repo.save(List.of(skill), false); + +// 当 force=true 时,会覆盖已存在的技能 +repo.save(List.of(skill), true); +``` + +**表结构** (自动创建): + +```sql +-- 技能表 +CREATE TABLE agentscope_skills ( + name VARCHAR(255) NOT NULL PRIMARY KEY, + 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 agentscope_skill_resources ( + skill_name VARCHAR(255) 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_name, resource_path), + FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +``` + 这种保护适用于所有仓库操作: `getSkill()`、`save()`、`delete()` 和 `skillExists()`。 详细安全指南请参阅 [Claude Agent Skills 安全考虑](https://platform.claude.com/docs/zh-CN/agents-and-tools/agent-skills/overview#安全考虑)。 From a54a7ca20a5d48a3bb7613a0852ae5b3f58d2440 Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Mon, 19 Jan 2026 16:34:15 +0800 Subject: [PATCH 3/8] feat(mysql): add MysqlSkillRepository and associated tests for skill management --- agentscope-distribution/agentscope-all/pom.xml | 7 +++++++ agentscope-distribution/agentscope-bom/pom.xml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index 64d678f32..cd1b2c090 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 + compile + true + + io.modelcontextprotocol.sdk diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 95fa95d06..a4fbb9b3f 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 + ${project.version} + From 86baf509ddf1a013e629d522c52cc833c1867514 Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:00:50 +0800 Subject: [PATCH 4/8] docs(mysql): update MySQL storage documentation and rename artifact --- .../agentscope-all/pom.xml | 2 +- .../agentscope-bom/pom.xml | 2 +- .../pom.xml | 2 +- .../mysql/MysqlSkillRepository.java | 0 .../mysql/MysqlSkillRepositoryTest.java | 0 agentscope-extensions/pom.xml | 2 +- docs/en/task/agent-skill.md | 110 +----------------- docs/zh/task/agent-skill.md | 110 +----------------- 8 files changed, 8 insertions(+), 220 deletions(-) rename agentscope-extensions/{agentscope-extensions-skill-mysql => agentscope-extensions-skill-mysql-repository}/pom.xml (95%) rename agentscope-extensions/{agentscope-extensions-skill-mysql => agentscope-extensions-skill-mysql-repository}/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java (100%) rename agentscope-extensions/{agentscope-extensions-skill-mysql => agentscope-extensions-skill-mysql-repository}/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java (100%) diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index cd1b2c090..cafe4c844 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -195,7 +195,7 @@ io.agentscope - agentscope-extensions-skill-mysql + agentscope-extensions-skill-mysql-repository compile true diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index a4fbb9b3f..bf4d97443 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -300,7 +300,7 @@ io.agentscope - agentscope-extensions-skill-mysql + agentscope-extensions-skill-mysql-repository ${project.version} diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml similarity index 95% rename from agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml rename to agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml index eebfb810c..bbaac0431 100644 --- a/agentscope-extensions/agentscope-extensions-skill-mysql/pom.xml +++ b/agentscope-extensions/agentscope-extensions-skill-mysql-repository/pom.xml @@ -26,7 +26,7 @@ ../pom.xml - agentscope-extensions-skill-mysql + agentscope-extensions-skill-mysql-repository AgentScope Java - Extensions - Skill MySQL AgentScope Extensions - MySQL Skill Storage diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/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 similarity index 100% rename from agentscope-extensions/agentscope-extensions-skill-mysql/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java rename to agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/main/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepository.java diff --git a/agentscope-extensions/agentscope-extensions-skill-mysql/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 similarity index 100% rename from agentscope-extensions/agentscope-extensions-skill-mysql/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java rename to agentscope-extensions/agentscope-extensions-skill-mysql-repository/src/test/java/io/agentscope/core/skill/repository/mysql/MysqlSkillRepositoryTest.java diff --git a/agentscope-extensions/pom.xml b/agentscope-extensions/pom.xml index 4028924df..7cbddb50c 100644 --- a/agentscope-extensions/pom.xml +++ b/agentscope-extensions/pom.xml @@ -52,6 +52,6 @@ agentscope-extensions-higress agentscope-extensions-kotlin agentscope-extensions-nacos - agentscope-extensions-skill-mysql + agentscope-extensions-skill-mysql-repository diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index 11d225366..2583a63ed 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -199,7 +199,8 @@ ReActAgent agent = ReActAgent.builder() Skills need to remain available after application restart, or be shared across different environments. Persistence storage supports: - File system storage -- MySQL database storage +- database storage + - MySQL Database Storage (agentscope-extensions-skill-mysql-repository) - Git repository (not yet implemented) #### File System Storage @@ -210,113 +211,6 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` -#### MySQL Database Storage - -MySQL storage is suitable for production environments that require high availability, multi-instance sharing, and transactional guarantees. - -**Add Dependency** (Maven): - -```xml - - io.agentscope - agentscope-extensions-skill-mysql - ${agentscope.version} - -``` - -**Basic Usage**: - -```java -// Configure DataSource (using HikariCP as example) -HikariConfig config = new HikariConfig(); -config.setJdbcUrl("jdbc:mysql://localhost:3306/agentscope?useSSL=false&serverTimezone=UTC"); -config.setUsername("your_username"); -config.setPassword("your_password"); -DataSource dataSource = new HikariDataSource(config); - -// Create repository (auto-creates database and tables) -MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); - -// Save skills -repo.save(List.of(skill), false); - -// Load skill -AgentSkill loaded = repo.getSkill("data_analysis"); - -// Get all skills -List allSkills = repo.getAllSkills(); - -// Delete skill -repo.delete("data_analysis"); -``` - -**Using Builder Pattern** (recommended for custom configuration): - -```java -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .databaseName("my_database") // Custom database name, default: agentscope - .skillsTableName("my_skills") // Custom skills table name, default: agentscope_skills - .resourcesTableName("my_resources") // Custom resources table name, default: agentscope_skill_resources - .createIfNotExist(true) // Auto-create database and tables - .writeable(true) // Allow write operations - .build(); -``` - -**Read-Only Mode**: - -```java -// Create read-only repository for shared access -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .createIfNotExist(false) // Require existing database/tables - .writeable(false) // Read-only mode - .build(); - -// Read operations work normally -AgentSkill skill = repo.getSkill("data_analysis"); - -// Write operations return false without throwing exception -boolean saved = repo.save(List.of(newSkill), false); // Returns false -``` - -**Force Overwrite Existing Skills**: - -```java -// When force=false, throws IllegalStateException if skill already exists -repo.save(List.of(skill), false); - -// When force=true, overwrites existing skills -repo.save(List.of(skill), true); -``` - -**Table Schema** (auto-created): - -```sql --- Skills table -CREATE TABLE agentscope_skills ( - name VARCHAR(255) NOT NULL PRIMARY KEY, - 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; - --- Resources table with foreign key constraint -CREATE TABLE agentscope_skill_resources ( - skill_name VARCHAR(255) 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_name, resource_path), - FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -``` - -This protection applies to all repository operations: `getSkill()`, `save()`, `delete()`, and `skillExists()`. - For detailed security guidelines, please refer to [Claude Agent Skills Security Considerations](https://platform.claude.com/docs/zh-CN/agents-and-tools/agent-skills/overview#安全考虑). ### Performance Optimization Recommendations diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index 96abff236..511b7bae3 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -196,7 +196,8 @@ ReActAgent agent = ReActAgent.builder() Skills 需要在应用重启后保持可用,或者在不同环境间共享。持久化存储支持: - 文件系统存储 -- MySQL 数据库存储 +- 数据库存储 + - MySQL数据库存储 (agentscope-extensions-skill-mysql-repository) - Git 仓库 (暂未实现) #### 文件系统存储 @@ -207,113 +208,6 @@ repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); ``` -#### MySQL 数据库存储 - -MySQL 存储适用于需要高可用性、多实例共享和事务保证的生产环境。 - -**添加依赖** (Maven): - -```xml - - io.agentscope - agentscope-extensions-skill-mysql - ${agentscope.version} - -``` - -**基本使用**: - -```java -// 配置数据源 (以 HikariCP 为例) -HikariConfig config = new HikariConfig(); -config.setJdbcUrl("jdbc:mysql://localhost:3306/agentscope?useSSL=false&serverTimezone=UTC"); -config.setUsername("your_username"); -config.setPassword("your_password"); -DataSource dataSource = new HikariDataSource(config); - -// 创建仓库 (自动创建数据库和表) -MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); - -// 保存技能 -repo.save(List.of(skill), false); - -// 加载技能 -AgentSkill loaded = repo.getSkill("data_analysis"); - -// 获取所有技能 -List allSkills = repo.getAllSkills(); - -// 删除技能 -repo.delete("data_analysis"); -``` - -**使用 Builder 模式** (推荐用于自定义配置): - -```java -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .databaseName("my_database") // 自定义数据库名, 默认: agentscope - .skillsTableName("my_skills") // 自定义技能表名, 默认: agentscope_skills - .resourcesTableName("my_resources") // 自定义资源表名, 默认: agentscope_skill_resources - .createIfNotExist(true) // 自动创建数据库和表 - .writeable(true) // 允许写操作 - .build(); -``` - -**只读模式**: - -```java -// 创建只读仓库用于共享访问 -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .createIfNotExist(false) // 要求数据库/表已存在 - .writeable(false) // 只读模式 - .build(); - -// 读取操作正常工作 -AgentSkill skill = repo.getSkill("data_analysis"); - -// 写操作返回 false,不会抛出异常 -boolean saved = repo.save(List.of(newSkill), false); // 返回 false -``` - -**强制覆盖已存在的技能**: - -```java -// 当 force=false 时,如果技能已存在会抛出 IllegalStateException -repo.save(List.of(skill), false); - -// 当 force=true 时,会覆盖已存在的技能 -repo.save(List.of(skill), true); -``` - -**表结构** (自动创建): - -```sql --- 技能表 -CREATE TABLE agentscope_skills ( - name VARCHAR(255) NOT NULL PRIMARY KEY, - 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 agentscope_skill_resources ( - skill_name VARCHAR(255) 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_name, resource_path), - FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -``` - -这种保护适用于所有仓库操作: `getSkill()`、`save()`、`delete()` 和 `skillExists()`。 - 详细安全指南请参阅 [Claude Agent Skills 安全考虑](https://platform.claude.com/docs/zh-CN/agents-and-tools/agent-skills/overview#安全考虑)。 ### 性能优化建议 From 13aae1294178d9647c0f77e50944a0a67feb171f Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:12:05 +0800 Subject: [PATCH 5/8] docs(mysql): update MySQL storage documentation and rename artifact --- docs/en/task/agent-skill.md | 19 +++++++++++++++++++ docs/zh/task/agent-skill.md | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index a2696246b..2f702f56b 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -265,6 +265,25 @@ AgentSkill loaded = repo.getSkill("data_analysis"); #### MySQL Database Storage +```java +// Using constructor +DataSource dataSource = createDataSource(); +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); + +// Using builder pattern +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(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) #### JAR Resource Adapter (Read-Only) diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index 743d68c0e..d6d905cb0 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -264,6 +264,25 @@ AgentSkill loaded = repo.getSkill("data_analysis"); #### MySQL数据库存储 +```java +// Using constructor +DataSource dataSource = createDataSource(); +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); + +// Using builder pattern +MysqlSkillRepository repo = MysqlSkillRepository.builder() + .dataSource(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仓库 (暂未实现) #### Jar中的resource路径存储 (适配器) From 917aa328c351b4262e959deac58d74955bc9f511 Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:50:04 +0800 Subject: [PATCH 6/8] refactor(mysql): enhance MysqlSkillRepository with full constructor and update documentation --- .../mysql/MysqlSkillRepository.java | 468 ++++++------------ .../mysql/MysqlSkillRepositoryTest.java | 211 ++------ docs/en/task/agent-skill.md | 28 +- docs/zh/task/agent-skill.md | 28 +- 4 files changed, 207 insertions(+), 528 deletions(-) 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 index 5e581cb11..930be80b8 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -39,8 +40,8 @@ * structure: * *
    - *
  • Skills table: stores skill metadata (name, description, content, source) - *
  • Resources table: stores skill resources (skill_name, resource_path, + *
  • Skills table: stores skill metadata (skill_id, name, description, content, source) + *
  • Resources table: stores skill resources (skill_id, resource_path, * resource_content) *
* @@ -49,7 +50,8 @@ * *
  * CREATE TABLE IF NOT EXISTS agentscope_skills (
- *     name VARCHAR(255) NOT NULL PRIMARY KEY,
+ *     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,
@@ -58,13 +60,13 @@
  * ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
  *
  * CREATE TABLE IF NOT EXISTS agentscope_skill_resources (
- *     skill_name VARCHAR(255) NOT NULL,
+ *     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_name, resource_path),
- *     FOREIGN KEY (skill_name) REFERENCES agentscope_skills(name) ON DELETE CASCADE
+ *     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;
  * 
* @@ -83,19 +85,18 @@ * Example usage: * *
{@code
- * // Using constructor
+ * // Using simple constructor with default database/table names
  * DataSource dataSource = createDataSource();
- * MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true);
+ * MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true);
  *
- * // Using builder pattern
- * MysqlSkillRepository repo = MysqlSkillRepository.builder()
- *         .dataSource(dataSource)
- *         .databaseName("my_database")
- *         .skillsTableName("my_skills")
- *         .resourcesTableName("my_resources")
- *         .createIfNotExist(true)
- *         .writeable(true)
- *         .build();
+ * // Using full constructor for custom configuration
+ * MysqlSkillRepository repo = new MysqlSkillRepository(
+ *         dataSource,
+ *         "my_database",
+ *         "my_skills",
+ *         "my_resources",
+ *         true,  // createIfNotExist
+ *         true); // writeable
  *
  * // Save a skill
  * AgentSkill skill = new AgentSkill("my-skill", "Description", "Content", resources);
@@ -142,50 +143,29 @@ public class MysqlSkillRepository implements AgentSkillRepository {
     private boolean writeable;
 
     /**
-     * Create a MysqlSkillRepository with default settings.
+     * Create a MysqlSkillRepository with default database and table names.
      *
      * 

* This constructor uses default database name ({@code agentscope}) and table - * names, - * and does NOT auto-create the database or tables. If the database or tables do - * not exist, - * an {@link IllegalStateException} will be thrown. - * - * @param dataSource DataSource for database connections - * @throws IllegalArgumentException if dataSource is null - * @throws IllegalStateException if database or tables do not exist - */ - public MysqlSkillRepository(DataSource dataSource) { - this(dataSource, false); - } - - /** - * Create a MysqlSkillRepository with optional auto-creation of database and - * tables. - * - *

- * This constructor uses default database name ({@code agentscope}) and table - * names. - * 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. + * 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) { + public MysqlSkillRepository( + DataSource dataSource, boolean createIfNotExist, boolean writeable) { this( dataSource, DEFAULT_DATABASE_NAME, DEFAULT_SKILLS_TABLE_NAME, DEFAULT_RESOURCES_TABLE_NAME, createIfNotExist, - true); + writeable); } /** @@ -290,27 +270,28 @@ private void createDatabaseIfNotExist() { * Create the skills and resources tables if they don't exist. */ private void createTablesIfNotExist() { - // Create skills table + // Create skills table with skill_id as primary key and name as unique String createSkillsTableSql = "CREATE TABLE IF NOT EXISTS " + getFullTableName(skillsTableName) - + " (name VARCHAR(255) NOT NULL PRIMARY KEY, description TEXT NOT NULL," + + " (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 foreign key + // Create resources table with skill_id as foreign key String createResourcesTableSql = "CREATE TABLE IF NOT EXISTS " + getFullTableName(resourcesTableName) - + " (skill_name VARCHAR(255) NOT NULL, resource_path VARCHAR(500) NOT NULL," + + " (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_name, resource_path)," - + " FOREIGN KEY (skill_name) REFERENCES " + + " UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (skill_id, resource_path)," + + " FOREIGN KEY (skill_id) REFERENCES " + getFullTableName(skillsTableName) - + "(name) ON DELETE CASCADE)" + + "(skill_id) ON DELETE CASCADE)" + " DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"; try (Connection conn = dataSource.getConnection()) { @@ -410,17 +391,18 @@ public AgentSkill getSkill(String name) { validateSkillName(name); String selectSkillSql = - "SELECT name, description, skill_content, source FROM " + "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_name = ?"; + + " WHERE skill_id = ?"; try (Connection conn = dataSource.getConnection()) { // Load skill metadata + long skillId; String description; String skillContent; String source; @@ -431,16 +413,17 @@ public AgentSkill getSkill(String name) { 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 + // Load skill resources using skill_id Map resources = new HashMap<>(); try (PreparedStatement stmt = conn.prepareStatement(selectResourcesSql)) { - stmt.setString(1, name); + stmt.setLong(1, skillId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { String path = rs.getString("resource_path"); @@ -482,21 +465,22 @@ public List getAllSkillNames() { @Override public List getAllSkills() { String selectAllSkillsSql = - "SELECT name, description, skill_content, source FROM " + "SELECT skill_id, name, description, skill_content, source FROM " + getFullTableName(skillsTableName) + " ORDER BY name"; String selectAllResourcesSql = - "SELECT skill_name, resource_path, resource_content FROM " + "SELECT skill_id, resource_path, resource_content FROM " + getFullTableName(resourcesTableName); try (Connection conn = dataSource.getConnection()) { - // Load all skills in one query - Map skillBuilders = new HashMap<>(); + // 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"); @@ -508,24 +492,24 @@ public List getAllSkills() { .description(description) .skillContent(skillContent) .source(source); - skillBuilders.put(name, builder); + skillBuilders.put(skillId, builder); } } - // Load all resources in one query and map them to skills + // 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()) { - String skillName = rs.getString("skill_name"); + long skillId = rs.getLong("skill_id"); String resourcePath = rs.getString("resource_path"); String resourceContent = rs.getString("resource_content"); - AgentSkill.Builder builder = skillBuilders.get(skillName); + AgentSkill.Builder builder = skillBuilders.get(skillId); if (builder != null) { builder.addResource(resourcePath, resourceContent); } else { logger.warn( - "Found orphaned resource for non-existent skill: {}", skillName); + "Found orphaned resource for non-existent skill_id: {}", skillId); } } } @@ -559,9 +543,17 @@ public boolean save(List skills, boolean force) { } try (Connection conn = dataSource.getConnection()) { - // Pre-check: validate all skill names first + // 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 @@ -599,13 +591,13 @@ public boolean save(List skills, boolean force) { logger.debug("Deleted existing skill for overwrite: {}", skillName); } - // Insert skill - insertSkill(conn, skill); + // Insert skill and get generated skill_id + long skillId = insertSkill(conn, skill); - // Insert resources - insertResources(conn, skillName, skill.getResources()); + // Insert resources using skill_id + insertResources(conn, skillId, skill.getResources()); - logger.info("Successfully saved skill: {}", skillName); + logger.info("Successfully saved skill: {} (skill_id={})", skillName, skillId); } conn.commit(); @@ -615,7 +607,7 @@ public boolean save(List skills, boolean force) { conn.rollback(); throw e; } finally { - conn.setAutoCommit(true); + restoreAutoCommit(conn); } } catch (SQLException e) { @@ -625,95 +617,106 @@ public boolean save(List skills, boolean force) { } /** - * Insert a skill into the database. + * 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 void insertSkill(Connection conn, AgentSkill skill) throws SQLException { + 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)) { + 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. + * Insert resources for a skill using batch processing. * *

- * This method creates a single PreparedStatement outside the loop and reuses it - * for all resources to improve performance by avoiding repeated SQL parsing. + * 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 skillName the skill name + * @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, String skillName, Map resources) + private void insertResources(Connection conn, long skillId, Map resources) throws SQLException { if (resources == null || resources.isEmpty()) { - logger.debug("No resources to insert for skill: {}", skillName); + logger.debug("No resources to insert for skill_id: {}", skillId); return; } - // Validate all resource paths first - for (String path : resources.keySet()) { - validateResourcePath(path); - } + // Note: Resource paths are validated in save() before transaction starts String insertSql = "INSERT INTO " + getFullTableName(resourcesTableName) - + " (skill_name, resource_path, resource_content) VALUES (?, ?, ?)"; - - int insertedCount = 0; + + " (skill_id, resource_path, resource_content) VALUES (?, ?, ?)"; - // Create PreparedStatement once outside the loop and reuse it + // 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.setString(1, skillName); + stmt.setLong(1, skillId); stmt.setString(2, path); stmt.setString(3, content); - int affected = stmt.executeUpdate(); - if (affected > 0) { + 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++; - logger.debug("Inserted resource '{}' for skill '{}'", path, skillName); - } else { - logger.warn("Failed to insert resource '{}' for skill '{}'", path, skillName); + } else if (results[i] == Statement.EXECUTE_FAILED) { + logger.error("Failed to insert resource at batch index {}", i); } - - // Clear parameters for next iteration - stmt.clearParameters(); } - } - logger.debug( - "Inserted {} resources for skill '{}' (total: {})", - insertedCount, - skillName, - resources.size()); - - if (insertedCount != resources.size()) { - throw new SQLException( - "Failed to insert all resources for skill '" - + skillName - + "'. Expected: " - + resources.size() - + ", Inserted: " - + insertedCount); + 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); + } } } @@ -742,7 +745,7 @@ public boolean delete(String skillName) { conn.rollback(); throw e; } finally { - conn.setAutoCommit(true); + restoreAutoCommit(conn); } } catch (SQLException e) { @@ -754,21 +757,16 @@ public boolean delete(String skillName) { /** * 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 resources first (if foreign key doesn't have CASCADE) - String deleteResourcesSql = - "DELETE FROM " + getFullTableName(resourcesTableName) + " WHERE skill_name = ?"; - - try (PreparedStatement stmt = conn.prepareStatement(deleteResourcesSql)) { - stmt.setString(1, skillName); - stmt.executeUpdate(); - } - - // Delete skill + // Delete skill by name - resources will be deleted via ON DELETE CASCADE String deleteSkillSql = "DELETE FROM " + getFullTableName(skillsTableName) + " WHERE name = ?"; @@ -877,21 +875,19 @@ public DataSource getDataSource() { /** * 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() { - String deleteResourcesSql = "DELETE FROM " + getFullTableName(resourcesTableName); + // 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 resources first - try (PreparedStatement stmt = conn.prepareStatement(deleteResourcesSql)) { - stmt.executeUpdate(); - } - - // Delete all skills + // Delete all skills (resources are deleted via CASCADE) int deleted; try (PreparedStatement stmt = conn.prepareStatement(deleteSkillsSql)) { deleted = stmt.executeUpdate(); @@ -905,7 +901,7 @@ public int clearAllSkills() { conn.rollback(); throw e; } finally { - conn.setAutoCommit(true); + restoreAutoCommit(conn); } } catch (SQLException e) { @@ -913,6 +909,24 @@ public int clearAllSkills() { } } + /** + * 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. * @@ -982,196 +996,4 @@ private void validateIdentifier(String identifier, String identifierType) { + identifier); } } - - /** - * Creates a new Builder instance for constructing MysqlSkillRepository. - * - *

- * The builder pattern provides a fluent API for configuring the repository - * with custom settings. - * - *

- * Example usage: - * - *

{@code
-     * MysqlSkillRepository repo = MysqlSkillRepository.builder()
-     *         .dataSource(dataSource)
-     *         .databaseName("my_database")
-     *         .skillsTableName("my_skills")
-     *         .createIfNotExist(true)
-     *         .writeable(true)
-     *         .build();
-     * }
- * - * @return a new Builder instance - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Builder class for constructing MysqlSkillRepository instances. - * - *

- * This builder provides a fluent API for configuring all aspects of the - * repository, - * including database connection, table names, and behavior options. - * - *

- * Required fields: - *

    - *
  • {@code dataSource} - Must be set before calling build() - *
- * - *

- * Optional fields with defaults: - *

    - *
  • {@code databaseName} - defaults to "agentscope" - *
  • {@code skillsTableName} - defaults to "agentscope_skills" - *
  • {@code resourcesTableName} - defaults to "agentscope_skill_resources" - *
  • {@code createIfNotExist} - defaults to false - *
  • {@code writeable} - defaults to true - *
- * - *

- * Example: - * - *

{@code
-     * MysqlSkillRepository repo = MysqlSkillRepository.builder()
-     *         .dataSource(dataSource)
-     *         .databaseName("custom_db")
-     *         .skillsTableName("custom_skills")
-     *         .resourcesTableName("custom_resources")
-     *         .createIfNotExist(true)
-     *         .writeable(true)
-     *         .build();
-     * }
- */ - public static class Builder { - - private 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 = false; - private boolean writeable = true; - - /** - * Creates a new Builder instance with default values. - */ - public Builder() { - // Default constructor with default values - } - - /** - * Sets the DataSource for database connections. - * - *

- * This is a required field and must be set before calling build(). - * - * @param dataSource the DataSource to use (must not be null) - * @return this builder for method chaining - */ - public Builder dataSource(DataSource dataSource) { - this.dataSource = dataSource; - return this; - } - - /** - * Sets the database name for storing skills. - * - *

- * If not set, defaults to "agentscope". - * - * @param databaseName the database name (uses default if null or empty) - * @return this builder for method chaining - */ - public Builder databaseName(String databaseName) { - this.databaseName = databaseName; - return this; - } - - /** - * Sets the table name for storing skills. - * - *

- * If not set, defaults to "agentscope_skills". - * - * @param skillsTableName the skills table name (uses default if null or empty) - * @return this builder for method chaining - */ - public Builder skillsTableName(String skillsTableName) { - this.skillsTableName = skillsTableName; - return this; - } - - /** - * Sets the table name for storing skill resources. - * - *

- * If not set, defaults to "agentscope_skill_resources". - * - * @param resourcesTableName the resources table name (uses default if null or - * empty) - * @return this builder for method chaining - */ - public Builder resourcesTableName(String resourcesTableName) { - this.resourcesTableName = resourcesTableName; - return this; - } - - /** - * Sets whether to automatically create the database and tables if they don't - * exist. - * - *

- * If set to true, the database and tables will be created during construction - * if they don't already exist. If false (default), an exception will be thrown - * if the database or tables are missing. - * - * @param createIfNotExist true to auto-create database and tables - * @return this builder for method chaining - */ - public Builder createIfNotExist(boolean createIfNotExist) { - this.createIfNotExist = createIfNotExist; - return this; - } - - /** - * Sets whether the repository supports write operations. - * - *

- * If set to false, save and delete operations will be rejected. - * Defaults to true. - * - * @param writeable true to allow write operations - * @return this builder for method chaining - */ - public Builder writeable(boolean writeable) { - this.writeable = writeable; - return this; - } - - /** - * Builds and returns a new MysqlSkillRepository instance. - * - *

- * This method validates that all required fields are set and creates - * the repository with the configured options. - * - * @return a new MysqlSkillRepository instance - * @throws IllegalArgumentException if dataSource is null - * @throws IllegalStateException if createIfNotExist is false and - * database/tables don't 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 index c2019d252..ba6f71fe3 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -69,6 +70,8 @@ public class MysqlSkillRepositoryTest { @Mock private ResultSet mockResultSet; + @Mock private ResultSet mockGeneratedKeysResultSet; + private AutoCloseable mockitoCloseable; @BeforeEach @@ -76,6 +79,12 @@ 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 @@ -96,16 +105,7 @@ class ConstructorTests { void testConstructorWithNullDataSource() { assertThrows( IllegalArgumentException.class, - () -> new MysqlSkillRepository(null), - "DataSource cannot be null"); - } - - @Test - @DisplayName("Should throw exception when DataSource is null with createIfNotExist flag") - void testConstructorWithNullDataSourceAndCreateIfNotExist() { - assertThrows( - IllegalArgumentException.class, - () -> new MysqlSkillRepository(null, true), + () -> new MysqlSkillRepository(null, true, true), "DataSource cannot be null"); } @@ -114,7 +114,7 @@ void testConstructorWithNullDataSourceAndCreateIfNotExist() { void testConstructorWithCreateIfNotExistTrue() throws SQLException { when(mockStatement.execute()).thenReturn(true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); assertEquals("agentscope", repo.getDatabaseName()); assertEquals("agentscope_skills", repo.getSkillsTableName()); @@ -123,6 +123,17 @@ void testConstructorWithCreateIfNotExistTrue() throws SQLException { 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") @@ -132,7 +143,7 @@ void testConstructorWithDatabaseNotExist() throws SQLException { assertThrows( IllegalStateException.class, - () -> new MysqlSkillRepository(mockDataSource, false), + () -> new MysqlSkillRepository(mockDataSource, false, true), "Database does not exist"); } @@ -145,7 +156,7 @@ void testConstructorWithSkillsTableNotExist() throws SQLException { assertThrows( IllegalStateException.class, - () -> new MysqlSkillRepository(mockDataSource, false), + () -> new MysqlSkillRepository(mockDataSource, false, true), "Table does not exist"); } @@ -158,7 +169,7 @@ void testConstructorWithResourcesTableNotExist() throws SQLException { assertThrows( IllegalStateException.class, - () -> new MysqlSkillRepository(mockDataSource, false), + () -> new MysqlSkillRepository(mockDataSource, false, true), "Table does not exist"); } @@ -169,7 +180,7 @@ void testConstructorWithAllTablesExist() throws SQLException { // database exists, skills table exists, resources table exists when(mockResultSet.next()).thenReturn(true, true, true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, false); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, false, true); assertEquals("agentscope", repo.getDatabaseName()); assertEquals("agentscope_skills", repo.getSkillsTableName()); @@ -376,7 +387,7 @@ class SkillNameValidationTests { @BeforeEach void setUp() throws SQLException { when(mockStatement.execute()).thenReturn(true); - repo = new MysqlSkillRepository(mockDataSource, true); + repo = new MysqlSkillRepository(mockDataSource, true, true); } @Test @@ -428,7 +439,7 @@ class CrudOperationTests { @BeforeEach void setUp() throws SQLException { when(mockStatement.execute()).thenReturn(true); - repo = new MysqlSkillRepository(mockDataSource, true); + repo = new MysqlSkillRepository(mockDataSource, true, true); } @Test @@ -508,11 +519,12 @@ void testSaveSkill() throws SQLException { @Test @DisplayName("Should save skill with resources") void testSaveSkillWithResources() throws SQLException { - // Mock executeUpdate for both skill insertion and resource insertions - // Note: insertResources uses executeUpdate() in a loop, not batch processing + // 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( @@ -525,8 +537,10 @@ void testSaveSkillWithResources() throws SQLException { boolean saved = repo.save(List.of(skill), false); assertTrue(saved); - // Verify executeUpdate was called: 1 for skill insert + 2 for resource inserts - verify(mockStatement, atLeast(3)).executeUpdate(); + // 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 @@ -671,7 +685,7 @@ void testDeleteWhenReadOnly() throws SQLException { void testSetWriteable() throws SQLException { when(mockStatement.execute()).thenReturn(true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); assertTrue(repo.isWriteable()); @@ -694,7 +708,7 @@ class RepositoryInfoTests { void testGetRepositoryInfo() throws SQLException { when(mockStatement.execute()).thenReturn(true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); AgentSkillRepositoryInfo info = repo.getRepositoryInfo(); @@ -709,7 +723,7 @@ void testGetRepositoryInfo() throws SQLException { void testGetSource() throws SQLException { when(mockStatement.execute()).thenReturn(true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); String source = repo.getSource(); @@ -747,7 +761,7 @@ class CloseAndCleanupTests { void testClose() throws SQLException { when(mockStatement.execute()).thenReturn(true); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); repo.close(); // Should not throw exception @@ -760,155 +774,10 @@ void testClearAllSkills() throws SQLException { when(mockStatement.execute()).thenReturn(true); when(mockStatement.executeUpdate()).thenReturn(5); - MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true); + MysqlSkillRepository repo = new MysqlSkillRepository(mockDataSource, true, true); int deleted = repo.clearAllSkills(); assertEquals(5, deleted); } } - - // ==================== Builder Tests ==================== - - @Nested - @DisplayName("Builder Tests") - class BuilderTests { - - @Test - @DisplayName("Should create repository with builder using defaults") - void testBuilderWithDefaults() throws SQLException { - when(mockStatement.execute()).thenReturn(true); - - MysqlSkillRepository repo = - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .createIfNotExist(true) - .build(); - - assertNotNull(repo); - 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 builder using custom values") - void testBuilderWithCustomValues() throws SQLException { - when(mockStatement.execute()).thenReturn(true); - - MysqlSkillRepository repo = - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .databaseName("custom_db") - .skillsTableName("custom_skills") - .resourcesTableName("custom_resources") - .createIfNotExist(true) - .writeable(false) - .build(); - - assertNotNull(repo); - assertEquals("custom_db", repo.getDatabaseName()); - assertEquals("custom_skills", repo.getSkillsTableName()); - assertEquals("custom_resources", repo.getResourcesTableName()); - assertFalse(repo.isWriteable()); - } - - @Test - @DisplayName("Should throw exception when dataSource is null in builder") - void testBuilderWithNullDataSource() { - assertThrows( - IllegalArgumentException.class, - () -> - MysqlSkillRepository.builder() - .dataSource(null) - .createIfNotExist(true) - .build(), - "DataSource cannot be null"); - } - - @Test - @DisplayName("Should use defaults when null values provided to builder") - void testBuilderWithNullValues() throws SQLException { - when(mockStatement.execute()).thenReturn(true); - - MysqlSkillRepository repo = - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .databaseName(null) - .skillsTableName(null) - .resourcesTableName(null) - .createIfNotExist(true) - .build(); - - assertEquals("agentscope", repo.getDatabaseName()); - assertEquals("agentscope_skills", repo.getSkillsTableName()); - assertEquals("agentscope_skill_resources", repo.getResourcesTableName()); - } - - @Test - @DisplayName("Should create read-only repository with builder") - void testBuilderReadOnly() throws SQLException { - when(mockStatement.execute()).thenReturn(true); - - MysqlSkillRepository repo = - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .createIfNotExist(true) - .writeable(false) - .build(); - - assertFalse(repo.isWriteable()); - } - - @Test - @DisplayName("Should support method chaining in builder") - void testBuilderMethodChaining() throws SQLException { - when(mockStatement.execute()).thenReturn(true); - - // Verify that all methods return the same builder instance for chaining - MysqlSkillRepository.Builder builder = MysqlSkillRepository.builder(); - MysqlSkillRepository.Builder returned = - builder.dataSource(mockDataSource) - .databaseName("db") - .skillsTableName("skills") - .resourcesTableName("resources") - .createIfNotExist(true) - .writeable(true); - - // All calls should return the same builder - assertEquals(builder, returned.dataSource(mockDataSource)); - } - - @Test - @DisplayName("Should reject invalid identifiers in builder") - void testBuilderWithInvalidIdentifiers() { - assertThrows( - IllegalArgumentException.class, - () -> - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .databaseName("db; DROP TABLE users;") - .createIfNotExist(true) - .build(), - "Database name contains invalid characters"); - } - - @Test - @DisplayName( - "Should throw exception when database does not exist and createIfNotExist=false") - void testBuilderWithoutCreateAndDatabaseNotExist() throws SQLException { - when(mockStatement.executeQuery()).thenReturn(mockResultSet); - when(mockResultSet.next()).thenReturn(false); - - assertThrows( - IllegalStateException.class, - () -> - MysqlSkillRepository.builder() - .dataSource(mockDataSource) - .createIfNotExist(false) - .build(), - "Database does not exist"); - } - } } diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index 2f702f56b..9ac4aab5c 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -250,11 +250,6 @@ skillBox.codeExecution() Skills need to remain available after application restart, or be shared across different environments. Persistence storage supports: -- File system storage -- database storage - - MySQL Database Storage (agentscope-extensions-skill-mysql-repository) -- Git repository (not yet implemented) - #### File System Storag ```java @@ -266,19 +261,18 @@ AgentSkill loaded = repo.getSkill("data_analysis"); #### MySQL Database Storage ```java -// Using constructor +// Using simple constructor with default database/table names DataSource dataSource = createDataSource(); -MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); - -// Using builder pattern -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .databaseName("my_database") - .skillsTableName("my_skills") - .resourcesTableName("my_resources") - .createIfNotExist(true) - .writeable(true) - .build(); +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); + +// Using full constructor for custom configuration +MysqlSkillRepository repo = new MysqlSkillRepository( + dataSource, + "my_database", + "my_skills", + "my_resources", + true, // createIfNotExist + true); // writeable repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index d6d905cb0..dd37df161 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -249,11 +249,6 @@ skillBox.codeExecution() Skills 需要在应用重启后保持可用,或者在不同环境间共享。持久化存储支持: -- 文件系统存储 -- 数据库存储 - - MySQL数据库存储 (agentscope-extensions-skill-mysql-repository) -- Git 仓库 (暂未实现) - #### 文件系统存储 ```java @@ -265,19 +260,18 @@ AgentSkill loaded = repo.getSkill("data_analysis"); #### MySQL数据库存储 ```java -// Using constructor +// 使用简单构造函数(使用默认数据库/表名) DataSource dataSource = createDataSource(); -MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true); - -// Using builder pattern -MysqlSkillRepository repo = MysqlSkillRepository.builder() - .dataSource(dataSource) - .databaseName("my_database") - .skillsTableName("my_skills") - .resourcesTableName("my_resources") - .createIfNotExist(true) - .writeable(true) - .build(); +MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); + +// 使用完整构造函数进行自定义配置 +MysqlSkillRepository repo = new MysqlSkillRepository( + dataSource, + "my_database", + "my_skills", + "my_resources", + true, // createIfNotExist + true); // writeable repo.save(List.of(skill), false); AgentSkill loaded = repo.getSkill("data_analysis"); From 4901390a32c798ca9101c717684d5202055ec82a Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Thu, 29 Jan 2026 18:17:15 +0800 Subject: [PATCH 7/8] refactor(mysql): implement Builder pattern for MysqlSkillRepository configuration --- .../mysql/MysqlSkillRepository.java | 152 ++++++++- .../mysql/MysqlSkillRepositoryTest.java | 304 ++++++++++++++---- docs/en/task/agent-skill.md | 20 +- docs/zh/task/agent-skill.md | 16 +- 4 files changed, 393 insertions(+), 99 deletions(-) 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 index 930be80b8..887bded25 100644 --- 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 @@ -89,14 +89,14 @@ * DataSource dataSource = createDataSource(); * MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); * - * // Using full constructor for custom configuration - * MysqlSkillRepository repo = new MysqlSkillRepository( - * dataSource, - * "my_database", - * "my_skills", - * "my_resources", - * true, // createIfNotExist - * true); // writeable + * // 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); @@ -178,6 +178,10 @@ public MysqlSkillRepository( * 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) @@ -193,7 +197,7 @@ public MysqlSkillRepository( * @throws IllegalStateException if createIfNotExist is false and * database/tables do not exist */ - public MysqlSkillRepository( + private MysqlSkillRepository( DataSource dataSource, String databaseName, String skillsTableName, @@ -996,4 +1000,134 @@ private void validateIdentifier(String identifier, String identifierType) { + 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 index ba6f71fe3..96e33c405 100644 --- 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 @@ -187,18 +187,18 @@ void testConstructorWithAllTablesExist() throws SQLException { } @Test - @DisplayName("Should create repository with custom names") + @DisplayName("Should create repository with custom names using Builder") void testConstructorWithCustomNames() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, - "custom_db", - "custom_skills", - "custom_resources", - true, - true); + 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()); @@ -206,12 +206,18 @@ void testConstructorWithCustomNames() throws SQLException { } @Test - @DisplayName("Should use default names when null provided") + @DisplayName("Should use default names when null provided via Builder") void testConstructorWithNullNamesUsesDefaults() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository(mockDataSource, null, null, null, true, true); + MysqlSkillRepository.builder(mockDataSource) + .databaseName(null) + .skillsTableName(null) + .resourcesTableName(null) + .createIfNotExist(true) + .writeable(true) + .build(); assertEquals("agentscope", repo.getDatabaseName()); assertEquals("agentscope_skills", repo.getSkillsTableName()); @@ -219,12 +225,18 @@ void testConstructorWithNullNamesUsesDefaults() throws SQLException { } @Test - @DisplayName("Should use default names when empty string provided") + @DisplayName("Should use default names when empty string provided via Builder") void testConstructorWithEmptyNamesUsesDefaults() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository(mockDataSource, " ", " ", " ", true, true); + MysqlSkillRepository.builder(mockDataSource) + .databaseName(" ") + .skillsTableName(" ") + .resourcesTableName(" ") + .createIfNotExist(true) + .writeable(true) + .build(); assertEquals("agentscope", repo.getDatabaseName()); assertEquals("agentscope_skills", repo.getSkillsTableName()); @@ -232,6 +244,143 @@ void testConstructorWithEmptyNamesUsesDefaults() throws SQLException { } } + // ==================== 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 @@ -244,13 +393,11 @@ void testRejectsDatabaseNameWithSemicolon() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, - "db; DROP DATABASE mysql; --", - "skills", - "resources", - true, - true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName("db; DROP DATABASE mysql; --") + .skillsTableName("skills") + .resourcesTableName("resources") + .build(), "Database name contains invalid characters"); } @@ -260,13 +407,11 @@ void testRejectsTableNameWithSemicolon() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, - "valid_db", - "table; DROP TABLE users; --", - "resources", - true, - true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName("valid_db") + .skillsTableName("table; DROP TABLE users; --") + .resourcesTableName("resources") + .build(), "Table name contains invalid characters"); } @@ -276,8 +421,11 @@ void testRejectsDatabaseNameWithSpace() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, "db name", "skills", "resources", true, true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName("db name") + .skillsTableName("skills") + .resourcesTableName("resources") + .build(), "Database name contains invalid characters"); } @@ -287,13 +435,11 @@ void testRejectsTableNameWithSpace() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, - "valid_db", - "table name", - "resources", - true, - true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName("valid_db") + .skillsTableName("table name") + .resourcesTableName("resources") + .build(), "Table name contains invalid characters"); } @@ -303,8 +449,11 @@ void testRejectsDatabaseNameStartingWithNumber() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, "123db", "skills", "resources", true, true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName("123db") + .skillsTableName("skills") + .resourcesTableName("resources") + .build(), "Database name contains invalid characters"); } @@ -315,8 +464,11 @@ void testRejectsDatabaseNameExceedingMaxLength() { assertThrows( IllegalArgumentException.class, () -> - new MysqlSkillRepository( - mockDataSource, longName, "skills", "resources", true, true), + MysqlSkillRepository.builder(mockDataSource) + .databaseName(longName) + .skillsTableName("skills") + .resourcesTableName("resources") + .build(), "Database name cannot exceed 64 characters"); } @@ -326,13 +478,13 @@ void testAcceptsValidIdentifiers() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, - "my_database_123", - "my_skills_456", - "my_resources_789", - true, - true); + 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()); @@ -345,13 +497,13 @@ void testAcceptsNamesStartingWithUnderscore() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, - "_private_db", - "_private_skills", - "_private_resources", - true, - true); + 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()); @@ -364,13 +516,13 @@ void testAcceptsMaxLengthNames() throws SQLException { String maxLengthName = "a".repeat(64); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, - maxLengthName, - maxLengthName, - maxLengthName, - true, - true); + MysqlSkillRepository.builder(mockDataSource) + .databaseName(maxLengthName) + .skillsTableName(maxLengthName) + .resourcesTableName(maxLengthName) + .createIfNotExist(true) + .writeable(true) + .build(); assertEquals(maxLengthName, repo.getDatabaseName()); } @@ -657,8 +809,13 @@ void testSaveWhenReadOnly() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, "db", "skills", "resources", true, false); + 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); @@ -672,8 +829,13 @@ void testDeleteWhenReadOnly() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, "db", "skills", "resources", true, false); + MysqlSkillRepository.builder(mockDataSource) + .databaseName("db") + .skillsTableName("skills") + .resourcesTableName("resources") + .createIfNotExist(true) + .writeable(false) + .build(); boolean deleted = repo.delete("test-skill"); @@ -736,13 +898,13 @@ void testGetSourceWithCustomNames() throws SQLException { when(mockStatement.execute()).thenReturn(true); MysqlSkillRepository repo = - new MysqlSkillRepository( - mockDataSource, - "custom_db", - "custom_skills", - "custom_resources", - true, - true); + MysqlSkillRepository.builder(mockDataSource) + .databaseName("custom_db") + .skillsTableName("custom_skills") + .resourcesTableName("custom_resources") + .createIfNotExist(true) + .writeable(true) + .build(); String source = repo.getSource(); diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index 9ac4aab5c..bd44dcb45 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -168,8 +168,6 @@ ReActAgent agent = ReActAgent.builder() ### Feature 1: Progressive Disclosure of Tools -Bind Tools to Skills for on-demand activation. Avoids context pollution from pre-registering all Tools, only passing relevant Tools to LLM when the Skill is actively used. - **Example Code**: ```java @@ -250,7 +248,7 @@ skillBox.codeExecution() Skills need to remain available after application restart, or be shared across different environments. Persistence storage supports: -#### File System Storag +#### File System Storage ```java AgentSkillRepository repo = new FileSystemSkillRepository(Path.of("./skills")); @@ -265,14 +263,14 @@ AgentSkill loaded = repo.getSkill("data_analysis"); DataSource dataSource = createDataSource(); MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); -// Using full constructor for custom configuration -MysqlSkillRepository repo = new MysqlSkillRepository( - dataSource, - "my_database", - "my_skills", - "my_resources", - true, // createIfNotExist - true); // writeable +// 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"); diff --git a/docs/zh/task/agent-skill.md b/docs/zh/task/agent-skill.md index dd37df161..c4b76efd2 100644 --- a/docs/zh/task/agent-skill.md +++ b/docs/zh/task/agent-skill.md @@ -264,14 +264,14 @@ AgentSkill loaded = repo.getSkill("data_analysis"); DataSource dataSource = createDataSource(); MysqlSkillRepository repo = new MysqlSkillRepository(dataSource, true, true); -// 使用完整构造函数进行自定义配置 -MysqlSkillRepository repo = new MysqlSkillRepository( - dataSource, - "my_database", - "my_skills", - "my_resources", - true, // createIfNotExist - true); // writeable +// 使用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"); From e828ee89ae8f4c12ecb718ef320fd8c208934ddc Mon Sep 17 00:00:00 2001 From: jianjun159 <128395511+jianjun159@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:11:13 +0800 Subject: [PATCH 8/8] docs(mysql): update agent-skill documentation to include progressive disclosure of tools --- docs/en/task/agent-skill.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/en/task/agent-skill.md b/docs/en/task/agent-skill.md index bd44dcb45..9f520c3b1 100644 --- a/docs/en/task/agent-skill.md +++ b/docs/en/task/agent-skill.md @@ -168,6 +168,10 @@ ReActAgent agent = ReActAgent.builder() ### Feature 1: Progressive Disclosure of Tools +Bind Tools to Skills for on-demand activation. Avoids context pollution from pre-registering all Tools, only passing relevant Tools to LLM when the Skill is actively used. + +**Lifecycle of Progressively Disclosed Tools**: Tool lifecycle remains consistent with Skill lifecycle. Once a Skill is activated, Tools remain available throughout the entire session, avoiding the call failures caused by Tool deactivation after each conversation round in the old mechanism. + **Example Code**: ```java