This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/cayenne.git
commit 5cf755ef8b4f2464834a42776a9df2f45da3cf38 Author: Andrus Adamchik <[email protected]> AuthorDate: Sun May 24 11:50:54 2026 -0400 CAY-2949 CayenneModeler MCP: dbimport_run tool --- CLAUDE.md | 10 + cayenne-mcp-server/pom.xml | 6 + .../org/apache/cayenne/mcp/CayenneMcpServer.java | 2 + .../mcp/tools/dbimport/DbImportRunTool.java | 352 +++++++++++++++++++++ .../tools/dbimport/InstrumentedDbImportAction.java | 118 +++++++ .../mcp/tools/dbimport/protocol/DbImportError.java | 24 ++ .../tools/dbimport/protocol/DbImportErrorCode.java | 30 ++ .../tools/dbimport/protocol/DbImportResolved.java | 33 ++ .../tools/dbimport/protocol/DbImportRunResult.java | 35 ++ .../tools/dbimport/protocol/DbImportStatus.java | 26 ++ .../tools/dbimport/protocol/DbImportSummary.java | 36 +++ .../dbimport/protocol/DbImportValidation.java | 36 +++ .../cayenne/mcp/tools/dbimport/DbImportRunIT.java | 198 ++++++++++++ .../tools/dbimport/DbImportRunValidationTest.java | 247 +++++++++++++++ .../dbimport-fixtures/no-dbimport/TestMap.map.xml | 7 + .../no-dbimport/cayenne-project.xml | 4 + .../with-dbimport/TestMap.map.xml | 11 + .../with-dbimport/cayenne-project.xml | 4 + 18 files changed, 1179 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 5525ae86c..a2013a18f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,16 @@ Do not set `<scope>` inside `<dependencyManagement>` (except `<scope>import</sco pins versions only — each consuming `<dependency>` declares its own scope explicitly. +## Java Style + +Never use fully-qualified class names inline in code. Always add a proper `import` statement and use the simple name. + +Line width is 120 characters — applies to code, Javadoc, and other comments. + +Avoid `var` — only use it when the type is immediately obvious from the right-hand side of the assignment +(e.g., `var list = new ArrayList<String>()`). Never use `var` for method return values, streams, or anything +that requires reading the right-hand side carefully to determine the type. + ## CI Matrix GitHub Actions runs on push to master/STABLE-* branches: diff --git a/cayenne-mcp-server/pom.xml b/cayenne-mcp-server/pom.xml index cf8f52b54..f440cb47e 100644 --- a/cayenne-mcp-server/pom.xml +++ b/cayenne-mcp-server/pom.xml @@ -56,6 +56,12 @@ <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.cayenne</groupId> + <artifactId>cayenne-dbsync</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java index f0eb6971d..3119e2ce7 100644 --- a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java @@ -26,6 +26,7 @@ import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import org.apache.cayenne.modeler.pref.PrefsLocator; import org.apache.cayenne.mcp.tools.cgen.CgenRunTool; +import org.apache.cayenne.mcp.tools.dbimport.DbImportRunTool; import org.apache.cayenne.mcp.tools.openproject.OpenProjectTool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +57,7 @@ public class CayenneMcpServer { .serverInfo("cayenne-mcp-server", version) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) .tools(CgenRunTool.spec(jsonMapper)) + .tools(DbImportRunTool.spec(jsonMapper, prefsLocator)) .tools(OpenProjectTool.spec(jsonMapper, prefsLocator)) .build(); diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java new file mode 100644 index 000000000..a158ae0ed --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java @@ -0,0 +1,352 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.cayenne.configuration.DataChannelDescriptor; +import org.apache.cayenne.configuration.xml.DataChannelMetaData; +import org.apache.cayenne.datasource.DriverDataSource; +import org.apache.cayenne.dbsync.DbSyncModule; +import org.apache.cayenne.dbsync.reverse.configuration.ToolsModule; +import org.apache.cayenne.dbsync.reverse.dbimport.DbImportConfiguration; +import org.apache.cayenne.dbsync.reverse.dbimport.DbImportModule; +import org.apache.cayenne.dbsync.reverse.dbimport.ReverseEngineering; +import org.apache.cayenne.di.DIBootstrap; +import org.apache.cayenne.di.Injector; +import org.apache.cayenne.map.DataMap; +import org.apache.cayenne.modeler.pref.PreferenceNodeIds; +import org.apache.cayenne.modeler.pref.PrefsLocator; +import org.apache.cayenne.modeler.pref.adapters.ClasspathPrefs; +import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs; +import org.apache.cayenne.modeler.pref.dbconnector.DBConnector; +import org.apache.cayenne.mcp.log.McpLoggingHandler; +import org.apache.cayenne.mcp.project.McpProjectLoaderModule; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportError; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportErrorCode; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportResolved; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportSummary; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportValidation; +import org.apache.cayenne.project.Project; +import org.apache.cayenne.project.ProjectLoader; +import org.apache.cayenne.project.ProjectModule; +import org.apache.cayenne.resource.URLResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.Driver; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * MCP tool that runs Cayenne dbimport for a single DataMap inside a Cayenne project. + * Reads reverse-engineering filter config from the DataMap's {@code <reverse-engineering>} + * block and JDBC connection info from the DBConnector stored in CayenneModeler preferences + * for this DataMap. Returns a structured JSON summary of what changed. + * + * @since 5.0 + */ +public class DbImportRunTool { + + public static final String NAME = "dbimport_run"; + + private static final Logger LOGGER = LoggerFactory.getLogger(DbImportRunTool.class); + + private final PrefsLocator prefsLocator; + private final Injector injector; + + // Memoized driver classloader — reused while the classpath entries list is unchanged + private volatile CachedClassLoader cachedClassLoader; + + public DbImportRunTool(PrefsLocator prefsLocator) { + this.prefsLocator = prefsLocator; + this.injector = DIBootstrap.createInjector( + new DbSyncModule(), + new ToolsModule(LOGGER), + new DbImportModule(), + new ProjectModule(), + new McpProjectLoaderModule(), + binder -> binder.bind(InstrumentedDbImportAction.class).to(InstrumentedDbImportAction.class) + ); + } + + public static McpServerFeatures.SyncToolSpecification spec(McpJsonMapper jsonMapper, + PrefsLocator prefsLocator) { + DbImportRunTool tool = new DbImportRunTool(prefsLocator); + + McpSchema.Tool descriptor = new McpSchema.Tool( + NAME, + null, + """ + Run Cayenne dbimport for a named DataMap. Reads reverse-engineering filters \ + from the DataMap's <reverse-engineering> block and JDBC connection from the \ + DBConnector that CayenneModeler stored for this DataMap. Returns token-count \ + summary and resolved connection metadata; actual schema changes are written \ + to the DataMap XML on disk.""", + new McpSchema.JsonSchema( + "object", + Map.of( + "projectPath", Map.of( + "type", "string", + "description", "Absolute path to the top-level Cayenne project descriptor (cayenne-*.xml), not a DataMap file"), + "dataMap", Map.of( + "type", "string", + "description", "Name of the target DataMap as it appears in the <map name='...'> element of the project descriptor") + ), + List.of("projectPath", "dataMap"), + null, null, null + ), + null, null, null + ); + + return new McpServerFeatures.SyncToolSpecification(descriptor, (exchange, request) -> { + Map<String, Object> args = request.arguments(); + String projectPath = args != null ? (String) args.getOrDefault("projectPath", "") : ""; + String dataMapName = args != null ? (String) args.getOrDefault("dataMap", "") : ""; + + DbImportRunResult result = tool.run(projectPath, dataMapName); + + String json; + try { + json = jsonMapper.writeValueAsString(result); + } catch (IOException e) { + json = """ + {"status":"error","error":{"code":"dbimport_runtime_error",\ + "message":"Serialization failed: %s"}}""".formatted(e.getMessage()); + } + + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(json))) + .isError(false) + .build(); + }); + } + + public DbImportRunResult run(String projectPath, String dataMapName) { + + // Step 1 — project file readable? + Path projectFile = Path.of(projectPath); + if (!Files.isReadable(projectFile)) { + return validationFailed(DbImportErrorCode.project_not_found, + "No readable file at %s".formatted(projectPath), + new DbImportValidation(false, null, null, null, null, null)); + } + + // Step 2 — parses as Cayenne project? + Project project; + try { + // TODO: loading the project here, and then again within InstrumentedDbImportAction + project = injector + .getInstance(ProjectLoader.class) + .loadProject(new URLResource(projectFile.toUri().toURL())); + } catch (Exception e) { + return validationFailed(DbImportErrorCode.project_parse_failed, + "Cayenne project loader rejected the descriptor: %s".formatted(e.getMessage()), + new DbImportValidation(true, null, null, null, null, null)); + } + + // Step 3 — DataMap present? + DataChannelDescriptor descriptor = (DataChannelDescriptor) project.getRootNode(); + DataMap dataMap = descriptor.getDataMap(dataMapName); + if (dataMap == null) { + String available = descriptor.getDataMaps().stream() + .map(DataMap::getName) + .sorted() + .collect(Collectors.joining("', '", "'", "'")); + return validationFailed(DbImportErrorCode.datamap_not_found, + "Project loaded successfully but contains no DataMap named '%s'. Available DataMaps: %s." + .formatted(dataMapName, available), + new DbImportValidation(true, false, null, null, null, null)); + } + + // Step 4 — <reverse-engineering> config present? + DataChannelMetaData metaData = injector.getInstance(DataChannelMetaData.class); + ReverseEngineering reverseEngineering = metaData.get(dataMap, ReverseEngineering.class); + if (reverseEngineering == null) { + return validationFailed(DbImportErrorCode.reverse_engineering_config_missing, + "DataMap '%s' has no <reverse-engineering> configuration. Configure dbimport for this DataMap in CayenneModeler before invoking the tool." + .formatted(dataMapName), + new DbImportValidation(true, true, false, null, null, null)); + } + + // Resolve DataMap file path for preference node lookup + Path dataMapFile = resolveDataMapFile(dataMap); + + // Step 5 — DBConnector stored in preferences? + String dataMapId = PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString()); + DBConnector connector = new DataMapPrefs(prefsLocator.dataMapNode(dataMapId)).getConnector(); + if (connector == null) { + return validationFailed(DbImportErrorCode.dbconnector_not_configured, + """ + No DBConnector is stored in preferences for DataMap '%s' (project '%s'). \ + In CayenneModeler, open this project, right-click the DataMap, \ + choose 'Reengineer Database Schema…', and pick a connection — \ + its values will be saved to this DataMap's preferences.\ + """.formatted(dataMapName, projectPath), + new DbImportValidation(true, true, true, false, null, null)); + } + + DbImportResolved resolved = new DbImportResolved( + dataMapFile.toAbsolutePath().toString(), + connector.getUrl(), + connector.getJdbcDriver(), + connector.getDbAdapter() + ); + + // Step 6 — JDBC driver loadable from Preferences → Classpath? + ClassLoader driverCl = buildDriverClassLoader(); + Driver driver; + try { + driver = (Driver) driverCl.loadClass(connector.getJdbcDriver()).getDeclaredConstructor().newInstance(); + } catch (ClassNotFoundException e) { + return validationFailed(DbImportErrorCode.jdbc_driver_not_loadable, + "JDBC driver class '%s' could not be loaded. In CayenneModeler, open Preferences → Classpath, add the driver jar, then re-run — the MCP server reads the classpath fresh on each call." + .formatted(connector.getJdbcDriver()), + new DbImportValidation(true, true, true, true, false, null), + resolved); + } catch (Exception e) { + return validationFailed(DbImportErrorCode.jdbc_driver_not_loadable, + "JDBC driver class '%s' could not be instantiated: %s" + .formatted(connector.getJdbcDriver(), e.getMessage()), + new DbImportValidation(true, true, true, true, false, null), + resolved); + } + + // Step 7 — JDBC connection opens? + Connection conn; + try { + conn = new DriverDataSource( + driver, + connector.getUrl(), + connector.getUserName(), + connector.getPassword()).getConnection(); + + } catch (SQLException e) { + String sqlState = e.getSQLState() != null ? " [SQLState: %s]".formatted(e.getSQLState()) : ""; + return validationFailed(DbImportErrorCode.jdbc_connection_failed, + "Could not open JDBC connection to '%s' as '%s': %s%s." + .formatted(connector.getUrl(), connector.getUserName(), e.getMessage(), sqlState), + new DbImportValidation(true, true, true, true, true, false), + resolved); + } + + // All validation passed — run dbimport + Supplier<List<String>> stopCapture = McpLoggingHandler.startCapture("org.apache.cayenne.dbsync"); + List<String> warnings; + try { + InstrumentedDbImportAction action = injector.getInstance(InstrumentedDbImportAction.class); + DbImportConfiguration config = buildConfig(connector, dataMapFile, projectFile); + action.execute(config); + + int considered = action.getCapturedTokens().size(); + DbImportSummary summary = action.buildSummary(considered); + String status = considered == 0 ? "up_to_date" : "imported"; + warnings = stopCapture.get(); + return new DbImportRunResult(status, summary, resolved, warnings, DbImportValidation.ALL_PASSED, null); + } catch (Exception e) { + warnings = stopCapture.get(); + InstrumentedDbImportAction action = injector.getInstance(InstrumentedDbImportAction.class); + DbImportSummary partial = action.buildSummary(0); + String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getName(); + return new DbImportRunResult("error", partial, resolved, warnings, DbImportValidation.ALL_PASSED, + new DbImportError(DbImportErrorCode.dbimport_runtime_error, msg)); + } finally { + try { + conn.close(); + } catch (SQLException ignored) { + } + } + } + + private DbImportConfiguration buildConfig(DBConnector connector, Path dataMapFile, Path projectFile) { + DbImportConfiguration config = new DbImportConfiguration(); + config.setDriver(connector.getJdbcDriver()); + config.setUrl(connector.getUrl()); + config.setUsername(connector.getUserName()); + config.setPassword(connector.getPassword()); + config.setAdapter(connector.getDbAdapter()); + config.setTargetDataMap(dataMapFile.toFile()); + config.setCayenneProject(projectFile.toFile()); + config.setUseDataMapReverseEngineering(true); + return config; + } + + private Path resolveDataMapFile(DataMap dataMap) { + try { + URL sourceUrl = dataMap.getConfigurationSource().getURL(); + return Path.of(sourceUrl.toURI()); + } catch (Exception e) { + throw new IllegalStateException("Cannot resolve DataMap file path for '%s'".formatted(dataMap.getName()), e); + } + } + + private ClassLoader buildDriverClassLoader() { + List<String> entries = new ClasspathPrefs(prefsLocator.appNode(ClasspathPrefs.NODE)).getEntries(); + CachedClassLoader cached = this.cachedClassLoader; + if (cached != null && cached.entries.equals(entries)) { + return cached.loader; + } + URL[] urls = entries.stream() + .map(e -> { + try { + return Path.of(e).toUri().toURL(); + } catch (Exception ex) { + return null; + } + }) + .filter(Objects::nonNull) + .toArray(URL[]::new); + ClassLoader loader = new URLClassLoader(urls, getClass().getClassLoader()); + this.cachedClassLoader = new CachedClassLoader(entries, loader); + return loader; + } + + private static DbImportRunResult validationFailed(DbImportErrorCode code, String message, + DbImportValidation validation) { + return validationFailed(code, message, validation, null); + } + + private static DbImportRunResult validationFailed(DbImportErrorCode code, String message, + DbImportValidation validation, + DbImportResolved resolved) { + return new DbImportRunResult( + "validation_failed", + DbImportSummary.ZERO, + resolved, + List.of(), + validation, + new DbImportError(code, message) + ); + } + + private record CachedClassLoader(List<String> entries, ClassLoader loader) { + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java new file mode 100644 index 000000000..2c0cc5b4a --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java @@ -0,0 +1,118 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport; + +import org.apache.cayenne.configuration.DataChannelDescriptorLoader; +import org.apache.cayenne.configuration.DataMapLoader; +import org.apache.cayenne.configuration.runtime.DataSourceFactory; +import org.apache.cayenne.configuration.runtime.DbAdapterFactory; +import org.apache.cayenne.configuration.xml.DataChannelMetaData; +import org.apache.cayenne.dbsync.merge.token.MergerToken; +import org.apache.cayenne.dbsync.merge.token.model.AddRelationshipToModel; +import org.apache.cayenne.dbsync.merge.token.model.CreateTableToModel; +import org.apache.cayenne.dbsync.merge.token.model.DropTableToModel; +import org.apache.cayenne.dbsync.merge.factory.MergerTokenFactoryProvider; +import org.apache.cayenne.dbsync.reverse.dbimport.DefaultDbImportAction; +import org.apache.cayenne.di.Inject; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportSummary; +import org.apache.cayenne.project.ProjectSaver; +import org.slf4j.Logger; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Extends {@link DefaultDbImportAction} to capture the merger token list for summary + * reporting. Overrides {@link #log(List)} — the single protected hook that receives + * the final, sorted token list immediately before application. + * + * @since 5.0 + */ +public class InstrumentedDbImportAction extends DefaultDbImportAction { + + private List<MergerToken> capturedTokens = List.of(); + + public InstrumentedDbImportAction( + @Inject Logger logger, + @Inject ProjectSaver projectSaver, + @Inject DataSourceFactory dataSourceFactory, + @Inject DbAdapterFactory adapterFactory, + @Inject DataMapLoader mapLoader, + @Inject MergerTokenFactoryProvider mergerTokenFactoryProvider, + @Inject DataChannelDescriptorLoader dataChannelDescriptorLoader, + @Inject DataChannelMetaData metaData) { + super(logger, projectSaver, dataSourceFactory, adapterFactory, mapLoader, + mergerTokenFactoryProvider, dataChannelDescriptorLoader, metaData); + } + + @Override + protected Collection<MergerToken> log(List<MergerToken> tokens) { + capturedTokens = List.copyOf(tokens); + return super.log(tokens); + } + + /** + * Builds a {@link DbImportSummary} from the captured token list. + * + * @param tokensApplied number of tokens actually applied (equals tokensConsidered on + * a successful run, 0 if an exception aborted before the apply phase) + */ + public DbImportSummary buildSummary(int tokensApplied) { + int entitiesAdded = 0; + int entitiesRemoved = 0; + int relationshipsAdded = 0; + Set<String> modifiedEntities = new HashSet<>(); + + Set<String> addedEntityNames = new HashSet<>(); + for (MergerToken token : capturedTokens) { + switch (token) { + case CreateTableToModel ignored -> { + entitiesAdded++; + addedEntityNames.add(token.getTokenValue()); + } + case DropTableToModel ignored -> entitiesRemoved++; + case AddRelationshipToModel ignored -> relationshipsAdded++; + default -> { + // Column-level and other entity-scoped tokens: tokenValue is "entity.column" or "entity" + String tv = token.getTokenValue(); + int dot = tv.indexOf('.'); + modifiedEntities.add(dot >= 0 ? tv.substring(0, dot) : tv); + } + } + } + + // Entities added this run don't count as modified + modifiedEntities.removeAll(addedEntityNames); + + return new DbImportSummary( + capturedTokens.size(), + tokensApplied, + entitiesAdded, + entitiesRemoved, + modifiedEntities.size(), + relationshipsAdded + ); + } + + public List<MergerToken> getCapturedTokens() { + return capturedTokens; + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java new file mode 100644 index 000000000..ac1619361 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java @@ -0,0 +1,24 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +/** + * @since 5.0 + */ +public record DbImportError(DbImportErrorCode code, String message) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java new file mode 100644 index 000000000..352a6d351 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java @@ -0,0 +1,30 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +public enum DbImportErrorCode { + project_not_found, + project_parse_failed, + datamap_not_found, + reverse_engineering_config_missing, + dbconnector_not_configured, + jdbc_driver_not_loadable, + jdbc_connection_failed, + dbimport_runtime_error +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java new file mode 100644 index 000000000..d03e1d2ec --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java @@ -0,0 +1,33 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +/** + * Connection and DataMap metadata resolved at runtime by {@code dbimport_run}. + * Deliberately omits {@code userName} and {@code password} — no field means no + * accidental serialization. + * + * @since 5.0 + */ +public record DbImportResolved( + String dataMapFile, + String jdbcUrl, + String jdbcDriver, + String dbAdapter +) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java new file mode 100644 index 000000000..504169232 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java @@ -0,0 +1,35 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +import java.util.List; + +/** + * Top-level envelope returned by the {@code dbimport_run} MCP tool. + * + * @since 5.0 + */ +public record DbImportRunResult( + String status, + DbImportSummary summary, + DbImportResolved resolved, + List<String> warnings, + DbImportValidation validation, + DbImportError error +) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java new file mode 100644 index 000000000..06aae2618 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java @@ -0,0 +1,26 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +public enum DbImportStatus { + imported, + up_to_date, + validation_failed, + error +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java new file mode 100644 index 000000000..baea98b8e --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java @@ -0,0 +1,36 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +/** + * Merger-token counters returned by {@code dbimport_run}. + * + * @since 5.0 + */ +public record DbImportSummary( + int tokensConsidered, + int tokensApplied, + int entitiesAdded, + int entitiesRemoved, + int entitiesModified, + int relationshipsAdded +) { + public static final DbImportSummary ZERO = + new DbImportSummary(0, 0, 0, 0, 0, 0); +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java new file mode 100644 index 000000000..f869b3f11 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java @@ -0,0 +1,36 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport.protocol; + +/** + * Per-step validation flags for {@code dbimport_run}. Boxed {@link Boolean} so unchecked slots can be {@code null} + * (short-circuit on first failure). + * + * @since 5.0 + */ +public record DbImportValidation( + Boolean projectFound, + Boolean dataMapFound, + Boolean reverseEngineeringConfigPresent, + Boolean dbConnectorPresent, + Boolean jdbcDriverLoadable, + Boolean jdbcConnectionOpened +) { + public static final DbImportValidation ALL_PASSED = new DbImportValidation(true, true, true, true, true, true); +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java new file mode 100644 index 000000000..caf34e10b --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java @@ -0,0 +1,198 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport; + +import org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult; +import org.apache.cayenne.modeler.pref.PreferenceNodeIds; +import org.apache.cayenne.modeler.pref.PrefsLocator; +import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs; +import org.apache.cayenne.modeler.pref.dbconnector.DBConnector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.UUID; +import java.util.prefs.Preferences; + +import static org.junit.jupiter.api.Assertions.*; + +public class DbImportRunIT { + + private static final String HSQL_ADAPTER = HSQLDBNoSchemaAdapter.class.getName(); + private static final String HSQL_DRIVER = "org.hsqldb.jdbc.JDBCDriver"; + + @TempDir + Path tempDir; + + private String dbUrl; + private Connection dbConn; + private Preferences prefsRoot; + private PrefsLocator prefsLocator; + private DbImportRunTool tool; + private Path projectFile; + private Path dataMapFile; + + @BeforeEach + public void setUp() throws Exception { + dbUrl = "jdbc:hsqldb:mem:dbimport_it_%s;shutdown=true" + .formatted(UUID.randomUUID().toString().replace("-", "")); + dbConn = DriverManager.getConnection(dbUrl, "SA", ""); + try (Statement s = dbConn.createStatement()) { + s.execute(""" + CREATE TABLE artist ( + id INTEGER NOT NULL PRIMARY KEY, + name VARCHAR(255) + )"""); + s.execute(""" + CREATE TABLE painting ( + id INTEGER NOT NULL PRIMARY KEY, + title VARCHAR(255), + artist_id INTEGER, + FOREIGN KEY (artist_id) REFERENCES artist(id) + )"""); + } + + prefsRoot = Preferences.userRoot() + .node("cayenne-test/dbimport-it-" + UUID.randomUUID().toString().replace("-", "")); + prefsLocator = new PrefsLocator(prefsRoot); + tool = new DbImportRunTool(prefsLocator); + + // Write fixture files + projectFile = DbImportRunValidationTest.copyFixture("with-dbimport", tempDir); + dataMapFile = tempDir.resolve("TestMap.map.xml"); + + // Store connector in prefs + storeConnector(dbUrl, "SA", "", HSQL_DRIVER, HSQL_ADAPTER); + } + + @AfterEach + public void tearDown() throws Exception { + prefsRoot.removeNode(); + if (dbConn != null && !dbConn.isClosed()) { + dbConn.close(); + } + } + + @Test + public void happyPath() throws IOException { + DbImportRunResult result = tool.run(projectFile.toString(), "TestMap"); + + assertEquals("imported", result.status()); + assertNull(result.error()); + assertNotNull(result.resolved()); + assertEquals(dbUrl, result.resolved().jdbcUrl()); + assertEquals(HSQL_DRIVER, result.resolved().jdbcDriver()); + + assertTrue(result.summary().tokensApplied() > 0); + assertEquals(result.summary().tokensConsidered(), result.summary().tokensApplied()); + assertEquals(2, result.summary().entitiesAdded(), "Expected 2 new entities (artist, painting)"); + assertEquals(0, result.summary().entitiesRemoved()); + assertTrue(result.summary().relationshipsAdded() >= 0, "Relationship count must be non-negative"); + + assertTrue(result.validation().projectFound()); + assertTrue(result.validation().dataMapFound()); + assertTrue(result.validation().reverseEngineeringConfigPresent()); + assertTrue(result.validation().dbConnectorPresent()); + assertTrue(result.validation().jdbcDriverLoadable()); + assertTrue(result.validation().jdbcConnectionOpened()); + + // DataMap XML on disk should now contain the two db-entities + String xml = Files.readString(dataMapFile); + assertTrue(xml.contains("artist"), "DataMap XML should contain 'artist' entity"); + assertTrue(xml.contains("painting"), "DataMap XML should contain 'painting' entity"); + } + + @Test + public void idempotency() { + tool.run(projectFile.toString(), "TestMap"); + + DbImportRunResult second = tool.run(projectFile.toString(), "TestMap"); + + assertEquals("up_to_date", second.status()); + assertNull(second.error()); + assertEquals(0, second.summary().tokensConsidered()); + assertEquals(0, second.summary().tokensApplied()); + assertEquals(0, second.summary().entitiesAdded()); + assertEquals(0, second.summary().entitiesModified()); + assertEquals(0, second.summary().entitiesRemoved()); + assertEquals(0, second.summary().relationshipsAdded()); + } + + @Test + public void drift() throws Exception { + // First import — establishes baseline + tool.run(projectFile.toString(), "TestMap"); + + // Add a column to the live DB + try (Statement s = dbConn.createStatement()) { + s.execute("ALTER TABLE artist ADD COLUMN bio VARCHAR(1000)"); + } + + DbImportRunResult result = tool.run(projectFile.toString(), "TestMap"); + + assertEquals("imported", result.status(), result.error() != null ? result.error().message() : ""); + assertEquals(1, result.summary().tokensApplied()); + assertEquals(1, result.summary().entitiesModified()); + assertEquals(0, result.summary().entitiesAdded()); + + // Verify column is in the XML + String xml = Files.readString(dataMapFile); + assertTrue(xml.contains("bio"), "bio attribute should be present in DataMap XML"); + } + + @Test + public void connectorNotConfigured() { + // Clear the connector from prefs + new DataMapPrefs(prefsLocator.dataMapNode( + PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString()))) + .setConnector(new DBConnector()); // empty connector clears URL sentinel + + DbImportRunResult result = tool.run(projectFile.toString(), "TestMap"); + + assertEquals("validation_failed", result.status()); + assertFalse(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + } + + private void storeConnector( + String url, + String user, + String password, + String driver, + String adapter) { + + DBConnector c = new DBConnector(); + c.setUrl(url); + c.setUserName(user); + c.setPassword(password); + c.setJdbcDriver(driver); + c.setDbAdapter(adapter); + + Preferences mapPrefs = prefsLocator.dataMapNode(PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString())); + new DataMapPrefs(mapPrefs).setConnector(c); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java new file mode 100644 index 000000000..c4f09307c --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java @@ -0,0 +1,247 @@ +/***************************************************************** + * 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 + * + * https://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.cayenne.mcp.tools.dbimport; + +import org.apache.cayenne.modeler.pref.PreferenceNodeIds; +import org.apache.cayenne.modeler.pref.PrefsLocator; +import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs; +import org.apache.cayenne.modeler.pref.dbconnector.DBConnector; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportErrorCode; +import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; +import java.util.prefs.BackingStoreException; +import java.util.prefs.Preferences; + +import static org.junit.jupiter.api.Assertions.*; + +public class DbImportRunValidationTest { + + private static DbImportRunTool tool; + private static PrefsLocator prefsLocator; + + private Preferences testPrefsRoot; + + @BeforeAll + public static void setUpClass() { + prefsLocator = new PrefsLocator(Preferences.userRoot().node("cayenne-test/dbimport-validation")); + tool = new DbImportRunTool(prefsLocator); + } + + @AfterEach + public void cleanupPrefs() throws BackingStoreException { + if (testPrefsRoot != null) { + testPrefsRoot.removeNode(); + testPrefsRoot = null; + } + } + + @Test + public void projectNotFound() { + DbImportRunResult result = tool.run("/no/such/file/cayenne-project.xml", "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.project_not_found, result.error().code()); + assertFalse(result.validation().projectFound()); + assertNull(result.validation().dataMapFound()); + assertNull(result.validation().reverseEngineeringConfigPresent()); + assertNull(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNull(result.resolved()); + assertNoCredentialsInResult(result); + } + + @Test + public void projectParseFailed(@TempDir Path tmp) throws IOException { + Path badXml = tmp.resolve("cayenne-project.xml"); + Files.writeString(badXml, "this is not xml <<< garbage"); + + DbImportRunResult result = tool.run(badXml.toString(), "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.project_parse_failed, result.error().code()); + assertTrue(result.validation().projectFound()); + assertNull(result.validation().dataMapFound()); + assertNull(result.validation().reverseEngineeringConfigPresent()); + assertNull(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNoCredentialsInResult(result); + } + + @Test + public void dataMapNotFound() throws URISyntaxException { + String projectPath = fixtureProject("no-dbimport"); + + DbImportRunResult result = tool.run(projectPath, "NoSuchMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.datamap_not_found, result.error().code()); + assertTrue(result.error().message().contains("NoSuchMap")); + assertTrue(result.error().message().contains("TestMap"), "Available maps should be listed"); + assertTrue(result.validation().projectFound()); + assertFalse(result.validation().dataMapFound()); + assertNull(result.validation().reverseEngineeringConfigPresent()); + assertNull(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNoCredentialsInResult(result); + } + + @Test + public void reverseEngineeringConfigMissing() throws URISyntaxException { + String projectPath = fixtureProject("no-dbimport"); + + DbImportRunResult result = tool.run(projectPath, "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.reverse_engineering_config_missing, result.error().code()); + assertTrue(result.validation().projectFound()); + assertTrue(result.validation().dataMapFound()); + assertFalse(result.validation().reverseEngineeringConfigPresent()); + assertNull(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNoCredentialsInResult(result); + } + + @Test + public void dbConnectorNotConfigured(@TempDir Path tmp) throws IOException { + Path projectFile = copyFixture("with-dbimport", tmp); + Preferences isolatedRoot = isolatedPrefsRoot(); + // No connector stored — DataMapPrefs node is empty + + DbImportRunTool isolatedTool = new DbImportRunTool(new PrefsLocator(isolatedRoot)); + DbImportRunResult result = isolatedTool.run(projectFile.toString(), "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.dbconnector_not_configured, result.error().code()); + assertTrue(result.validation().projectFound()); + assertTrue(result.validation().dataMapFound()); + assertTrue(result.validation().reverseEngineeringConfigPresent()); + assertFalse(result.validation().dbConnectorPresent()); + assertNull(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNull(result.resolved()); + assertNoCredentialsInResult(result); + } + + @Test + public void jdbcDriverNotLoadable(@TempDir Path tmp) throws IOException { + Path projectFile = copyFixture("with-dbimport", tmp); + Path dataMapFile = tmp.resolve("TestMap.map.xml"); + + Preferences isolatedRoot = isolatedPrefsRoot(); + PrefsLocator locator = new PrefsLocator(isolatedRoot); + + DBConnector connector = new DBConnector(); + connector.setUrl("jdbc:hsqldb:mem:drivertest"); + connector.setUserName("SA"); + connector.setPassword(""); + connector.setJdbcDriver("com.nonexistent.Driver"); + connector.setDbAdapter("org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter"); + new DataMapPrefs(locator.dataMapNode( + PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString()))) + .setConnector(connector); + + DbImportRunTool isolatedTool = new DbImportRunTool(locator); + DbImportRunResult result = isolatedTool.run(projectFile.toString(), "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.jdbc_driver_not_loadable, result.error().code()); + assertTrue(result.error().message().contains("com.nonexistent.Driver")); + assertTrue(result.validation().dbConnectorPresent()); + assertFalse(result.validation().jdbcDriverLoadable()); + assertNull(result.validation().jdbcConnectionOpened()); + assertNotNull(result.resolved(), "resolved should be populated even on driver failure"); + assertEquals("jdbc:hsqldb:mem:drivertest", result.resolved().jdbcUrl()); + assertNoCredentialsInResult(result); + } + + @Test + public void jdbcConnectionFailed(@TempDir Path tmp) throws IOException { + Path projectFile = copyFixture("with-dbimport", tmp); + Path dataMapFile = tmp.resolve("TestMap.map.xml"); + + Preferences isolatedRoot = isolatedPrefsRoot(); + PrefsLocator locator = new PrefsLocator(isolatedRoot); + + DBConnector connector = new DBConnector(); + // HSQL network server on localhost:1 — not running, will refuse + connector.setUrl("jdbc:hsqldb:hsql://localhost:1/nonexistent"); + connector.setUserName("SA"); + connector.setPassword(""); + connector.setJdbcDriver("org.hsqldb.jdbc.JDBCDriver"); + connector.setDbAdapter("org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter"); + new DataMapPrefs(locator.dataMapNode( + PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString()))) + .setConnector(connector); + + DbImportRunTool isolatedTool = new DbImportRunTool(locator); + DbImportRunResult result = isolatedTool.run(projectFile.toString(), "TestMap"); + + assertEquals("validation_failed", result.status()); + assertEquals(DbImportErrorCode.jdbc_connection_failed, result.error().code()); + assertTrue(result.validation().jdbcDriverLoadable()); + assertFalse(result.validation().jdbcConnectionOpened()); + assertNotNull(result.resolved()); + assertNoCredentialsInResult(result); + } + + private static String fixtureProject(String fixture) throws URISyntaxException { + return Paths.get(DbImportRunValidationTest.class + .getResource("/dbimport-fixtures/" + fixture + "/cayenne-project.xml") + .toURI()).toString(); + } + + private Preferences isolatedPrefsRoot() { + testPrefsRoot = Preferences.userRoot() + .node("cayenne-test/dbimport-val-" + UUID.randomUUID().toString().replace("-", "")); + return testPrefsRoot; + } + + static Path copyFixture(String fixture, Path dir) throws IOException { + for (String name : new String[]{"cayenne-project.xml", "TestMap.map.xml"}) { + try (InputStream in = DbImportRunValidationTest.class + .getResourceAsStream("/dbimport-fixtures/" + fixture + "/" + name)) { + Files.copy(in, dir.resolve(name)); + } + } + return dir.resolve("cayenne-project.xml"); + } + + private static void assertNoCredentialsInResult(DbImportRunResult result) { + // Structural guarantee: DbImportResolved has no password/userName fields. + // Belt-and-suspenders: verify the toString representation contains neither. + String s = result.toString(); + assertFalse(s.contains("password"), "password must not appear in result"); + assertFalse(s.contains("userName"), "userName must not appear in result"); + } +} diff --git a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml new file mode 100644 index 000000000..2f2bf0e1d --- /dev/null +++ b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<data-map xmlns="http://cayenne.apache.org/schema/12/modelMap" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://cayenne.apache.org/schema/12/modelMap http://cayenne.apache.org/schema/12/modelMap.xsd" + project-version="12"> + <property name="defaultPackage" value="com.example"/> +</data-map> diff --git a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml new file mode 100644 index 000000000..f40e0ac36 --- /dev/null +++ b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<domain xmlns="http://cayenne.apache.org/schema/12/domain" project-version="12"> + <map name="TestMap"/> +</domain> diff --git a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml new file mode 100644 index 000000000..6793a11a5 --- /dev/null +++ b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<data-map xmlns="http://cayenne.apache.org/schema/12/modelMap" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://cayenne.apache.org/schema/12/modelMap http://cayenne.apache.org/schema/12/modelMap.xsd" + project-version="12"> + <property name="defaultPackage" value="com.example"/> + <dbImport xmlns="http://cayenne.apache.org/schema/12/dbimport"> + <skipRelationshipsLoading>false</skipRelationshipsLoading> + <skipPrimaryKeyLoading>false</skipPrimaryKeyLoading> + </dbImport> +</data-map> diff --git a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml new file mode 100644 index 000000000..f40e0ac36 --- /dev/null +++ b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<domain xmlns="http://cayenne.apache.org/schema/12/domain" project-version="12"> + <map name="TestMap"/> +</domain>
