From 964a3bd9dd0dc4dd709abbca56c390c77dd650f7 Mon Sep 17 00:00:00 2001 From: hironico Date: Fri, 20 Mar 2026 09:46:53 +0100 Subject: [PATCH] Add MongoDB suport by sending mongosh -like commands to mongodb. --- .../java/net/hironico/minisql/DbConfig.java | 33 + .../ctrl/MongoQueryResultCallable.java | 637 ++++++++++++++++++ .../minisql/model/SQLResultSetTableModel.java | 21 + .../minisql/ui/AbstractQueryAction.java | 14 +- .../minisql/ui/config/DbConfigPanel.java | 186 ++++- pom.xml | 8 + 6 files changed, 869 insertions(+), 30 deletions(-) create mode 100644 hironico-minisql/src/main/java/net/hironico/minisql/ctrl/MongoQueryResultCallable.java diff --git a/hironico-minisql/src/main/java/net/hironico/minisql/DbConfig.java b/hironico-minisql/src/main/java/net/hironico/minisql/DbConfig.java index e71ae0a..12bb2dc 100644 --- a/hironico-minisql/src/main/java/net/hironico/minisql/DbConfig.java +++ b/hironico-minisql/src/main/java/net/hironico/minisql/DbConfig.java @@ -31,6 +31,12 @@ public class DbConfig implements Cloneable { private static final Logger LOGGER = Logger.getLogger(DbConfig.class.getName()); + /** Connection type constant for standard JDBC relational databases. */ + public static final String CONNECTION_TYPE_JDBC = "jdbc"; + + /** Connection type constant for MongoDB document databases. */ + public static final String CONNECTION_TYPE_MONGODB = "mongodb"; + private static final String superSecret = "EkRg0PcE2yv80Zhal+xTsGLSsIyZhlkttEbd2bMNT3Q="; private static final SecretKey secretKey = NicoCrypto.generate(superSecret); private static final NicoCrypto crypto = new NicoCrypto(); @@ -67,6 +73,33 @@ public class DbConfig implements Cloneable { @JacksonXmlProperty(localName = "useQuotedIdentifiers", isAttribute = true) public Boolean useQuotedIdentifiers = Boolean.FALSE; + /** + * The type of this connection. Supported values are "jdbc" (default) and "mongodb". + * When set to "mongodb", the jdbcUrl field holds the MongoDB connection string + * (e.g. mongodb://localhost:27017/myDatabase) and queries are executed using + * mongosh-style syntax instead of SQL. + */ + @JsonProperty("type") + @JacksonXmlProperty(localName = "type", isAttribute = true) + public String type = "jdbc"; + + /** + * Returns true when this configuration targets a MongoDB server. + * URL-first detection: if {@link #jdbcUrl} starts with {@code mongodb://} or + * {@code mongodb+srv://} the config is treated as MongoDB regardless of the + * stored {@link #type} field. The stored {@code type} is used as a fallback when + * the URL is empty or not yet set. + */ + public boolean isMongoDB() { + // URL-based detection takes priority (always set when the user connects) + if (jdbcUrl != null && !jdbcUrl.isBlank()) { + String url = jdbcUrl.trim().toLowerCase(); + return url.startsWith("mongodb://") || url.startsWith("mongodb+srv://"); + } + // Fall back to the stored type field + return CONNECTION_TYPE_MONGODB.equalsIgnoreCase(type); + } + public Object clone() throws CloneNotSupportedException { return super.clone(); } diff --git a/hironico-minisql/src/main/java/net/hironico/minisql/ctrl/MongoQueryResultCallable.java b/hironico-minisql/src/main/java/net/hironico/minisql/ctrl/MongoQueryResultCallable.java new file mode 100644 index 0000000..4fab3f7 --- /dev/null +++ b/hironico-minisql/src/main/java/net/hironico/minisql/ctrl/MongoQueryResultCallable.java @@ -0,0 +1,637 @@ +package net.hironico.minisql.ctrl; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.*; +import net.hironico.minisql.DbConfig; +import net.hironico.minisql.model.SQLResultSetTableModel; +import org.bson.Document; + +import java.util.*; +import java.util.concurrent.Callable; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Callable implementation that executes MongoDB queries using mongosh-style syntax + * and returns results as {@link SQLResultSetTableModel} objects. + * + *

Supported commands:

+ * + * + *

The MongoDB connection string is taken from {@link DbConfig#jdbcUrl}. + * Credentials may be embedded in the URI ({@code mongodb://user:pass@host/db}) + * or provided separately via {@link DbConfig#user} and {@link DbConfig#password}.

+ */ +public class MongoQueryResultCallable implements Callable> { + + private static final Logger LOGGER = Logger.getLogger(MongoQueryResultCallable.class.getName()); + + /** Matches: show dbs | show databases | show collections */ + private static final Pattern SHOW_CMD = Pattern.compile( + "^\\s*show\\s+(\\w+)\\s*$", Pattern.CASE_INSENSITIVE); + + /** Matches: use */ + private static final Pattern USE_CMD = Pattern.compile( + "^\\s*use\\s+(\\w+)\\s*$", Pattern.CASE_INSENSITIVE); + + /** Matches: db.createCollection("name") or db.createCollection('name') */ + private static final Pattern CREATE_COLLECTION_CMD = Pattern.compile( + "^\\s*db\\.createCollection\\([\"']([\\w]+)[\"']\\)\\s*;?\\s*$", + Pattern.CASE_INSENSITIVE); + + /** + * Matches direct database-level method calls: db.method(args). + * Used for db.runCommand(), db.adminCommand(), db.getCollectionNames(), etc. + * This pattern is checked BEFORE COLLECTION_CMD so that two-level calls like + * db.runCommand() are not confused with collection operations. + */ + private static final Pattern DB_METHOD_CMD = Pattern.compile( + "^\\s*db\\.([\\w]+)\\((.*)\\)\\s*;?\\s*$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + /** Matches: db..() — the core pattern for collection operations */ + private static final Pattern COLLECTION_CMD = Pattern.compile( + "^\\s*db\\.([\\w]+)\\.([\\w]+)\\((.*)\\)\\s*;?\\s*$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE); + + private final String query; + private final DbConfig config; + + /** + * Constructs a new MongoQueryResultCallable. + * + * @param query the mongosh-style query string to execute + * @param config the database configuration holding the MongoDB connection string + */ + public MongoQueryResultCallable(String query, DbConfig config) { + this.query = query; + this.config = config; + } + + /** + * Builds the MongoDB connection string, injecting separate user/password credentials + * into the URI when they are not already embedded in it. + */ + private String buildConnectionString() { + String connStr = config.jdbcUrl == null ? "" : config.jdbcUrl.trim(); + String user = config.user == null ? "" : config.user.trim(); + String password = config.password == null ? "" : DbConfig.decryptPassword(config.password).trim(); + + // Inject credentials only when they are provided separately and not already in the URI + if (!user.isEmpty() && !connStr.contains("@")) { + String encodedUser = user.replace("@", "%40"); + String encodedPassword = password.replace("@", "%40"); + String credentials = encodedPassword.isEmpty() ? encodedUser : encodedUser + ":" + encodedPassword; + if (connStr.startsWith("mongodb+srv://")) { + connStr = "mongodb+srv://" + credentials + "@" + connStr.substring("mongodb+srv://".length()); + } else if (connStr.startsWith("mongodb://")) { + connStr = "mongodb://" + credentials + "@" + connStr.substring("mongodb://".length()); + } + } + return connStr; + } + + @Override + public List call() throws Exception { + List results = new ArrayList<>(); + + String connStr = buildConnectionString(); + ConnectionString connectionString = new ConnectionString(connStr); + + MongoClientSettings settings = MongoClientSettings.builder() + .applyConnectionString(connectionString) + .build(); + + try (MongoClient client = MongoClients.create(settings)) { + + String dbName = connectionString.getDatabase(); + MongoDatabase db = (dbName != null && !dbName.isEmpty()) ? client.getDatabase(dbName) : null; + + String trimmed = query == null ? "" : query.trim(); + + // --- show dbs / show databases / show collections --- + Matcher showMatcher = SHOW_CMD.matcher(trimmed); + if (showMatcher.matches()) { + String what = showMatcher.group(1).toLowerCase(); + if ("dbs".equals(what) || "databases".equals(what)) { + results.add(executeShowDbs(client)); + } else if ("collections".equals(what)) { + requireDatabase(db); + results.add(executeShowCollections(db)); + } else { + throw new Exception("Unknown show command: show " + what + + "\nSupported: show dbs, show databases, show collections"); + } + return results; + } + + // --- use --- + Matcher useMatcher = USE_CMD.matcher(trimmed); + if (useMatcher.matches()) { + String newDb = useMatcher.group(1); + SQLResultSetTableModel model = new SQLResultSetTableModel( + "use", trimmed, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, "Message"); + model.addRow(new Object[]{ + "Switched to database: " + newDb + + ". Please update the connection URL to use this database permanently."}); + results.add(model); + return results; + } + + // --- db.createCollection("name") --- + Matcher createMatcher = CREATE_COLLECTION_CMD.matcher(trimmed); + if (createMatcher.matches()) { + requireDatabase(db); + String collName = createMatcher.group(1); + db.createCollection(collName); + SQLResultSetTableModel model = new SQLResultSetTableModel( + "createCollection", trimmed, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, "Message"); + model.addRow(new Object[]{"Collection '" + collName + "' created successfully."}); + results.add(model); + return results; + } + + // --- db.() — database-level commands: runCommand, adminCommand, etc. + // This MUST be checked before COLLECTION_CMD because COLLECTION_CMD is more specific + // (three-part path db.col.method) and won't match two-part db.method calls. + Matcher dbMethodMatcher = DB_METHOD_CMD.matcher(trimmed); + if (dbMethodMatcher.matches()) { + String method = dbMethodMatcher.group(1).toLowerCase(); + String argsStr = dbMethodMatcher.group(2).trim(); + SQLResultSetTableModel result = executeDbMethod(client, db, method, argsStr, trimmed); + if (result != null) { + results.add(result); + } + return results; + } + + // --- db..() --- + Matcher collMatcher = COLLECTION_CMD.matcher(trimmed); + if (collMatcher.matches()) { + requireDatabase(db); + String collectionName = collMatcher.group(1); + String method = collMatcher.group(2).toLowerCase(); + String argsStr = collMatcher.group(3).trim(); + MongoCollection collection = db.getCollection(collectionName); + SQLResultSetTableModel result = executeCollectionMethod(collection, method, argsStr, trimmed); + if (result != null) { + results.add(result); + } + return results; + } + + throw new Exception("Unrecognized MongoDB command:\n" + trimmed + + "\n\nSupported commands:\n" + + " show dbs | show databases | show collections\n" + + " db.runCommand({...}) | db.adminCommand({...})\n" + + " db.getCollectionNames() | db.stats()\n" + + " db.collection.find({filter}, {projection})\n" + + " db.collection.findOne({filter})\n" + + " db.collection.insertOne({doc}) | db.collection.insertMany([...])\n" + + " db.collection.updateOne({filter},{update}) | db.collection.updateMany({filter},{update})\n" + + " db.collection.deleteOne({filter}) | db.collection.deleteMany({filter})\n" + + " db.collection.countDocuments({filter})\n" + + " db.collection.aggregate([{stage1},...]) | db.collection.drop()\n" + + " db.createCollection(\"name\")"); + } + } + + // ------------------------------------------------------------------------- + // Helper: ensure a database is selected + // ------------------------------------------------------------------------- + + private void requireDatabase(MongoDatabase db) throws Exception { + if (db == null) { + throw new Exception( + "No database selected. Include the database name in the connection URL, " + + "e.g. mongodb://localhost:27017/myDatabase"); + } + } + + // ------------------------------------------------------------------------- + // show dbs / show collections + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeShowDbs(MongoClient client) { + SQLResultSetTableModel model = new SQLResultSetTableModel( + "Databases", "show dbs", SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "name", "sizeOnDisk", "empty"); + List dbs = new ArrayList<>(); + client.listDatabases().into(dbs); + for (Document d : dbs) { + model.addRow(new Object[]{d.getString("name"), d.get("sizeOnDisk"), d.getBoolean("empty", false)}); + } + return model; + } + + private SQLResultSetTableModel executeShowCollections(MongoDatabase db) { + SQLResultSetTableModel model = new SQLResultSetTableModel( + "Collections", "show collections", SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "collection_name"); + List cols = new ArrayList<>(); + db.listCollectionNames().into(cols); + Collections.sort(cols); + for (String c : cols) { + model.addRow(new Object[]{c}); + } + return model; + } + + // ------------------------------------------------------------------------- + // Collection method dispatcher + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeCollectionMethod(MongoCollection collection, + String method, String argsStr, + String originalQuery) throws Exception { + return switch (method) { + case "find" -> executeFind(collection, argsStr, originalQuery, false); + case "findone" -> executeFind(collection, argsStr, originalQuery, true); + case "insertone" -> executeInsertOne(collection, argsStr, originalQuery); + case "insertmany" -> executeInsertMany(collection, argsStr, originalQuery); + case "updateone" -> executeUpdate(collection, argsStr, originalQuery, false); + case "updatemany" -> executeUpdate(collection, argsStr, originalQuery, true); + case "deleteone" -> executeDelete(collection, argsStr, originalQuery, false); + case "deletemany" -> executeDelete(collection, argsStr, originalQuery, true); + case "countdocuments" -> executeCount(collection, argsStr, originalQuery); + case "aggregate" -> executeAggregate(collection, argsStr, originalQuery); + case "drop" -> executeDrop(collection, originalQuery); + default -> throw new Exception("Unsupported collection method: " + method + + "\nSupported: find, findOne, insertOne, insertMany, updateOne, updateMany, " + + "deleteOne, deleteMany, countDocuments, aggregate, drop"); + }; + } + + // ------------------------------------------------------------------------- + // find / findOne + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeFind(MongoCollection collection, + String argsStr, String originalQuery, + boolean findOne) throws Exception { + Document filter = new Document(); + Document projection = null; + + if (!argsStr.isEmpty()) { + List args = splitTopLevelArgs(argsStr); + if (!args.isEmpty() && !args.get(0).trim().isEmpty()) { + filter = Document.parse(args.get(0).trim()); + } + if (args.size() > 1 && !args.get(1).trim().isEmpty()) { + projection = Document.parse(args.get(1).trim()); + } + } + + FindIterable iterable = collection.find(filter); + if (projection != null) { + iterable = iterable.projection(projection); + } + if (findOne) { + iterable = iterable.limit(1); + } + + List docs = new ArrayList<>(); + iterable.into(docs); + return documentsToTableModel(docs, findOne ? "findOne" : "find", originalQuery); + } + + // ------------------------------------------------------------------------- + // insertOne / insertMany + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeInsertOne(MongoCollection collection, + String argsStr, String originalQuery) throws Exception { + if (argsStr.isEmpty()) { + throw new Exception("insertOne requires a document argument, e.g. db.col.insertOne({name: \"Alice\"})"); + } + Document doc = Document.parse(argsStr.trim()); + collection.insertOne(doc); + + SQLResultSetTableModel model = new SQLResultSetTableModel( + "insertOne", originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "acknowledged", "insertedId"); + Object id = doc.get("_id"); + model.addRow(new Object[]{true, id != null ? id.toString() : "generated"}); + return model; + } + + private SQLResultSetTableModel executeInsertMany(MongoCollection collection, + String argsStr, String originalQuery) throws Exception { + if (argsStr.isEmpty()) { + throw new Exception("insertMany requires an array of documents, e.g. db.col.insertMany([{a:1},{a:2}])"); + } + Document wrapper = Document.parse("{\"docs\":" + argsStr.trim() + "}"); + List docs = wrapper.getList("docs", Document.class); + collection.insertMany(docs); + + SQLResultSetTableModel model = new SQLResultSetTableModel( + "insertMany", originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "acknowledged", "insertedCount"); + model.addRow(new Object[]{true, docs.size()}); + return model; + } + + // ------------------------------------------------------------------------- + // updateOne / updateMany + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeUpdate(MongoCollection collection, + String argsStr, String originalQuery, + boolean many) throws Exception { + List args = splitTopLevelArgs(argsStr); + if (args.size() < 2) { + String cmd = many ? "updateMany" : "updateOne"; + throw new Exception(cmd + " requires two arguments: db.col." + cmd + "({filter}, {update})"); + } + Document filter = Document.parse(args.get(0).trim()); + Document update = Document.parse(args.get(1).trim()); + long modified = many + ? collection.updateMany(filter, update).getModifiedCount() + : collection.updateOne(filter, update).getModifiedCount(); + + String title = many ? "updateMany" : "updateOne"; + SQLResultSetTableModel model = new SQLResultSetTableModel( + title, originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "acknowledged", "modifiedCount"); + model.addRow(new Object[]{true, modified}); + return model; + } + + // ------------------------------------------------------------------------- + // deleteOne / deleteMany + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeDelete(MongoCollection collection, + String argsStr, String originalQuery, + boolean many) throws Exception { + Document filter = argsStr.isEmpty() ? new Document() : Document.parse(argsStr.trim()); + long deleted = many + ? collection.deleteMany(filter).getDeletedCount() + : collection.deleteOne(filter).getDeletedCount(); + + String title = many ? "deleteMany" : "deleteOne"; + SQLResultSetTableModel model = new SQLResultSetTableModel( + title, originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "acknowledged", "deletedCount"); + model.addRow(new Object[]{true, deleted}); + return model; + } + + // ------------------------------------------------------------------------- + // countDocuments + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeCount(MongoCollection collection, + String argsStr, String originalQuery) { + Document filter = (argsStr.isEmpty() || "{}".equals(argsStr.trim())) + ? new Document() : Document.parse(argsStr.trim()); + long count = collection.countDocuments(filter); + + SQLResultSetTableModel model = new SQLResultSetTableModel( + "countDocuments", originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "count"); + model.addRow(new Object[]{count}); + return model; + } + + // ------------------------------------------------------------------------- + // aggregate + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeAggregate(MongoCollection collection, + String argsStr, String originalQuery) throws Exception { + if (argsStr.isEmpty()) { + throw new Exception("aggregate requires a pipeline array, e.g. db.col.aggregate([{$match:{a:1}}])"); + } + Document wrapper = Document.parse("{\"pipeline\":" + argsStr.trim() + "}"); + List pipeline = wrapper.getList("pipeline", Document.class); + + List results = new ArrayList<>(); + collection.aggregate(pipeline).into(results); + return documentsToTableModel(results, "aggregate", originalQuery); + } + + // ------------------------------------------------------------------------- + // drop + // ------------------------------------------------------------------------- + + private SQLResultSetTableModel executeDrop(MongoCollection collection, + String originalQuery) { + collection.drop(); + SQLResultSetTableModel model = new SQLResultSetTableModel( + "drop", originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, "Message"); + model.addRow(new Object[]{"Collection dropped successfully."}); + return model; + } + + // ------------------------------------------------------------------------- + // Direct database-level commands (db.method(...)) + // ------------------------------------------------------------------------- + + /** + * Dispatches direct database-level commands such as {@code db.runCommand()}, + * {@code db.adminCommand()}, {@code db.stats()}, and {@code db.getCollectionNames()}. + * + * @param client the active MongoClient + * @param db the selected database (may be null if no database in the URL) + * @param method lower-cased method name (e.g. "runcommand", "adminccommand") + * @param argsStr raw argument string extracted from inside the parentheses + * @param originalQuery the full original query string + * @return a {@link SQLResultSetTableModel} containing the result + * @throws Exception if the command cannot be executed + */ + private SQLResultSetTableModel executeDbMethod(MongoClient client, MongoDatabase db, + String method, String argsStr, + String originalQuery) throws Exception { + return switch (method) { + case "runcommand" -> { + requireDatabase(db); + if (argsStr.isEmpty()) { + throw new Exception("runCommand requires a command document, e.g. db.runCommand({hello: 1})"); + } + Document cmd = Document.parse(argsStr); + Document result = db.runCommand(cmd); + yield documentToKeyValueTableModel(result, "runCommand", originalQuery); + } + case "admincommand" -> { + if (argsStr.isEmpty()) { + throw new Exception("adminCommand requires a command document, e.g. db.adminCommand({ping: 1})"); + } + Document cmd = Document.parse(argsStr); + Document result = client.getDatabase("admin").runCommand(cmd); + yield documentToKeyValueTableModel(result, "adminCommand", originalQuery); + } + case "stats" -> { + requireDatabase(db); + Document result = db.runCommand(new Document("dbStats", 1)); + yield documentToKeyValueTableModel(result, "stats", originalQuery); + } + case "getcollectionnames" -> { + requireDatabase(db); + yield executeShowCollections(db); + } + case "getcollectioninfos" -> { + requireDatabase(db); + List infos = new ArrayList<>(); + db.listCollections().into(infos); + yield documentsToTableModel(infos, "getCollectionInfos", originalQuery); + } + case "listcollections" -> { + requireDatabase(db); + List infos = new ArrayList<>(); + db.listCollections().into(infos); + yield documentsToTableModel(infos, "listCollections", originalQuery); + } + case "createcollection" -> { + // Handles db.createCollection("name") when name is passed without quotes in the DB_METHOD_CMD match + requireDatabase(db); + String collName = argsStr.trim().replaceAll("^[\"']|[\"']$", ""); + db.createCollection(collName); + SQLResultSetTableModel model = new SQLResultSetTableModel( + "createCollection", originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, "Message"); + model.addRow(new Object[]{"Collection '" + collName + "' created successfully."}); + yield model; + } + default -> throw new Exception("Unsupported database method: db." + method + "()\n" + + "Supported: db.runCommand({...}), db.adminCommand({...}), db.stats(), " + + "db.getCollectionNames(), db.getCollectionInfos(), db.listCollections(), " + + "db.createCollection(\"name\")"); + }; + } + + /** + * Converts a single MongoDB {@link Document} into a key-value {@link SQLResultSetTableModel} + * with columns {@code field} and {@code value}. Nested documents and arrays are rendered + * as their JSON string representation. + * + * @param doc the document to display + * @param title the display title for the result tab + * @param originalQuery the originating query string + * @return a table model with one row per top-level field in the document + */ + private SQLResultSetTableModel documentToKeyValueTableModel(Document doc, + String title, + String originalQuery) { + SQLResultSetTableModel model = new SQLResultSetTableModel( + title, originalQuery, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, + "field", "value"); + if (doc == null) { + model.addRow(new Object[]{"(no result)", ""}); + return model; + } + for (String key : doc.keySet()) { + Object val = doc.get(key); + String valStr = val == null ? "null" : val.toString(); + model.addRow(new Object[]{key, valStr}); + } + return model; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Converts a list of MongoDB {@link Document} objects into a {@link SQLResultSetTableModel}. + * Column names are derived from the union of keys across all documents, preserving + * insertion order. The {@code _id} field is always placed first when present. + */ + private SQLResultSetTableModel documentsToTableModel(List docs, + String title, String query) { + if (docs.isEmpty()) { + SQLResultSetTableModel model = new SQLResultSetTableModel( + title, query, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, "(no results)"); + return model; + } + + // Collect all column names preserving order; _id first + LinkedHashSet colSet = new LinkedHashSet<>(); + for (Document doc : docs) { + if (doc.containsKey("_id")) colSet.add("_id"); + } + for (Document doc : docs) { + colSet.addAll(doc.keySet()); + } + String[] columns = colSet.toArray(new String[0]); + + SQLResultSetTableModel model = new SQLResultSetTableModel( + title, query, SQLResultSetTableModel.DISPLAY_TYPE_TABLE, columns); + + for (Document doc : docs) { + Object[] row = new Object[columns.length]; + for (int i = 0; i < columns.length; i++) { + Object val = doc.get(columns[i]); + row[i] = val == null ? null : val.toString(); + } + model.addRow(row); + } + return model; + } + + /** + * Splits a comma-separated argument string at the top level, respecting + * nested braces {@code {}}, brackets {@code []}, and parentheses {@code ()}. + * + * @param argsStr the raw argument string from inside the method call + * @return list of individual argument strings, trimmed + */ + private List splitTopLevelArgs(String argsStr) { + List args = new ArrayList<>(); + int depth = 0; + StringBuilder current = new StringBuilder(); + boolean inString = false; + char stringChar = 0; + + for (int i = 0; i < argsStr.length(); i++) { + char c = argsStr.charAt(i); + + if (inString) { + current.append(c); + if (c == stringChar && (i == 0 || argsStr.charAt(i - 1) != '\\')) { + inString = false; + } + } else if (c == '"' || c == '\'') { + inString = true; + stringChar = c; + current.append(c); + } else if (c == '{' || c == '[' || c == '(') { + depth++; + current.append(c); + } else if (c == '}' || c == ']' || c == ')') { + depth--; + current.append(c); + } else if (c == ',' && depth == 0) { + args.add(current.toString().trim()); + current = new StringBuilder(); + } else { + current.append(c); + } + } + + if (!current.isEmpty()) { + args.add(current.toString().trim()); + } + return args; + } +} diff --git a/hironico-minisql/src/main/java/net/hironico/minisql/model/SQLResultSetTableModel.java b/hironico-minisql/src/main/java/net/hironico/minisql/model/SQLResultSetTableModel.java index be1ac39..51ca45b 100644 --- a/hironico-minisql/src/main/java/net/hironico/minisql/model/SQLResultSetTableModel.java +++ b/hironico-minisql/src/main/java/net/hironico/minisql/model/SQLResultSetTableModel.java @@ -52,6 +52,27 @@ public SQLResultSetTableModel(ResultSet resultSet, String title, String query, i this.displayType = displayType; } + /** + * Creates an empty SQLResultSetTableModel with explicit column names and classes. + * Use this constructor for non-JDBC result sources such as MongoDB, where data + * is provided programmatically rather than through a {@link ResultSet}. + * Rows can be added afterwards via {@link #addRow(Object[])}. + * + * @param title display title shown in the result tab header + * @param query the originating query string (for reference) + * @param displayType one of the {@code DISPLAY_TYPE_*} constants + * @param columnNames names of the columns to create in the model + */ + public SQLResultSetTableModel(String title, String query, int displayType, String... columnNames) { + super(); + this.title = title == null ? "Results" : title; + this.query = query == null ? "N/A" : query; + this.displayType = displayType; + this.classNames = new Class[columnNames.length]; + java.util.Arrays.fill(this.classNames, Object.class); + setColumnIdentifiers(columnNames); + } + private void setupData(ResultSet resultSet) throws SQLException { if (resultSet == null) { diff --git a/hironico-minisql/src/main/java/net/hironico/minisql/ui/AbstractQueryAction.java b/hironico-minisql/src/main/java/net/hironico/minisql/ui/AbstractQueryAction.java index cd87471..71794c6 100644 --- a/hironico-minisql/src/main/java/net/hironico/minisql/ui/AbstractQueryAction.java +++ b/hironico-minisql/src/main/java/net/hironico/minisql/ui/AbstractQueryAction.java @@ -1,6 +1,7 @@ package net.hironico.minisql.ui; import net.hironico.common.swing.ribbon.AbstractRibbonAction; +import net.hironico.minisql.ctrl.MongoQueryResultCallable; import net.hironico.minisql.ctrl.QueryResultCallable; import net.hironico.minisql.model.SQLResultSetTableModel; import net.hironico.minisql.ui.editor.QueryPanel; @@ -34,9 +35,16 @@ public static void executeQueryAsync(QueryPanel queryPanel) { queryPanel.setResultsComponent(new JLabel("Executing query, please wait.")); - QueryResultCallable queryCall = new QueryResultCallable(sql, queryPanel.getConfig(), queryPanel.isBatchMode()); - queryCall.addQueryHistoryListener(QueryHistory.getInstance()); - final Future> futureResults = MainWindow.executorService.submit(queryCall); + // Choose the right callable depending on the connection type + final Future> futureResults; + if (queryPanel.getConfig() != null && queryPanel.getConfig().isMongoDB()) { + MongoQueryResultCallable mongoCall = new MongoQueryResultCallable(sql, queryPanel.getConfig()); + futureResults = MainWindow.executorService.submit(mongoCall); + } else { + QueryResultCallable queryCall = new QueryResultCallable(sql, queryPanel.getConfig(), queryPanel.isBatchMode()); + queryCall.addQueryHistoryListener(QueryHistory.getInstance()); + futureResults = MainWindow.executorService.submit(queryCall); + } Runnable waitQueryRun = () -> { try { diff --git a/hironico-minisql/src/main/java/net/hironico/minisql/ui/config/DbConfigPanel.java b/hironico-minisql/src/main/java/net/hironico/minisql/ui/config/DbConfigPanel.java index ac846b9..5ccdb08 100644 --- a/hironico-minisql/src/main/java/net/hironico/minisql/ui/config/DbConfigPanel.java +++ b/hironico-minisql/src/main/java/net/hironico/minisql/ui/config/DbConfigPanel.java @@ -14,7 +14,13 @@ import java.util.logging.Logger; import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; import net.hironico.minisql.DbConfigFile; import net.hironico.minisql.DbConfig; import org.jdesktop.swingx.JXColorSelectionButton; @@ -103,6 +109,15 @@ public class DbConfigPanel extends JPanel { /** Checkbox for enabling quoted identifiers */ private JCheckBox chkUseQuotedIdentifiers = null; + /** Hint label shown under the connection URL when MongoDB type is detected */ + private JLabel lblConnectionTypeHint = null; + + /** Connection type constant for standard JDBC connections */ + public static final String CONNECTION_TYPE_JDBC = DbConfig.CONNECTION_TYPE_JDBC; + + /** Connection type constant for MongoDB connections */ + public static final String CONNECTION_TYPE_MONGODB = DbConfig.CONNECTION_TYPE_MONGODB; + /** * Constructs a new DbConfigPanel with reference to parent ConfigPanel. * Initializes the UI components. @@ -117,7 +132,7 @@ public DbConfigPanel(ConfigPanel parentConfigPanel) { /** * Clears all form fields to their default empty state. - * Resets text fields and checkbox to initial values. + * Resets text fields and checkbox to initial values, then resets form to JDBC mode. */ public void clearForm() { getTxtName().setText(""); @@ -127,11 +142,13 @@ public void clearForm() { getTxtDriverClassName().setText(""); getTxtStatementSeparator().setText(""); getChkUseQuotedIdentifiers().setSelected(false); + updateFormForConnectionType(CONNECTION_TYPE_JDBC); } /** * Loads the specified database configuration into the form fields. - * Clears existing form data and populates fields with configuration values. + * The connection type is detected automatically from the URL prefix + * ({@code mongodb://} or {@code mongodb+srv://} → MongoDB; anything else → JDBC). * * @param name the name of the configuration to load */ @@ -141,8 +158,7 @@ public void loadSelectedConfig(String name) { return; } - clearForm(); - + // Populate fields first (URL must be set before type is detected) getTxtName().setText(cfg.name); getTxtJdbcUrl().setText(cfg.jdbcUrl); getTxtUser().setText(cfg.user); @@ -153,6 +169,9 @@ public void loadSelectedConfig(String name) { getColorChooser().getChooser().setColor(conColor); getColorChooser().setBackground(conColor); getChkUseQuotedIdentifiers().setSelected(cfg.useQuotedIdentifiers); + + // Adapt form layout based on the detected connection type + updateFormForConnectionType(detectConnectionType(cfg.jdbcUrl)); } /** @@ -193,21 +212,26 @@ protected void initialize() { gc.insets.top = 0; add(getTxtJdbcUrl(), gc); + // URL hint for MongoDB (shown/hidden automatically by DocumentListener on the URL field) gc.gridy = 5; + gc.insets.top = 2; + add(getLblConnectionTypeHint(), gc); + + gc.gridy = 6; gc.insets.top = 5; add(getLblUser(), gc); - gc.gridy = 6; + gc.gridy = 7; gc.insets.top = 0; add(getTxtUser(), gc); - gc.gridy = 7; + gc.gridy = 8; gc.insets.top = 5; gc.gridwidth = 3; add(getLblPassword(), gc); // Password row with field and buttons - gc.gridy = 8; + gc.gridy = 9; gc.gridx = 0; gc.gridwidth = 1; gc.weightx = 1.0; @@ -232,31 +256,32 @@ protected void initialize() { gc.insets.left = 0; gc.fill = GridBagConstraints.HORIZONTAL; - gc.gridy = 9; + // JDBC-only fields (hidden automatically when MongoDB URL is detected) + gc.gridy = 10; gc.insets.top = 5; add(getLblDriverClassName(), gc); - gc.gridy = 10; + gc.gridy = 11; gc.insets.top = 0; add(getTxtDriverClassName(), gc); - gc.gridy = 11; + gc.gridy = 12; gc.insets.top = 5; add(getLblStatementSeparator(), gc); - gc.gridy = 12; + gc.gridy = 13; gc.insets.top = 5; add(getTxtStatementSeparator(), gc); - gc.gridy = 13; + gc.gridy = 14; gc.insets.top = 5; add(getTxtColor(), gc); - gc.gridy = 14; + gc.gridy = 15; gc.insets.top = 5; add(getColorChooser(), gc); - gc.gridy = 15; + gc.gridy = 16; gc.anchor = GridBagConstraints.NORTH; gc.weighty = 1.0; gc.insets.top = 0; @@ -287,7 +312,8 @@ protected JToolBar getToolbar() { /** * Gets or creates the test connection button. - * Sets up action listener to test database connectivity using current configuration. + * Tests a JDBC connection using {@link DbConfig#getConnection()} or a MongoDB + * connection using the MongoDB Java driver, depending on the selected connection type. * * @return the JButton for testing connections */ @@ -300,17 +326,43 @@ protected JButton getBtnTestConnection() { @Override public void actionPerformed(ActionEvent e) { DbConfig cfg = DbConfigPanel.this.saveDbConfig(); - try { - assert cfg != null; - try (Connection con = cfg.getConnection()) { - JOptionPane.showMessageDialog(DbConfigPanel.this, "It works !", "Yeah...", - JOptionPane.INFORMATION_MESSAGE); + if (cfg == null) { + return; + } + + if (CONNECTION_TYPE_MONGODB.equalsIgnoreCase(cfg.type)) { + // Test MongoDB connection + try { + String connStr = cfg.jdbcUrl == null ? "" : cfg.jdbcUrl.trim(); + ConnectionString connectionString = new ConnectionString(connStr); + MongoClientSettings settings = MongoClientSettings.builder() + .applyConnectionString(connectionString) + .build(); + try (MongoClient mongoClient = MongoClients.create(settings)) { + // Execute a ping command to verify the connection + mongoClient.getDatabase("admin").runCommand(new org.bson.Document("ping", 1)); + JOptionPane.showMessageDialog(DbConfigPanel.this, "MongoDB connection successful!", "Yeah...", + JOptionPane.INFORMATION_MESSAGE); + } + } catch (Exception ex) { + LOGGER.log(Level.SEVERE, "Problem while testing MongoDB connection.", ex); + JOptionPane.showMessageDialog(DbConfigPanel.this, + "Problem while testing MongoDB connection.\n" + ex.getMessage(), "Error...", + JOptionPane.ERROR_MESSAGE); + } + } else { + // Test JDBC connection + try { + try (Connection con = cfg.getConnection()) { + JOptionPane.showMessageDialog(DbConfigPanel.this, "It works !", "Yeah...", + JOptionPane.INFORMATION_MESSAGE); + } + } catch (Exception ex) { + LOGGER.log(Level.SEVERE, "Problem while testing connection.", ex); + JOptionPane.showMessageDialog(DbConfigPanel.this, + "Problem while testing connection.\n" + ex.getMessage(), "Error...", + JOptionPane.ERROR_MESSAGE); } - } catch (Exception ex) { - LOGGER.log(Level.SEVERE, "Problem while testing connection.", ex); - JOptionPane.showMessageDialog(DbConfigPanel.this, - "Problem while testing connection.\n" + ex.getMessage(), "Error...", - JOptionPane.ERROR_MESSAGE); } } }); @@ -375,6 +427,7 @@ private DbConfig saveDbConfig() { } cfg.jdbcUrl = getTxtJdbcUrl().getText(); + cfg.type = detectConnectionType(cfg.jdbcUrl); cfg.user = getTxtUser().getText(); cfg.password = DbConfig.encryptPassword(String.copyValueOf(getTxtPassword().getPassword())); cfg.driverClassName = getTxtDriverClassName().getText(); @@ -549,13 +602,26 @@ protected JLabel getLblJdbcUrl() { } /** - * Gets or creates the JDBC URL text field. + * Gets or creates the JDBC/MongoDB URL text field. + * A {@link DocumentListener} on this field automatically adapts the form layout + * whenever the URL is changed: if the URL starts with {@code mongodb://} or + * {@code mongodb+srv://} the MongoDB-specific layout is shown, otherwise the + * standard JDBC layout is shown. * - * @return the JTextField for JDBC connection URL + * @return the JTextField for the connection URL */ protected JTextField getTxtJdbcUrl() { if (txtJdbcUrl == null) { txtJdbcUrl = new JTextField(); + txtJdbcUrl.getDocument().addDocumentListener(new DocumentListener() { + private void onUrlChanged() { + String url = txtJdbcUrl.getText(); + updateFormForConnectionType(detectConnectionType(url)); + } + @Override public void insertUpdate(DocumentEvent e) { onUrlChanged(); } + @Override public void removeUpdate(DocumentEvent e) { onUrlChanged(); } + @Override public void changedUpdate(DocumentEvent e) { onUrlChanged(); } + }); } return txtJdbcUrl; @@ -797,4 +863,70 @@ public void actionPerformed(ActionEvent e) { } return btnCopyPassword; } + + // ------------------------------------------------------------------------- + // MongoDB / Connection-type UI helpers + // ------------------------------------------------------------------------- + + /** + * Detects the connection type from the URL string. + * Returns {@link #CONNECTION_TYPE_MONGODB} when the URL starts with + * {@code mongodb://} or {@code mongodb+srv://}; returns {@link #CONNECTION_TYPE_JDBC} + * for all other values (including null/blank). + * + * @param url the connection URL to inspect + * @return {@code "mongodb"} or {@code "jdbc"} + */ + protected static String detectConnectionType(String url) { + if (url != null && !url.isBlank()) { + String lower = url.trim().toLowerCase(); + if (lower.startsWith("mongodb://") || lower.startsWith("mongodb+srv://")) { + return CONNECTION_TYPE_MONGODB; + } + } + return CONNECTION_TYPE_JDBC; + } + + /** + * Gets or creates the connection URL hint label. + * Shows a format hint for MongoDB connection strings and is hidden for JDBC connections. + * + * @return the JLabel displaying the MongoDB connection URL format hint + */ + protected JLabel getLblConnectionTypeHint() { + if (lblConnectionTypeHint == null) { + lblConnectionTypeHint = new JLabel( + "" + + "MongoDB URL format: mongodb://[user:password@]host[:port]/[database]
" + + "JDBC URL format: jdbc:RDBMS_VENDOR://[user:password@]host[:port]/[database]
"); + } + return lblConnectionTypeHint; + } + + /** + * Updates the form labels and field visibility to match the selected connection type. + *
    + *
  • JDBC: shows driver class name and batch separator; hides MongoDB URL hint; + * restores the URL label to "JDBC URL:".
  • + *
  • MongoDB: hides driver class name and batch separator; shows the MongoDB URL + * hint; changes the URL label to "Connection URL (MongoDB):".
  • + *
+ * + * @param type the connection type string, either {@link #CONNECTION_TYPE_JDBC} + * or {@link #CONNECTION_TYPE_MONGODB} + */ + protected void updateFormForConnectionType(String type) { + boolean isMongo = CONNECTION_TYPE_MONGODB.equalsIgnoreCase(type); + + // Enable/Disable JDBC-only fields + getLblDriverClassName().setEnabled(!isMongo); + getTxtDriverClassName().setEnabled(!isMongo); + + if (isMongo) { + getTxtDriverClassName().setText(""); + } + + revalidate(); + repaint(); + } } diff --git a/pom.xml b/pom.xml index 91d2968..a79346d 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,7 @@ 2.17.1 2.7.7 3.6.2 + 5.1.0 4.13.2 @@ -301,5 +302,12 @@ test + + + org.mongodb + mongodb-driver-sync + ${mongodb.version} + +