You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shardingsphere.apache.org by zh...@apache.org on 2026/10/14 16:30:36 UTC
(shardingsphere) branch master updated: Add MCP unit tests (#40167)
This is an automated email from the ASF dual-hosted git repository.
terrymanu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shardingsphere.git
The following commit(s) were added to refs/heads/master by this push:
new 01446d4fad4 Add MCP unit tests (#40167)
01446d4fad4 is described below
commit 01446d4fad48450e7fc3eb3b3631b25d6bf411df
Author: Liang Zhang <zh...@apache.org>
AuthorDate: Thu Oct 15 00:30:27 2026 +0800
Add MCP unit tests (#40167)
* Add MCP unit tests
* Add MCP unit tests
---
.../transport/HttpTransportOriginUtilsTest.java | 27 +-
.../transport/MCPTransportErrorFactoryTest.java | 8 +
.../MCPCompletionSpecificationFactoryTest.java | 23 +
.../MCPResourceSpecificationFactoryTest.java | 19 +
.../tool/MCPToolSpecificationFactoryTest.java | 20 +
.../http/SessionAttributionResolverTest.java | 13 +
.../SessionManagedStdioTransportProviderTest.java | 86 +++
.../core/completion/MCPCompletionServiceTest.java | 11 +
.../core/handler/core/CoreHandlerProviderTest.java | 98 +++
.../GovernanceMetadataQueryServiceTest.java | 25 +
.../core/protocol/error/MCPErrorConverterTest.java | 61 ++
.../core/resource/MCPResourceControllerTest.java | 10 +
.../tool/handler/ToolDefinitionRegistryTest.java | 145 +++++
.../execute/ExecuteUpdateToolHandlerTest.java | 36 ++
.../execute/MCPJdbcStatementExecutorTest.java | 84 +++
.../MCPJdbcTransactionResourceManagerTest.java | 30 +
.../MCPJdbcTransactionStatementExecutorTest.java | 23 +
.../handler/execute/MCPSQLExecutionFacadeTest.java | 665 +++++++++++++++++++++
.../tool/model/EncryptWorkflowRequestTest.java | 12 +
.../EncryptWorkflowApplyArtifactValidatorTest.java | 21 +
.../EncryptWorkflowValidationServiceTest.java | 33 +
.../ReadwriteSplittingRuleWorkflowRequestTest.java | 7 +
...eadwriteSplittingStatusWorkflowRequestTest.java | 12 +
...plittingAlgorithmRecommendationServiceTest.java | 41 +-
.../ReadwriteSplittingInspectionServiceTest.java | 14 +
...SplittingStatusWorkflowPlanningServiceTest.java | 10 +
.../ShadowAlgorithmCleanupWorkflowRequestTest.java | 12 +
.../ShadowDefaultAlgorithmWorkflowRequestTest.java | 19 +
.../tool/model/ShadowRuleWorkflowRequestTest.java | 20 +
.../ShadowAlgorithmRecommendationServiceTest.java | 32 +-
.../tool/service/ShadowInspectionServiceTest.java | 28 +
.../handler/ShardingTableResourceHandlerTest.java | 3 +-
.../PlanShardingTableRuleToolHandlerTest.java | 3 +-
...ShardingAlgorithmRecommendationServiceTest.java | 25 +
.../ShardingDistSQLPlanningServiceTest.java | 37 ++
.../ShardingWorkflowPlanningServiceTest.java | 60 +-
.../registry/MCPRegistryMetadataCommandTest.java | 83 ++-
.../jdbc/MCPJdbcDatabaseProfileLoaderTest.java | 147 +++++
.../RuntimeDatabaseValidationServiceTest.java | 43 ++
...MCPCompletionTargetDescriptorValidatorTest.java | 63 +-
.../descriptor/MCPDescriptorCatalogIndexTest.java | 12 +
.../descriptor/MCPDescriptorCatalogLoaderTest.java | 116 ++++
.../MCPPromptDescriptorValidatorTest.java | 31 +-
.../MCPResourceDescriptorValidatorTest.java | 49 +-
...PResourceNavigationDescriptorValidatorTest.java | 46 +-
.../MCPToolDescriptorCatalogValidatorTest.java | 142 ++++-
.../MCPToolInputSchemaValidatorTest.java | 48 +-
.../MCPToolOutputSchemaValidatorTest.java | 65 ++
.../validator/ISOOffsetDateTimeValidatorTest.java | 60 ++
.../validator/MCPDescriptionValidatorTest.java | 70 +++
.../MCPPromptGuidanceMetaValidatorTest.java | 65 ++
.../resource/MCPUriPathSegmentUtilsTest.java | 20 +-
.../mcp/support/resource/MCPUriTemplateTest.java | 34 +-
.../service/WorkflowLifecycleUtilsTest.java | 45 +-
.../service/WorkflowPlanPayloadBuilderTest.java | 15 +
55 files changed, 2784 insertions(+), 143 deletions(-)
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/HttpTransportOriginUtilsTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/HttpTransportOriginUtilsTest.java
index 8a45de63b8d..283d424258f 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/HttpTransportOriginUtilsTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/HttpTransportOriginUtilsTest.java
@@ -17,25 +17,28 @@
package org.apache.shardingsphere.mcp.bootstrap.transport;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
class HttpTransportOriginUtilsTest {
- @Test
- void assertNormalizeOrigin() {
- assertThat(HttpTransportOriginUtils.normalizeOrigin("HTTPS://Gateway.Example.Test:8443"), is("https://gateway.example.test:8443"));
- }
-
- @Test
- void assertNormalizeOriginWithIpv6Origin() {
- assertThat(HttpTransportOriginUtils.normalizeOrigin("http://[::1]:8080"), is("http://[::1]:8080"));
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertNormalizeOriginArguments")
+ void assertNormalizeOrigin(final String name, final String origin, final String expected) {
+ assertThat(HttpTransportOriginUtils.normalizeOrigin(origin), is(expected));
}
- @Test
- void assertNormalizeOriginWithMalformedOrigin() {
- assertThat(HttpTransportOriginUtils.normalizeOrigin("://bad-origin"), is(""));
+ private static Stream<Arguments> assertNormalizeOriginArguments() {
+ return Stream.of(
+ Arguments.of("HTTPS host normalization", "HTTPS://Gateway.Example.Test:8443", "https://gateway.example.test:8443"),
+ Arguments.of("IPv6 origin", "http://[::1]:8080", "http://[::1]:8080"),
+ Arguments.of("malformed origin", "://bad-origin", ""),
+ Arguments.of("unsupported scheme", "ftp://foo.example.test", ""));
}
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/MCPTransportErrorFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/MCPTransportErrorFactoryTest.java
index 722f79e38cf..71e85596ccb 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/MCPTransportErrorFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/MCPTransportErrorFactoryTest.java
@@ -31,6 +31,7 @@ import org.apache.shardingsphere.test.infra.framework.extension.log.LogCaptureSe
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import java.sql.SQLException;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -107,4 +108,11 @@ class MCPTransportErrorFactoryTest {
assertThat(actual.getJsonRpcError().code(), is(McpSchema.ErrorCodes.INVALID_PARAMS));
assertThat(actual.getJsonRpcError().message(), is("Invalid request."));
}
+
+ @Test
+ void assertCreateErrorWithDatabaseFailure() {
+ McpError actual = MCPTransportErrorFactory.createError(new SQLException("foo_message"));
+ assertThat(actual.getJsonRpcError().code(), is(McpSchema.ErrorCodes.INTERNAL_ERROR));
+ assertThat(actual.getJsonRpcError().message(), is("MCP query failed."));
+ }
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/completion/MCPCompletionSpecificationFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/completion/MCPCompletionSpecificationFactoryTest.java
index 22e0b0f85a4..ad3705edf9d 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/completion/MCPCompletionSpecificationFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/completion/MCPCompletionSpecificationFactoryTest.java
@@ -23,7 +23,10 @@ import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import org.apache.shardingsphere.database.connector.core.metadata.identifier.IdentifierCasePolicyFactory;
import org.apache.shardingsphere.infra.metadata.identifier.DatabaseIdentifierContext;
+import org.apache.shardingsphere.mcp.api.capability.completion.MCPCompletionTargetDescriptor;
import org.apache.shardingsphere.mcp.api.session.MCPSessionIdentity;
+import org.apache.shardingsphere.mcp.core.completion.MCPCompletionResult;
+import org.apache.shardingsphere.mcp.core.completion.MCPCompletionService;
import org.apache.shardingsphere.mcp.core.context.MCPRuntimeContext;
import org.apache.shardingsphere.mcp.core.session.MCPSessionManager;
import org.apache.shardingsphere.mcp.core.workflow.InMemoryWorkflowSessionStore;
@@ -36,6 +39,7 @@ import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnaps
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowKind;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowLifecycle;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
import java.time.Instant;
import java.util.List;
@@ -46,7 +50,10 @@ import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.when;
class MCPCompletionSpecificationFactoryTest {
@@ -193,6 +200,22 @@ class MCPCompletionSpecificationFactoryTest {
assertThat(actual.completion().values(), is(List.of("plan-new", "plan-old")));
}
+ @Test
+ void assertCompleteWithContextArguments() {
+ McpSchema.ResourceReference reference = new McpSchema.ResourceReference("shardingsphere://databases/{database}/schemas/{schema}/tables/{table}");
+ Map<String, String> contextArgs = Map.of("database", "foo_db", "schema", "foo_schema");
+ try (
+ MockedConstruction<MCPCompletionService> mocked = mockConstruction(MCPCompletionService.class,
+ (mock, context) -> when(mock.complete(eq("session-1"), any(MCPCompletionTargetDescriptor.class), eq("table"), eq("foo"), eq(contextArgs)))
+ .thenReturn(new MCPCompletionResult(List.of("foo_table"), 1, false, Map.of())))) {
+ SyncCompletionSpecification actualSpecification = findCompletion(new MCPCompletionSpecificationFactory(mock(MCPRuntimeContext.class)).createCompletionSpecifications(), reference);
+ McpSchema.CompleteResult actual = actualSpecification.completionHandler().apply(createExchange("session-1"),
+ new McpSchema.CompleteRequest(reference, new McpSchema.CompleteRequest.CompleteArgument("table", "foo"), new McpSchema.CompleteRequest.CompleteContext(contextArgs)));
+ assertThat(actual.completion().values(), is(List.of("foo_table")));
+ assertThat(actual.completion().total(), is(1));
+ }
+ }
+
private MCPCompletionSpecificationFactory createFactory(final MCPRuntimeContext runtimeContext) {
return new MCPCompletionSpecificationFactory(runtimeContext);
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/resource/MCPResourceSpecificationFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/resource/MCPResourceSpecificationFactoryTest.java
index 8cf70dbe4dc..ac06b5ef8cd 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/resource/MCPResourceSpecificationFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/resource/MCPResourceSpecificationFactoryTest.java
@@ -192,6 +192,25 @@ class MCPResourceSpecificationFactoryTest {
assertNotNull(actualSpecification.readHandler());
}
+ @Test
+ void assertCreateResourceTemplateSpecificationsWithAnnotations() {
+ MCPResourceDescriptor descriptor = new MCPResourceDescriptor("shardingsphere://foo/{database}", "foo_resource", "Foo Resource", "Foo description.", "application/json",
+ new MCPResourceAnnotations(List.of("user"), 0.6D, "2025-01-01T00:00:00Z"), Map.of("foo_key", "foo_value"));
+ try (MockedStatic<ResourceDefinitionRegistry> mocked = mockStatic(ResourceDefinitionRegistry.class)) {
+ mocked.when(ResourceDefinitionRegistry::getSupportedResourceDescriptors).thenReturn(List.of(descriptor));
+ mocked.when(() -> ResourceDefinitionRegistry.dispatch(any(MCPFeatureRuntimeRequestContext.class), eq("shardingsphere://foo/foo_db")))
+ .thenReturn(Optional.of(new MCPMapPayload(Map.of("foo_field", "foo_value"))));
+ SyncResourceTemplateSpecification actualSpecification = new MCPResourceSpecificationFactory(createRuntimeContext()).createResourceTemplateSpecifications().getFirst();
+ assertThat(actualSpecification.resourceTemplate().annotations().priority(), is(0.6D));
+ assertThat(actualSpecification.resourceTemplate().meta(), is(Map.of("foo_key", "foo_value")));
+ ReadResourceResult actual = actualSpecification.readHandler().apply(createExchange(), new ReadResourceRequest("shardingsphere://foo/foo_db"));
+ assertThat(actual.contents().getFirst().uri(), is("shardingsphere://foo/foo_db"));
+ ArgumentCaptor<MCPFeatureRuntimeRequestContext> requestContextCaptor = ArgumentCaptor.forClass(MCPFeatureRuntimeRequestContext.class);
+ mocked.verify(() -> ResourceDefinitionRegistry.dispatch(requestContextCaptor.capture(), eq("shardingsphere://foo/foo_db")));
+ assertThat(requestContextCaptor.getValue().getSessionIdentity().getSessionId(), is("session-1"));
+ }
+ }
+
private SyncResourceSpecification findResourceSpecification(final Collection<SyncResourceSpecification> specifications, final String uri) {
return specifications.stream().filter(each -> uri.equals(each.resource().uri())).findFirst().orElseThrow();
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
index 075a95db4bc..e2323fe1479 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
@@ -19,7 +19,9 @@ package org.apache.shardingsphere.mcp.bootstrap.transport.capability.tool;
import ch.qos.logback.classic.spi.ThrowableProxy;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
+import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.spec.McpError;
+import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
@@ -180,4 +182,22 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
assertTrue(actual.isError());
}
}
+
+ @Test
+ void assertCreateToolSpecificationsPreservesNestedArgumentsAfterClarification() {
+ try (MockedStatic<ToolDefinitionRegistry> mocked = mockStatic(ToolDefinitionRegistry.class)) {
+ String toolName = "database_gateway_plan_encrypt_rule";
+ MCPToolDefinition definition = mockSupportedTool(mocked,
+ createPlanningToolDescriptor(toolName, Map.of("plan_id", Map.of("type", "string", "description", "Existing workflow plan identifier."))));
+ mocked.when(() -> ToolDefinitionRegistry.dispatch(any(MCPFeatureRuntimeRequestContext.class), eq(definition), any()))
+ .thenReturn(new MCPMapPayload(createClarifyingPayload(createClarifyingQuestion("custom_properties.display-name", "string", false, "Provide display name."))),
+ new MCPMapPayload(Map.of("status", "planned")));
+ McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", "foo_display")));
+ SyncToolSpecification actualSpecification = new MCPToolSpecificationFactory(createRuntimeContext(MCPTransportType.STDIO)).createToolSpecifications().getFirst();
+ CallToolResult actual = actualSpecification.callHandler().apply(exchange, new McpSchema.CallToolRequest(toolName, Map.of("custom_properties", Map.of("foo_key", "foo_value"))));
+ assertThat(actual.structuredContent(), is(Map.of("status", "planned")));
+ mocked.verify(() -> ToolDefinitionRegistry.dispatch(any(MCPFeatureRuntimeRequestContext.class), eq(definition),
+ eq(Map.of("plan_id", "plan-1", "custom_properties", Map.of("foo_key", "foo_value", "display-name", "foo_display")))));
+ }
+ }
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/SessionAttributionResolverTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/SessionAttributionResolverTest.java
index 4e907625891..31471b2b795 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/SessionAttributionResolverTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/SessionAttributionResolverTest.java
@@ -62,6 +62,19 @@ class SessionAttributionResolverTest {
assertSessionIdentity(new SessionAttributionResolver(null).resolve(Map.of(), "session-1"), "", "", Map.of());
}
+ @Test
+ void assertResolveFromRequestWithoutSubject() {
+ SessionAttributionResolver resolver = new SessionAttributionResolver(new SessionAttributionSourceConfiguration("X-Test-Subject", "X-Test-Source", "X-Test-Attr-"));
+ MCPSessionIdentity actual = resolver.resolve(mock(HttpServletRequest.class), "session-1");
+ assertSessionIdentity(actual, "", "", Map.of());
+ }
+
+ @Test
+ void assertGetSummary() {
+ SessionAttributionResolver resolver = new SessionAttributionResolver(new SessionAttributionSourceConfiguration("X-Test-Subject", "X-Test-Source", "X-Test-Attr-"));
+ assertThat(resolver.getSummary(), is("trusted-header:X-Test-Subject"));
+ }
+
private void assertSessionIdentity(final MCPSessionIdentity actual, final String subject, final String source, final Map<String, String> attributes) {
assertThat(actual.getSessionId(), is("session-1"));
assertThat(actual.getSubject(), is(subject));
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/stdio/SessionManagedStdioTransportProviderTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/stdio/SessionManagedStdioTransportProviderTest.java
index 896f40110f6..df48cac96bb 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/stdio/SessionManagedStdioTransportProviderTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/stdio/SessionManagedStdioTransportProviderTest.java
@@ -17,27 +17,42 @@
package org.apache.shardingsphere.mcp.bootstrap.transport.server.stdio;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
+import io.modelcontextprotocol.spec.McpSchema.JSONRPCNotification;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransport;
import org.apache.shardingsphere.mcp.bootstrap.transport.MCPTransportConstants;
import org.apache.shardingsphere.mcp.bootstrap.transport.MCPTransportJsonMapperFactory;
import org.apache.shardingsphere.mcp.core.session.MCPSessionManager;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import reactor.core.publisher.Flux;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.io.InputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SessionManagedStdioTransportProviderTest {
@@ -154,6 +169,77 @@ class SessionManagedStdioTransportProviderTest {
assertFalse(sessionManager.hasSession("session-id"));
}
+ @Test
+ void assertSendMessage() throws IOException {
+ McpJsonMapper jsonMapper = mock(McpJsonMapper.class);
+ JSONRPCNotification message = new JSONRPCNotification("2.0", "foo_method", Map.of());
+ String expectedMessage = "{\"jsonrpc\":\"2.0\",\"method\":\"foo_method\",\"params\":{}}";
+ when(jsonMapper.writeValueAsString(message)).thenReturn(expectedMessage);
+ ByteArrayOutputStream actualOutput = new ByteArrayOutputStream();
+ try (PipedOutputStream inputWriter = new PipedOutputStream(); PipedInputStream input = new PipedInputStream(inputWriter); PrintStream output = new PrintStream(actualOutput)) {
+ InputStream originalInput = System.in;
+ PrintStream originalOutput = System.out;
+ SessionManagedStdioTransportProvider provider;
+ try {
+ System.setIn(input);
+ System.setOut(output);
+ provider = new SessionManagedStdioTransportProvider(new MCPSessionManager(Map.of()), jsonMapper);
+ } finally {
+ System.setIn(originalInput);
+ System.setOut(originalOutput);
+ }
+ McpServerSession.Factory sessionFactory = mock(McpServerSession.Factory.class);
+ McpServerSession session = mock(McpServerSession.class);
+ when(sessionFactory.create(any(McpServerTransport.class))).thenReturn(session);
+ when(session.getId()).thenReturn("foo_session");
+ provider.setSessionFactory(sessionFactory);
+ ArgumentCaptor<McpServerTransport> transportCaptor = ArgumentCaptor.forClass(McpServerTransport.class);
+ verify(sessionFactory).create(transportCaptor.capture());
+ try {
+ transportCaptor.getValue().sendMessage(message).block(Duration.ofSeconds(5L));
+ String expectedOutput = expectedMessage + "\n";
+ String actual = Flux.interval(Duration.ofMillis(10L)).map(ignored -> actualOutput.toString(StandardCharsets.UTF_8))
+ .filter(expectedOutput::equals).next().block(Duration.ofSeconds(5L));
+ assertThat(actual, is(expectedOutput));
+ verify(jsonMapper).writeValueAsString(message);
+ } finally {
+ input.close();
+ transportCaptor.getValue().closeGracefully().block(Duration.ofSeconds(5L));
+ }
+ }
+ }
+
+ @Test
+ void assertUnmarshalFrom() {
+ McpJsonMapper jsonMapper = mock(McpJsonMapper.class);
+ Map<String, Object> data = Map.of("foo_key", "foo_value");
+ TypeRef<Map<String, Object>> typeRef = new TypeRef<>() {
+ };
+ Map<String, Object> expected = Map.of("foo_result", "foo_value");
+ when(jsonMapper.convertValue(data, typeRef)).thenReturn(expected);
+ InputStream originalInput = System.in;
+ SessionManagedStdioTransportProvider provider;
+ try {
+ System.setIn(InputStream.nullInputStream());
+ provider = new SessionManagedStdioTransportProvider(new MCPSessionManager(Map.of()), jsonMapper);
+ } finally {
+ System.setIn(originalInput);
+ }
+ McpServerSession.Factory sessionFactory = mock(McpServerSession.Factory.class);
+ McpServerSession session = mock(McpServerSession.class);
+ when(sessionFactory.create(any(McpServerTransport.class))).thenReturn(session);
+ when(session.getId()).thenReturn("foo_session");
+ provider.setSessionFactory(sessionFactory);
+ ArgumentCaptor<McpServerTransport> transportCaptor = ArgumentCaptor.forClass(McpServerTransport.class);
+ verify(sessionFactory).create(transportCaptor.capture());
+ try {
+ Map<String, Object> actual = transportCaptor.getValue().unmarshalFrom(data, typeRef);
+ assertThat(actual, sameInstance(expected));
+ } finally {
+ transportCaptor.getValue().closeGracefully().block(Duration.ofSeconds(5L));
+ }
+ }
+
private SessionManagedStdioTransportProvider createProvider(final MCPSessionManager sessionManager) {
InputStream originalInput = System.in;
try {
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/completion/MCPCompletionServiceTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/completion/MCPCompletionServiceTest.java
index 6aa75d69d23..be15915f9ea 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/completion/MCPCompletionServiceTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/completion/MCPCompletionServiceTest.java
@@ -74,6 +74,17 @@ import static org.mockito.Mockito.when;
class MCPCompletionServiceTest {
+ @Test
+ void assertCompleteWithoutSupportingHandler() {
+ MCPCompletionService completionService = createCompletionService(createRuntimeContext(mock(WorkflowSessionContext.class)), List.of());
+ MCPCompletionResult actual = completionService.complete("session-1", createDescriptor("foo_prompt", "value", 50), "value", "", Map.of());
+ assertTrue(actual.getValues().isEmpty());
+ assertThat(actual.getTotal(), is(0));
+ assertFalse(actual.hasMore());
+ assertThat(actual.getMeta().get(MCPShardingSphereMetadataKeys.DIAGNOSTIC), is("no_candidates"));
+ assertThat(actual.getMeta().get(MCPShardingSphereMetadataKeys.CANDIDATE_COUNT), is(0));
+ }
+
@Test
void assertCompleteDatabaseValues() {
MCPCompletionResult actual = complete(mock(WorkflowSessionContext.class), createDescriptor("inspect_metadata", "database", 1), "database", "", new LinkedHashMap<>());
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/handler/core/CoreHandlerProviderTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/handler/core/CoreHandlerProviderTest.java
index ad5d306f9e3..9c9733d35cd 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/handler/core/CoreHandlerProviderTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/handler/core/CoreHandlerProviderTest.java
@@ -17,22 +17,120 @@
package org.apache.shardingsphere.mcp.core.handler.core;
+import org.apache.shardingsphere.infra.spi.ShardingSphereServiceLoader;
+import org.apache.shardingsphere.mcp.api.MCPHandlerProvider;
import org.apache.shardingsphere.mcp.api.capability.completion.MCPCompletionHandler;
import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceHandler;
+import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceURIVariables;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolHandler;
import org.apache.shardingsphere.mcp.core.completion.handler.MetadataCompletionHandler;
import org.apache.shardingsphere.mcp.core.completion.handler.WorkflowPlanIdCompletionHandler;
+import org.apache.shardingsphere.mcp.core.resource.ResourceTestDataFactory;
+import org.apache.shardingsphere.mcp.core.resource.ResourceTestDataFactory.RequestContextFixture;
+import org.apache.shardingsphere.mcp.core.resource.handler.metadata.MetadataResourceHandler;
+import org.apache.shardingsphere.mcp.support.MCPFeatureRequestContext;
+import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureQueryFacade;
+import org.apache.shardingsphere.mcp.support.resource.MCPUriTemplate;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.util.Collection;
import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
class CoreHandlerProviderTest {
+ private final CoreHandlerProvider provider = ShardingSphereServiceLoader.getServiceInstances(MCPHandlerProvider.class).stream()
+ .filter(CoreHandlerProvider.class::isInstance).map(CoreHandlerProvider.class::cast).findFirst().orElseThrow();
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("metadataResourceCases")
+ void assertGetResourceHandlersWithMetadata(final String name, final String template, final String uri, final String nameField, final Collection<String> expectedNames) {
+ MetadataResourceHandler actualHandler = (MetadataResourceHandler) provider.getResourceHandlers().stream()
+ .filter(handler -> template.equals(handler.getResourceUriTemplate())).findFirst().orElseThrow();
+ try (RequestContextFixture fixture = ResourceTestDataFactory.createRequestContextFixture(ResourceTestDataFactory.createDatabaseMetadata())) {
+ MCPResourceURIVariables variables = new MCPUriTemplate(template).parse(uri).orElseThrow();
+ Map<String, Object> actual = actualHandler.handle(fixture.getRequestContext(), variables).toPayload();
+ assertThat(actual.get("count"), is(expectedNames.size()));
+ assertThat(((Collection<?>) actual.get("items")).stream().map(item -> ((Map<?, ?>) item).get(nameField)).toList(), is(expectedNames));
+ assertThat(((Map<?, ?>) actual.get("self_resource")).get("uri"), is(uri));
+ }
+ }
+
+ private static Stream<Arguments> metadataResourceCases() {
+ return Stream.of(
+ Arguments.of("databases", "shardingsphere://databases", "shardingsphere://databases", "database", List.of("logic_db", "runtime_db", "warehouse")),
+ Arguments.of("database", "shardingsphere://databases/{database}", "shardingsphere://databases/logic_db", "database", List.of("logic_db")),
+ Arguments.of("schemas", "shardingsphere://databases/{database}/schemas", "shardingsphere://databases/logic_db/schemas", "schema", List.of("public")),
+ Arguments.of("schema", "shardingsphere://databases/{database}/schemas/{schema}", "shardingsphere://databases/logic_db/schemas/public", "schema", List.of("public")),
+ Arguments.of("sequences", "shardingsphere://databases/{database}/schemas/{schema}/sequences", "shardingsphere://databases/runtime_db/schemas/public/sequences", "sequence",
+ List.of("order_seq")),
+ Arguments.of("sequence", "shardingsphere://databases/{database}/schemas/{schema}/sequences/{sequence}",
+ "shardingsphere://databases/runtime_db/schemas/public/sequences/order_seq", "sequence", List.of("order_seq")),
+ Arguments.of("tables", "shardingsphere://databases/{database}/schemas/{schema}/tables", "shardingsphere://databases/logic_db/schemas/public/tables", "table", List.of("order_items",
+ "orders")),
+ Arguments.of("table", "shardingsphere://databases/{database}/schemas/{schema}/tables/{table}", "shardingsphere://databases/logic_db/schemas/public/tables/orders", "table",
+ List.of("orders")),
+ Arguments.of("views", "shardingsphere://databases/{database}/schemas/{schema}/views", "shardingsphere://databases/logic_db/schemas/public/views", "view", List.of("orders_view")),
+ Arguments.of("view", "shardingsphere://databases/{database}/schemas/{schema}/views/{view}", "shardingsphere://databases/logic_db/schemas/public/views/orders_view", "view",
+ List.of("orders_view")),
+ Arguments.of("table columns", "shardingsphere://databases/{database}/schemas/{schema}/tables/{table}/columns",
+ "shardingsphere://databases/logic_db/schemas/public/tables/orders/columns", "column", List.of("order_id")),
+ Arguments.of("table column", "shardingsphere://databases/{database}/schemas/{schema}/tables/{table}/columns/{column}",
+ "shardingsphere://databases/logic_db/schemas/public/tables/orders/columns/order_id", "column", List.of("order_id")),
+ Arguments.of("view columns", "shardingsphere://databases/{database}/schemas/{schema}/views/{view}/columns",
+ "shardingsphere://databases/logic_db/schemas/public/views/orders_view/columns", "column", List.of("order_id")),
+ Arguments.of("view column", "shardingsphere://databases/{database}/schemas/{schema}/views/{view}/columns/{column}",
+ "shardingsphere://databases/logic_db/schemas/public/views/orders_view/columns/order_id", "column", List.of("order_id")),
+ Arguments.of("indexes", "shardingsphere://databases/{database}/schemas/{schema}/tables/{table}/indexes", "shardingsphere://databases/logic_db/schemas/public/tables/orders/indexes",
+ "index", List.of("order_idx")),
+ Arguments.of("index", "shardingsphere://databases/{database}/schemas/{schema}/tables/{table}/indexes/{index}",
+ "shardingsphere://databases/logic_db/schemas/public/tables/orders/indexes/order_idx", "index", List.of("order_idx")));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("governanceResourceCases")
+ void assertGetResourceHandlersWithGovernanceMetadata(final String name, final String template, final String uri, final String sql, final Map<String, Object> expectedRow) {
+ MCPFeatureRequestContext requestContext = mock(MCPFeatureRequestContext.class);
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ when(requestContext.getQueryFacade()).thenReturn(queryFacade);
+ when(queryFacade.query("foo_db", sql)).thenReturn(List.of(expectedRow));
+ MetadataResourceHandler actualHandler = (MetadataResourceHandler) provider.getResourceHandlers().stream()
+ .filter(handler -> template.equals(handler.getResourceUriTemplate())).findFirst().orElseThrow();
+ Map<String, Object> actual = actualHandler.handle(requestContext, new MCPUriTemplate(template).parse(uri).orElseThrow()).toPayload();
+ assertThat(actual.get("items"), is(List.of(expectedRow)));
+ assertThat(((Map<?, ?>) actual.get("self_resource")).get("uri"), is(uri));
+ verify(queryFacade).query("foo_db", sql);
+ }
+
+ private static Stream<Arguments> governanceResourceCases() {
+ return Stream.of(
+ Arguments.of("storage units", "shardingsphere://databases/{database}/storage-units", "shardingsphere://databases/foo_db/storage-units",
+ "SHOW STORAGE UNITS FROM foo_db", Map.of("name", "foo_ds")),
+ Arguments.of("storage unit", "shardingsphere://databases/{database}/storage-units/{storageUnit}", "shardingsphere://databases/foo_db/storage-units/foo_ds",
+ "SHOW STORAGE UNITS FROM foo_db", Map.of("name", "foo_ds")),
+ Arguments.of("storage unit rules", "shardingsphere://databases/{database}/storage-units/{storageUnit}/used-by-rules",
+ "shardingsphere://databases/foo_db/storage-units/foo_ds/used-by-rules",
+ "SHOW RULES USED STORAGE UNIT foo_ds FROM foo_db", Map.of("name", "foo_rule")),
+ Arguments.of("single tables", "shardingsphere://databases/{database}/single-tables", "shardingsphere://databases/foo_db/single-tables",
+ "SHOW SINGLE TABLES FROM foo_db", Map.of("table_name", "foo_table")),
+ Arguments.of("single table", "shardingsphere://databases/{database}/single-tables/{table}", "shardingsphere://databases/foo_db/single-tables/foo_table",
+ "SHOW SINGLE TABLE foo_table FROM foo_db", Map.of("table_name", "foo_table")),
+ Arguments.of("default single storage unit", "shardingsphere://databases/{database}/single-table/default-storage-unit",
+ "shardingsphere://databases/foo_db/single-table/default-storage-unit",
+ "SHOW DEFAULT SINGLE TABLE STORAGE UNIT FROM foo_db", Map.of("storage_unit_name", "foo_ds")));
+ }
+
@Test
void assertGetToolHandlers() {
Collection<MCPToolHandler<?>> actual = new CoreHandlerProvider().getToolHandlers();
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/metadata/GovernanceMetadataQueryServiceTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/metadata/GovernanceMetadataQueryServiceTest.java
index d1620567fe3..c6f41d795fb 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/metadata/GovernanceMetadataQueryServiceTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/metadata/GovernanceMetadataQueryServiceTest.java
@@ -19,9 +19,13 @@ package org.apache.shardingsphere.mcp.core.metadata;
import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureQueryFacade;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -32,6 +36,27 @@ class GovernanceMetadataQueryServiceTest {
private final GovernanceMetadataQueryService service = new GovernanceMetadataQueryService();
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("storageUnitAttributeCases")
+ void assertQueryStorageUnitsWithOtherAttributes(final String name, final Object attributes, final Object expected) {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ when(queryFacade.query("foo_db", "SHOW STORAGE UNITS FROM foo_db")).thenReturn(List.of(Map.of("name", "foo_ds", "other_attributes", attributes)));
+ List<Map<String, Object>> actual = service.queryStorageUnits(queryFacade, "foo_db");
+ assertThat(actual, is(List.of(Map.of("name", "foo_ds", "other_attributes", expected))));
+ }
+
+ private static Stream<Arguments> storageUnitAttributeCases() {
+ return Stream.of(
+ Arguments.of("nested collections", List.of(Map.of("PASSWORD", "foo_password"), List.of(Map.of("pool", 8))), List.of(Map.of("PASSWORD", "******"), List.of(Map.of("pool", 8)))),
+ Arguments.of("JSON array", "[{\"secret\":\"foo_secret\"},7]", List.of(Map.of("secret", "******"), 7)),
+ Arguments.of("ordinary text", "foo_pool=8", "foo_pool=8"),
+ Arguments.of("sensitive text", "PASSWORD=foo_password", "******"),
+ Arguments.of("malformed sensitive JSON", "{\"token\":", "******"),
+ Arguments.of("malformed ordinary JSON", "{\"pool\":", "{\"pool\":"),
+ Arguments.of("ordinary scalar", 8, 8),
+ Arguments.of("ordinary map", Map.of("pool", 8), Map.of("pool", 8)));
+ }
+
@Test
void assertQueryStorageUnits() {
MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/protocol/error/MCPErrorConverterTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/protocol/error/MCPErrorConverterTest.java
index 825877711a4..0a721d0bd64 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/protocol/error/MCPErrorConverterTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/protocol/error/MCPErrorConverterTest.java
@@ -61,6 +61,7 @@ import java.sql.SQLTransientConnectionException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
@@ -73,6 +74,66 @@ import static org.mockito.Mockito.when;
class MCPErrorConverterTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("argumentContractRecoveryCases")
+ void assertConvertArgumentContractWithoutSafeRetry(final String name, final String category, final String expectedType, final String expectedMessage, final String expectedModelAction) {
+ Map<String, Object> actual = MCPErrorConverter.convert(new MCPToolArgumentContractViolationException("foo_tool", "foo_field", category, expectedType, List.of(), Map.of())).toPayload();
+ assertThat(actual.get("summary"), is(expectedMessage));
+ Map<?, ?> actualRecovery = (Map<?, ?>) actual.get("recovery");
+ assertThat(actualRecovery.get("category"), is(category));
+ assertThat(actualRecovery.get("field"), is("foo_field"));
+ assertThat(actualRecovery.get("model_action"), is(expectedModelAction));
+ assertThat(Objects.toString(actualRecovery.get("expected_type"), ""), is(expectedType));
+ assertFalse(actualRecovery.containsKey("allowed_values"));
+ assertFalse(actualRecovery.containsKey("suggested_arguments"));
+ Map<?, ?> actualNextAction = (Map<?, ?>) ((List<?>) actual.get("next_actions")).getFirst();
+ assertThat(actualNextAction.get("type"), is("ask_user"));
+ assertThat(actualNextAction.get("required_inputs"), is(List.of("foo_field")));
+ }
+
+ private static Stream<Arguments> argumentContractRecoveryCases() {
+ return Stream.of(
+ Arguments.of("unknown argument", "unknown_argument", "", "foo_field is not a supported argument for foo_tool.",
+ "Remove arguments that are not declared by the tool inputSchema before retrying."),
+ Arguments.of("invalid argument type", "invalid_argument_type", "integer", "foo_field must be an integer.", "Retry with a value that matches the declared inputSchema type."),
+ Arguments.of("general schema mismatch", "invalid_argument", "", "foo_field does not match the input schema for foo_tool.",
+ "Retry with a value that matches the declared inputSchema type."));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("missingArgumentRecoveryCases")
+ void assertConvertMissingArgumentRecovery(final String name, final String field, final String expectedResource, final String expectedAction) {
+ Map<String, Object> actual = MCPErrorConverter.convert(new MCPMissingToolArgumentException(field)).toPayload();
+ Map<?, ?> actualRecovery = (Map<?, ?>) actual.get("recovery");
+ assertThat(actualRecovery.get("category"), is("missing_argument"));
+ assertThat(actualRecovery.get("missing_fields"), is(List.of(field)));
+ Collection<?> actualResources = (Collection<?>) actualRecovery.get("resources_to_read");
+ assertThat(actualResources.isEmpty(), is(expectedResource.isEmpty()));
+ Map<?, ?> actualNextAction = (Map<?, ?>) ((List<?>) actual.get("next_actions")).getFirst();
+ assertThat(actualNextAction.get("type"), is(expectedAction));
+ assertThat(Objects.toString(actualNextAction.get("resource_uri"), ""), is(expectedResource));
+ }
+
+ private static Stream<Arguments> missingArgumentRecoveryCases() {
+ return Stream.of(
+ Arguments.of("workflow identifier", "plan_id", "shardingsphere://capabilities", "resource_read"),
+ Arguments.of("SQL text", "sql", "", "ask_user"),
+ Arguments.of("schema name", "schema", "", "ask_user"));
+ }
+
+ @Test
+ void assertConvertInvalidUpdateExecutionMode() {
+ Map<String, Object> actual = MCPErrorConverter.convert(new MCPInvalidExecutionModeException("database_gateway_execute_update", List.of("preview", "execute"),
+ Map.of("sql", "UPDATE foo SET id=1", "execution_mode", "preview"))).toPayload();
+ Map<?, ?> actualRecovery = (Map<?, ?>) actual.get("recovery");
+ assertThat(actualRecovery.get("model_action"), is("Retry with execution_mode=preview or execution_mode=execute."));
+ assertThat(actualRecovery.get("allowed_values"), is(List.of("preview", "execute")));
+ assertThat(actualRecovery.get("suggested_arguments"), is(Map.of("sql", "UPDATE foo SET id=1", "execution_mode", "preview")));
+ Map<?, ?> actualNextAction = (Map<?, ?>) ((List<?>) actual.get("next_actions")).getFirst();
+ assertThat(actualNextAction.get("tool_name"), is("database_gateway_execute_update"));
+ assertThat(actualNextAction.get("arguments"), is(Map.of("sql", "UPDATE foo SET id=1", "execution_mode", "preview")));
+ }
+
@ParameterizedTest(name = "{0}")
@MethodSource("assertConvertCases")
void assertConvert(final String name, final Throwable cause, final String expectedMessage) {
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/resource/MCPResourceControllerTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/resource/MCPResourceControllerTest.java
index 583b983a86b..4c1b7586ae7 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/resource/MCPResourceControllerTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/resource/MCPResourceControllerTest.java
@@ -62,6 +62,16 @@ class MCPResourceControllerTest {
}
}
+ @Test
+ void assertHandleWithEmptyResourceURI() {
+ try (MockedStatic<ResourceDefinitionRegistry> mocked = mockStatic(ResourceDefinitionRegistry.class)) {
+ mocked.when(() -> ResourceDefinitionRegistry.dispatch(any(MCPFeatureRuntimeRequestContext.class), eq(""))).thenReturn(Optional.empty());
+ UnsupportedResourceUriException actual = assertThrows(UnsupportedResourceUriException.class, () -> createController().handle("session-1", ""));
+ assertThat(actual.getResourceUri(), is(""));
+ assertThat(actual.getMessage(), is("Unsupported resource URI."));
+ }
+ }
+
@Test
void assertHandleWithHandlerException() {
try (MockedStatic<ResourceDefinitionRegistry> mocked = mockStatic(ResourceDefinitionRegistry.class)) {
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
index bc7e2a3653e..551b319c10d 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
@@ -22,6 +22,7 @@ import org.apache.shardingsphere.infra.spi.ShardingSphereServiceLoader;
import org.apache.shardingsphere.mcp.api.MCPHandlerProvider;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
+import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolHandler;
import org.apache.shardingsphere.mcp.api.exception.MCPInvalidRequestException;
import org.apache.shardingsphere.mcp.api.payload.MCPSuccessPayload;
import org.apache.shardingsphere.mcp.api.session.MCPSessionIdentity;
@@ -35,27 +36,171 @@ import org.apache.shardingsphere.mcp.core.protocol.exception.MCPToolArgumentCont
import org.apache.shardingsphere.mcp.core.protocol.exception.UnsupportedToolException;
import org.apache.shardingsphere.mcp.core.resource.ResourceTestDataFactory;
import org.apache.shardingsphere.mcp.core.resource.ResourceTestDataFactory.RequestContextFixture;
+import org.apache.shardingsphere.mcp.support.MCPFeatureRequestContext;
import org.apache.shardingsphere.mcp.support.security.MCPRuntimeProtectionPolicy;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.MockedStatic;
import org.mockito.internal.configuration.plugins.Plugins;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.isA;
+import static org.hamcrest.Matchers.sameInstance;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
class ToolDefinitionRegistryTest {
+ @Test
+ void assertGetToolDefinitionWithEmptyName() {
+ UnsupportedToolException actual = assertThrows(UnsupportedToolException.class, () -> ToolDefinitionRegistry.getToolDefinition(""));
+ assertThat(actual.getToolName(), is(""));
+ assertThat(actual.getMessage(), is("Unsupported tool."));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("registrationFailureCases")
+ @SuppressWarnings("unchecked")
+ void assertGetSupportedToolDescriptorsWithInvalidRegistration(final String name, final Collection<String> toolNames, final String expectedMessage) {
+ Collection<MCPToolHandler<?>> handlers = new ArrayList<>(toolNames.size());
+ for (String each : toolNames) {
+ MCPToolHandler<MCPFeatureRequestContext> handler = mock(MCPToolHandler.class);
+ when(handler.getToolName()).thenReturn(each);
+ if (!each.isEmpty()) {
+ when(handler.getContextType()).thenReturn(MCPFeatureRequestContext.class);
+ }
+ handlers.add(handler);
+ }
+ MCPHandlerProvider handlerProvider = mock(MCPHandlerProvider.class);
+ when(handlerProvider.getToolHandlers()).thenReturn(handlers);
+ try (MockedStatic<ShardingSphereServiceLoader> mocked = mockStatic(ShardingSphereServiceLoader.class)) {
+ mocked.when(() -> ShardingSphereServiceLoader.getServiceInstances(MCPHandlerProvider.class)).thenReturn(List.of(handlerProvider));
+ Class<?> registryClass = assertDoesNotThrow(() -> Class.forName(ToolDefinitionRegistry.class.getName(), false, createIsolatedToolDefinitionRegistryClassLoader()));
+ InvocationTargetException actual = assertThrows(InvocationTargetException.class, () -> Plugins.getMemberAccessor().invoke(registryClass.getMethod("getSupportedToolDescriptors"), null));
+ assertThat(actual.getCause(), isA(ExceptionInInitializerError.class));
+ assertThat(actual.getCause().getCause().getMessage(), is(String.format(expectedMessage, handlers.iterator().next().getClass().getName())));
+ }
+ }
+
+ private static Stream<Arguments> registrationFailureCases() {
+ return Stream.of(
+ Arguments.of("empty tool name", List.of(""), "Tool name is required for `%1$s`."),
+ Arguments.of("duplicate tool name", List.of("database_gateway_search_metadata", "database_gateway_search_metadata"),
+ "Duplicate tool name `database_gateway_search_metadata` with `%1$s` and `%1$s`."),
+ Arguments.of("descriptor without handler", List.of("database_gateway_search_metadata"), "MCP tool descriptor `database_gateway_validate_runtime_database` has no registered handler."));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("validArgumentCases")
+ @SuppressWarnings("unchecked")
+ void assertDispatchWithSchemaValue(final String name, final Map<String, Object> property, final Object value) {
+ Map<String, Object> schema = Map.of("type", "object", "properties", Map.of("foo_value", property), "additionalProperties", true);
+ MCPToolDescriptor descriptor = mock(MCPToolDescriptor.class);
+ when(descriptor.getName()).thenReturn("foo_tool");
+ when(descriptor.getInputSchema()).thenReturn(schema);
+ MCPToolHandler<MCPFeatureRequestContext> handler = mock(MCPToolHandler.class);
+ when(handler.getContextType()).thenReturn(MCPFeatureRequestContext.class);
+ MCPFeatureRuntimeRequestContext context = mock(MCPFeatureRuntimeRequestContext.class);
+ Map<String, Object> args = Map.of("foo_value", value, "foo_extra", "foo_text");
+ MCPSuccessPayload expected = mock(MCPSuccessPayload.class);
+ when(handler.handle(context, args)).thenReturn(expected);
+ MCPSuccessPayload actual = ToolDefinitionRegistry.dispatch(context, new MCPToolDefinition(descriptor, handler), args);
+ assertThat(actual, sameInstance(expected));
+ verify(handler).handle(context, args);
+ }
+
+ private static Stream<Arguments> validArgumentCases() {
+ return Stream.of(
+ Arguments.of("byte integer", Map.of("type", "integer"), (byte) 1),
+ Arguments.of("short integer", Map.of("type", "integer"), (short) 2),
+ Arguments.of("long integer", Map.of("type", "integer"), 3L),
+ Arguments.of("big integer", Map.of("type", "integer", "minimum", 0, "maximum", 10), BigInteger.TEN),
+ Arguments.of("number", Map.of("type", "number"), 1.5D),
+ Arguments.of("array without item schema", Map.of("type", "array"), List.of("foo_text")),
+ Arguments.of("additional property schema", Map.of("type", "object", "additionalProperties", Map.of("type", "string")), Map.of("foo_key", "foo_text")),
+ Arguments.of("schema without type", Map.of("enum", List.of("foo_text")), "foo_text"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("integerRangeCases")
+ void assertDispatchWithIntegerRangeViolation(final String name, final Map<String, Object> property, final Object value, final int expectedSuggestion) {
+ MCPToolDescriptor descriptor = mock(MCPToolDescriptor.class);
+ when(descriptor.getName()).thenReturn("foo_tool");
+ when(descriptor.getInputSchema()).thenReturn(Map.of("type", "object", "properties", Map.of("foo_value", property), "additionalProperties", false));
+ MCPInvalidToolArgumentException actual = assertThrows(MCPInvalidToolArgumentException.class,
+ () -> ToolDefinitionRegistry.dispatch(mock(MCPFeatureRuntimeRequestContext.class), new MCPToolDefinition(descriptor, mock(MCPToolHandler.class)), Map.of("foo_value", value)));
+ assertThat(actual.getArgumentPath(), is("foo_value"));
+ assertThat(actual.getSuggestedValue(), is(expectedSuggestion));
+ assertThat(actual.getMinimumValue(), is(0));
+ assertThat(actual.getMaximumValue(), is(10));
+ }
+
+ private static Stream<Arguments> integerRangeCases() {
+ return Stream.of(
+ Arguments.of("below minimum", Map.of("type", "integer", "minimum", 0, "maximum", 10), -1, 0),
+ Arguments.of("above maximum", Map.of("type", "integer", "minimum", 0, "maximum", 10), BigInteger.valueOf(Long.MAX_VALUE), 10),
+ Arguments.of("valid default", Map.of("type", "integer", "minimum", 0, "maximum", 10, "default", 5), 11L, 5));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("requiredNestedArgumentCases")
+ void assertDispatchWithInvalidRequiredNestedArgument(final String name, final Map<String, Object> property, final Object value, final String expectedPath) {
+ MCPToolDescriptor descriptor = mock(MCPToolDescriptor.class);
+ when(descriptor.getName()).thenReturn("foo_tool");
+ when(descriptor.getInputSchema()).thenReturn(Map.of("type", "object", "properties", Map.of("foo_value", property), "required", List.of("foo_value"), "additionalProperties", false));
+ MCPToolArgumentContractViolationException actual = assertThrows(MCPToolArgumentContractViolationException.class,
+ () -> ToolDefinitionRegistry.dispatch(mock(MCPFeatureRuntimeRequestContext.class), new MCPToolDefinition(descriptor, mock(MCPToolHandler.class)), Map.of("foo_value", value)));
+ assertThat(actual.getArgumentPath(), is(expectedPath));
+ assertThat(actual.getCategory(), is("invalid_argument_type"));
+ assertTrue(actual.getSuggestedArguments().isEmpty());
+ }
+
+ private static Stream<Arguments> requiredNestedArgumentCases() {
+ return Stream.of(
+ Arguments.of("array item", Map.of("type", "array", "items", Map.of("type", "integer")), List.of("foo_text"), "foo_value[0]"),
+ Arguments.of("object in array", Map.of("type", "array", "items", Map.of("type", "object", "properties", Map.of("foo_key", Map.of("type", "boolean")))),
+ List.of(Map.of("foo_key", "foo_text")), "foo_value[0].foo_key"),
+ Arguments.of("array in object", Map.of("type", "object", "properties", Map.of("foo_key", Map.of("type", "array", "items", Map.of("type", "number")))),
+ Map.of("foo_key", List.of("foo_text")), "foo_value.foo_key[0]"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("safeExecutionModeCases")
+ void assertDispatchWithMissingExecutionModeSuggestion(final String name, final Map<String, Object> property, final Map<String, Object> expectedSuggestion) {
+ MCPToolDescriptor descriptor = mock(MCPToolDescriptor.class);
+ when(descriptor.getName()).thenReturn("foo_tool");
+ when(descriptor.getInputSchema()).thenReturn(Map.of("type", "object", "properties", Map.of("execution_mode", property), "required", List.of("execution_mode"), "additionalProperties", false));
+ MCPExecutionModeRequiredException actual = assertThrows(MCPExecutionModeRequiredException.class,
+ () -> ToolDefinitionRegistry.dispatch(mock(MCPFeatureRuntimeRequestContext.class), new MCPToolDefinition(descriptor, mock(MCPToolHandler.class)), Map.of()));
+ assertThat(actual.getSuggestedArguments(), is(expectedSuggestion));
+ }
+
+ private static Stream<Arguments> safeExecutionModeCases() {
+ return Stream.of(
+ Arguments.of("manual review", Map.of("type", "string", "enum", List.of("execute", "manual-only")), Map.of("execution_mode", "manual-only")),
+ Arguments.of("first declared mode", Map.of("type", "string", "enum", List.of("execute")), Map.of("execution_mode", "execute")),
+ Arguments.of("no declared mode", Map.of("type", "string"), Map.of()));
+ }
+
@Test
void assertGetSupportedToolDescriptors() {
List<MCPToolDescriptor> actual = ToolDefinitionRegistry.getSupportedToolDescriptors();
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
index 3be679d3ecf..d95054d57ee 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
@@ -22,6 +22,7 @@ import org.apache.shardingsphere.mcp.api.exception.MCPUnsupportedException;
import org.apache.shardingsphere.mcp.api.payload.MCPSuccessPayload;
import org.apache.shardingsphere.mcp.api.session.MCPSessionIdentity;
import org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidToolArgumentException;
+import org.apache.shardingsphere.mcp.core.tool.request.MCPToolArguments;
import org.apache.shardingsphere.mcp.support.MCPFeatureRequestContext;
import org.apache.shardingsphere.mcp.support.database.capability.MCPDatabaseCapability;
import org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPStatement;
@@ -29,25 +30,60 @@ import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureCapabilityFa
import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureExecutionFacade;
import org.apache.shardingsphere.mcp.support.database.tool.request.SQLExecutionRequest;
import org.apache.shardingsphere.mcp.support.database.tool.result.SQLExecutionResult;
+import org.apache.shardingsphere.mcp.support.descriptor.CoreToolNames;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class ExecuteUpdateToolHandlerTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("previewSideEffectCases")
+ void assertHandlePreviewSideEffectScope(final String name, final String sql, final SupportedMCPStatement statementClass, final String statementType, final String savepointName,
+ final String expectedClass, final String expectedScope) {
+ MCPFeatureExecutionFacade executionFacade = mock(MCPFeatureExecutionFacade.class);
+ MCPFeatureRequestContext requestContext = mock(MCPFeatureRequestContext.class);
+ when(requestContext.getExecutionFacade()).thenReturn(executionFacade);
+ ClassificationResult classificationResult = new ClassificationResult(statementClass, statementType, sql, savepointName, List.of(), false);
+ try (MockedStatic<SQLExecutionToolHandlerSupport> mocked = mockStatic(SQLExecutionToolHandlerSupport.class)) {
+ mocked.when(() -> SQLExecutionToolHandlerSupport.analyze(eq(requestContext), any(MCPToolArguments.class), eq(sql))).thenReturn(classificationResult);
+ mocked.when(() -> SQLExecutionToolHandlerSupport.checkExecutionArguments(any(MCPToolArguments.class), eq(CoreToolNames.EXECUTE_UPDATE))).thenCallRealMethod();
+ mocked.when(() -> SQLExecutionToolHandlerSupport.isQueryStatement(any(ClassificationResult.class))).thenCallRealMethod();
+ Map<String, Object> actual = new ExecuteUpdateToolHandler().handle(requestContext, Map.of("database", "logic_db", "sql", sql, "execution_mode", "preview")).toPayload();
+ assertThat(actual.get("statement_class"), is(expectedClass));
+ assertThat(actual.get("side_effect_scope"), is(List.of(expectedScope)));
+ assertFalse((boolean) actual.get("would_execute"));
+ verifyNoInteractions(executionFacade);
+ }
+ }
+
+ private static Stream<Arguments> previewSideEffectCases() {
+ return Stream.of(
+ Arguments.of("privilege metadata", "GRANT SELECT ON foo_table TO foo_user", SupportedMCPStatement.DCL, "GRANT", "", "dcl", "privilege-metadata"),
+ Arguments.of("transaction state", "BEGIN", SupportedMCPStatement.TRANSACTION_CONTROL, "BEGIN", "", "transaction_control", "transaction-state"),
+ Arguments.of("savepoint state", "SAVEPOINT foo_savepoint", SupportedMCPStatement.SAVEPOINT, "SAVEPOINT", "foo_savepoint", "savepoint", "transaction-state"));
+ }
+
@Test
void assertHandleUpdateStatement() {
MCPFeatureExecutionFacade executionFacade = mock(MCPFeatureExecutionFacade.class);
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcStatementExecutorTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcStatementExecutorTest.java
index d95abe9b1d6..fdb2995e0fc 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcStatementExecutorTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcStatementExecutorTest.java
@@ -51,6 +51,8 @@ import java.sql.SQLSyntaxErrorException;
import java.sql.SQLTimeoutException;
import java.sql.SQLTransientConnectionException;
import java.sql.Statement;
+import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -60,6 +62,7 @@ import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -72,6 +75,87 @@ import static org.mockito.Mockito.when;
class MCPJdbcStatementExecutorTest {
+ @Test
+ void assertExecuteWithOwnedConnectionStateFailure() throws SQLException {
+ RuntimeDatabaseConfiguration databaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ IllegalStateException cause = new IllegalStateException("foo connection state failed");
+ when(databaseConfig.openConnection("foo_db")).thenThrow(cause);
+ MCPJdbcStatementExecutor executor = new MCPJdbcStatementExecutor(Map.of("foo_db", databaseConfig), mock(MCPJdbcTransactionResourceManager.class));
+ SQLExecutionRequest request = new SQLExecutionRequest("foo_session", "foo_db", "", "SELECT 1", 0, 0);
+ ClassificationResult classification = new ClassificationResult(SupportedMCPStatement.QUERY, "SELECT", "SELECT 1", "", List.of(), false);
+ MCPTransactionStateException actual = assertThrows(MCPTransactionStateException.class,
+ () -> executor.execute(request, classification, createDatabaseCapability(SchemaExecutionSemantics.FIXED_TO_DATABASE)));
+ assertThat(actual.getMessage(), is(cause.getMessage()));
+ assertThat(actual.getCause(), sameInstance(cause));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("readOnlyFailureCases")
+ void assertExecuteReadOnlyWithFailure(final String name, final boolean executionFailure, final boolean rollbackFailure, final boolean autoCommitFailure, final boolean readOnlyFailure,
+ final String expectedMessage, final Collection<String> expectedSuppressed) throws SQLException {
+ Statement statement = mock(Statement.class);
+ Connection connection = createStatementConnection(statement);
+ when(connection.getAutoCommit()).thenReturn(true);
+ if (executionFailure) {
+ when(statement.execute("SELECT 1")).thenThrow(new SQLException("execution failed"));
+ } else {
+ when(statement.execute("SELECT 1")).thenReturn(true);
+ ResultSet resultSet = createResultSet(createColumns(), List.of(List.of(1, "foo_status")));
+ when(statement.getResultSet()).thenReturn(resultSet);
+ }
+ if (rollbackFailure) {
+ doThrow(new SQLException("rollback failed")).when(connection).rollback();
+ }
+ if (autoCommitFailure) {
+ doThrow(new SQLException("autoCommit failed")).when(connection).setAutoCommit(true);
+ }
+ if (readOnlyFailure) {
+ doThrow(new SQLException("readOnly failed")).when(connection).setReadOnly(false);
+ }
+ RuntimeDatabaseConfiguration databaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ when(databaseConfig.openConnection("foo_db")).thenReturn(connection);
+ MCPJdbcStatementExecutor executor = new MCPJdbcStatementExecutor(Map.of("foo_db", databaseConfig), mock(MCPJdbcTransactionResourceManager.class));
+ SQLExecutionRequest request = new SQLExecutionRequest("foo_session", "foo_db", "", "SELECT 1", 0, 0, true);
+ ClassificationResult classification = new ClassificationResult(SupportedMCPStatement.QUERY, "SELECT", "SELECT 1", "", List.of(), false);
+ MCPDatabaseQueryFailedException actual = assertThrows(MCPDatabaseQueryFailedException.class,
+ () -> executor.execute(request, classification, createDatabaseCapability(SchemaExecutionSemantics.FIXED_TO_DATABASE)));
+ assertThat(actual.getCause().getMessage(), is(expectedMessage));
+ assertThat(Arrays.stream(actual.getCause().getSuppressed()).map(Throwable::getMessage).toList(), is(expectedSuppressed));
+ verify(connection).rollback();
+ verify(connection).setAutoCommit(true);
+ verify(connection).setReadOnly(false);
+ verify(connection).close();
+ }
+
+ private static Stream<Arguments> readOnlyFailureCases() {
+ return Stream.of(
+ Arguments.of("execution failure", true, false, false, false, "execution failed", List.of()),
+ Arguments.of("rollback failure", false, true, false, false, "rollback failed", List.of()),
+ Arguments.of("autoCommit restoration failure", false, false, true, false, "autoCommit failed", List.of()),
+ Arguments.of("readOnly restoration failure", false, false, false, true, "readOnly failed", List.of()),
+ Arguments.of("execution and cleanup failures", true, true, true, true, "execution failed", List.of("rollback failed", "autoCommit failed", "readOnly failed")));
+ }
+
+ @Test
+ void assertExecuteReadOnlyWithAutoCommitAlreadyDisabled() throws SQLException {
+ Statement statement = mock(Statement.class);
+ when(statement.execute("SELECT 1")).thenReturn(true);
+ ResultSet resultSet = createResultSet(createColumns(), List.of(List.of(1, "foo_status")));
+ when(statement.getResultSet()).thenReturn(resultSet);
+ Connection connection = createStatementConnection(statement);
+ RuntimeDatabaseConfiguration databaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ when(databaseConfig.openConnection("foo_db")).thenReturn(connection);
+ MCPJdbcStatementExecutor executor = new MCPJdbcStatementExecutor(Map.of("foo_db", databaseConfig), mock(MCPJdbcTransactionResourceManager.class));
+ SQLExecutionResult actual = executor.execute(new SQLExecutionRequest("foo_session", "foo_db", "", "SELECT 1", 0, 0, true),
+ new ClassificationResult(SupportedMCPStatement.QUERY, "SELECT", "SELECT 1", "", List.of(), false), createDatabaseCapability(SchemaExecutionSemantics.FIXED_TO_DATABASE));
+ assertThat(actual.getRows(), is(List.of(List.of(1, "foo_status"))));
+ verify(connection).rollback();
+ verify(connection, never()).setAutoCommit(true);
+ verify(connection, never()).setAutoCommit(false);
+ verify(connection).setReadOnly(false);
+ verify(connection).close();
+ }
+
@ParameterizedTest(name = "{0}")
@MethodSource("assertExecuteCases")
void assertExecute(final String name, final SQLExecutionRequest executionRequest, final ClassificationResult classificationResult, final Connection connection,
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionResourceManagerTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionResourceManagerTest.java
index 64b303ccee5..7e6a87d6646 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionResourceManagerTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionResourceManagerTest.java
@@ -46,6 +46,36 @@ import static org.mockito.Mockito.when;
class MCPJdbcTransactionResourceManagerTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("commitCleanupFailureCases")
+ void assertCommitTransactionWithCleanupFailure(final String name, final boolean resetFailure, final boolean closeFailure, final String expectedMessage,
+ final int expectedSuppressed) throws SQLException {
+ Connection connection = mock(Connection.class);
+ RuntimeDatabaseConfiguration databaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ when(databaseConfig.openConnection("foo_db")).thenReturn(connection);
+ MCPJdbcTransactionResourceManager manager = new MCPJdbcTransactionResourceManager(Map.of("foo_db", databaseConfig));
+ manager.beginTransaction("foo_session", "foo_db");
+ if (resetFailure) {
+ doThrow(new SQLException("reset failed")).when(connection).setAutoCommit(true);
+ }
+ if (closeFailure) {
+ doThrow(new SQLException("close failed")).when(connection).close();
+ }
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> manager.commitTransaction("foo_session"));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ assertThat(actual.getCause().getSuppressed().length, is(expectedSuppressed));
+ assertTrue(manager.findTransactionConnection("foo_session", "foo_db").isEmpty());
+ verify(connection).commit();
+ verify(connection).close();
+ }
+
+ private static Stream<Arguments> commitCleanupFailureCases() {
+ return Stream.of(
+ Arguments.of("reset failure", true, false, "reset failed", 0),
+ Arguments.of("close failure", false, true, "close failed", 0),
+ Arguments.of("reset and close failure", true, true, "reset failed", 1));
+ }
+
@ParameterizedTest(name = "{0}")
@MethodSource("assertFindTransactionConnectionCases")
void assertFindTransactionConnection(final String name,
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionStatementExecutorTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionStatementExecutorTest.java
index 6013dff935d..07e1ac4b561 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionStatementExecutorTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPJdbcTransactionStatementExecutorTest.java
@@ -48,11 +48,34 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MCPJdbcTransactionStatementExecutorTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("unsupportedTransactionCases")
+ void assertExecuteWithUnsupportedTransactionCapability(final String name, final SupportedMCPStatement statementClass, final String statementType, final String expectedMessage) {
+ MCPSessionManager sessionManager = mock(MCPSessionManager.class);
+ when(sessionManager.hasSession("foo_session")).thenReturn(true);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ ClassificationResult classification = new ClassificationResult(statementClass, statementType, statementType, "foo_savepoint", List.of(), false);
+ MCPUnsupportedException actual = assertThrows(MCPUnsupportedException.class,
+ () -> new MCPJdbcTransactionStatementExecutor(sessionManager).execute("foo_session", "foo_db", capability, classification));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ verify(sessionManager, never()).getTransactionResourceManager();
+ }
+
+ private static Stream<Arguments> unsupportedTransactionCases() {
+ return Stream.of(
+ Arguments.of("begin", SupportedMCPStatement.TRANSACTION_CONTROL, "BEGIN", "Transaction control is not supported."),
+ Arguments.of("commit", SupportedMCPStatement.TRANSACTION_CONTROL, "COMMIT", "Transaction control is not supported."),
+ Arguments.of("rollback", SupportedMCPStatement.TRANSACTION_CONTROL, "ROLLBACK", "Transaction control is not supported."),
+ Arguments.of("rollback savepoint", SupportedMCPStatement.SAVEPOINT, "ROLLBACK TO SAVEPOINT", "Savepoint is not supported."),
+ Arguments.of("release savepoint", SupportedMCPStatement.SAVEPOINT, "RELEASE SAVEPOINT", "Savepoint is not supported."));
+ }
+
@ParameterizedTest(name = "{0}")
@MethodSource("assertExecuteCases")
void assertExecute(final String name, final String sql, final SupportedMCPStatement statementClass, final String expectedStatementType,
diff --git a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPSQLExecutionFacadeTest.java b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPSQLExecutionFacadeTest.java
index d1502f48eeb..89959bcf41b 100644
--- a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPSQLExecutionFacadeTest.java
+++ b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/MCPSQLExecutionFacadeTest.java
@@ -17,12 +17,19 @@
package org.apache.shardingsphere.mcp.core.tool.handler.execute;
+import org.apache.shardingsphere.database.connector.core.metadata.database.enums.NullsOrderType;
import org.apache.shardingsphere.database.connector.core.metadata.identifier.IdentifierCasePolicyFactory;
import org.apache.shardingsphere.database.connector.core.metadata.identifier.IdentifierCasePolicySet;
+import org.apache.shardingsphere.database.connector.core.spi.DatabaseTypedSPILoader;
+import org.apache.shardingsphere.database.connector.core.type.DatabaseType;
import org.apache.shardingsphere.infra.metadata.identifier.DatabaseIdentifierContext;
+import org.apache.shardingsphere.infra.parser.ShardingSphereSQLParserEngine;
+import org.apache.shardingsphere.infra.spi.type.typed.TypedSPILoader;
import org.apache.shardingsphere.mcp.api.exception.MCPInvalidRequestException;
import org.apache.shardingsphere.mcp.api.exception.MCPQueryFailedException;
+import org.apache.shardingsphere.mcp.api.exception.MCPUnsupportedException;
import org.apache.shardingsphere.mcp.core.protocol.exception.MCPBannedSQLStatementException;
+import org.apache.shardingsphere.mcp.core.protocol.exception.MCPUnsupportedSQLStatementException;
import org.apache.shardingsphere.mcp.core.session.MCPSessionExecutionCoordinator;
import org.apache.shardingsphere.mcp.core.session.MCPSessionNotExistedException;
import org.apache.shardingsphere.mcp.support.database.capability.MCPDatabaseCapability;
@@ -33,11 +40,108 @@ import org.apache.shardingsphere.mcp.support.database.exception.DatabaseCapabili
import org.apache.shardingsphere.mcp.support.database.exception.StatementClassNotSupportedException;
import org.apache.shardingsphere.mcp.support.database.tool.request.SQLExecutionRequest;
import org.apache.shardingsphere.mcp.support.database.tool.result.SQLExecutionResult;
+import org.apache.shardingsphere.sql.parser.spi.SQLStatementVisitorFacade;
+import org.apache.shardingsphere.sql.parser.statement.core.enums.AggregationType;
+import org.apache.shardingsphere.sql.parser.statement.core.enums.CombineType;
+import org.apache.shardingsphere.sql.parser.statement.core.enums.OrderDirection;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.ddl.index.IndexNameSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.ddl.index.IndexSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.ddl.routine.FunctionNameSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.ReturningSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.assignment.ColumnAssignmentSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.assignment.InsertValuesSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.assignment.SetAssignmentSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.column.ColumnSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.column.OnDuplicateKeyColumnsSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.combine.CombineSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.datetime.DatetimeExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.exec.ExecSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.BetweenExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.BinaryOperationExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.CaseWhenExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.CollateExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.ExpressionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.ExpressionWithParamsSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.FunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.InExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.IntervalExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.IntervalUnit;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.KeyValueSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.ListExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.NotExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.QuantifySubqueryExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.RowExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.TypeCastExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.UnaryOperationExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.ValuesExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.complex.CommonExpressionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.simple.LiteralExpressionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.subquery.SubqueryExpressionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.expr.subquery.SubquerySegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.interval.IntervalDayToSecondExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.AggregationProjectionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.DatetimeProjectionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.ExpressionProjectionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.IntervalExpressionProjection;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.ProjectionsSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.ShorthandProjectionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.item.SubqueryProjectionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.merge.MergeWhenAndThenSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.order.GroupBySegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.order.OrderBySegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.order.item.ExpressionOrderByItemSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.pagination.limit.ExpressionLimitValueSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.pagination.limit.LimitSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.predicate.HavingSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.predicate.HierarchicalQuerySegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.predicate.WhereSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableConditionalIntoElseSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableConditionalIntoSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableConditionalIntoThenSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableConditionalIntoWhenThenSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableInsertIntoSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.table.MultiTableInsertType;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlElementFunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlPiFunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlQueryAndExistsFunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlSerializeFunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlTableColumnSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlTableFunctionSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.dml.xml.XmlTableOptionsSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.ModelColumnSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.ModelSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.OutputSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.OwnerSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.WindowItemSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.WindowSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.match.MatchAgainstExpression;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.CollectionTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.DeleteMultiTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.FunctionTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.JoinTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.SimpleTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.SubqueryTableSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.segment.generic.table.TableNameSegment;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.SQLStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dal.EmptyStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.ddl.database.AlterDatabaseStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.ddl.database.DropDatabaseStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.ddl.index.AlterIndexStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.ddl.view.AlterViewStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.ddl.view.CreateMaterializedViewStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dml.DeleteStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dml.InsertStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dml.MergeStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dml.SelectStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.statement.type.dml.UpdateStatement;
+import org.apache.shardingsphere.sql.parser.statement.core.value.identifier.IdentifierValue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
+import org.mockito.MockedStatic;
import java.sql.SQLException;
import java.sql.SQLSyntaxErrorException;
@@ -45,22 +149,583 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
+import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MCPSQLExecutionFacadeTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource({"parsedNamespaceStatements", "parsedExpressionStatements", "parsedXMLStatements"})
+ void assertExecuteWithParsedCrossSchemaObjects(final String name, final String databaseType, final String sql, final SQLStatement statement, final String expectedObject) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPJdbcTransactionStatementExecutor transactionExecutor = mock(MCPJdbcTransactionStatementExecutor.class);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ MCPDatabaseCapability capability = createCapability(Set.of(SupportedMCPStatement.QUERY, SupportedMCPStatement.DML, SupportedMCPStatement.DDL, SupportedMCPStatement.DCL));
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, transactionExecutor, statementExecutor);
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> ignored = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenReturn(statement))) {
+ MCPInvalidRequestException actual = assertThrows(MCPInvalidRequestException.class, () -> facade.execute(createExecutionRequest(sql)));
+ assertThat(actual.getMessage(), is(String.format("Cross-schema SQL is not supported for database `logic_db`: `%s`.", expectedObject)));
+ verifyNoInteractions(transactionExecutor, statementExecutor);
+ }
+ }
+
+ private static Stream<Arguments> parsedNamespaceStatements() {
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ CreateMaterializedViewStatement materializedView = new CreateMaterializedViewStatement(oracle);
+ materializedView.setView(createTable("logic_db", "foo_view"));
+ materializedView.setSelect(selectExpression(oracle, createForeignFunction()));
+ IndexSegment index = new IndexSegment(0, 0, new IndexNameSegment(0, 0, new IdentifierValue("foo_index")));
+ index.setOwner(new OwnerSegment(0, 0, new IdentifierValue("other_db")));
+ DatabaseType postgresql = TypedSPILoader.getService(DatabaseType.class, "PostgreSQL");
+ AlterDatabaseStatement alterDatabase = new AlterDatabaseStatement(postgresql);
+ alterDatabase.setDatabaseName("other_db");
+ alterDatabase.setRenameDatabaseName("foo_new_database");
+ AlterViewStatement renameView = new AlterViewStatement(postgresql);
+ renameView.setView(createTable("other_db", "foo_view"));
+ renameView.setRenameView(new SimpleTableSegment(new TableNameSegment(0, 0, new IdentifierValue("foo_new_view"))));
+ DatabaseType sqlserver = TypedSPILoader.getService(DatabaseType.class, "SQLServer");
+ AlterViewStatement redefineView = new AlterViewStatement(sqlserver);
+ redefineView.setView(createTable("logic_db", "foo_view"));
+ redefineView.setSelect(SelectStatement.builder().databaseType(sqlserver).projections(starProjection()).from(createTable("other_db", "foo_table")).build());
+ DeleteMultiTableSegment deleteTables = new DeleteMultiTableSegment();
+ deleteTables.getActualDeleteTables().add(createTable("logic_db", "foo_orders"));
+ JoinTableSegment relation = new JoinTableSegment();
+ relation.setLeft(createTable("logic_db", "foo_orders"));
+ DatabaseType mysql = TypedSPILoader.getService(DatabaseType.class, "MySQL");
+ relation.setRight(new SubqueryTableSegment(0, 0, new SubquerySegment(0, 0,
+ SelectStatement.builder().databaseType(mysql).projections(starProjection()).from(createTable("other_db", "foo_table")).build(), "SELECT * FROM other_db.foo_table")));
+ relation.setCondition(new LiteralExpressionSegment(0, 0, true));
+ deleteTables.setRelationTable(relation);
+ IndexSegment renameIndex = new IndexSegment(0, 0, new IndexNameSegment(0, 0, new IdentifierValue("foo_new_index")));
+ return Stream.of(
+ Arguments.of("materialized view body", "Oracle", "CREATE MATERIALIZED VIEW logic_db.foo_view AS SELECT other_db.foo_read() FROM dual", materializedView, "other_db.foo_read"),
+ Arguments.of("alter and rename index", "PostgreSQL", "ALTER INDEX other_db.foo_index RENAME TO foo_new_index", new AlterIndexStatement(postgresql, index, renameIndex, null),
+ "other_db.foo_index"),
+ Arguments.of("alter and rename database", "PostgreSQL", "ALTER DATABASE other_db RENAME TO foo_new_database", alterDatabase, "other_db"),
+ Arguments.of("drop database", "PostgreSQL", "DROP DATABASE other_db", new DropDatabaseStatement(postgresql, "other_db", false), "other_db"),
+ Arguments.of("rename view", "PostgreSQL", "ALTER VIEW other_db.foo_view RENAME TO foo_new_view", renameView, "other_db.foo_view"),
+ Arguments.of("redefine view", "SQLServer", "ALTER VIEW logic_db.foo_view AS SELECT * FROM other_db.foo_table", redefineView, "other_db.foo_table"),
+ Arguments.of("delete via subquery relation", "MySQL", "DELETE logic_db.foo_orders FROM logic_db.foo_orders JOIN (SELECT * FROM other_db.foo_table) foo_source ON 1=1",
+ DeleteStatement.builder().databaseType(mysql).table(deleteTables).build(), "other_db.foo_table"));
+ }
+
+ private static SimpleTableSegment createTable(final String owner, final String name) {
+ SimpleTableSegment result = new SimpleTableSegment(new TableNameSegment(0, 0, new IdentifierValue(name)));
+ result.setOwner(new OwnerSegment(0, 0, new IdentifierValue(owner)));
+ return result;
+ }
+
+ private static SelectStatement selectExpression(final DatabaseType databaseType, final ExpressionSegment expression) {
+ ProjectionsSegment projections = new ProjectionsSegment(0, 0);
+ projections.getProjections().add(new ExpressionProjectionSegment(0, 0, expression.getText(), expression));
+ return SelectStatement.builder().databaseType(databaseType).projections(projections).build();
+ }
+
+ private static FunctionSegment createForeignFunction() {
+ FunctionSegment result = new FunctionSegment(0, 0, "foo_read", "other_db.foo_read()");
+ result.setOwner(new OwnerSegment(0, 0, new IdentifierValue("other_db")));
+ return result;
+ }
+
+ private static ProjectionsSegment starProjection() {
+ ProjectionsSegment result = new ProjectionsSegment(0, 0);
+ result.getProjections().add(new ShorthandProjectionSegment(0, 0));
+ return result;
+ }
+
+ private static Stream<Arguments> parsedExpressionStatements() {
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ FunctionSegment function = createForeignFunction();
+ KeyValueSegment keyValue = new KeyValueSegment(0, 0, "'foo':other_db.foo_read()");
+ keyValue.setKey(new LiteralExpressionSegment(0, 0, "foo"));
+ keyValue.setValue(function);
+ FunctionSegment json = new FunctionSegment(0, 0, "JSON_OBJECT", "JSON_OBJECT('foo':other_db.foo_read())");
+ json.getParameters().add(keyValue);
+ MatchAgainstExpression match = new MatchAgainstExpression(0, 0, function, "", "MATCH(foo_content) AGAINST(other_db.foo_read())");
+ match.getColumns().add(new ColumnSegment(0, 0, new IdentifierValue("foo_content")));
+ SubquerySegment subquery = new SubquerySegment(0, 0, selectExpression(oracle, function), "SELECT other_db.foo_read() FROM dual");
+ ProjectionsSegment subqueryProjections = new ProjectionsSegment(0, 0);
+ subqueryProjections.getProjections().add(new SubqueryProjectionSegment(subquery, "(SELECT other_db.foo_read() FROM dual)"));
+ LiteralExpressionSegment literal = new LiteralExpressionSegment(0, 0, 1);
+ ListExpression list = new ListExpression(0, 0);
+ list.getItems().add(function);
+ RowExpression row = new RowExpression(0, 0, "(other_db.foo_read(), 1)");
+ row.getItems().add(function);
+ row.getItems().add(literal);
+ RowExpression comparisonRow = new RowExpression(0, 0, "(1, 1)");
+ comparisonRow.getItems().addAll(List.of(literal, literal));
+ FunctionSegment datetimeFunction = new FunctionSegment(0, 0, "COALESCE", "COALESCE(other_db.foo_read() AT TIME ZONE 'UTC', NULL)");
+ datetimeFunction.getParameters().add(new DatetimeExpression(0, 0, function, new ExpressionProjectionSegment(0, 0, "'UTC'", new LiteralExpressionSegment(0, 0, "UTC")), "AT TIME ZONE"));
+ datetimeFunction.getParameters().add(new LiteralExpressionSegment(0, 0, null));
+ ValuesExpression values = new ValuesExpression(0, 0);
+ values.getRowConstructorList().add(new InsertValuesSegment(0, 0, List.of(function)));
+ DatabaseType postgresql = TypedSPILoader.getService(DatabaseType.class, "PostgreSQL");
+ DatabaseType mysql = TypedSPILoader.getService(DatabaseType.class, "MySQL");
+ DatabaseType sqlserver = TypedSPILoader.getService(DatabaseType.class, "SQLServer");
+ return Stream.of(
+ Arguments.of("JSON key value", "SQLServer", "SELECT JSON_OBJECT('foo':other_db.foo_read())", selectExpression(sqlserver, json), "other_db.foo_read"),
+ Arguments.of("match expression", "MySQL", "SELECT MATCH(foo_content) AGAINST(other_db.foo_read()) FROM foo_documents", selectExpression(mysql, match), "other_db.foo_read"),
+ Arguments.of("scalar subquery projection", "Oracle", "SELECT (SELECT other_db.foo_read() FROM dual) FROM dual",
+ SelectStatement.builder().databaseType(oracle).projections(subqueryProjections).build(), "other_db.foo_read"),
+ Arguments.of("scalar subquery expression", "Oracle", "SELECT 1 + (SELECT other_db.foo_read() FROM dual) FROM dual",
+ selectExpression(oracle, new BinaryOperationExpression(0, 0, new LiteralExpressionSegment(0, 0, 1), new SubqueryExpressionSegment(subquery), "+", "1 + subquery")),
+ "other_db.foo_read"),
+ Arguments.of("collection table expression", "Oracle", "SELECT * FROM TABLE(other_db.foo_read())",
+ SelectStatement.builder().databaseType(oracle).projections(starProjection()).from(new CollectionTableSegment(function)).build(), "other_db.foo_read"),
+ Arguments.of("between expression", "PostgreSQL", "SELECT 1 BETWEEN other_db.foo_read() AND 3",
+ selectExpression(postgresql, new BetweenExpression(0, 0, literal, function, literal, false)), "other_db.foo_read"),
+ Arguments.of("in expression", "PostgreSQL", "SELECT 1 IN (other_db.foo_read())", selectExpression(postgresql, new InExpression(0, 0, literal, list, false)), "other_db.foo_read"),
+ Arguments.of("not expression", "PostgreSQL", "SELECT NOT other_db.foo_read()", selectExpression(postgresql, new NotExpression(0, 0, function, false)), "other_db.foo_read"),
+ Arguments.of("type cast expression", "PostgreSQL", "SELECT CAST(other_db.foo_read() AS INTEGER)",
+ selectExpression(postgresql, new TypeCastExpression(0, 0, "CAST", function, "INTEGER")), "other_db.foo_read"),
+ Arguments.of("collate expression", "MySQL", "SELECT other_db.foo_read() COLLATE utf8_bin",
+ selectExpression(mysql, new CollateExpression(0, 0, new LiteralExpressionSegment(0, 0, "utf8_bin"), function)), "other_db.foo_read"),
+ Arguments.of("datetime expression", "Oracle", "SELECT COALESCE(other_db.foo_read() AT TIME ZONE 'UTC', NULL) FROM dual",
+ selectExpression(oracle, datetimeFunction), "other_db.foo_read"),
+ Arguments.of("interval expression", "MySQL", "SELECT INTERVAL other_db.foo_read() DAY", selectExpression(mysql, new IntervalExpression(0, 0, function, IntervalUnit.DAY, "INTERVAL")),
+ "other_db.foo_read"),
+ Arguments.of("unary expression", "MySQL", "SELECT -other_db.foo_read()", selectExpression(mysql, new UnaryOperationExpression(0, 0, function, "-", "-other_db.foo_read()")),
+ "other_db.foo_read"),
+ Arguments.of("row expression", "MySQL", "SELECT (other_db.foo_read(), 1) = (1, 1)",
+ selectExpression(mysql, new BinaryOperationExpression(0, 0, row, comparisonRow, "=", "tuple comparison")), "other_db.foo_read"),
+ Arguments.of("values expression", "MySQL", "SELECT * FROM (VALUES ROW(other_db.foo_read())) foo_values",
+ SelectStatement.builder().databaseType(mysql).projections(starProjection()).from(new SubqueryTableSegment(0, 0,
+ new SubquerySegment(0, 0, selectExpression(mysql, values), "VALUES ROW(other_db.foo_read())"))).build(),
+ "other_db.foo_read"),
+ Arguments.of("case expression", "PostgreSQL", "SELECT CASE other_db.foo_read() WHEN 1 THEN 1 ELSE 1 END",
+ selectExpression(postgresql, new CaseWhenExpression(0, 0, function, List.of(literal), List.of(literal), literal, "CASE")), "other_db.foo_read"),
+ Arguments.of("quantified subquery", "MySQL", "SELECT 1 = ANY (SELECT other_db.foo_read())",
+ selectExpression(mysql, new BinaryOperationExpression(0, 0, literal,
+ new QuantifySubqueryExpression(0, 0, new SubquerySegment(0, 0, selectExpression(mysql, function), "SELECT other_db.foo_read()"), "ANY"), "=", "quantified comparison")),
+ "other_db.foo_read"));
+ }
+
+ private static Stream<Arguments> parsedXMLStatements() {
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ FunctionSegment function = createForeignFunction();
+ LiteralExpressionSegment literal = new LiteralExpressionSegment(0, 0, "foo");
+ XmlElementFunctionSegment element = new XmlElementFunctionSegment(0, 0, "XMLELEMENT", new IdentifierValue("foo_node"), "XMLELEMENT");
+ element.getXmlAttributes().add(function);
+ element.getParameters().add(literal);
+ XmlQueryAndExistsFunctionSegment query = new XmlQueryAndExistsFunctionSegment(0, 0, "XMLQUERY", "'/root'", "XMLQUERY");
+ query.getParameters().add(function);
+ XmlTableOptionsSegment options = new XmlTableOptionsSegment(0, 0, "PASSING other_db.foo_read() COLUMNS foo_value VARCHAR2(100) PATH '/root' DEFAULT 'foo'");
+ options.getParameters().add(function);
+ options.getXmlTableColumnSegments().add(new XmlTableColumnSegment(0, 0, "foo_value", "VARCHAR2(100)", "'/root'", literal, "foo_value"));
+ XmlTableFunctionSegment table = new XmlTableFunctionSegment(0, 0, "XMLTABLE", null, "'/root'", options, "XMLTABLE");
+ return Stream.of(
+ Arguments.of("XML attributes", "Oracle", "SELECT XMLELEMENT(foo_node, XMLATTRIBUTES(other_db.foo_read()), 'foo') FROM dual", selectExpression(oracle, element), "other_db.foo_read"),
+ Arguments.of("XML evaluated name", "Oracle", "SELECT XMLPI(EVALNAME other_db.foo_read(), 'foo') FROM dual",
+ selectExpression(oracle, new XmlPiFunctionSegment(0, 0, "XMLPI", function, literal, "XMLPI")), "other_db.foo_read"),
+ Arguments.of("XML static name", "Oracle", "SELECT XMLPI(NAME foo_node, other_db.foo_read()) FROM dual",
+ selectExpression(oracle, new XmlPiFunctionSegment(0, 0, "XMLPI", "foo_node", function, "XMLPI")), "other_db.foo_read"),
+ Arguments.of("XML query parameters", "Oracle", "SELECT XMLQUERY('/root' PASSING other_db.foo_read() RETURNING CONTENT) FROM dual", selectExpression(oracle, query),
+ "other_db.foo_read"),
+ Arguments.of("XML serialize parameters", "Oracle", "SELECT XMLSERIALIZE(CONTENT other_db.foo_read() AS VARCHAR2(100)) FROM dual",
+ selectExpression(oracle, new XmlSerializeFunctionSegment(0, 0, "XMLSERIALIZE", function, "VARCHAR2(100)", null, null, null, "XMLSERIALIZE")), "other_db.foo_read"),
+ Arguments.of("XML table parameters", "Oracle", "SELECT * FROM XMLTABLE('/root' PASSING other_db.foo_read() COLUMNS foo_value VARCHAR2(100) PATH '/root' DEFAULT 'foo')",
+ SelectStatement.builder().databaseType(oracle).projections(starProjection()).from(new FunctionTableSegment(0, 0, table)).build(), "other_db.foo_read"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource({"parsedSelectClauses", "parsedDMLClauses", "parsedMergeClauses"})
+ void assertExecuteWithParsedClauseReferences(final String name, final String databaseType, final String sql, final SQLStatement statement, final Set<String> expectedObjects) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ MCPDatabaseCapability capability = createCapability(Set.of(SupportedMCPStatement.QUERY, SupportedMCPStatement.DML, SupportedMCPStatement.DDL));
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor);
+ SQLExecutionRequest request = createExecutionRequest(sql);
+ SQLExecutionResult expectedResult = mock(SQLExecutionResult.class);
+ when(statementExecutor.execute(eq(request), any(ClassificationResult.class), eq(capability))).thenReturn(expectedResult);
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> ignored = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenReturn(statement))) {
+ assertThat(facade.execute(request), sameInstance(expectedResult));
+ ArgumentCaptor<ClassificationResult> actual = ArgumentCaptor.forClass(ClassificationResult.class);
+ verify(statementExecutor).execute(eq(request), actual.capture(), eq(capability));
+ assertThat(actual.getValue().getReferencedObjects().stream().map(SQLStatementObjectName::getObjectName).collect(Collectors.toSet()), hasItems(expectedObjects.toArray(new String[0])));
+ }
+ }
+
+ private static Stream<Arguments> parsedSelectClauses() {
+ WindowItemSegment item = new WindowItemSegment(0, 0);
+ item.setPartitionListSegments(List.of(createCurrentFunction("foo_partition")));
+ item.setOrderBySegment(expressionOrder(createCurrentFunction("foo_order")));
+ item.setFrameClause(new CommonExpressionSegment(0, 0, "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW"));
+ AggregationProjectionSegment aggregation = new AggregationProjectionSegment(0, 0, AggregationType.SUM, "SUM(logic_db.foo_value())");
+ aggregation.getParameters().add(createCurrentFunction("foo_value"));
+ aggregation.setWindow(item);
+ ProjectionsSegment aggregationProjections = new ProjectionsSegment(0, 0);
+ aggregationProjections.getProjections().add(aggregation);
+ WindowSegment window = new WindowSegment(0, 0);
+ window.getItemSegments().add(item);
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ ModelSegment model = new ModelSegment(0, 0);
+ model.getReferenceModelSelects().add(new SubquerySegment(0, 0, selectExpression(oracle, createCurrentFunction("foo_reference")), "SELECT logic_db.foo_reference() FROM dual"));
+ model.getCellAssignmentSelects().add(new SubquerySegment(0, 0, selectExpression(oracle, createCurrentFunction("foo_cell")), "SELECT logic_db.foo_cell() FROM dual"));
+ model.getOrderBySegments().add(expressionOrder(createCurrentFunction("foo_model_order")));
+ model.getPartitionColumns().add(new ModelColumnSegment(0, 0, createCurrentFunction("foo_model_partition")));
+ model.getDimensionColumns().add(new ModelColumnSegment(0, 0, createCurrentFunction("foo_dimension")));
+ model.getMeasureColumns().add(new ModelColumnSegment(0, 0, createCurrentFunction("foo_measure")));
+ model.getCellAssignmentColumns().add(new ColumnSegment(0, 0, new IdentifierValue("foo_measure")));
+ ProjectionsSegment datetime = new ProjectionsSegment(0, 0);
+ datetime.getProjections().add(new DatetimeProjectionSegment(0, 0, createCurrentFunction("foo_datetime"),
+ new ExpressionProjectionSegment(0, 0, "'UTC'", new LiteralExpressionSegment(0, 0, "UTC")), "AT TIME ZONE"));
+ ProjectionsSegment interval = new ProjectionsSegment(0, 0);
+ IntervalExpressionProjection intervalProjection = new IntervalExpressionProjection(0, 0, createCurrentFunction("foo_interval_left"), null,
+ createCurrentFunction("foo_interval_right"), "INTERVAL DAY TO SECOND");
+ intervalProjection.setDayToSecondExpression(new IntervalDayToSecondExpression(0, 0, "DAY", "TO", "SECOND"));
+ interval.getProjections().add(intervalProjection);
+ DatabaseType postgresql = TypedSPILoader.getService(DatabaseType.class, "PostgreSQL");
+ LimitSegment limit = new LimitSegment(0, 0, new ExpressionLimitValueSegment(0, 0, createCurrentFunction("foo_offset")),
+ new ExpressionLimitValueSegment(0, 0, createCurrentFunction("foo_limit")));
+ return Stream.of(
+ Arguments.of("aggregate window", "PostgreSQL",
+ "SELECT SUM(logic_db.foo_value()) OVER (PARTITION BY logic_db.foo_partition() ORDER BY logic_db.foo_order() ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)",
+ SelectStatement.builder().databaseType(postgresql).projections(aggregationProjections).build(), Set.of("logic_db.foo_value", "logic_db.foo_partition", "logic_db.foo_order")),
+ Arguments.of("window clauses", "PostgreSQL",
+ "SELECT 1 WINDOW foo_window AS (PARTITION BY logic_db.foo_partition() ORDER BY logic_db.foo_order() ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)",
+ SelectStatement.builder().databaseType(postgresql).projections(starProjection()).window(window).build(), Set.of("logic_db.foo_partition", "logic_db.foo_order")),
+ Arguments.of("pagination expressions", "PostgreSQL", "SELECT 1 LIMIT logic_db.foo_limit() OFFSET logic_db.foo_offset()",
+ SelectStatement.builder().databaseType(postgresql).projections(starProjection()).limit(limit).build(), Set.of("logic_db.foo_offset", "logic_db.foo_limit")),
+ Arguments.of("model clauses", "Oracle", "SELECT * FROM dual MODEL REFERENCE foo_ref ON (SELECT logic_db.foo_reference() FROM dual) DIMENSION BY (foo_key) MEASURES (foo_value) "
+ + "MAIN foo_main PARTITION BY (logic_db.foo_model_partition()) DIMENSION BY (logic_db.foo_dimension() foo_key) MEASURES (logic_db.foo_measure() foo_measure) "
+ + "RULES (foo_measure[FOR foo_key IN (SELECT logic_db.foo_cell() FROM dual)] ORDER BY logic_db.foo_model_order() = 1)",
+ SelectStatement.builder().databaseType(oracle).projections(starProjection()).model(model).build(),
+ Set.of("logic_db.foo_reference", "logic_db.foo_cell", "logic_db.foo_model_order", "logic_db.foo_model_partition", "logic_db.foo_dimension", "logic_db.foo_measure")),
+ Arguments.of("datetime projections", "Oracle", "SELECT logic_db.foo_datetime() AT TIME ZONE 'UTC' FROM dual",
+ SelectStatement.builder().databaseType(oracle).projections(datetime).build(), Set.of("logic_db.foo_datetime")),
+ Arguments.of("interval projections", "Oracle", "SELECT (logic_db.foo_interval_left() - logic_db.foo_interval_right()) DAY TO SECOND FROM dual",
+ SelectStatement.builder().databaseType(oracle).projections(interval).build(), Set.of("logic_db.foo_interval_left", "logic_db.foo_interval_right")));
+ }
+
+ private static FunctionSegment createCurrentFunction(final String name) {
+ FunctionSegment result = new FunctionSegment(0, 0, name, "logic_db." + name + "()");
+ result.setOwner(new OwnerSegment(0, 0, new IdentifierValue("logic_db")));
+ return result;
+ }
+
+ private static OrderBySegment expressionOrder(final ExpressionSegment expression) {
+ return new OrderBySegment(0, 0, List.of(new ExpressionOrderByItemSegment(0, 0, expression.getText(), OrderDirection.ASC, NullsOrderType.FIRST, expression)));
+ }
+
+ private static Stream<Arguments> parsedDMLClauses() {
+ DatabaseType mysql = TypedSPILoader.getService(DatabaseType.class, "MySQL");
+ DatabaseType postgresql = TypedSPILoader.getService(DatabaseType.class, "PostgreSQL");
+ DatabaseType sqlserver = TypedSPILoader.getService(DatabaseType.class, "SQLServer");
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ SimpleTableSegment table = createTable("logic_db", "foo_orders");
+ InsertStatement insertAssignment = InsertStatement.builder().databaseType(mysql).table(table).setAssignment(assignment("foo_insert"))
+ .onDuplicateKeyColumns(new OnDuplicateKeyColumnsSegment(0, 0, assignment("foo_duplicate").getAssignments())).build();
+ InsertStatement insertReturning = InsertStatement.builder().databaseType(postgresql).table(table).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_value")))))
+ .returning(new ReturningSegment(0, 0, expressionProjection(createCurrentFunction("foo_returning")))).build();
+ ExecSegment exec = new ExecSegment(0, 0);
+ exec.setProcedureName(new FunctionNameSegment(0, 0, new IdentifierValue("foo_procedure")));
+ exec.getExpressionSegments().add(new LiteralExpressionSegment(0, 0, 1));
+ InsertStatement insertExec = InsertStatement.builder().databaseType(sqlserver).table(table).exec(exec).output(output("foo_output")).build();
+ MultiTableInsertIntoSegment multi = new MultiTableInsertIntoSegment(0, 0);
+ multi.getInsertStatements().add(InsertStatement.builder().databaseType(oracle).table(table).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_all"))))).build());
+ MultiTableConditionalIntoSegment conditional = new MultiTableConditionalIntoSegment(0, 0);
+ conditional.getWhenThenSegments().add(new MultiTableConditionalIntoWhenThenSegment(0, 0, createCurrentFunction("foo_when"), new MultiTableConditionalIntoThenSegment(0, 0,
+ List.of(InsertStatement.builder().databaseType(oracle).table(table).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_then"))))).build()))));
+ conditional.setElseSegment(new MultiTableConditionalIntoElseSegment(0, 0,
+ List.of(InsertStatement.builder().databaseType(oracle).table(table).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_else"))))).build())));
+ UpdateStatement updateReturning = UpdateStatement.builder().databaseType(postgresql).table(table).setAssignment(assignment("foo_update"))
+ .from(createTable("logic_db", "foo_source")).returning(new ReturningSegment(0, 0, expressionProjection(createCurrentFunction("foo_returning")))).build();
+ UpdateStatement updateOrdered = UpdateStatement.builder().databaseType(mysql).table(table).setAssignment(assignment("foo_update"))
+ .where(new WhereSegment(0, 0, createCurrentFunction("foo_filter"))).orderBy(expressionOrder(createCurrentFunction("foo_order"))).build();
+ return Stream.of(
+ Arguments.of("insert assignments", "MySQL", "INSERT INTO logic_db.foo_orders SET foo_value=logic_db.foo_insert() ON DUPLICATE KEY UPDATE foo_value=logic_db.foo_duplicate()",
+ insertAssignment, Set.of("logic_db.foo_orders", "logic_db.foo_insert", "logic_db.foo_duplicate")),
+ Arguments.of("insert returning", "PostgreSQL", "INSERT INTO logic_db.foo_orders VALUES (logic_db.foo_value()) RETURNING logic_db.foo_returning()",
+ insertReturning, Set.of("logic_db.foo_orders", "logic_db.foo_value", "logic_db.foo_returning")),
+ Arguments.of("insert exec output", "SQLServer", "INSERT INTO logic_db.foo_orders OUTPUT logic_db.foo_output() EXEC foo_procedure 1",
+ insertExec, Set.of("logic_db.foo_orders", "logic_db.foo_output")),
+ Arguments.of("insert into all", "Oracle", "INSERT ALL INTO logic_db.foo_orders VALUES (logic_db.foo_all()) SELECT 1 FROM dual",
+ InsertStatement.builder().databaseType(oracle).multiTableInsertType(MultiTableInsertType.ALL).multiTableInsertInto(multi)
+ .insertSelect(new SubquerySegment(0, 0, selectExpression(oracle, new LiteralExpressionSegment(0, 0, 1)), "SELECT 1 FROM dual")).build(),
+ Set.of("logic_db.foo_all")),
+ Arguments.of("conditional insert into", "Oracle", "INSERT FIRST WHEN logic_db.foo_when()=1 THEN INTO logic_db.foo_orders VALUES (logic_db.foo_then()) "
+ + "ELSE INTO logic_db.foo_orders VALUES (logic_db.foo_else()) SELECT 1 FROM dual",
+ InsertStatement.builder().databaseType(oracle).multiTableInsertType(MultiTableInsertType.FIRST).multiTableConditionalInto(conditional)
+ .insertSelect(new SubquerySegment(0, 0, selectExpression(oracle, new LiteralExpressionSegment(0, 0, 1)), "SELECT 1 FROM dual")).build(),
+ Set.of("logic_db.foo_when", "logic_db.foo_then", "logic_db.foo_else")),
+ Arguments.of("update from returning", "PostgreSQL", "UPDATE logic_db.foo_orders SET foo_value=logic_db.foo_update() FROM logic_db.foo_source RETURNING logic_db.foo_returning()",
+ updateReturning, Set.of("logic_db.foo_orders", "logic_db.foo_source", "logic_db.foo_update", "logic_db.foo_returning")),
+ Arguments.of("update ordered", "MySQL", "UPDATE logic_db.foo_orders SET foo_value=logic_db.foo_update() WHERE logic_db.foo_filter()=1 ORDER BY logic_db.foo_order()", updateOrdered,
+ Set.of("logic_db.foo_orders", "logic_db.foo_update", "logic_db.foo_filter", "logic_db.foo_order")),
+ Arguments.of("update output", "SQLServer", "UPDATE logic_db.foo_orders SET foo_value=logic_db.foo_update() OUTPUT logic_db.foo_output()",
+ UpdateStatement.builder().databaseType(sqlserver).table(table).setAssignment(assignment("foo_update")).output(output("foo_output")).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_update", "logic_db.foo_output")),
+ Arguments.of("delete ordered", "MySQL", "DELETE FROM logic_db.foo_orders ORDER BY logic_db.foo_order()",
+ DeleteStatement.builder().databaseType(mysql).table(table).orderBy(expressionOrder(createCurrentFunction("foo_order"))).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_order")),
+ Arguments.of("delete returning", "PostgreSQL", "DELETE FROM logic_db.foo_orders RETURNING logic_db.foo_returning()",
+ DeleteStatement.builder().databaseType(postgresql).table(table).returning(new ReturningSegment(0, 0, expressionProjection(createCurrentFunction("foo_returning")))).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_returning")),
+ Arguments.of("delete output", "SQLServer", "DELETE FROM logic_db.foo_orders OUTPUT logic_db.foo_output()",
+ DeleteStatement.builder().databaseType(sqlserver).table(table).output(output("foo_output")).build(), Set.of("logic_db.foo_orders", "logic_db.foo_output")));
+ }
+
+ private static SetAssignmentSegment assignment(final String functionName) {
+ return new SetAssignmentSegment(0, 0, List.of(new ColumnAssignmentSegment(0, 0, List.of(new ColumnSegment(0, 0, new IdentifierValue("foo_value"))), createCurrentFunction(functionName))));
+ }
+
+ private static ProjectionsSegment expressionProjection(final ExpressionSegment expression) {
+ ProjectionsSegment result = new ProjectionsSegment(0, 0);
+ result.getProjections().add(new ExpressionProjectionSegment(0, 0, expression.getText(), expression));
+ return result;
+ }
+
+ private static OutputSegment output(final String functionName) {
+ OutputSegment result = new OutputSegment(0, 0);
+ result.setOutputColumns(expressionProjection(createCurrentFunction(functionName)));
+ return result;
+ }
+
+ private static Stream<Arguments> parsedMergeClauses() {
+ DatabaseType oracle = TypedSPILoader.getService(DatabaseType.class, "Oracle");
+ DatabaseType sqlserver = TypedSPILoader.getService(DatabaseType.class, "SQLServer");
+ SimpleTableSegment target = createTable("logic_db", "foo_orders");
+ SimpleTableSegment source = createTable("logic_db", "foo_source");
+ UpdateStatement update = UpdateStatement.builder().databaseType(oracle).setAssignment(assignment("foo_update"))
+ .deleteWhere(new WhereSegment(0, 0, createCurrentFunction("foo_delete"))).build();
+ InsertStatement insert = InsertStatement.builder().databaseType(oracle).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_insert")))))
+ .where(new WhereSegment(0, 0, createCurrentFunction("foo_where"))).build();
+ MergeWhenAndThenSegment updateClause = new MergeWhenAndThenSegment(0, 0, "WHEN MATCHED");
+ updateClause.setAndExpr(createCurrentFunction("foo_matched"));
+ updateClause.setUpdate(UpdateStatement.builder().databaseType(sqlserver).setAssignment(assignment("foo_update")).build());
+ MergeWhenAndThenSegment insertClause = new MergeWhenAndThenSegment(0, 0, "WHEN NOT MATCHED");
+ insertClause.setAndExpr(createCurrentFunction("foo_unmatched"));
+ insertClause.setInsert(InsertStatement.builder().databaseType(sqlserver).values(List.of(new InsertValuesSegment(0, 0, List.of(createCurrentFunction("foo_insert"))))).build());
+ DatabaseType postgresql = TypedSPILoader.getService(DatabaseType.class, "PostgreSQL");
+ CombineSegment combine = new CombineSegment(0, 0, new SubquerySegment(0, 0, selectExpression(postgresql, createCurrentFunction("foo_left")), "SELECT logic_db.foo_left()"),
+ CombineType.UNION, new SubquerySegment(0, 0, selectExpression(postgresql, createCurrentFunction("foo_right")), "SELECT logic_db.foo_right()"));
+ HierarchicalQuerySegment hierarchical = new HierarchicalQuerySegment(0, 0);
+ hierarchical.setStartWith(createCurrentFunction("foo_start"));
+ hierarchical.setConnectBy(createCurrentFunction("foo_connect"));
+ return Stream.of(
+ Arguments.of("merge update and insert", "Oracle", "MERGE INTO logic_db.foo_orders USING logic_db.foo_source ON (logic_db.foo_on()=1) "
+ + "WHEN MATCHED THEN UPDATE SET foo_value=logic_db.foo_update() DELETE WHERE logic_db.foo_delete()=1 "
+ + "WHEN NOT MATCHED THEN INSERT (foo_value) VALUES (logic_db.foo_insert()) WHERE logic_db.foo_where()=1",
+ MergeStatement.builder().databaseType(oracle).target(target).source(source).expression(new ExpressionWithParamsSegment(0, 0, createCurrentFunction("foo_on")))
+ .update(update).insert(insert).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_source", "logic_db.foo_on", "logic_db.foo_update", "logic_db.foo_delete", "logic_db.foo_insert", "logic_db.foo_where")),
+ Arguments.of("merge conditional clauses", "SQLServer", "MERGE INTO logic_db.foo_orders USING logic_db.foo_source ON logic_db.foo_on()=1 "
+ + "WHEN MATCHED AND logic_db.foo_matched()=1 THEN UPDATE SET foo_value=logic_db.foo_update() "
+ + "WHEN NOT MATCHED AND logic_db.foo_unmatched()=1 THEN INSERT (foo_value) VALUES (logic_db.foo_insert()) OUTPUT logic_db.foo_output()",
+ MergeStatement.builder().databaseType(sqlserver).target(target).source(source).expression(new ExpressionWithParamsSegment(0, 0, createCurrentFunction("foo_on")))
+ .whenAndThens(List.of(updateClause, insertClause)).output(output("foo_output")).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_source", "logic_db.foo_on", "logic_db.foo_matched", "logic_db.foo_unmatched", "logic_db.foo_update", "logic_db.foo_insert",
+ "logic_db.foo_output")),
+ Arguments.of("combined selects", "PostgreSQL", "SELECT logic_db.foo_left() UNION SELECT logic_db.foo_right()",
+ SelectStatement.builder().databaseType(postgresql).projections(expressionProjection(createCurrentFunction("foo_left"))).combine(combine).build(),
+ Set.of("logic_db.foo_left", "logic_db.foo_right")),
+ Arguments.of("hierarchical select", "Oracle", "SELECT * FROM dual START WITH logic_db.foo_start()=1 CONNECT BY logic_db.foo_connect()=1",
+ SelectStatement.builder().databaseType(oracle).projections(starProjection()).hierarchicalQuery(hierarchical).build(), Set.of("logic_db.foo_start", "logic_db.foo_connect")),
+ Arguments.of("select clause references", "PostgreSQL", "SELECT * FROM logic_db.foo_orders WHERE logic_db.foo_where()=1 GROUP BY logic_db.foo_group() "
+ + "HAVING logic_db.foo_having()=1 ORDER BY logic_db.foo_order()",
+ SelectStatement.builder().databaseType(postgresql).projections(starProjection()).from(target).where(new WhereSegment(0, 0, createCurrentFunction("foo_where")))
+ .groupBy(new GroupBySegment(0, 0, expressionOrder(createCurrentFunction("foo_group")).getOrderByItems()))
+ .having(new HavingSegment(0, 0, createCurrentFunction("foo_having"))).orderBy(expressionOrder(createCurrentFunction("foo_order"))).build(),
+ Set.of("logic_db.foo_orders", "logic_db.foo_where", "logic_db.foo_group", "logic_db.foo_having", "logic_db.foo_order")));
+ }
+
+ @Test
+ void assertExecuteWithMissingStatementVisitor() {
+ DatabaseType databaseType = TypedSPILoader.getService(DatabaseType.class, "MySQL");
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ when(capability.getDatabaseType()).thenReturn("MySQL");
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), mock(MCPJdbcStatementExecutor.class));
+ try (MockedStatic<DatabaseTypedSPILoader> mocked = mockStatic(DatabaseTypedSPILoader.class, CALLS_REAL_METHODS)) {
+ mocked.when(() -> DatabaseTypedSPILoader.findService(SQLStatementVisitorFacade.class, databaseType)).thenReturn(Optional.empty());
+ MCPUnsupportedException actual = assertThrows(MCPUnsupportedException.class, () -> facade.execute(createExecutionRequest("SELECT 1")));
+ assertThat(actual.getMessage(), is("SQL parser is not available for database type `MySQL`."));
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("malformedDelimiterInputs")
+ void assertExecuteWithMalformedDelimiter(final String name, final String databaseType, final String sql) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), mock(MCPJdbcStatementExecutor.class));
+ try (MockedConstruction<ShardingSphereSQLParserEngine> actual = mockConstruction(ShardingSphereSQLParserEngine.class)) {
+ assertThrows(MCPUnsupportedSQLStatementException.class, () -> facade.execute(createExecutionRequest(sql)));
+ assertTrue(actual.constructed().isEmpty());
+ }
+ }
+
+ private static Stream<Arguments> malformedDelimiterInputs() {
+ return Stream.of(
+ Arguments.of("bare string quote", "MySQL", "SELECT '"),
+ Arguments.of("bare identifier quote", "MySQL", "SELECT `"),
+ Arguments.of("bare ANSI quote", "PostgreSQL", "SELECT \""),
+ Arguments.of("unterminated fallback block comment", "ClickHouse", "SELECT 1 /* foo"));
+ }
+
+ @Test
+ void assertExecuteWithEmptyParsedStatement() {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ when(capability.getDatabaseType()).thenReturn("PostgreSQL");
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor);
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> ignored = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenReturn(new EmptyStatement(TypedSPILoader.getService(DatabaseType.class, "PostgreSQL"))))) {
+ assertThrows(MCPUnsupportedSQLStatementException.class, () -> facade.execute(createExecutionRequest("/**/")));
+ verifyNoInteractions(statementExecutor);
+ }
+ }
+
+ @Test
+ void assertExecuteWithParenthesizedStatement() {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ when(capability.getDatabaseType()).thenReturn("MySQL");
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor);
+ SQLStatement statement = selectExpression(TypedSPILoader.getService(DatabaseType.class, "MySQL"), new LiteralExpressionSegment(0, 0, 1));
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> ignored = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenReturn(statement))) {
+ assertThrows(MCPUnsupportedSQLStatementException.class, () -> facade.execute(createExecutionRequest("(SELECT 1)")));
+ verifyNoInteractions(statementExecutor);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("parserFailureInputs")
+ void assertExecuteWithParserFailure(final String name, final String databaseType, final String sql) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = mock(MCPDatabaseCapability.class);
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor);
+ IllegalArgumentException expectedCause = new IllegalArgumentException("foo_invalid_sql");
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> actual = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenThrow(expectedCause))) {
+ MCPUnsupportedSQLStatementException actualException = assertThrows(MCPUnsupportedSQLStatementException.class, () -> facade.execute(createExecutionRequest(sql)));
+ assertThat(actualException.getCause(), sameInstance(expectedCause));
+ assertThat(actual.constructed().size(), is(1));
+ verify(actual.constructed().get(0)).parse(sql, false);
+ verifyNoInteractions(statementExecutor);
+ }
+ }
+
+ private static Stream<Arguments> parserFailureInputs() {
+ return Stream.of(
+ Arguments.of("fallback block comment masks semicolons", "ClickHouse", "SELECT 1 /* ; SELECT 2 */"),
+ Arguments.of("fallback line comment masks semicolons", "ClickHouse", "SELECT 1 -- ; SELECT 2\n +2"),
+ Arguments.of("fallback line comment without newline", "ClickHouse", "SELECT 1 -- ; SELECT 2"),
+ Arguments.of("incomplete division", "MySQL", "SELECT 1 /"),
+ Arguments.of("spaced comment delimiter", "MySQL", "SELECT 1 / *"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("supportedLexicalQueries")
+ void assertExecuteWithSupportedLexicalQuery(final String name, final String databaseType, final String sql) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = createCapability(Set.of(SupportedMCPStatement.QUERY));
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ SQLExecutionRequest request = createExecutionRequest(sql);
+ SQLExecutionResult expectedResult = mock(SQLExecutionResult.class);
+ when(statementExecutor.execute(eq(request), any(), eq(capability))).thenReturn(expectedResult);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor);
+ SQLStatement statement = selectExpression(TypedSPILoader.getService(DatabaseType.class, databaseType), new LiteralExpressionSegment(0, 0, 1));
+ try (
+ MockedConstruction<ShardingSphereSQLParserEngine> ignored = mockConstruction(ShardingSphereSQLParserEngine.class,
+ (mock, context) -> when(mock.parse(anyString(), eq(false))).thenReturn(statement))) {
+ assertThat(facade.execute(request), sameInstance(expectedResult));
+ ArgumentCaptor<ClassificationResult> actual = ArgumentCaptor.forClass(ClassificationResult.class);
+ verify(statementExecutor).execute(eq(request), actual.capture(), eq(capability));
+ assertThat(actual.getValue().getStatementClass(), is(SupportedMCPStatement.QUERY));
+ assertThat(actual.getValue().getNormalizedSql(), is(sql));
+ }
+ }
+
+ private static Stream<Arguments> supportedLexicalQueries() {
+ return Stream.of(
+ Arguments.of("Oracle trailing fullwidth whitespace", "Oracle", "SELECT 1 FROM dual "),
+ Arguments.of("Oracle leading fullwidth whitespace", "Oracle", " SELECT 1 FROM dual"),
+ Arguments.of("division expression", "MySQL", "SELECT 1 / 2"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("quotedExplainPrefixes")
+ void assertExecuteExplainWithQuotedKeyword(final String name, final String databaseType, final String sql) {
+ MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
+ MCPSessionExecutionCoordinator coordinator = mock(MCPSessionExecutionCoordinator.class);
+ MCPDatabaseCapability capability = createCapability(Set.of(SupportedMCPStatement.EXPLAIN));
+ when(capability.getDatabaseType()).thenReturn(databaseType);
+ when(capabilityProvider.provide("logic_db")).thenReturn(Optional.of(capability));
+ mockSessionLock(coordinator);
+ MCPJdbcStatementExecutor statementExecutor = mock(MCPJdbcStatementExecutor.class);
+ ClassificationResult classification = new ClassificationResult(SupportedMCPStatement.QUERY, "SELECT", "SELECT 1", "", List.of(), false);
+ MCPSQLExecutionFacade facade = createFacade(capabilityProvider, coordinator, mock(MCPJdbcTransactionStatementExecutor.class), statementExecutor, createMCPStatementAnalyzer(classification));
+ MCPInvalidRequestException actual = assertThrows(MCPInvalidRequestException.class, () -> facade.executeExplain(createExecutionRequest(sql), "SELECT 1"));
+ assertThat(actual.getMessage(), is("explain_sql must start with EXPLAIN."));
+ verifyNoInteractions(statementExecutor);
+ }
+
+ private static Stream<Arguments> quotedExplainPrefixes() {
+ return Stream.of(
+ Arguments.of("ANSI quoted keyword", "PostgreSQL", "\"EXPLAIN\" SELECT 1"),
+ Arguments.of("backtick quoted keyword", "MySQL", "`EXPLAIN` SELECT 1"),
+ Arguments.of("bracket quoted keyword", "SQLServer", "[EXPLAIN] SELECT 1"));
+ }
+
@Test
void assertExecuteWithMissingSession() {
MCPDatabaseCapabilityProvider capabilityProvider = mock(MCPDatabaseCapabilityProvider.class);
diff --git a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/model/EncryptWorkflowRequestTest.java b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/model/EncryptWorkflowRequestTest.java
index ed9b0faa5c9..a0f3822707f 100644
--- a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/model/EncryptWorkflowRequestTest.java
+++ b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/model/EncryptWorkflowRequestTest.java
@@ -17,6 +17,7 @@
package org.apache.shardingsphere.mcp.feature.encrypt.tool.model;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -70,4 +71,15 @@ class EncryptWorkflowRequestTest {
assertThat(actualRequest.getPrimaryAlgorithmProperties().get("aes-key-value"), is("current-key"));
assertThat(actualRequest.getOptions().getAssistedQueryAlgorithmProperties().get("digest-algorithm-name"), is("SHA-256"));
}
+
+ @Test
+ void assertMergeFromBaseRequest() {
+ WorkflowRequest previous = new WorkflowRequest();
+ previous.setDatabase("foo_db");
+ EncryptWorkflowRequest current = new EncryptWorkflowRequest();
+ current.setColumn("foo_column");
+ EncryptWorkflowRequest actual = EncryptWorkflowRequest.merge(previous, current);
+ assertThat(actual.getDatabase(), is("foo_db"));
+ assertThat(actual.getColumn(), is("foo_column"));
+ }
}
diff --git a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowApplyArtifactValidatorTest.java b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowApplyArtifactValidatorTest.java
index 01897fcaf28..0354b3b3d78 100644
--- a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowApplyArtifactValidatorTest.java
+++ b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowApplyArtifactValidatorTest.java
@@ -90,6 +90,27 @@ class EncryptWorkflowApplyArtifactValidatorTest {
assertTrue(actual.isEmpty());
}
+ @Test
+ void assertValidateUnavailableQueryAlgorithms() {
+ EncryptWorkflowRequest request = new EncryptWorkflowRequest();
+ request.getOptions().setRequiresEqualityFilter(true);
+ request.getOptions().setAssistedQueryAlgorithmType("FOO_ASSISTED");
+ request.getOptions().setRequiresLikeQuery(true);
+ request.getOptions().setLikeQueryAlgorithmType("FOO_LIKE");
+ WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();
+ snapshot.setRequest(request);
+ String sql = "CREATE ENCRYPT RULE `foo_table` (COLUMNS((NAME=`foo_column`, CIPHER=`foo_cipher`, ENCRYPT_ALGORITHM(TYPE(NAME='aes')))))";
+ try (MockedStatic<WorkflowAlgorithmUtils> mocked = mockStatic(WorkflowAlgorithmUtils.class, CALLS_REAL_METHODS)) {
+ mocked.when(() -> WorkflowAlgorithmUtils.isAlgorithmServiceAvailable(EncryptAlgorithm.class, "FOO_ASSISTED", Map.of())).thenReturn(false);
+ mocked.when(() -> WorkflowAlgorithmUtils.isAlgorithmServiceAvailable(EncryptAlgorithm.class, "FOO_LIKE", Map.of())).thenReturn(false);
+ List<Map<String, Object>> actual = new EncryptWorkflowApplyArtifactValidator().validate(snapshot, List.of(new ExecutableWorkflowArtifact(sql, sql)));
+ assertThat(actual.size(), is(2));
+ assertThat(actual.get(0).get("message"),
+ is("Generated encrypt DistSQL references assisted query algorithm `FOO_ASSISTED`, but it cannot be loaded or initialized by EncryptAlgorithm SPI."));
+ assertThat(actual.get(1).get("message"), is("Generated encrypt DistSQL references like query algorithm `FOO_LIKE`, but it cannot be loaded or initialized by EncryptAlgorithm SPI."));
+ }
+ }
+
private ExecutableWorkflowArtifact createArtifact(final String sql, final String displaySql) {
return new ExecutableWorkflowArtifact(sql, displaySql);
}
diff --git a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowValidationServiceTest.java b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowValidationServiceTest.java
index a5af54a939b..cc8282f06d1 100644
--- a/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowValidationServiceTest.java
+++ b/mcp/features/encrypt/src/test/java/org/apache/shardingsphere/mcp/feature/encrypt/tool/service/EncryptWorkflowValidationServiceTest.java
@@ -203,6 +203,39 @@ class EncryptWorkflowValidationServiceTest {
assertThat(((Map<?, ?>) ((List<?>) actual.get("mismatches")).getFirst()).get("actual"), is("logic_column=email"));
}
+ @Test
+ void assertValidateDropWithRemainingRule() {
+ WorkflowContextSnapshot snapshot = createSnapshot("plan-1", "session-1", "executed", "drop");
+ EncryptRuleInspectionService inspectionService = mock(EncryptRuleInspectionService.class);
+ when(inspectionService.queryEncryptRules(any(), any(), any())).thenReturn(List.of(createRuleRow()));
+ Map<String, Object> actual = createService(inspectionService).validate(snapshot, createQueryFacade()).toMap();
+ assertThat(actual.get("overall_status"), is("failed"));
+ assertThat(getValidationSection(actual, "rule").get("details"), is("Encrypt rule still exists."));
+ assertThat(((Map<?, ?>) ((List<?>) actual.get("mismatches")).getFirst()).get("code"), is(WorkflowIssueCode.RULE_STATE_MISMATCH));
+ }
+
+ @Test
+ void assertValidateQueryAlgorithmMismatch() {
+ WorkflowContextSnapshot snapshot = createSnapshot("plan-1", "session-1", "executed", "create");
+ EncryptWorkflowRequest request = (EncryptWorkflowRequest) snapshot.getRequest();
+ request.getOptions().setRequiresEqualityFilter(true);
+ request.getOptions().setAssistedQueryColumnName("phone_assisted");
+ request.getOptions().setAssistedQueryAlgorithmType("MD5");
+ request.getOptions().setRequiresLikeQuery(true);
+ request.getOptions().setLikeQueryColumnName("phone_like");
+ request.getOptions().setLikeQueryAlgorithmType("CHAR_DIGEST_LIKE");
+ EncryptRuleInspectionService inspectionService = mock(EncryptRuleInspectionService.class);
+ when(inspectionService.queryEncryptRules(any(), any(), any())).thenReturn(List.of(Map.of(
+ "logic_column", "phone", "cipher_column", "phone_cipher", "encryptor_type", "AES", "assisted_query_column", "phone_assisted",
+ "assisted_query_type", "SM3", "like_query_column", "phone_like", "like_query_type", "CHAR_DIGEST_LIKE")));
+ MCPFeatureQueryFacade queryFacade = createQueryFacade();
+ when(queryFacade.isSameIdentifier("logic_db", IdentifierScope.COLUMN, "phone_assisted", "phone_assisted")).thenReturn(true);
+ when(queryFacade.isSameIdentifier("logic_db", IdentifierScope.COLUMN, "phone_like", "phone_like")).thenReturn(true);
+ Map<String, Object> actual = createService(inspectionService).validate(snapshot, queryFacade).toMap();
+ assertThat(actual.get("overall_status"), is("failed"));
+ assertThat(((Map<?, ?>) ((List<?>) actual.get("mismatches")).getFirst()).get("expected"), is("assisted_query_type=MD5"));
+ }
+
private MCPFeatureQueryFacade createQueryFacade() {
MCPFeatureQueryFacade result = mock(MCPFeatureQueryFacade.class);
when(result.isSameIdentifier("logic_db", IdentifierScope.COLUMN, "phone", "phone")).thenReturn(true);
diff --git a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingRuleWorkflowRequestTest.java b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingRuleWorkflowRequestTest.java
index b5994e77c98..771bcee4958 100644
--- a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingRuleWorkflowRequestTest.java
+++ b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingRuleWorkflowRequestTest.java
@@ -59,6 +59,13 @@ class ReadwriteSplittingRuleWorkflowRequestTest {
assertThat(actual.getDatabase(), is("logic_db"));
}
+ @Test
+ void assertSetReadStorageUnitsWithMissingListValues() {
+ ReadwriteSplittingRuleWorkflowRequest request = new ReadwriteSplittingRuleWorkflowRequest();
+ request.setReadStorageUnits(List.of(" ", " foo_read "));
+ assertThat(request.getReadStorageUnits(), is(List.of("foo_read")));
+ }
+
private ReadwriteSplittingRuleWorkflowRequest createRequest() {
ReadwriteSplittingRuleWorkflowRequest result = new ReadwriteSplittingRuleWorkflowRequest();
result.setRuleName("readwrite_ds");
diff --git a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingStatusWorkflowRequestTest.java b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingStatusWorkflowRequestTest.java
index e98ae80beab..784d746af99 100644
--- a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingStatusWorkflowRequestTest.java
+++ b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/model/ReadwriteSplittingStatusWorkflowRequestTest.java
@@ -17,6 +17,7 @@
package org.apache.shardingsphere.mcp.feature.readwritesplitting.tool.model;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -34,4 +35,15 @@ class ReadwriteSplittingStatusWorkflowRequestTest {
assertThat(actual.getRuleName(), is("readwrite_ds"));
assertThat(actual.getStorageUnit(), is("read_ds_0"));
}
+
+ @Test
+ void assertMergeFromBaseRequest() {
+ WorkflowRequest previous = new WorkflowRequest();
+ previous.setDatabase("foo_db");
+ ReadwriteSplittingStatusWorkflowRequest current = new ReadwriteSplittingStatusWorkflowRequest();
+ current.setStorageUnit("foo_read");
+ ReadwriteSplittingStatusWorkflowRequest actual = ReadwriteSplittingStatusWorkflowRequest.merge(previous, current);
+ assertThat(actual.getDatabase(), is("foo_db"));
+ assertThat(actual.getStorageUnit(), is("foo_read"));
+ }
}
diff --git a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingAlgorithmRecommendationServiceTest.java b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingAlgorithmRecommendationServiceTest.java
index 7d016852e81..fce2f1c8efd 100644
--- a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingAlgorithmRecommendationServiceTest.java
+++ b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingAlgorithmRecommendationServiceTest.java
@@ -22,10 +22,14 @@ import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmCandidate;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssue;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssueCode;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -35,24 +39,24 @@ class ReadwriteSplittingAlgorithmRecommendationServiceTest {
private final ReadwriteSplittingAlgorithmRecommendationService service = new ReadwriteSplittingAlgorithmRecommendationService();
- @Test
- void assertRecommendSpecifiedAlgorithm() {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertRecommendAlgorithmCases")
+ void assertRecommendAlgorithm(final String name, final String algorithmType, final List<Map<String, Object>> algorithmRows, final String expectedType, final String expectedReason) {
ReadwriteSplittingRuleWorkflowRequest request = new ReadwriteSplittingRuleWorkflowRequest();
- request.setLoadBalancerType("round_robin");
- List<AlgorithmCandidate> actual = service.recommendLoadBalanceAlgorithms(
- request, List.of(Map.of("type", "ROUND_ROBIN")), new LinkedList<>());
- assertThat(actual.getFirst().getAlgorithmType(), is("ROUND_ROBIN"));
+ request.setLoadBalancerType(algorithmType);
+ List<AlgorithmCandidate> actual = service.recommendLoadBalanceAlgorithms(request, algorithmRows, new LinkedList<>());
+ assertThat(actual.getFirst().getAlgorithmType(), is(expectedType));
assertThat(actual.getFirst().getAlgorithmRole(), is("primary"));
+ assertThat(actual.getFirst().getRecommendationReason(), is(expectedReason));
}
- @Test
- void assertRecommendDefaultAlgorithm() {
- ReadwriteSplittingRuleWorkflowRequest request = new ReadwriteSplittingRuleWorkflowRequest();
- List<AlgorithmCandidate> actual = service.recommendLoadBalanceAlgorithms(
- request, List.of(Map.of("type", "RANDOM"), Map.of("type", "ROUND_ROBIN")), new LinkedList<>());
- assertThat(actual.getFirst().getAlgorithmType(), is("ROUND_ROBIN"));
- assertThat(actual.getFirst().getRecommendationReason(), is(
- "Selected from algorithms reported by the current Proxy using MCP's built-in load-balance preference order."));
+ private static Stream<Arguments> assertRecommendAlgorithmCases() {
+ return Stream.of(
+ Arguments.of("specified algorithm", "round_robin", List.of(Map.of("type", "ROUND_ROBIN")), "ROUND_ROBIN", "User specified load-balance algorithm."),
+ Arguments.of("default algorithm", "", List.of(Map.of("type", "RANDOM"), Map.of("type", "ROUND_ROBIN")), "ROUND_ROBIN",
+ "Selected from algorithms reported by the current Proxy using MCP's built-in load-balance preference order."),
+ Arguments.of("first available algorithm", "", List.of(Map.of("type", "FOO_ALGORITHM")), "FOO_ALGORITHM",
+ "Selected from algorithms reported by the current Proxy using MCP's built-in load-balance preference order."));
}
@Test
@@ -63,4 +67,13 @@ class ReadwriteSplittingAlgorithmRecommendationServiceTest {
assertTrue(service.recommendLoadBalanceAlgorithms(request, List.of(Map.of("type", "RANDOM")), issues).isEmpty());
assertThat(issues.getFirst().getCode(), is(WorkflowIssueCode.ALGORITHM_NOT_FOUND));
}
+
+ @Test
+ void assertRecommendWithoutAlgorithms() {
+ List<WorkflowIssue> issues = new LinkedList<>();
+ List<AlgorithmCandidate> actual = service.recommendLoadBalanceAlgorithms(new ReadwriteSplittingRuleWorkflowRequest(), List.of(), issues);
+ assertTrue(actual.isEmpty());
+ assertThat(issues.getFirst().getCode(), is(WorkflowIssueCode.ALGORITHM_NOT_FOUND));
+ assertThat(issues.getFirst().getMessage(), is("No load-balance algorithm is available from the current Proxy."));
+ }
}
diff --git a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingInspectionServiceTest.java b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingInspectionServiceTest.java
index b2c6623cedf..18f3de1bfea 100644
--- a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingInspectionServiceTest.java
+++ b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingInspectionServiceTest.java
@@ -89,4 +89,18 @@ class ReadwriteSplittingInspectionServiceTest {
when(queryFacade.query(eq("logic_db"), eq("COUNT READWRITE_SPLITTING RULE FROM logic_db"))).thenReturn(List.of(Map.of("count", 1)));
assertThat(new ReadwriteSplittingInspectionService().queryRuleCount(queryFacade, "logic_db"), is(List.of(Map.of("count", 1))));
}
+
+ @Test
+ void assertQueryRule() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ReadwriteSplittingInspectionService().queryRule(queryFacade, "foo_db", "foo_rule");
+ verify(queryFacade).query("foo_db", "SHOW READWRITE_SPLITTING RULE foo_rule FROM foo_db");
+ }
+
+ @Test
+ void assertQueryStatuses() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ReadwriteSplittingInspectionService().queryStatuses(queryFacade, "foo_db");
+ verify(queryFacade).query("foo_db", "SHOW STATUS FROM READWRITE_SPLITTING RULES FROM foo_db");
+ }
}
diff --git a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingStatusWorkflowPlanningServiceTest.java b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingStatusWorkflowPlanningServiceTest.java
index 272779ff8ab..7981a5665e8 100644
--- a/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingStatusWorkflowPlanningServiceTest.java
+++ b/mcp/features/readwrite-splitting/src/test/java/org/apache/shardingsphere/mcp/feature/readwritesplitting/tool/service/ReadwriteSplittingStatusWorkflowPlanningServiceTest.java
@@ -90,6 +90,16 @@ class ReadwriteSplittingStatusWorkflowPlanningServiceTest {
verify(queryFacade, never()).query("logic_db", "SHOW STATUS FROM READWRITE_SPLITTING RULE readwrite_ds FROM logic_db");
}
+ @Test
+ void assertPlanClarifiesMissingDatabase() {
+ ReadwriteSplittingStatusWorkflowRequest request = new ReadwriteSplittingStatusWorkflowRequest();
+ request.setTargetStatus("enable");
+ WorkflowContextSnapshot actual = planningService.plan(new TestWorkflowSessionContext(), mock(MCPFeatureQueryFacade.class), request);
+ assertThat(actual.getStatus(), is(WorkflowLifecycle.STATUS_CLARIFYING));
+ assertThat(actual.getIssues().getFirst().getCode(), is(WorkflowIssueCode.DATABASE_REQUIRED));
+ assertTrue(actual.getRuleArtifacts().isEmpty());
+ }
+
private MCPFeatureQueryFacade createQueryFacade(final List<Map<String, Object>> statuses) {
MCPFeatureQueryFacade result = mock(MCPFeatureQueryFacade.class);
when(result.isSameIdentifier("logic_db", IdentifierScope.TABLE, "readwrite_ds", "readwrite_ds")).thenReturn(true);
diff --git a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowAlgorithmCleanupWorkflowRequestTest.java b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowAlgorithmCleanupWorkflowRequestTest.java
index 4840dcd4074..e92332e7fbf 100644
--- a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowAlgorithmCleanupWorkflowRequestTest.java
+++ b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowAlgorithmCleanupWorkflowRequestTest.java
@@ -17,6 +17,7 @@
package org.apache.shardingsphere.mcp.feature.shadow.tool.model;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -34,4 +35,15 @@ class ShadowAlgorithmCleanupWorkflowRequestTest {
assertThat(actual.getDatabase(), is("logic_db"));
assertThat(actual.getAlgorithmName(), is("shadow_algorithm"));
}
+
+ @Test
+ void assertMergeFromBaseRequest() {
+ WorkflowRequest previous = new WorkflowRequest();
+ previous.setDatabase("foo_db");
+ ShadowAlgorithmCleanupWorkflowRequest current = new ShadowAlgorithmCleanupWorkflowRequest();
+ current.setAlgorithmName("foo_algorithm");
+ ShadowAlgorithmCleanupWorkflowRequest actual = ShadowAlgorithmCleanupWorkflowRequest.merge(previous, current);
+ assertThat(actual.getDatabase(), is("foo_db"));
+ assertThat(actual.getAlgorithmName(), is("foo_algorithm"));
+ }
}
diff --git a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowDefaultAlgorithmWorkflowRequestTest.java b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowDefaultAlgorithmWorkflowRequestTest.java
index 8806285aa5f..4198b52401b 100644
--- a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowDefaultAlgorithmWorkflowRequestTest.java
+++ b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowDefaultAlgorithmWorkflowRequestTest.java
@@ -17,6 +17,7 @@
package org.apache.shardingsphere.mcp.feature.shadow.tool.model;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import java.util.Map;
@@ -36,4 +37,22 @@ class ShadowDefaultAlgorithmWorkflowRequestTest {
assertThat(actual.getAlgorithmType(), is("SQL_HINT"));
assertThat(actual.getAlgorithmProperties().get("k"), is("v"));
}
+
+ @Test
+ void assertMergeFromBaseRequest() {
+ WorkflowRequest previous = new WorkflowRequest();
+ previous.setDatabase("foo_db");
+ ShadowDefaultAlgorithmWorkflowRequest current = new ShadowDefaultAlgorithmWorkflowRequest();
+ current.setAlgorithmType("SQL_HINT");
+ ShadowDefaultAlgorithmWorkflowRequest actual = ShadowDefaultAlgorithmWorkflowRequest.merge(previous, current);
+ assertThat(actual.getDatabase(), is("foo_db"));
+ assertThat(actual.getAlgorithmType(), is("SQL_HINT"));
+ }
+
+ @Test
+ void assertGetAlgorithmProperties() {
+ ShadowDefaultAlgorithmWorkflowRequest request = new ShadowDefaultAlgorithmWorkflowRequest();
+ request.putAlgorithmProperties(Map.of("foo_key", "foo_value"));
+ assertThat(request.getAlgorithmProperties("primary"), is(Map.of("foo_key", "foo_value")));
+ }
}
diff --git a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowRuleWorkflowRequestTest.java b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowRuleWorkflowRequestTest.java
index 7677c0537df..9fea3b60f07 100644
--- a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowRuleWorkflowRequestTest.java
+++ b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/model/ShadowRuleWorkflowRequestTest.java
@@ -17,12 +17,14 @@
package org.apache.shardingsphere.mcp.feature.shadow.tool.model;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertTrue;
class ShadowRuleWorkflowRequestTest {
@@ -41,4 +43,22 @@ class ShadowRuleWorkflowRequestTest {
assertThat(actual.getTable(), is("t_order"));
assertThat(actual.getAlgorithmProperties().get("operation"), is("insert"));
}
+
+ @Test
+ void assertMergeFromBaseRequest() {
+ WorkflowRequest previous = new WorkflowRequest();
+ previous.setDatabase("foo_db");
+ ShadowRuleWorkflowRequest current = new ShadowRuleWorkflowRequest();
+ current.setTableName("foo_table");
+ ShadowRuleWorkflowRequest actual = ShadowRuleWorkflowRequest.merge(previous, current);
+ assertThat(actual.getDatabase(), is("foo_db"));
+ assertThat(actual.getTable(), is("foo_table"));
+ }
+
+ @Test
+ void assertGetAlgorithmProperties() {
+ ShadowRuleWorkflowRequest request = new ShadowRuleWorkflowRequest();
+ request.putAlgorithmProperties(Map.of("foo_key", "foo_value"));
+ assertTrue(request.getAlgorithmProperties("assisted").isEmpty());
+ }
}
diff --git a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowAlgorithmRecommendationServiceTest.java b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowAlgorithmRecommendationServiceTest.java
index fa8a5d64839..612758f9585 100644
--- a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowAlgorithmRecommendationServiceTest.java
+++ b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowAlgorithmRecommendationServiceTest.java
@@ -22,10 +22,14 @@ import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssue;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssueCode;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -35,23 +39,23 @@ class ShadowAlgorithmRecommendationServiceTest {
private final ShadowAlgorithmRecommendationService service = new ShadowAlgorithmRecommendationService();
- @Test
- void assertRecommendSpecifiedAlgorithm() {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertRecommendAlgorithmCases")
+ void assertRecommendAlgorithm(final String name, final String algorithmType, final List<Map<String, Object>> algorithmRows, final String expectedType, final String expectedReason) {
WorkflowRequest request = new WorkflowRequest();
- request.setAlgorithmType("value_match");
- List<AlgorithmCandidate> actual = service.recommendShadowAlgorithms(
- request, List.of(Map.of("type", "VALUE_MATCH")), new LinkedList<>());
- assertThat(actual.getFirst().getAlgorithmType(), is("VALUE_MATCH"));
+ request.setAlgorithmType(algorithmType);
+ List<AlgorithmCandidate> actual = service.recommendShadowAlgorithms(request, algorithmRows, new LinkedList<>());
+ assertThat(actual.getFirst().getAlgorithmType(), is(expectedType));
+ assertThat(actual.getFirst().getRecommendationReason(), is(expectedReason));
}
- @Test
- void assertRecommendSqlHintDefault() {
- WorkflowRequest request = new WorkflowRequest();
- List<AlgorithmCandidate> actual = service.recommendShadowAlgorithms(
- request, List.of(Map.of("type", "VALUE_MATCH"), Map.of("type", "SQL_HINT")), new LinkedList<>());
- assertThat(actual.getFirst().getAlgorithmType(), is("SQL_HINT"));
- assertThat(actual.getFirst().getRecommendationReason(), is(
- "Selected from algorithms reported by the current Proxy using MCP's built-in shadow preference order."));
+ private static Stream<Arguments> assertRecommendAlgorithmCases() {
+ return Stream.of(
+ Arguments.of("specified algorithm", "value_match", List.of(Map.of("type", "VALUE_MATCH")), "VALUE_MATCH", "User specified shadow algorithm."),
+ Arguments.of("default algorithm", "", List.of(Map.of("type", "VALUE_MATCH"), Map.of("type", "SQL_HINT")), "SQL_HINT",
+ "Selected from algorithms reported by the current Proxy using MCP's built-in shadow preference order."),
+ Arguments.of("first available algorithm", "", List.of(Map.of("type", "FOO_ALGORITHM")), "FOO_ALGORITHM",
+ "Selected from algorithms reported by the current Proxy using MCP's built-in shadow preference order."));
}
@Test
diff --git a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowInspectionServiceTest.java b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowInspectionServiceTest.java
index 896bb2f1263..7b690b1a587 100644
--- a/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowInspectionServiceTest.java
+++ b/mcp/features/shadow/src/test/java/org/apache/shardingsphere/mcp/feature/shadow/tool/service/ShadowInspectionServiceTest.java
@@ -74,4 +74,32 @@ class ShadowInspectionServiceTest {
MCPQueryFailedException actual = assertThrows(MCPQueryFailedException.class, () -> new ShadowInspectionService().queryAlgorithmPlugins(queryFacade));
assertThat(actual, is(expected));
}
+
+ @Test
+ void assertQueryTableRules() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ShadowInspectionService().queryTableRules(queryFacade, "foo_db");
+ verify(queryFacade).query("foo_db", "SHOW SHADOW TABLE RULES FROM foo_db");
+ }
+
+ @Test
+ void assertQueryAlgorithms() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ShadowInspectionService().queryAlgorithms(queryFacade, "foo_db");
+ verify(queryFacade).query("foo_db", "SHOW SHADOW ALGORITHMS FROM foo_db");
+ }
+
+ @Test
+ void assertQueryDefaultAlgorithm() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ShadowInspectionService().queryDefaultAlgorithm(queryFacade, "foo_db");
+ verify(queryFacade).query("foo_db", "SHOW DEFAULT SHADOW ALGORITHM FROM foo_db");
+ }
+
+ @Test
+ void assertQueryRuleCount() {
+ MCPFeatureQueryFacade queryFacade = mock(MCPFeatureQueryFacade.class);
+ new ShadowInspectionService().queryRuleCount(queryFacade, "foo_db");
+ verify(queryFacade).query("foo_db", "COUNT SHADOW RULE FROM foo_db");
+ }
}
diff --git a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/resource/handler/ShardingTableResourceHandlerTest.java b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/resource/handler/ShardingTableResourceHandlerTest.java
index 6dd4edfad40..730031e9a93 100644
--- a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/resource/handler/ShardingTableResourceHandlerTest.java
+++ b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/resource/handler/ShardingTableResourceHandlerTest.java
@@ -51,7 +51,8 @@ class ShardingTableResourceHandlerTest {
ShardingInspectionService.class, (mock, context) -> stubInspectionService(mock, rows))) {
MCPFeatureRequestContext requestContext = mock(MCPFeatureRequestContext.class);
when(requestContext.getQueryFacade()).thenReturn(mock(MCPFeatureQueryFacade.class));
- MCPSuccessPayload actual = handlerSupplier.get().handle(requestContext, uriVariables);
+ ShardingTableResourceHandler handler = handlerSupplier.get();
+ MCPSuccessPayload actual = handler.handle(handler.getContextType().cast(requestContext), uriVariables);
assertThat(((List<?>) actual.toPayload().get("items")).size(), is(rows.size()));
assertThat(((Map<?, ?>) actual.toPayload().get("self_resource")).get("uri"), is(expectedSelfUri));
}
diff --git a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/handler/PlanShardingTableRuleToolHandlerTest.java b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/handler/PlanShardingTableRuleToolHandlerTest.java
index 988f80be1bc..4786d80a41e 100644
--- a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/handler/PlanShardingTableRuleToolHandlerTest.java
+++ b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/handler/PlanShardingTableRuleToolHandlerTest.java
@@ -47,7 +47,8 @@ class PlanShardingTableRuleToolHandlerTest {
ShardingFeatureDefinition.TABLE_RULE_WORKFLOW_KIND,
"CREATE SHARDING TABLE RULE `t_order`(DATANODES('ds_${0..1}.t_order_${0..1}'))")))) {
WorkflowContextFixture fixture = ShardingWorkflowToolHandlerTestFixture.createWorkflowContextFixture();
- MCPSuccessPayload actual = new PlanShardingTableRuleToolHandler().handle(fixture.requestContext(), Map.of(
+ PlanShardingTableRuleToolHandler handler = new PlanShardingTableRuleToolHandler();
+ MCPSuccessPayload actual = handler.handle(handler.getContextType().cast(fixture.requestContext()), Map.of(
"database", "logic_db",
"table", "t_order",
"column", "order_id",
diff --git a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingAlgorithmRecommendationServiceTest.java b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingAlgorithmRecommendationServiceTest.java
index 46c472a3bfe..2291424ee5e 100644
--- a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingAlgorithmRecommendationServiceTest.java
+++ b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingAlgorithmRecommendationServiceTest.java
@@ -22,10 +22,15 @@ import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmCandidate;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssue;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssueCode;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -65,4 +70,24 @@ class ShardingAlgorithmRecommendationServiceTest {
assertTrue(service.recommend(request, List.of(Map.of("type", "INLINE")), List.of(), true, false, issues).isEmpty());
assertThat(issues.getFirst().getCode(), is(WorkflowIssueCode.ALGORITHM_NOT_FOUND));
}
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertRecommendMissingOrFallbackAlgorithmsArguments")
+ void assertRecommendMissingOrFallbackAlgorithms(final String name, final String keyGeneratorType, final List<Map<String, Object>> algorithms,
+ final List<Map<String, Object>> keyGenerators, final boolean includeSharding, final List<String> expectedTypes,
+ final List<String> expectedIssueCodes) {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setKeyGeneratorType(keyGeneratorType);
+ List<WorkflowIssue> issues = new ArrayList<>(expectedIssueCodes.size());
+ List<AlgorithmCandidate> actual = service.recommend(request, algorithms, keyGenerators, includeSharding, true, issues);
+ assertThat(actual.stream().map(AlgorithmCandidate::getAlgorithmType).toList(), is(expectedTypes));
+ assertThat(issues.stream().map(WorkflowIssue::getCode).toList(), is(expectedIssueCodes));
+ }
+
+ private static Stream<Arguments> assertRecommendMissingOrFallbackAlgorithmsArguments() {
+ return Stream.of(
+ Arguments.of("empty catalogs", "", List.of(), List.of(), true, List.of(), List.of(WorkflowIssueCode.ALGORITHM_NOT_FOUND, WorkflowIssueCode.ALGORITHM_NOT_FOUND)),
+ Arguments.of("fallback algorithms", "", List.of(Map.of("type", "FOO_ALGORITHM")), List.of(Map.of("type", "FOO_GENERATOR")), true, List.of("FOO_ALGORITHM", "FOO_GENERATOR"), List.of()),
+ Arguments.of("unavailable key generator", "FOO_GENERATOR", List.of(), List.of(Map.of("type", "SNOWFLAKE")), false, List.of(), List.of(WorkflowIssueCode.ALGORITHM_NOT_FOUND)));
+ }
}
diff --git a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingDistSQLPlanningServiceTest.java b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingDistSQLPlanningServiceTest.java
index b6b1993be19..ca92b6e1990 100644
--- a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingDistSQLPlanningServiceTest.java
+++ b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingDistSQLPlanningServiceTest.java
@@ -196,6 +196,43 @@ class ShardingDistSQLPlanningServiceTest {
assertThat(new ShardingDistSQLPlanningService().planComponentCleanup(request).getSql(), is("DROP SHARDING KEY GENERATOR `snowflake_generator`"));
}
+ @Test
+ void assertPlanTableReferenceRuleDrop() {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setRuleName("foo_reference");
+ RuleArtifact actual = new ShardingDistSQLPlanningService().planTableReferenceRule(request, "drop");
+ assertThat(actual.getSql(), is("DROP SHARDING TABLE REFERENCE RULE `foo_reference`"));
+ }
+
+ @Test
+ void assertPlanDefaultStrategyDrop() {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setDefaultStrategyType("table");
+ RuleArtifact actual = new ShardingDistSQLPlanningService().planDefaultStrategy(request, "drop");
+ assertThat(actual.getSql(), is("DROP DEFAULT SHARDING TABLE STRATEGY"));
+ }
+
+ @Test
+ void assertPlanKeyGeneratorDrop() {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setKeyGeneratorName("foo_generator");
+ RuleArtifact actual = new ShardingDistSQLPlanningService().planKeyGenerator(request, "drop");
+ assertThat(actual.getSql(), is("DROP SHARDING KEY GENERATOR `foo_generator`"));
+ }
+
+ @Test
+ void assertPlanTableRuleWithAuditors() {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setTable("foo_table");
+ request.setDataNodes("foo_ds.foo_table");
+ request.setColumn("foo_column");
+ request.setAlgorithmType("INLINE");
+ request.getAuditorNames().addAll(List.of("foo_auditor", "bar_auditor"));
+ RuleArtifact actual = new ShardingDistSQLPlanningService().planTableRule(request, "create");
+ assertThat(actual.getSql(), is("CREATE SHARDING TABLE RULE `foo_table`(DATANODES('foo_ds.foo_table'), TABLE_STRATEGY(TYPE='standard', SHARDING_COLUMN=`foo_column`, "
+ + "SHARDING_ALGORITHM(TYPE(NAME='inline'))), AUDIT_STRATEGY(TYPE(NAME='foo_auditor'), TYPE(NAME='bar_auditor'), ALLOW_HINT_DISABLE=FALSE))"));
+ }
+
private ShardingWorkflowRequest createTableRuleRequest() {
ShardingWorkflowRequest result = new ShardingWorkflowRequest();
result.setTable("t_order");
diff --git a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningServiceTest.java b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningServiceTest.java
index cbe3df79557..53ffed3ee81 100644
--- a/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningServiceTest.java
+++ b/mcp/features/sharding/src/test/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningServiceTest.java
@@ -330,11 +330,27 @@ class ShardingWorkflowPlanningServiceTest {
assertThat(((ShardingWorkflowRequest) actual.getRequest()).getKeyGeneratorProperties(), is(Map.of("worker-id", "1")));
}
- @Test
- void assertPlanTableRuleRejectsDataNodeAndStorageUnitInputs() {
- ShardingWorkflowRequest request = createTableRuleRequest();
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertPlanTableRuleConflictingAutoTableInputsArguments")
+ void assertPlanTableRuleConflictingAutoTableInputs(final String name, final String dataNodes, final String strategyType, final String shardingColumns,
+ final List<String> expectedConflictingInputs) {
+ ShardingWorkflowRequest request = createDatabaseRequest();
+ request.setTable("t_order");
+ request.setDataNodes(dataNodes);
request.setStorageUnits("ds_0");
- assertTableRuleInputConflict(request, List.of("data_nodes", "storage_units"));
+ request.setStrategyType(strategyType);
+ request.setShardingColumns(shardingColumns);
+ WorkflowContextSnapshot actual = planningService.planTableRule(new TestWorkflowSessionContext(), mock(MCPFeatureQueryFacade.class), request);
+ assertThat(actual.getStatus(), is(WorkflowLifecycle.STATUS_FAILED));
+ assertThat(actual.getIssues().getFirst().getCode(), is(WorkflowIssueCode.RULE_INPUT_CONFLICT));
+ assertThat(actual.getIssues().getFirst().getDetails().get("conflicting_inputs"), is(expectedConflictingInputs));
+ }
+
+ private static Stream<Arguments> assertPlanTableRuleConflictingAutoTableInputsArguments() {
+ return Stream.of(
+ Arguments.of("data nodes and storage units", "ds_0.t_order", "none", "", List.of("data_nodes", "storage_units")),
+ Arguments.of("complex auto-table strategy", "", "complex", "", List.of("storage_units", "strategy_type")),
+ Arguments.of("auto-table sharding columns", "", "standard", "order_id,user_id", List.of("storage_units", "sharding_columns")));
}
@Test
@@ -608,6 +624,42 @@ class ShardingWorkflowPlanningServiceTest {
assertThat(actual.getRuleArtifacts().getFirst().getSql(), is(expectedSQL));
}
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertPlanTableRuleRequiredStrategyInputsArguments")
+ void assertPlanTableRuleRequiredStrategyInputs(final String name, final String table, final String dataNodes, final String storageUnits, final String strategyType,
+ final String column, final String shardingColumns, final String algorithmType, final String expectedField, final String expectedMessage) {
+ ShardingWorkflowRequest request = new ShardingWorkflowRequest();
+ request.setDatabase("logic_db");
+ request.setTable(table);
+ request.setDataNodes(dataNodes);
+ request.setStorageUnits(storageUnits);
+ request.setStrategyType(strategyType);
+ request.setColumn(column);
+ request.setShardingColumns(shardingColumns);
+ request.setAlgorithmType(algorithmType);
+ WorkflowContextSnapshot actual = planningService.planTableRule(new TestWorkflowSessionContext(), mock(MCPFeatureQueryFacade.class), request);
+ assertThat(actual.getStatus(), is(WorkflowLifecycle.STATUS_CLARIFYING));
+ assertThat(actual.getIssues().getFirst().getCode(), is(WorkflowIssueCode.RULE_INPUT_REQUIRED));
+ assertThat(actual.getClarifiedIntent().getUnresolvedFields(), is(List.of(expectedField)));
+ assertThat(actual.getClarifiedIntent().getClarificationMessages(), is(List.of(expectedMessage)));
+ }
+
+ private static Stream<Arguments> assertPlanTableRuleRequiredStrategyInputsArguments() {
+ return Stream.of(
+ Arguments.of("missing table", "", "ds_0.t_order", "", "standard", "order_id", "", "INLINE", "table", "Please provide target logical table."),
+ Arguments.of("missing auto-table column", "t_order", "", "ds_0", "standard", "", "", "HASH_MOD", "column", "Please provide auto table sharding column."),
+ Arguments.of("missing auto-table algorithm", "t_order", "", "ds_0", "standard", "order_id", "", "", "algorithm_type", "Please provide auto table sharding algorithm type."),
+ Arguments.of("missing complex columns", "t_order", "ds_0.t_order", "", "complex", "", "", "COMPLEX_INLINE", "sharding_columns",
+ "Please provide at least two sharding columns for complex strategy."),
+ Arguments.of("one complex column", "t_order", "ds_0.t_order", "", "complex", "", "order_id", "COMPLEX_INLINE", "sharding_columns",
+ "Please provide at least two sharding columns for complex strategy."),
+ Arguments.of("missing complex algorithm", "t_order", "ds_0.t_order", "", "complex", "", "order_id,user_id", "", "algorithm_type", "Please provide sharding algorithm type."),
+ Arguments.of("missing hint algorithm", "t_order", "ds_0.t_order", "", "hint", "", "", "", "algorithm_type", "Please provide sharding algorithm type."),
+ Arguments.of("unknown strategy", "t_order", "ds_0.t_order", "", "foo_strategy", "", "", "", "strategy_type", "Please provide strategy_type as standard, complex, hint, or none."),
+ Arguments.of("missing standard column", "t_order", "ds_0.t_order", "", "standard", "", "", "INLINE", "column", "Please provide sharding column."),
+ Arguments.of("missing standard algorithm", "t_order", "ds_0.t_order", "", "standard", "order_id", "", "", "algorithm_type", "Please provide sharding algorithm type."));
+ }
+
private ShardingWorkflowRequest createDatabaseRequest() {
ShardingWorkflowRequest result = new ShardingWorkflowRequest();
result.setDatabase("logic_db");
diff --git a/mcp/registry/src/test/java/org/apache/shardingsphere/mcp/registry/MCPRegistryMetadataCommandTest.java b/mcp/registry/src/test/java/org/apache/shardingsphere/mcp/registry/MCPRegistryMetadataCommandTest.java
index 357e9e4c160..ef224a59c20 100644
--- a/mcp/registry/src/test/java/org/apache/shardingsphere/mcp/registry/MCPRegistryMetadataCommandTest.java
+++ b/mcp/registry/src/test/java/org/apache/shardingsphere/mcp/registry/MCPRegistryMetadataCommandTest.java
@@ -21,13 +21,18 @@ import org.apache.shardingsphere.infra.util.json.JsonEngine;
import org.apache.shardingsphere.infra.util.json.JsonTypeReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
+import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
@@ -75,13 +80,85 @@ class MCPRegistryMetadataCommandTest {
}
@Test
- void assertExecuteRejectsMissingHttpUrl() throws IOException {
+ void assertExecuteRejectsUnknownArgument() {
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class, () -> MCPRegistryMetadataCommand.execute("--unknown"));
+ assertThat(actual.getMessage(), is("Unknown argument: --unknown"));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertExecuteRejectsServerFieldsCases")
+ void assertExecuteRejectsServerFields(final String name, final Map<String, Object> fields, final String expectedMessage) throws IOException {
+ Map<String, Object> server = createServerMetadata();
+ server.putAll(fields);
+ Path serverPath = createServerJson(server);
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class,
+ () -> MCPRegistryMetadataCommand.execute("--path", serverPath.toString(), "--validate-only", "--allow-snapshot"));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Stream<Arguments> assertExecuteRejectsServerFieldsCases() {
+ return Stream.of(
+ Arguments.of("schema", Map.of("$schema", "unsupported"), "server.json must use the official MCP Registry schema."),
+ Arguments.of("name", Map.of("name", "io.github.apache/other-mcp"), "server.json name must match the published ShardingSphere MCP server name."),
+ Arguments.of("description type", Map.of("description", 1), "server.json field description must be a non-empty string."),
+ Arguments.of("description length", Map.of("description", "x".repeat(101)), "server.json field description must be at most 100 characters."),
+ Arguments.of("null version", Map.of("version", "null"), "server version must be a non-empty string."),
+ Arguments.of("latest version", Map.of("version", "latest"), "server version must not use latest."),
+ Arguments.of("version range", Map.of("version", "5.5.x"), "server version must be a specific version, not a range."),
+ Arguments.of("packages type", Map.of("packages", "invalid"), "server.json packages must be a non-empty array."),
+ Arguments.of("package type", Map.of("packages", List.of("invalid")), "MCP Registry package must be an object."));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertExecuteRejectsPackageFieldsCases")
+ void assertExecuteRejectsPackageFields(final String name, final Map<String, Object> fields, final String expectedMessage) throws IOException {
+ Map<String, Object> server = createServerMetadata();
+ getPackages(server).get(0).putAll(fields);
+ Path serverPath = createServerJson(server);
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class,
+ () -> MCPRegistryMetadataCommand.execute("--path", serverPath.toString(), "--validate-only", "--allow-snapshot"));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Stream<Arguments> assertExecuteRejectsPackageFieldsCases() {
+ return Stream.of(
+ Arguments.of("registry type", Map.of("registryType", "npm"), "MCP Registry package registryType must be oci."),
+ Arguments.of("identifier repository", Map.of("identifier", "ghcr.io/apache/other-mcp:5.5.4-SNAPSHOT"), "OCI identifier must target ghcr.io/apache/shardingsphere-mcp:<tag>."),
+ Arguments.of("identifier version", Map.of("identifier", "ghcr.io/apache/shardingsphere-mcp:5.5.3"), "OCI identifier tag must match the server version."),
+ Arguments.of("transport type", Map.of("transport", Map.of("type", "unsupported")), "MCP Registry package transport type must be stdio or streamable-http."),
+ Arguments.of("environment variables type", Map.of("environmentVariables", "invalid"), "MCP Registry package must define SHARDINGSPHERE_MCP_TRANSPORT."),
+ Arguments.of("missing config environment variable", Map.of("environmentVariables",
+ List.of(Map.of("name", "SHARDINGSPHERE_MCP_TRANSPORT", "description", "Transport mode", "isRequired", false, "isSecret", false, "format", "string"))),
+ "MCP Registry package must define SHARDINGSPHERE_MCP_CONFIG."));
+ }
+
+ @Test
+ void assertExecuteRejectsEmptyRuntimeArguments() throws IOException {
Map<String, Object> server = createServerMetadata();
- getPackages(server).get(1).put("transport", Map.of("type", "streamable-http"));
+ getPackages(server).get(1).put("runtimeArguments", List.of());
Path serverPath = createServerJson(server);
IllegalArgumentException actual = assertThrows(IllegalArgumentException.class,
() -> MCPRegistryMetadataCommand.execute("--path", serverPath.toString(), "--validate-only", "--allow-snapshot"));
- assertThat(actual.getMessage(), is("streamable-http transport must define a URL."));
+ assertThat(actual.getMessage(), is("streamable-http OCI package must define one Docker port runtime argument."));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertExecuteRejectsHttpUrlCases")
+ void assertExecuteRejectsHttpUrl(final String name, final Map<String, Object> transport, final String expectedMessage, final boolean expectedCause) throws IOException {
+ Map<String, Object> server = createServerMetadata();
+ getPackages(server).get(1).put("transport", transport);
+ Path serverPath = createServerJson(server);
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class,
+ () -> MCPRegistryMetadataCommand.execute("--path", serverPath.toString(), "--validate-only", "--allow-snapshot"));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ assertThat(actual.getCause() instanceof URISyntaxException, is(expectedCause));
+ }
+
+ private static Stream<Arguments> assertExecuteRejectsHttpUrlCases() {
+ return Stream.of(
+ Arguments.of("missing URL", Map.of("type", "streamable-http"), "streamable-http transport must define a URL.", false),
+ Arguments.of("unsupported scheme", Map.of("type", "streamable-http", "url", "ftp://127.0.0.1"), "streamable-http transport URL must be an HTTP URL.", false),
+ Arguments.of("invalid URI", Map.of("type", "streamable-http", "url", "http://["), "streamable-http transport URL must be an HTTP URL.", true));
}
@Test
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcDatabaseProfileLoaderTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcDatabaseProfileLoaderTest.java
index e1db7576f1b..5392934d157 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcDatabaseProfileLoaderTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcDatabaseProfileLoaderTest.java
@@ -30,14 +30,18 @@ import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import javax.sql.DataSource;
+import java.io.PrintWriter;
+import java.io.Writer;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -52,6 +56,149 @@ import static org.mockito.Mockito.when;
class MCPJdbcDatabaseProfileLoaderTest {
+ @Test
+ void assertLoadDataSourceWithUnsupportedCredentials() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, () -> actual.getConnection("foo_user", "foo_password"));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedLogWriter() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, actual::getLogWriter);
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedSetLogWriter() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, () -> actual.setLogWriter(new PrintWriter(Writer.nullWriter())));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedLoginTimeout() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, actual::getLoginTimeout);
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedSetLoginTimeout() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, () -> actual.setLoginTimeout(1));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedParentLogger() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertThrows(SQLFeatureNotSupportedException.class, actual::getParentLogger);
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWrapper() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ assertTrue(actual.isWrapperFor(DataSource.class));
+ assertFalse(actual.isWrapperFor(Connection.class));
+ assertThat(actual.unwrap(DataSource.class), sameInstance(actual));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithUnsupportedWrapper() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource actual = invocation.getArgument(1, DataSource.class);
+ SQLException actualException = assertThrows(SQLException.class, () -> actual.unwrap(Connection.class));
+ assertThat(actualException.getMessage(), is("Unable to unwrap runtime database data source to `java.sql.Connection`."));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
+ @Test
+ void assertLoadDataSourceWithNonSQLConnectionFailure() throws SQLException {
+ IllegalStateException expectedCause = new IllegalStateException("foo_invalid_config");
+ RuntimeDatabaseConnectionException expected = RuntimeDatabaseConnectionException.invalidConfiguration("logic_db", expectedCause);
+ Connection connection = mock(Connection.class);
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = createRuntimeDatabaseConfiguration(SupportDatabaseTypeFactoryMocker.createJdbcUrl("FixtureDB"), "1.0", true, true, connection);
+ when(runtimeDatabaseConfig.openConnection("logic_db")).thenReturn(connection).thenThrow(expected);
+ try (
+ MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata();
+ MockedStatic<DatabaseIdentifierContextFactory> mocked = mockStatic(DatabaseIdentifierContextFactory.class)) {
+ mocked.when(() -> DatabaseIdentifierContextFactory.create(any(), any(DataSource.class))).thenAnswer(invocation -> {
+ DataSource dataSource = invocation.getArgument(1, DataSource.class);
+ SQLException actual = assertThrows(SQLException.class, dataSource::getConnection);
+ assertThat(actual.getCause(), sameInstance(expected));
+ return new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
+ });
+ new MCPJdbcDatabaseProfileLoader().load("logic_db", runtimeDatabaseConfig);
+ }
+ }
+
@Test
void assertLoad() throws SQLException {
DatabaseIdentifierContext expectedIdentifierContext = new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet());
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/tool/service/RuntimeDatabaseValidationServiceTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/tool/service/RuntimeDatabaseValidationServiceTest.java
index 19abf413033..08a67c1a9f6 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/tool/service/RuntimeDatabaseValidationServiceTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/tool/service/RuntimeDatabaseValidationServiceTest.java
@@ -84,6 +84,49 @@ class RuntimeDatabaseValidationServiceTest {
mockedProfileLoaders.close();
}
+ @Test
+ void assertValidateWithVisibleMetadataCatalog() throws SQLException {
+ RuntimeDatabaseValidationService service = new RuntimeDatabaseValidationService();
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ when(runtimeDatabaseConfig.openConnection("logic_db")).thenReturn(connection);
+ when(connection.getMetaData()).thenReturn(databaseMetaData);
+ ResultSet catalogs = mock(ResultSet.class);
+ when(databaseMetaData.getCatalogs()).thenReturn(catalogs);
+ when(catalogs.next()).thenReturn(true, true, false);
+ when(catalogs.getString(1)).thenReturn("foo_other", "logic_db");
+ when(getProfileLoader().load(any(), any(RuntimeDatabaseConfiguration.class))).thenReturn(new RuntimeDatabaseProfile("logic_db", "FixtureDB", "1.0", TransactionCapability.LOCAL,
+ new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet())));
+ when(getMetadataLoader().load(any(), any(RuntimeDatabaseConfiguration.class), any(RuntimeDatabaseProfile.class))).thenReturn(createMetadata("public"));
+ RuntimeDatabaseValidationResult actual = service.validate(new RuntimeDatabaseValidationRequest("logic_db"), ignored -> Optional.of(runtimeDatabaseConfig));
+ assertThat(actual.getStatus(), is("ready"));
+ verify(catalogs).close();
+ }
+
+ @Test
+ void assertValidateWithVisibleMetadataSchema() throws SQLException {
+ RuntimeDatabaseValidationService service = new RuntimeDatabaseValidationService();
+ RuntimeDatabaseConfiguration runtimeDatabaseConfig = mock(RuntimeDatabaseConfiguration.class);
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ when(runtimeDatabaseConfig.openConnection("logic_db")).thenReturn(connection);
+ when(connection.getMetaData()).thenReturn(databaseMetaData);
+ ResultSet catalogs = mock(ResultSet.class);
+ when(databaseMetaData.getCatalogs()).thenReturn(catalogs);
+ ResultSet schemas = mock(ResultSet.class);
+ when(databaseMetaData.getSchemas()).thenReturn(schemas);
+ when(schemas.next()).thenReturn(true, true, false);
+ when(schemas.getString("TABLE_SCHEM")).thenReturn("foo_other", "logic_db");
+ when(getProfileLoader().load(any(), any(RuntimeDatabaseConfiguration.class))).thenReturn(new RuntimeDatabaseProfile("logic_db", "FixtureDB", "1.0", TransactionCapability.LOCAL,
+ new DatabaseIdentifierContext(IdentifierCasePolicyFactory.newSensitivePolicySet())));
+ when(getMetadataLoader().load(any(), any(RuntimeDatabaseConfiguration.class), any(RuntimeDatabaseProfile.class))).thenReturn(createMetadata("public"));
+ RuntimeDatabaseValidationResult actual = service.validate(new RuntimeDatabaseValidationRequest("logic_db"), ignored -> Optional.of(runtimeDatabaseConfig));
+ assertThat(actual.getStatus(), is("ready"));
+ verify(catalogs).close();
+ verify(schemas).close();
+ }
+
@Test
void assertValidateWithMissingDatabase() {
RuntimeDatabaseValidationService service = new RuntimeDatabaseValidationService();
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidatorTest.java
index 783e362fdc5..54dafec7efd 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidatorTest.java
@@ -20,8 +20,13 @@ package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.completion.MCPCompletionTargetDescriptor;
import org.apache.shardingsphere.mcp.api.capability.prompt.MCPPromptArgumentDescriptor;
import org.apache.shardingsphere.mcp.api.capability.prompt.MCPPromptDescriptor;
-import org.junit.jupiter.api.Test;
+import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceAnnotations;
+import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceDescriptor;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -31,24 +36,50 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class MCPCompletionTargetDescriptorValidatorTest {
- @Test
- void assertValidateWithUndeclaredContextArgument() {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidCompletionTargets")
+ void assertValidateWithInvalidCompletionTarget(final String name, final Collection<MCPCompletionTargetDescriptor> descriptors, final Collection<MCPResourceDescriptor> resources,
+ final String expectedMessage) {
MCPPromptDescriptor prompt = new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.",
List.of(new MCPPromptArgumentDescriptor("database", "Database", "Logical database.", false)), Map.of());
- MCPCompletionTargetDescriptor completion = new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50,
- Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, Map.of("database", List.of("tenant"))));
- IllegalStateException actual = assertThrows(IllegalStateException.class,
- () -> MCPCompletionTargetDescriptorValidator.validate(List.of(completion), List.of(prompt), List.of()));
- assertThat(actual.getMessage(), is("Completion target `prompt:test_prompt` context argument `tenant` for `database` is not declared by the target."));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPCompletionTargetDescriptorValidator.validate(descriptors, List.of(prompt), resources));
+ assertThat(actual.getMessage(), is(expectedMessage));
}
- @Test
- void assertValidateWithDuplicateArguments() {
- MCPPromptDescriptor prompt = new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.",
- List.of(new MCPPromptArgumentDescriptor("database", "Database", "Logical database.", false)), Map.of());
- MCPCompletionTargetDescriptor completion = new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database", "database"), 50, Map.of());
- IllegalStateException actual = assertThrows(IllegalStateException.class,
- () -> MCPCompletionTargetDescriptorValidator.validate(List.of(completion), List.of(prompt), List.of()));
- assertThat(actual.getMessage(), is("Completion target `prompt:test_prompt` must not contain duplicate arguments."));
+ private static Collection<Arguments> invalidCompletionTargets() {
+ MCPCompletionTargetDescriptor completion = new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50, Map.of());
+ MCPResourceDescriptor fixedResource = new MCPResourceDescriptor("shardingsphere://capabilities", "foo_fixed", "Capabilities", "Read capabilities.", "application/json",
+ MCPResourceAnnotations.EMPTY, Map.of());
+ MCPResourceDescriptor template = new MCPResourceDescriptor("shardingsphere://databases/{database}", "foo_template", "Database", "Read database.", "application/json",
+ MCPResourceAnnotations.EMPTY, Map.of());
+ return List.of(
+ Arguments.of("unknown context argument",
+ List.of(new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50,
+ Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, Map.of("database", List.of("tenant"))))),
+ List.of(), "Completion target `prompt:test_prompt` context argument `tenant` for `database` is not declared by the target."),
+ Arguments.of("duplicate arguments", List.of(new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database", "database"), 50, Map.of())), List.of(),
+ "Completion target `prompt:test_prompt` must not contain duplicate arguments."),
+ Arguments.of("duplicate target", List.of(completion, completion), List.of(), "Duplicate MCP completion target `prompt:test_prompt`."),
+ Arguments.of("unknown prompt", List.of(new MCPCompletionTargetDescriptor("prompt", "foo_unknown", List.of("database"), 50, Map.of())), List.of(),
+ "Completion target references unknown prompt `foo_unknown`."),
+ Arguments.of("unknown resource", List.of(new MCPCompletionTargetDescriptor("resource", "shardingsphere://foo_unknown", List.of("database"), 50, Map.of())), List.of(),
+ "Completion target references unknown resource `shardingsphere://foo_unknown`."),
+ Arguments.of("unsupported reference", List.of(new MCPCompletionTargetDescriptor("tool", "test_prompt", List.of("database"), 50, Map.of())), List.of(),
+ "Unsupported completion reference type `tool`."),
+ Arguments.of("fixed resource target", List.of(new MCPCompletionTargetDescriptor("resource", "shardingsphere://capabilities", List.of(), 50, Map.of())), List.of(fixedResource),
+ "Completion target `resource:shardingsphere://capabilities` must reference a resource template."),
+ Arguments.of("unknown resource argument", List.of(new MCPCompletionTargetDescriptor("resource", "shardingsphere://databases/{database}", List.of("schema"), 50, Map.of())),
+ List.of(template), "Completion target `resource:shardingsphere://databases/{database}` argument `schema` is not a URI template variable."),
+ Arguments.of("context metadata is not object",
+ List.of(new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50, Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, "database"))),
+ List.of(), "Completion target `prompt:test_prompt` metadata `org.apache.shardingsphere/required-context-arguments` must be an object."),
+ Arguments.of("unknown context metadata key",
+ List.of(new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50,
+ Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, Map.of("schema", List.of())))),
+ List.of(), "Completion target `prompt:test_prompt` required context argument `schema` is not declared by the target."),
+ Arguments.of("context value is not list",
+ List.of(new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50,
+ Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, Map.of("database", "schema")))),
+ List.of(), "Completion target `prompt:test_prompt` required context for `database` must be a list."));
}
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogIndexTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogIndexTest.java
index 3a48e2ff0f5..4eca72f22f1 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogIndexTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogIndexTest.java
@@ -35,6 +35,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class MCPDescriptorCatalogIndexTest {
+ @Test
+ void assertGetRequiredShardingSphereResourceMetadataWithUnknownResource() {
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPDescriptorCatalogIndex.getRequiredShardingSphereResourceMetadata("shardingsphere://foo_unknown"));
+ assertThat(actual.getMessage(), is("ShardingSphere MCP resource metadata descriptor is required for `shardingsphere://foo_unknown`."));
+ }
+
+ @Test
+ void assertGetRequiredPromptTemplateBindingWithUnknownPrompt() {
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPDescriptorCatalogIndex.getRequiredPromptTemplateBinding("foo_unknown"));
+ assertThat(actual.getMessage(), is("MCP prompt template binding is required for `foo_unknown`."));
+ }
+
@Test
void assertGetResourceDescriptors() {
Collection<MCPResourceDescriptor> actualDescriptors = MCPDescriptorCatalogIndex.getResourceDescriptors();
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogLoaderTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogLoaderTest.java
index c39a9899e87..6ae1e29a69b 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogLoaderTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogLoaderTest.java
@@ -17,6 +17,7 @@
package org.apache.shardingsphere.mcp.support.descriptor;
+import org.apache.shardingsphere.infra.util.directory.ClasspathResourceDirectoryReader;
import org.apache.shardingsphere.mcp.api.capability.completion.MCPCompletionTargetDescriptor;
import org.apache.shardingsphere.mcp.api.capability.prompt.MCPPromptArgumentDescriptor;
import org.apache.shardingsphere.mcp.api.capability.prompt.MCPPromptDescriptor;
@@ -24,22 +25,137 @@ import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceAnnotati
import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceDescriptor;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.MockedStatic;
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.sameInstance;
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.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
class MCPDescriptorCatalogLoaderTest {
+ private static final String DESCRIPTOR_DIRECTORY = "META-INF/shardingsphere-mcp/mcp-descriptors";
+
+ @TempDir
+ private Path tempDir;
+
+ @Test
+ void assertLoadYmlResourceFiltersNonYamlFiles() throws IOException {
+ Path descriptor = createDescriptorResource("foo-public-loader.yml", """
+ tools:
+ - name: foo_tool
+ title: Foo Tool
+ description: Read the foo tool.
+ inputSchema:
+ type: object
+ properties: {}
+ required: []
+ additionalProperties: false
+ outputSchema:
+ type: object
+ properties:
+ foo_value:
+ type: string
+ description: Foo value.
+ examples:
+ - foo_value: foo_value
+ annotations:
+ readOnlyHint: true
+ destructiveHint: false
+ idempotentHint: true
+ openWorldHint: true
+ """);
+ Files.writeString(descriptor.resolveSibling("foo.txt"), "foo_ignored");
+ ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+ try (
+ URLClassLoader classLoader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, originalClassLoader);
+ MockedStatic<ClasspathResourceDirectoryReader> mocked = mockStatic(ClasspathResourceDirectoryReader.class)) {
+ mocked.when(() -> ClasspathResourceDirectoryReader.read(DESCRIPTOR_DIRECTORY)).thenReturn(Stream.of(DESCRIPTOR_DIRECTORY + "/foo-public-loader.yml", DESCRIPTOR_DIRECTORY + "/foo.txt"));
+ Thread.currentThread().setContextClassLoader(classLoader);
+ MCPDescriptorCatalog actual = MCPDescriptorCatalogLoader.load();
+ assertThat(actual.getProtocolDescriptors().getToolDescriptors().stream().map(MCPToolDescriptor::getName).toList(), is(List.of("foo_tool")));
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ private Path createDescriptorResource(final String fileName, final String yaml) throws IOException {
+ Path result = tempDir.resolve(DESCRIPTOR_DIRECTORY).resolve(fileName);
+ Files.createDirectories(result.getParent());
+ Files.writeString(result, yaml);
+ return result;
+ }
+
+ @Test
+ void assertLoadWithResourceReadFailure() throws IOException {
+ ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+ ClassLoader classLoader = mock(ClassLoader.class);
+ URL resource = mock(URL.class);
+ IOException expectedCause = new IOException("foo_read_failure");
+ when(resource.toExternalForm()).thenReturn("file:/foo.yaml");
+ when(resource.openStream()).thenThrow(expectedCause);
+ when(classLoader.resources(DESCRIPTOR_DIRECTORY + "/foo.yaml")).thenReturn(Stream.of(resource));
+ try (MockedStatic<ClasspathResourceDirectoryReader> mocked = mockStatic(ClasspathResourceDirectoryReader.class)) {
+ mocked.when(() -> ClasspathResourceDirectoryReader.read(DESCRIPTOR_DIRECTORY)).thenReturn(Stream.of(DESCRIPTOR_DIRECTORY + "/foo.yaml"));
+ Thread.currentThread().setContextClassLoader(classLoader);
+ IllegalStateException actual = assertThrows(IllegalStateException.class, MCPDescriptorCatalogLoader::load);
+ assertThat(actual.getMessage(), is("Failed to load MCP descriptor resource `file:/foo.yaml`."));
+ assertThat(actual.getCause(), sameInstance(expectedCause));
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidValueTypes")
+ void assertLoadWithInvalidValueType(final String name, final String yaml, final String expectedMessage) throws IOException {
+ Path descriptor = createDescriptorResource("foo-public-loader.yaml", yaml);
+ ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
+ try (
+ URLClassLoader classLoader = new URLClassLoader(new URL[]{tempDir.toUri().toURL()}, originalClassLoader);
+ MockedStatic<ClasspathResourceDirectoryReader> mocked = mockStatic(ClasspathResourceDirectoryReader.class)) {
+ mocked.when(() -> ClasspathResourceDirectoryReader.read(DESCRIPTOR_DIRECTORY)).thenReturn(Stream.of(DESCRIPTOR_DIRECTORY + "/foo-public-loader.yaml"));
+ Thread.currentThread().setContextClassLoader(classLoader);
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class, MCPDescriptorCatalogLoader::load);
+ assertThat(actual.getMessage(), is(String.format(expectedMessage, descriptor.toUri().toURL().toExternalForm())));
+ } finally {
+ Thread.currentThread().setContextClassLoader(originalClassLoader);
+ }
+ }
+
+ private static Stream<Arguments> invalidValueTypes() {
+ return Stream.of(
+ Arguments.of("audience type", "resources: [{annotations: {audience: assistant}}]", "MCP descriptor resource `%s` expects list at `$.resources[0].annotations.audience`."),
+ Arguments.of("audience member", "resources: [{annotations: {audience: [1]}}]", "MCP descriptor resource `%s` expects string at `$.resources[0].annotations.audience[0]`."),
+ Arguments.of("priority type", "resources: [{annotations: {priority: foo}}]", "MCP descriptor resource `%s` expects number at `$.resources[0].annotations.priority`."),
+ Arguments.of("lastModified type", "resources: [{annotations: {lastModified: 1}}]", "MCP descriptor resource `%s` expects string at `$.resources[0].annotations.lastModified`."),
+ Arguments.of("tool title type", "tools: [{annotations: {title: 1, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true}}]",
+ "MCP descriptor resource `%s` expects string at `$.tools[0].annotations.title`."),
+ Arguments.of("tools collection type", "tools: foo", "MCP descriptor resource `%s` expects list at `$.tools`."));
+ }
+
@Test
void assertLoadValidatesDescriptorQuality() {
MCPDescriptorCatalog actual = MCPDescriptorCatalogLoader.load();
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidatorTest.java
index 1a568459bef..ce0ce959ff1 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidatorTest.java
@@ -18,8 +18,11 @@
package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.prompt.MCPPromptDescriptor;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -29,11 +32,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class MCPPromptDescriptorValidatorTest {
- @Test
- void assertValidate() {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidPrompts")
+ void assertValidateWithInvalidPrompt(final String name, final Collection<MCPPromptDescriptor> descriptors, final Collection<MCPPromptTemplateBinding> bindings,
+ final String expectedMessage) {
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPPromptDescriptorValidator.validate(descriptors, bindings));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Collection<Arguments> invalidPrompts() {
MCPPromptDescriptor prompt = new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.", List.of(), Map.of());
- MCPPromptTemplateBinding binding = new MCPPromptTemplateBinding("test_prompt", "META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md");
- IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPPromptDescriptorValidator.validate(List.of(prompt), List.of(binding)));
- assertThat(actual.getMessage(), is("Prompt template `META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md` contains unsupported model-facing placeholder `{database}`."));
+ MCPPromptTemplateBinding binding = new MCPPromptTemplateBinding("test_prompt", "META-INF/shardingsphere-mcp/prompts/fixture-uri-template.md");
+ return List.of(
+ Arguments.of("unsupported placeholder", List.of(prompt),
+ List.of(new MCPPromptTemplateBinding("test_prompt", "META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md")),
+ "Prompt template `META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md` contains unsupported model-facing placeholder `{database}`."),
+ Arguments.of("missing binding", List.of(prompt), List.of(), "Prompt `test_prompt` must declare an internal template binding."),
+ Arguments.of("duplicate prompt", List.of(prompt, prompt), List.of(binding), "Duplicate MCP prompt descriptor `test_prompt`."),
+ Arguments.of("undeclared placeholder", List.of(prompt), List.of(new MCPPromptTemplateBinding("test_prompt", "META-INF/shardingsphere-mcp/prompts/fixture-markdown-resource.md")),
+ "Prompt template `META-INF/shardingsphere-mcp/prompts/fixture-markdown-resource.md` has undeclared placeholder `database`."),
+ Arguments.of("client form metadata is not a list", List.of(new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.", List.of(),
+ Map.of("org.apache.shardingsphere/client-form-only-arguments", "database"))), List.of(binding),
+ "Prompt `test_prompt` metadata `org.apache.shardingsphere/client-form-only-arguments` must be a list."));
}
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidatorTest.java
index e50641f3956..09a8fb8022a 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidatorTest.java
@@ -19,8 +19,11 @@ package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceAnnotations;
import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceDescriptor;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -30,13 +33,43 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class MCPResourceDescriptorValidatorTest {
- @Test
- void assertValidate() {
- MCPResourceDescriptor descriptor = new MCPResourceDescriptor("shardingsphere://capabilities", "server-capability-catalog", "Server Capability Catalog",
- "Read the model-facing capability catalog.", "application/json", MCPResourceAnnotations.EMPTY, Map.of());
- MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(descriptor), List.of(descriptor), List.of(), List.of()),
- new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of()));
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidResources")
+ void assertValidateWithInvalidResource(final String name, final MCPDescriptorCatalog catalog, final String expectedMessage) {
IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPResourceDescriptorValidator.validate(catalog));
- assertThat(actual.getMessage(), is("Resource template `shardingsphere://capabilities` must contain template variables."));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Collection<Arguments> invalidResources() {
+ MCPResourceDescriptor fixedResource = createResource("shardingsphere://capabilities");
+ MCPResourceDescriptor template = createResource("shardingsphere://databases/{database}");
+ MCPUriVariableDescriptor variable = new MCPUriVariableDescriptor("database", "Database", "Logical database.", true, "database");
+ return List.of(
+ Arguments.of("template without variables", createCatalog(List.of(fixedResource), List.of(fixedResource), List.of()),
+ "Resource template `shardingsphere://capabilities` must contain template variables."),
+ Arguments.of("fixed resource with variables", createCatalog(List.of(template), List.of(), List.of()),
+ "Fixed resource `shardingsphere://databases/{database}` must not contain template variables."),
+ Arguments.of("blank URI", createCatalog(List.of(createResource(" ")), List.of(), List.of()), "Resource URI is required."),
+ Arguments.of("duplicate URI", createCatalog(List.of(fixedResource, fixedResource), List.of(), List.of()), "Duplicate MCP resource descriptor `shardingsphere://capabilities`."),
+ Arguments.of("missing variable metadata", createCatalog(List.of(), List.of(template), List.of()),
+ "Resource descriptor `shardingsphere://databases/{database}` must describe URI template variable `database`."),
+ Arguments.of("optional variable", createCatalog(List.of(), List.of(template), List.of(new MCPUriVariableDescriptor("database", "Database", "Logical database.", false, "database"))),
+ "Resource parameter `shardingsphere://databases/{database}.database` must be required because URI template variables are required."),
+ Arguments.of("non-template variable", createCatalog(List.of(), List.of(template), List.of(new MCPUriVariableDescriptor("schema", "Schema", "Logical schema.", true, "schema"))),
+ "Resource descriptor `shardingsphere://databases/{database}` declares non-template parameter `schema`."),
+ Arguments.of("duplicate variable metadata", createCatalog(List.of(), List.of(template), List.of(variable, variable)),
+ "Duplicate MCP resource parameter `shardingsphere://databases/{database}.database`."));
+ }
+
+ private static MCPResourceDescriptor createResource(final String uri) {
+ return new MCPResourceDescriptor(uri, "foo_resource", "Resource", "Read resource.", "application/json", MCPResourceAnnotations.EMPTY, Map.of());
+ }
+
+ private static MCPDescriptorCatalog createCatalog(final Collection<MCPResourceDescriptor> resources, final Collection<MCPResourceDescriptor> templates,
+ final Collection<MCPUriVariableDescriptor> variables) {
+ Collection<ShardingSphereMCPResourceMetadata> metadata = templates.stream().map(each -> new ShardingSphereMCPResourceMetadata(each.getUriTemplate(), variables,
+ "detail", "database", "", List.of(), List.of(), List.of())).toList();
+ return new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(resources, templates, List.of(), List.of()),
+ new MCPShardingSphereDescriptorCatalog(metadata, List.of(), List.of(), List.of(), List.of()));
}
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidatorTest.java
index 2bb573fca7c..53fbc8c4743 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidatorTest.java
@@ -19,8 +19,11 @@ package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -30,30 +33,27 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class MCPResourceNavigationDescriptorValidatorTest {
- @Test
- void assertValidateWithUnknownSource() {
- MCPResourceNavigationDescriptor navigation = new MCPResourceNavigationDescriptor("missing", "database_gateway_test_tool", List.of(), List.of(), "Read test tool.");
- IllegalStateException actual = assertThrows(IllegalStateException.class,
- () -> MCPResourceNavigationDescriptorValidator.validate(List.of(navigation), createCatalog()));
- assertThat(actual.getMessage(), is("Resource navigation references unknown source `missing`."));
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidNavigations")
+ void assertValidateWithInvalidNavigation(final String name, final Collection<MCPResourceNavigationDescriptor> descriptors, final String expectedMessage) {
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPResourceNavigationDescriptorValidator.validate(descriptors, createCatalog()));
+ assertThat(actual.getMessage(), is(expectedMessage));
}
- @Test
- void assertValidateWithDuplicateRequiredArguments() {
- MCPResourceNavigationDescriptor navigation = new MCPResourceNavigationDescriptor("database_gateway_test_tool", "database_gateway_test_tool",
- List.of("database", "database"), List.of(), "Read test tool.");
- IllegalStateException actual = assertThrows(IllegalStateException.class,
- () -> MCPResourceNavigationDescriptorValidator.validate(List.of(navigation), createCatalog()));
- assertThat(actual.getMessage(), is("Resource navigation `database_gateway_test_tool` to `database_gateway_test_tool` must not contain duplicate required arguments."));
- }
-
- @Test
- void assertValidateWithDuplicateCarriedArguments() {
- MCPResourceNavigationDescriptor navigation = new MCPResourceNavigationDescriptor("database_gateway_test_tool", "database_gateway_test_tool",
- List.of(), List.of("database", "database"), "Read test tool.");
- IllegalStateException actual = assertThrows(IllegalStateException.class,
- () -> MCPResourceNavigationDescriptorValidator.validate(List.of(navigation), createCatalog()));
- assertThat(actual.getMessage(), is("Resource navigation `database_gateway_test_tool` to `database_gateway_test_tool` must not contain duplicate carried arguments."));
+ private static Collection<Arguments> invalidNavigations() {
+ MCPResourceNavigationDescriptor navigation = new MCPResourceNavigationDescriptor("database_gateway_test_tool", "database_gateway_test_tool", List.of(), List.of(), "Read test tool.");
+ return List.of(
+ Arguments.of("unknown source", List.of(new MCPResourceNavigationDescriptor("missing", "database_gateway_test_tool", List.of(), List.of(), "Read test tool.")),
+ "Resource navigation references unknown source `missing`."),
+ Arguments.of("unknown target", List.of(new MCPResourceNavigationDescriptor("database_gateway_test_tool", "missing", List.of(), List.of(), "Read test tool.")),
+ "Resource navigation references unknown target `missing`."),
+ Arguments.of("duplicate required arguments", List.of(new MCPResourceNavigationDescriptor("database_gateway_test_tool", "database_gateway_test_tool",
+ List.of("database", "database"), List.of(), "Read test tool.")),
+ "Resource navigation `database_gateway_test_tool` to `database_gateway_test_tool` must not contain duplicate required arguments."),
+ Arguments.of("duplicate carried arguments", List.of(new MCPResourceNavigationDescriptor("database_gateway_test_tool", "database_gateway_test_tool",
+ List.of(), List.of("database", "database"), "Read test tool.")),
+ "Resource navigation `database_gateway_test_tool` to `database_gateway_test_tool` must not contain duplicate carried arguments."),
+ Arguments.of("duplicate endpoints", List.of(navigation, navigation), "Duplicate MCP resource navigation `database_gateway_test_tool` to `database_gateway_test_tool`."));
}
private MCPDescriptorCatalog createCatalog() {
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidatorTest.java
index 89823b62677..288822a9d64 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidatorTest.java
@@ -17,12 +17,17 @@
package org.apache.shardingsphere.mcp.support.descriptor;
+import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceAnnotations;
+import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceDescriptor;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -66,6 +71,141 @@ class MCPToolDescriptorCatalogValidatorTest {
assertThat(actual.getMessage(), is("Tool `database_gateway_test_tool` inputSchema additionalProperties must be a boolean."));
}
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidCoreTools")
+ void assertValidateWithInvalidCoreTool(final String name, final String toolName, final Map<String, Object> inputSchema, final Map<String, Object> outputSchema, final String expectedMessage) {
+ MCPToolDescriptor descriptor = new MCPToolDescriptor(toolName, "Test Tool", "Run a test tool.", inputSchema, outputSchema, MCPToolAnnotations.builder().build(), Map.of());
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(), List.of(), List.of(descriptor), List.of()),
+ new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of()));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(catalog));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Collection<Arguments> invalidCoreTools() {
+ Map<String, Object> inputSchema = createInputSchema();
+ Map<String, Object> searchOutput = createOutputSchema(List.of("response_mode", "items", "count", "search_context", "total_match_count"));
+ Map<String, Object> queryOutput = createOutputSchema(List.of("response_mode", "result_kind", "statement_class", "statement_type", "status", "returned_row_count",
+ "applied_max_rows", "applied_timeout_ms", "truncated", "next_actions"));
+ Map<String, Object> updateOutput = createOutputSchema(List.of("response_mode", "result_kind", "statement_class", "statement_type", "status", "columns", "rows", "returned_row_count",
+ "applied_max_rows", "applied_timeout_ms", "suggested_arguments", "next_actions"));
+ Map<String, Object> runtimeOutput = createOutputSchema(List.of("response_mode", "status", "database", "checks", "category", "recovery"));
+ Map<String, Object> validRuntimeOutput = replaceProperty(runtimeOutput, "response_mode", Map.of("type", "string", "description", "Mode.", "enum", List.of("validation")));
+ Map<String, Object> databaseInput = Map.of("type", "object", "properties", Map.of("database", Map.of("type", "string", "description", "Database.")),
+ "required", List.of("database"), "additionalProperties", false);
+ return List.of(
+ Arguments.of("search items schema is not object", "database_gateway_search_metadata", inputSchema,
+ replaceProperty(searchOutput, "items", Map.of("type", "array", "description", "Items.", "items", true)),
+ "Tool `database_gateway_search_metadata` outputSchema property `items.items` must be an object."),
+ Arguments.of("search item properties missing", "database_gateway_search_metadata", inputSchema,
+ replaceProperty(searchOutput, "items", Map.of("type", "array", "description", "Items.", "items", Map.of("type", "object"))),
+ "Tool `database_gateway_search_metadata` outputSchema property `items.items.properties` must declare properties."),
+ Arguments.of("search item field missing", "database_gateway_search_metadata", inputSchema,
+ replaceProperty(searchOutput, "items",
+ Map.of("type", "array", "description", "Items.", "items", Map.of("type", "object", "properties", Map.of("foo", Map.of("description", "Field."))))),
+ "Tool `database_gateway_search_metadata` outputSchema item must declare `database`."),
+ Arguments.of("search item field is not object", "database_gateway_search_metadata", inputSchema,
+ replaceProperty(searchOutput, "items", Map.of("type", "array", "description", "Items.", "items", Map.of("type", "object", "properties", Map.of("database", true)))),
+ "Tool `database_gateway_search_metadata` outputSchema item property `database` must be an object."),
+ Arguments.of("explain database missing", "database_gateway_execute_explain_query", inputSchema, queryOutput, "Tool `database_gateway_execute_explain_query` must declare `database`."),
+ Arguments.of("explain database optional", "database_gateway_execute_explain_query",
+ Map.of("type", "object", "properties", Map.of("database", Map.of("type", "string", "description", "Database.")), "required", List.of(), "additionalProperties", false),
+ queryOutput, "Tool `database_gateway_execute_explain_query` `database` must be required."),
+ Arguments.of("update mode missing", "database_gateway_execute_update", inputSchema, updateOutput, "Tool `database_gateway_execute_update` must declare execution_mode."),
+ Arguments.of("update mode optional", "database_gateway_execute_update", createModeInputSchema(List.of()), updateOutput,
+ "Tool `database_gateway_execute_update` execution_mode must be required."),
+ Arguments.of("update mode enum missing", "database_gateway_execute_update",
+ Map.of("type", "object", "properties", Map.of("execution_mode", Map.of("type", "string", "description", "Mode.")), "required", List.of("execution_mode"),
+ "additionalProperties", false),
+ updateOutput, "Tool `database_gateway_execute_update` execution_mode must allow execute and preview."),
+ Arguments.of("runtime response mode enum missing", "database_gateway_validate_runtime_database", databaseInput, runtimeOutput,
+ "Tool `database_gateway_validate_runtime_database` response_mode must allow validation."),
+ Arguments.of("runtime database missing", "database_gateway_validate_runtime_database", inputSchema, validRuntimeOutput,
+ "Tool `database_gateway_validate_runtime_database` must declare `database`."),
+ Arguments.of("runtime database optional", "database_gateway_validate_runtime_database",
+ Map.of("type", "object", "properties", Map.of("database", Map.of("type", "string", "description", "Database.")), "required", List.of(), "additionalProperties", false),
+ validRuntimeOutput, "Tool `database_gateway_validate_runtime_database` database must be required."),
+ Arguments.of("runtime credential exposed", "database_gateway_validate_runtime_database",
+ Map.of("type", "object", "properties",
+ Map.of("database", Map.of("type", "string", "description", "Database."), "password", Map.of("type", "string", "description", "Password.")), "required",
+ List.of("database"), "additionalProperties", false),
+ validRuntimeOutput, "Tool `database_gateway_validate_runtime_database` must not expose `password`."),
+ Arguments.of("runtime check items missing", "database_gateway_validate_runtime_database", databaseInput,
+ replaceProperty(validRuntimeOutput, "checks", Map.of("type", "array", "description", "Checks.")),
+ "Tool `database_gateway_validate_runtime_database` outputSchema property `checks.items` must be an object."),
+ Arguments.of("runtime check properties missing", "database_gateway_validate_runtime_database", databaseInput,
+ replaceProperty(validRuntimeOutput, "checks", Map.of("type", "array", "description", "Checks.", "items", Map.of("type", "object"))),
+ "Tool `database_gateway_validate_runtime_database` outputSchema property `checks.items.properties` must declare properties."),
+ Arguments.of("runtime check field missing", "database_gateway_validate_runtime_database", databaseInput,
+ replaceProperty(validRuntimeOutput, "checks",
+ Map.of("type", "array", "description", "Checks.", "items", Map.of("type", "object", "properties", Map.of("foo", Map.of("description", "Field."))))),
+ "Tool `database_gateway_validate_runtime_database` outputSchema check item must declare `name`."));
+ }
+
+ private static Map<String, Object> createOutputSchema(final Collection<String> fields) {
+ Map<String, Object> props = new LinkedHashMap<>(fields.size(), 1F);
+ for (String each : fields) {
+ props.put(each, Map.of("type", "string", "description", "Output field."));
+ }
+ if (props.containsKey("next_actions")) {
+ props.put("next_actions", Map.of("type", "array", "description", "Next steps.", "items", Map.of("type", "object", "properties",
+ Map.of("order", Map.of("description", "Order."), "type", Map.of("description", "Type."), "title", Map.of("description", "Title.")))));
+ }
+ return Map.of("type", "object", "properties", props, "examples", List.of(Map.of("status", "ok")));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> replaceProperty(final Map<String, Object> schema, final String name, final Object value) {
+ Map<String, Object> props = new LinkedHashMap<>((Map<String, Object>) schema.get("properties"));
+ props.put(name, value);
+ return Map.of("type", "object", "properties", props, "examples", schema.get("examples"));
+ }
+
+ private static Map<String, Object> createModeInputSchema(final Collection<String> required) {
+ return Map.of("type", "object", "properties", Map.of("execution_mode", Map.of("type", "string", "description", "Mode.", "enum", List.of("execute", "preview"))),
+ "required", required, "additionalProperties", false);
+ }
+
+ @Test
+ void assertValidateWithRelatedResourceMetadataType() {
+ MCPToolDescriptor descriptor = new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.", createInputSchema(), createOutputSchema(List.of("status")),
+ MCPToolAnnotations.builder().build(), Map.of(MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, "shardingsphere://foo_resource"));
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(), List.of(), List.of(descriptor), List.of()),
+ new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of()));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(catalog));
+ assertThat(actual.getMessage(), is("Tool `database_gateway_test_tool` metadata `org.apache.shardingsphere/related-resource-uris` must be a list."));
+ }
+
+ @Test
+ void assertValidateWithRelatedResourceWithoutMetadata() {
+ MCPToolDescriptor descriptor = new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.", createInputSchema(), createOutputSchema(List.of("status")),
+ MCPToolAnnotations.builder().build(), Map.of(MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, List.of("shardingsphere://foo_resource")));
+ MCPResourceDescriptor resource =
+ new MCPResourceDescriptor("shardingsphere://foo_resource", "foo_resource", "Resource", "Read resource.", "application/json", MCPResourceAnnotations.EMPTY, Map.of());
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(resource), List.of(), List.of(descriptor), List.of()),
+ new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of()));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(catalog));
+ assertThat(actual.getMessage(), is(
+ "Tool `database_gateway_test_tool` metadata `org.apache.shardingsphere/related-resource-uris` references resource `shardingsphere://foo_resource` without ShardingSphere metadata."));
+ }
+
+ @Test
+ void assertValidateWithDuplicateTools() {
+ MCPDescriptorCatalog catalog = createCatalog(createInputSchema());
+ MCPToolDescriptor descriptor = catalog.getProtocolDescriptors().getToolDescriptors().iterator().next();
+ MCPDescriptorCatalog duplicateCatalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(), List.of(), List.of(descriptor, descriptor), List.of()),
+ catalog.getShardingSphereDescriptors());
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(duplicateCatalog));
+ assertThat(actual.getMessage(), is("Duplicate MCP tool descriptor `database_gateway_test_tool`."));
+ }
+
+ @Test
+ void assertValidateWithMissingPlanningTool() {
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(new MCPProtocolDescriptorCatalog(List.of(), List.of(), List.of(), List.of()),
+ new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of(new MCPToolRuntimeDescriptor("foo_missing", "plan"))));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(catalog));
+ assertThat(actual.getMessage(), is("Planning runtime tool `foo_missing` must reference a registered tool descriptor."));
+ }
+
private MCPDescriptorCatalog createCatalog(final Map<String, Object> inputSchema) {
MCPToolDescriptor descriptor = new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.", inputSchema,
Map.of("type", "object", "properties", Map.of("status", Map.of("type", "string", "description", "Status.")), "examples", List.of(Map.of("status", "ok"))),
@@ -74,7 +214,7 @@ class MCPToolDescriptorCatalogValidatorTest {
new MCPShardingSphereDescriptorCatalog(List.of(), List.of(), List.of(), List.of(), List.of()));
}
- private Map<String, Object> createInputSchema() {
+ private static Map<String, Object> createInputSchema() {
return Map.of("type", "object", "properties", Map.of("query", Map.of("type", "string", "description", "Query.")),
"required", List.of("query"), "additionalProperties", false);
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolInputSchemaValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolInputSchemaValidatorTest.java
index 3208dfdd6e9..266d17d810b 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolInputSchemaValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolInputSchemaValidatorTest.java
@@ -19,11 +19,11 @@ package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
-import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
+import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
@@ -44,6 +44,37 @@ class MCPToolInputSchemaValidatorTest {
private static Stream<Arguments> invalidInputSchemaCases() {
return Stream.of(
+ Arguments.of("conditional composition",
+ Map.of("type", "object", "properties", Map.of("query", Map.of("type", "string", "description", "Query.")), "required", List.of("query"), "additionalProperties", false,
+ "if", Map.of("query", Map.of("type", "string"))),
+ "Tool `database_gateway_test_tool` inputSchema contains unsupported top-level field `if`."),
+ Arguments.of("top-level type", Map.of("type", "array", "properties", Map.of(), "required", List.of(), "additionalProperties", false),
+ "Tool `database_gateway_test_tool` inputSchema must be an object."),
+ Arguments.of("top-level properties", Map.of("type", "object", "properties", "foo", "required", List.of(), "additionalProperties", false),
+ "Tool `database_gateway_test_tool` inputSchema must declare properties."),
+ Arguments.of("top-level required", Map.of("type", "object", "properties", Map.of(), "required", "foo", "additionalProperties", false),
+ "Tool `database_gateway_test_tool` inputSchema required must be an array."),
+ Arguments.of("top-level additionalProperties", Map.of("type", "object", "properties", Map.of(), "required", List.of(), "additionalProperties", "foo"),
+ "Tool `database_gateway_test_tool` inputSchema additionalProperties must be a boolean."),
+ Arguments.of("properties on string", createInputSchemaWithQuery(Map.of("type", "string", "properties", Map.of(), "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` must use type `object` with properties."),
+ Arguments.of("nested properties is not object", createInputSchemaWithQuery(Map.of("type", "object", "properties", "foo", "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query.properties` must be an object."),
+ Arguments.of("property name is not string",
+ Map.of("type", "object", "properties", Map.of(1, Map.of("type", "string", "description", "Query.")), "required", List.of(), "additionalProperties", false),
+ "Tool `database_gateway_test_tool` inputSchema property name at `inputSchema.properties` must be a string."),
+ Arguments.of("required on string", createInputSchemaWithQuery(Map.of("type", "string", "required", List.of(), "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` must use type `object` with required properties."),
+ Arguments.of("nested required is not array", createInputSchemaWithQuery(Map.of("type", "object", "required", "foo", "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query.required` must be an array."),
+ Arguments.of("items on string", createInputSchemaWithQuery(Map.of("type", "string", "items", Map.of(), "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` must use type `array` with items."),
+ Arguments.of("additionalProperties on string", createInputSchemaWithQuery(Map.of("type", "string", "additionalProperties", false, "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` must use type `object` with additionalProperties."),
+ Arguments.of("range on string", createInputSchemaWithQuery(Map.of("type", "string", "minimum", 0, "maximum", 10, "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` only supports minimum, maximum and default for type `integer`."),
+ Arguments.of("integer default wrong type", createInputSchemaWithQuery(Map.of("type", "integer", "default", "foo", "description", "Query."), List.of("query")),
+ "Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query.default` must be an integer in the Java int range."),
Arguments.of("unsupported type", createInputSchemaWithQuery(Map.of("type", "unsupported", "description", "Query."), List.of("query")),
"Tool `database_gateway_test_tool` inputSchema at `inputSchema.properties.query` uses unsupported type `unsupported`."),
Arguments.of("property is not an object", createInputSchemaWithQuery("string", List.of("query")),
@@ -88,12 +119,21 @@ class MCPToolInputSchemaValidatorTest {
return Map.of("type", "object", "properties", Map.of("query", querySchema), "required", required, "additionalProperties", false);
}
- @Test
- void assertValidateWithStringDefault() {
- Map<String, Object> inputSchema = createInputSchemaWithQuery(Map.of("type", "string", "default", "query", "description", "Query."), List.of("query"));
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("validInputSchemaCases")
+ void assertValidateWithValidInputSchema(final String name, final Map<String, Object> inputSchema) {
assertDoesNotThrow(() -> MCPToolInputSchemaValidator.validate(createToolDescriptor(inputSchema)));
}
+ private static Stream<Arguments> validInputSchemaCases() {
+ return Stream.of(
+ Arguments.of("string default", createInputSchemaWithQuery(Map.of("type", "string", "default", "query", "description", "Query."), List.of("query"))),
+ Arguments.of("integer default without range", createInputSchemaWithQuery(Map.of("type", "integer", "default", 3, "description", "Query."), List.of("query"))),
+ Arguments.of("BigInteger bounds", createInputSchemaWithQuery(Map.of("type", "integer", "minimum", BigInteger.ZERO, "maximum", BigInteger.TEN, "default", BigInteger.TEN,
+ "description", "Query."), List.of("query"))),
+ Arguments.of("schema without type", createInputSchemaWithQuery(Map.of("description", "Query."), List.of("query"))));
+ }
+
private MCPToolDescriptor createToolDescriptor(final Map<String, Object> inputSchema) {
Map<String, Object> outputSchema = Map.of("type", "object", "properties", Map.of("status", Map.of("type", "string", "description", "Status.")),
"examples", List.of(Map.of("status", "ok")));
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidatorTest.java
index a0fc2c7d243..2297f20eb3d 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidatorTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidatorTest.java
@@ -20,7 +20,11 @@ package org.apache.shardingsphere.mcp.support.descriptor;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolAnnotations;
import org.apache.shardingsphere.mcp.api.capability.tool.MCPToolDescriptor;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -30,6 +34,67 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
class MCPToolOutputSchemaValidatorTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidOutputSchemas")
+ void assertValidateWithInvalidOutputSchema(final String name, final Map<String, Object> outputSchema, final String expectedMessage) {
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolOutputSchemaValidator.validate(createDescriptor(outputSchema)));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private MCPToolDescriptor createDescriptor(final Map<String, Object> outputSchema) {
+ return new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.", Map.of(), outputSchema, MCPToolAnnotations.builder().build(), Map.of());
+ }
+
+ private static Collection<Arguments> invalidOutputSchemas() {
+ return List.of(
+ Arguments.of("wrong schema type", Map.of("type", "array"), "Tool `database_gateway_test_tool` outputSchema must be an object."),
+ Arguments.of("empty properties", createOutputSchema(Map.of(), List.of(Map.of("status", "ok"))), "Tool `database_gateway_test_tool` outputSchema must declare properties."),
+ Arguments.of("missing examples", Map.of("type", "object", "properties", Map.of("status", Map.of("description", "Status."))),
+ "Tool `database_gateway_test_tool` outputSchema must declare examples."),
+ Arguments.of("unknown response mode", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("response_mode", "foo_unknown"))),
+ "Tool `database_gateway_test_tool` output example uses unknown response_mode `foo_unknown`."),
+ Arguments.of("unknown continuation mode", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("continuation_mode", "foo_unknown"))),
+ "Tool `database_gateway_test_tool` output example uses unknown continuation_mode `foo_unknown`."),
+ Arguments.of("unknown recovery category", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("recovery_category", "foo_unknown"))),
+ "Tool `database_gateway_test_tool` output example uses unknown recovery_category `foo_unknown`."),
+ Arguments.of("next actions is not array", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("next_actions", "foo_actions"))),
+ "Tool `database_gateway_test_tool` next_actions example must be an array."),
+ Arguments.of("action is not object", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("next_actions", List.of("foo_action")))),
+ "Tool `database_gateway_test_tool` next_actions example item must be an object."),
+ Arguments.of("unknown action type", createOutputSchema(Map.of("status", Map.of("description", "Status.")), List.of(Map.of("next_actions", List.of(Map.of("type", "foo_action"))))),
+ "Tool `database_gateway_test_tool` next_actions example uses unknown type `foo_action`."),
+ Arguments.of("critical field is not object", createOutputSchema(Map.of("summary", "foo_summary"), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` model-critical output field `summary` must be an object."),
+ Arguments.of("missing description", createOutputSchema(Map.of("summary", Map.of("type", "string")), List.of(Map.of("status", "ok"))),
+ "Tool model-critical output field `database_gateway_test_tool.summary` description is required."),
+ Arguments.of("resource placeholder", createOutputSchema(Map.of("summary", Map.of("description", "ShardingSphere MCP resource: foo")), List.of(Map.of("status", "ok"))),
+ "Tool model-critical output field `database_gateway_test_tool.summary` description must not be a placeholder description."),
+ Arguments.of("template placeholder", createOutputSchema(Map.of("summary", Map.of("description", "ShardingSphere MCP resource template: foo")), List.of(Map.of("status", "ok"))),
+ "Tool model-critical output field `database_gateway_test_tool.summary` description must not be a placeholder description."),
+ Arguments.of("action schema type", createOutputSchema(Map.of("next_actions", Map.of("type", "object", "description", "Next steps.")), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` next_actions must be an array."),
+ Arguments.of("action schema items", createOutputSchema(Map.of("next_actions", Map.of("type", "array", "description", "Next steps.", "items", "foo")), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` next_actions items must be an object."),
+ Arguments.of("action schema properties",
+ createOutputSchema(Map.of("next_actions", Map.of("type", "array", "description", "Next steps.", "items", Map.of())), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` next_actions items must declare properties."),
+ Arguments.of("action schema required field", createOutputSchema(Map.of("next_actions", createNextActionsSchema(Map.of())), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` next_actions item must declare `order`."),
+ Arguments.of("action schema field is not object", createOutputSchema(Map.of("next_actions", createNextActionsSchema(Map.of("order", 1))), List.of(Map.of("status", "ok"))),
+ "Tool `database_gateway_test_tool` next_actions item field `order` must be an object."),
+ Arguments.of("action schema missing field description",
+ createOutputSchema(Map.of("next_actions", createNextActionsSchema(Map.of("order", Map.of("type", "integer")))), List.of(Map.of("status", "ok"))),
+ "Tool next_actions item field `database_gateway_test_tool.order` description is required."));
+ }
+
+ private static Map<String, Object> createOutputSchema(final Map<String, Object> props, final Object examples) {
+ return Map.of("type", "object", "properties", props, "examples", examples);
+ }
+
+ private static Map<String, Object> createNextActionsSchema(final Map<String, Object> props) {
+ return Map.of("type", "array", "description", "Next steps.", "items", Map.of("type", "object", "properties", props));
+ }
+
@Test
void assertValidate() {
Map<String, Object> inputSchema = Map.of("type", "object", "properties", Map.of("query", Map.of("type", "string", "description", "Query.")),
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/ISOOffsetDateTimeValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/ISOOffsetDateTimeValidatorTest.java
new file mode 100644
index 00000000000..4b52bc5f3d3
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/ISOOffsetDateTimeValidatorTest.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shardingsphere.mcp.support.descriptor.yaml.validator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import javax.validation.ConstraintValidatorContext;
+import java.util.stream.Stream;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ISOOffsetDateTimeValidatorTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertIsValidCases")
+ void assertIsValid(final String name, final String value, final boolean expected) {
+ boolean actual = new ISOOffsetDateTimeValidator().isValid(value, mock(ConstraintValidatorContext.class));
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ void assertIsValidWithBlankValue() {
+ ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
+ ConstraintValidatorContext.ConstraintViolationBuilder builder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
+ when(context.buildConstraintViolationWithTemplate("is required")).thenReturn(builder);
+ assertFalse(new ISOOffsetDateTimeValidator().isValid(" ", context));
+ verify(context).disableDefaultConstraintViolation();
+ verify(builder).addConstraintViolation();
+ }
+
+ private static Stream<Arguments> assertIsValidCases() {
+ return Stream.of(
+ Arguments.of("without value", null, true),
+ Arguments.of("with offset", "2026-10-12T10:00:00+08:00", true),
+ Arguments.of("invalid date time", "foo_time", false));
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPDescriptionValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPDescriptionValidatorTest.java
new file mode 100644
index 00000000000..8990d6ceb0b
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPDescriptionValidatorTest.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shardingsphere.mcp.support.descriptor.yaml.validator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import javax.validation.ConstraintValidatorContext;
+import java.util.stream.Stream;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MCPDescriptionValidatorTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("assertIsValidCases")
+ void assertIsValid(final String name, final String value, final boolean expected) {
+ boolean actual = new MCPDescriptionValidator().isValid(value, mock(ConstraintValidatorContext.class));
+ assertThat(actual, is(expected));
+ }
+
+ @Test
+ void assertIsValidWithResourcePlaceholder() {
+ ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
+ ConstraintValidatorContext.ConstraintViolationBuilder builder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
+ when(context.buildConstraintViolationWithTemplate("must not be a placeholder description")).thenReturn(builder);
+ assertFalse(new MCPDescriptionValidator().isValid("ShardingSphere MCP resource: foo_resource", context));
+ verify(context).disableDefaultConstraintViolation();
+ verify(builder).addConstraintViolation();
+ }
+
+ @Test
+ void assertIsValidWithResourceTemplatePlaceholder() {
+ ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
+ ConstraintValidatorContext.ConstraintViolationBuilder builder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
+ when(context.buildConstraintViolationWithTemplate("must not be a placeholder description")).thenReturn(builder);
+ assertFalse(new MCPDescriptionValidator().isValid("ShardingSphere MCP resource template: foo_resource", context));
+ verify(context).disableDefaultConstraintViolation();
+ verify(builder).addConstraintViolation();
+ }
+
+ private static Stream<Arguments> assertIsValidCases() {
+ return Stream.of(
+ Arguments.of("without description", null, false),
+ Arguments.of("with description", "Read the current runtime capabilities.", true),
+ Arguments.of("blank description", " ", false));
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPPromptGuidanceMetaValidatorTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPPromptGuidanceMetaValidatorTest.java
new file mode 100644
index 00000000000..8e3e59c44b1
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/yaml/validator/MCPPromptGuidanceMetaValidatorTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.shardingsphere.mcp.support.descriptor.yaml.validator;
+
+import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
+import org.junit.jupiter.api.Test;
+
+import javax.validation.ConstraintValidatorContext;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MCPPromptGuidanceMetaValidatorTest {
+
+ @Test
+ void assertIsValidWithoutMetadata() {
+ assertTrue(new MCPPromptGuidanceMetaValidator().isValid(null, mock(ConstraintValidatorContext.class)));
+ }
+
+ @Test
+ void assertIsValidWithGuidance() {
+ assertTrue(new MCPPromptGuidanceMetaValidator().isValid(Map.of(MCPShardingSphereMetadataKeys.STOP_CONDITIONS, List.of("stop"),
+ MCPShardingSphereMetadataKeys.ASK_USER_CONDITIONS, List.of("ask")), mock(ConstraintValidatorContext.class)));
+ }
+
+ @Test
+ void assertIsValidWithoutStopConditions() {
+ ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
+ ConstraintValidatorContext.ConstraintViolationBuilder builder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
+ when(context.buildConstraintViolationWithTemplate("must declare " + MCPShardingSphereMetadataKeys.STOP_CONDITIONS)).thenReturn(builder);
+ assertFalse(new MCPPromptGuidanceMetaValidator().isValid(Map.of(MCPShardingSphereMetadataKeys.ASK_USER_CONDITIONS, List.of("ask")), context));
+ verify(builder).addConstraintViolation();
+ }
+
+ @Test
+ void assertIsValidWithEmptyAskUserConditions() {
+ ConstraintValidatorContext context = mock(ConstraintValidatorContext.class);
+ ConstraintValidatorContext.ConstraintViolationBuilder builder = mock(ConstraintValidatorContext.ConstraintViolationBuilder.class);
+ when(context.buildConstraintViolationWithTemplate("must declare " + MCPShardingSphereMetadataKeys.ASK_USER_CONDITIONS)).thenReturn(builder);
+ assertFalse(new MCPPromptGuidanceMetaValidator().isValid(Map.of(MCPShardingSphereMetadataKeys.STOP_CONDITIONS, List.of("stop"),
+ MCPShardingSphereMetadataKeys.ASK_USER_CONDITIONS, List.of()), context));
+ verify(context).disableDefaultConstraintViolation();
+ verify(builder).addConstraintViolation();
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriPathSegmentUtilsTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriPathSegmentUtilsTest.java
index 34aa2e96529..2e4f1478c82 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriPathSegmentUtilsTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriPathSegmentUtilsTest.java
@@ -18,7 +18,12 @@
package org.apache.shardingsphere.mcp.support.resource;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
+import java.util.List;
import java.util.Optional;
import static org.hamcrest.MatcherAssert.assertThat;
@@ -26,6 +31,16 @@ import static org.hamcrest.Matchers.is;
class MCPUriPathSegmentUtilsTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidEncodings")
+ void assertDecodePathSegmentWithMalformedEncoding(final String name, final String pathSegment) {
+ assertThat(MCPUriPathSegmentUtils.decodePathSegment(pathSegment), is(Optional.empty()));
+ }
+
+ private static Collection<Arguments> invalidEncodings() {
+ return List.of(Arguments.of("truncated escape", "foo%2"), Arguments.of("invalid hexadecimal", "logic%ZZdb"), Arguments.of("invalid UTF-8", "%FF"));
+ }
+
@Test
void assertEncodePathSegment() {
assertThat(MCPUriPathSegmentUtils.encodePathSegment("订单 / archive?"), is("%E8%AE%A2%E5%8D%95%20%2F%20archive%3F"));
@@ -43,9 +58,4 @@ class MCPUriPathSegmentUtilsTest {
assertThat(actualPathSegment, is(Optional.of("logic+db")));
}
- @Test
- void assertDecodePathSegmentWithMalformedPercentEncoding() {
- Optional<String> actualPathSegment = MCPUriPathSegmentUtils.decodePathSegment("logic%ZZdb");
- assertThat(actualPathSegment, is(Optional.empty()));
- }
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriTemplateTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriTemplateTest.java
index 17e91f58123..d3af4b8b44c 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriTemplateTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/resource/MCPUriTemplateTest.java
@@ -19,7 +19,11 @@ package org.apache.shardingsphere.mcp.support.resource;
import org.apache.shardingsphere.mcp.api.capability.resource.MCPResourceURIVariables;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -32,9 +36,24 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class MCPUriTemplateTest {
- @Test
- void assertCreateWithUnsupportedScheme() {
- assertThrows(IllegalArgumentException.class, () -> new MCPUriTemplate("unsupported://capabilities"));
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("invalidTemplates")
+ void assertCreateWithInvalidPath(final String name, final String template, final String expectedMessage) {
+ IllegalArgumentException actual = assertThrows(IllegalArgumentException.class, () -> new MCPUriTemplate(template));
+ assertThat(actual.getMessage(), is(expectedMessage));
+ }
+
+ private static Collection<Arguments> invalidTemplates() {
+ return List.of(
+ Arguments.of("unsupported scheme", "unsupported://capabilities", "Invalid URI `unsupported://capabilities`."),
+ Arguments.of("missing scheme separator", "invalid-template", "Invalid URI `invalid-template`."),
+ Arguments.of("embedded variable", "shardingsphere://databases/prefix-{database}",
+ "URI template variables must occupy a complete path segment in `shardingsphere://databases/prefix-{database}`."),
+ Arguments.of("empty path", "shardingsphere://", "URI path is required for `shardingsphere://`."),
+ Arguments.of("empty segment", "shardingsphere://databases//schemas", "URI segment is required for `shardingsphere://databases//schemas`."),
+ Arguments.of("nested variable marker", "shardingsphere://databases/{{database}}", "Invalid URI template variable `{database}` in `shardingsphere://databases/{{database}}`."),
+ Arguments.of("duplicate variable", "shardingsphere://databases/{database}/{database}",
+ "Duplicate URI template variable `database` in `shardingsphere://databases/{database}/{database}`."));
}
@Test
@@ -84,13 +103,4 @@ class MCPUriTemplateTest {
assertTrue(new MCPUriTemplate("shardingsphere://databases/{database}").overlaps(new MCPUriTemplate("shardingsphere://databases/logic_db")));
}
- @Test
- void assertCreateWithEmbeddedVariable() {
- assertThrows(IllegalArgumentException.class, () -> new MCPUriTemplate("shardingsphere://databases/prefix-{database}"));
- }
-
- @Test
- void assertCreateWithInvalidTemplate() {
- assertThrows(IllegalArgumentException.class, () -> new MCPUriTemplate("invalid-template"));
- }
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowLifecycleUtilsTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowLifecycleUtilsTest.java
index 654b769ee30..f783f597faa 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowLifecycleUtilsTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowLifecycleUtilsTest.java
@@ -21,14 +21,39 @@ import org.apache.shardingsphere.mcp.support.workflow.model.ClarifiedIntent;
import org.apache.shardingsphere.mcp.support.workflow.model.InteractionPlan;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
class WorkflowLifecycleUtilsTest {
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("dropWorkflowCases")
+ void assertIsDropWorkflow(final String name, final WorkflowContextSnapshot snapshot, final boolean expected) {
+ assertThat(WorkflowLifecycleUtils.isDropWorkflow(snapshot), is(expected));
+ }
+
+ private static Stream<Arguments> dropWorkflowCases() {
+ return Stream.of(
+ Arguments.of("before clarification", new WorkflowContextSnapshot(), false),
+ Arguments.of("drop operation", createSnapshot("drop"), true),
+ Arguments.of("create operation", createSnapshot("create"), false));
+ }
+
+ private static WorkflowContextSnapshot createSnapshot(final String operationType) {
+ WorkflowContextSnapshot result = new WorkflowContextSnapshot();
+ ClarifiedIntent clarifiedIntent = new ClarifiedIntent();
+ clarifiedIntent.setOperationType(operationType);
+ result.setClarifiedIntent(clarifiedIntent);
+ return result;
+ }
+
@Test
void assertIsNotOwnedBySessionWithBlankSnapshotSession() {
WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();
@@ -55,22 +80,4 @@ class WorkflowLifecycleUtilsTest {
void assertResolveCurrentStepWithoutInteractionPlan() {
assertThat(WorkflowLifecycleUtils.resolveCurrentStep(new WorkflowContextSnapshot()), is(""));
}
-
- @Test
- void assertIsDropWorkflow() {
- WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();
- ClarifiedIntent clarifiedIntent = new ClarifiedIntent();
- clarifiedIntent.setOperationType("drop");
- snapshot.setClarifiedIntent(clarifiedIntent);
- assertTrue(WorkflowLifecycleUtils.isDropWorkflow(snapshot));
- }
-
- @Test
- void assertIsDropWorkflowWithNonDropOperation() {
- WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();
- ClarifiedIntent clarifiedIntent = new ClarifiedIntent();
- clarifiedIntent.setOperationType("create");
- snapshot.setClarifiedIntent(clarifiedIntent);
- assertFalse(WorkflowLifecycleUtils.isDropWorkflow(snapshot));
- }
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowPlanPayloadBuilderTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowPlanPayloadBuilderTest.java
index b59c9721827..f89d27b73ee 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowPlanPayloadBuilderTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/workflow/service/WorkflowPlanPayloadBuilderTest.java
@@ -43,6 +43,21 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
class WorkflowPlanPayloadBuilderTest {
+ @Test
+ void assertBuildWithPreviewedPlan() {
+ WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();
+ snapshot.setPlanId("plan-1");
+ snapshot.setWorkflowKind(WorkflowKind.valueOf("mask.rule"));
+ snapshot.setStatus(WorkflowLifecycle.STATUS_PREVIEWED);
+ snapshot.setClarifiedIntent(new ClarifiedIntent());
+ WorkflowRequest request = new WorkflowRequest();
+ snapshot.setRequest(request);
+ snapshot.setInteractionPlan(InteractionPlan.create("plan-1", request, "Mask workflow plan.", List.of("Review"), List.of("rules")));
+ Map<String, Object> actual = WorkflowPlanPayloadBuilder.build(snapshot);
+ assertThat(actual.get("summary"), is("Workflow plan `plan-1` for mask.rule is `previewed`."));
+ assertThat(actual.get("status"), is(WorkflowLifecycle.STATUS_PREVIEWED));
+ }
+
@Test
void assertBuildIncludesIntentInference() {
WorkflowContextSnapshot snapshot = new WorkflowContextSnapshot();