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:
+ *
+ * - {@code show dbs} / {@code show databases} — list all databases
+ * - {@code show collections} — list collections in the current database
+ * - {@code db.collection.find({filter}, {projection})} — query documents
+ * - {@code db.collection.findOne({filter})} — query first matching document
+ * - {@code db.collection.insertOne({doc})} — insert a document
+ * - {@code db.collection.insertMany([{doc1},{doc2}])} — insert multiple documents
+ * - {@code db.collection.updateOne({filter}, {update})} — update one document
+ * - {@code db.collection.updateMany({filter}, {update})} — update many documents
+ * - {@code db.collection.deleteOne({filter})} — delete one document
+ * - {@code db.collection.deleteMany({filter})} — delete many documents
+ * - {@code db.collection.countDocuments({filter})} — count matching documents
+ * - {@code db.collection.aggregate([{stage1},{stage2}])} — aggregation pipeline
+ * - {@code db.collection.drop()} — drop a collection
+ * - {@code db.createCollection("name")} — create a collection
+ * - {@code db.runCommand({cmd: 1})} — run an arbitrary command against the current database
+ * - {@code db.adminCommand({cmd: 1})} — run an arbitrary command against the admin database
+ * - {@code db.getCollectionNames()} — list collections (alternative to show collections)
+ * - {@code db.stats()} — show database statistics
+ *
+ *
+ * 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}
+
+