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 @@
+
+
+
+
+ * 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
+ * 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
+ * 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:
+ *
+ *
+ * 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:
+ *
+ *
+ * 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:
+ *
- * 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
+ * These tests use mocked DataSource and Connection to verify the behavior of
* MysqlSkillRepository without requiring an actual MySQL database.
*
- * Test categories:
+ *
+ * Test categories:
*
* 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
- * 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 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:
- *
- *
- * 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:
- *
- *
- * 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
+ * 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:
+ *
+ *
+ * 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
{@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.
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * {@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.
+ *
+ *
+ *
+ */
+@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
- *
*/
@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
- *
*
@@ -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.
*
* {@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.
- *
- *
- *
- *
- *
- *
- *
- * {@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.
- *
- * {@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.
+ *
+ *