diff --git a/apps/tauri/src/App.vue b/apps/tauri/src/App.vue index 10bf36f0..37fddc99 100644 --- a/apps/tauri/src/App.vue +++ b/apps/tauri/src/App.vue @@ -27,6 +27,7 @@ import WelcomeTourDialog from "./ui/WelcomeTourDialog.vue"; import AboutDialog from "./ui/AboutDialog.vue"; import NewFolderDialog from "./ui/NewFolderDialog.vue"; import NewConnectionDialog, { + type MongoConnectPayload, type MysqlConnectPayload, type PostgresConnectPayload, type RedisConnectPayload, @@ -131,6 +132,22 @@ async function onConnectRedisFromDialog(payload: RedisConnectPayload) { } } +async function onConnectMongoFromDialog(payload: MongoConnectPayload) { + try { + await connections.connectMongo({ + name: payload.name, + host: payload.host, + port: payload.port, + database: payload.database, + username: payload.username || null, + password: payload.password || null, + authSource: payload.authSource || "admin", + }); + } catch { + // status already set in store + } +} + function onNewFolder() { newFolderOpen.value = true; } @@ -317,6 +334,7 @@ onUnmounted(() => { @connect-postgres="onConnectPostgresFromDialog" @connect-mysql="onConnectMysqlFromDialog" @connect-redis="onConnectRedisFromDialog" + @connect-mongo="onConnectMongoFromDialog" /> diff --git a/apps/tauri/src/api/mongo.ts b/apps/tauri/src/api/mongo.ts new file mode 100644 index 00000000..178cb38d --- /dev/null +++ b/apps/tauri/src/api/mongo.ts @@ -0,0 +1,86 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ConnectionProfile } from "./connection"; + +export type MongoFindResult = { + documents: unknown[]; + truncated: boolean; +}; + +export async function dbConnectMongo(opts: { + host: string; + port: number; + database: string; + username?: string | null; + password?: string | null; + authSource?: string | null; + id?: string | null; +}): Promise { + return invoke("db_connect_mongo", { + host: opts.host, + port: opts.port, + database: opts.database, + username: opts.username ?? null, + password: opts.password ?? null, + authSource: opts.authSource ?? null, + id: opts.id ?? null, + }); +} + +export async function profilesConnectMongo(opts: { + name: string; + host: string; + port: number; + database: string; + username?: string | null; + password?: string | null; + authSource?: string | null; + folderId?: string | null; + id?: string | null; +}): Promise { + return invoke("profiles_connect_mongo", { + request: { + name: opts.name, + host: opts.host, + port: opts.port, + database: opts.database, + username: opts.username ?? null, + password: opts.password ?? null, + authSource: opts.authSource ?? null, + folderId: opts.folderId ?? null, + id: opts.id ?? null, + }, + }); +} + +export async function mongoPing(id: string): Promise { + return invoke("mongo_ping", { id }); +} + +export async function mongoListDatabases(id: string): Promise { + return invoke("mongo_list_databases", { id }); +} + +export async function mongoListCollections( + id: string, + database: string, +): Promise { + return invoke("mongo_list_collections", { id, database }); +} + +export async function mongoFind( + id: string, + opts: { + database: string; + collection: string; + filterJson?: string | null; + limit?: number | null; + }, +): Promise { + return invoke("mongo_find", { + id, + database: opts.database, + collection: opts.collection, + filterJson: opts.filterJson ?? null, + limit: opts.limit ?? null, + }); +} diff --git a/apps/tauri/src/stores/connections.ts b/apps/tauri/src/stores/connections.ts index 4538dca3..8fd81443 100644 --- a/apps/tauri/src/stores/connections.ts +++ b/apps/tauri/src/stores/connections.ts @@ -11,6 +11,7 @@ import { profilesUpsert, type ConnectionProfile, } from "../api/connection"; +import { profilesConnectMongo } from "../api/mongo"; import { profilesConnectRedis } from "../api/redis"; import { DEMO_CONNECTION } from "../mock/demoCatalog"; import { useFoldersStore } from "./folders"; @@ -19,7 +20,7 @@ import { useShellStatusStore } from "./shellStatus"; export type LiveConnection = { id: string; - driver: "sqlite" | "postgres" | "mysql" | "redis"; + driver: "sqlite" | "postgres" | "mysql" | "redis" | "mongo"; name: string; /** SQLite file path. */ path?: string; @@ -49,7 +50,13 @@ function endpointLabel(conn: LiveConnection): string { const host = conn.host ?? "localhost"; const port = conn.port ?? - (conn.driver === "mysql" ? 3306 : conn.driver === "redis" ? 6379 : 5432); + (conn.driver === "mysql" + ? 3306 + : conn.driver === "redis" + ? 6379 + : conn.driver === "mongo" + ? 27017 + : 5432); const db = conn.database ?? (conn.driver === "redis" ? "0" : ""); return `${host}:${port}/${db}`; } @@ -100,6 +107,21 @@ function fromProfile(p: ConnectionProfile, connected = false): LiveConnection | connected, }; } + if (p.driver === "mongo" || p.driver === "mongodb") { + const host = p.host?.trim(); + if (!host) return null; + return { + id: p.id, + driver: "mongo", + name: p.name, + host, + port: p.port != null ? Number(p.port) : 27017, + database: p.database_name?.trim() || "querya", + user: p.username?.trim() || undefined, + folderId: p.folder_id ?? null, + connected, + }; + } return null; } @@ -135,6 +157,21 @@ function toProfile(conn: LiveConnection): ConnectionProfile { updated_at: now, }; } + if (conn.driver === "mongo") { + return { + id: conn.id, + driver: "mongo", + name: conn.name, + host: conn.host ?? null, + port: conn.port ?? 27017, + username: conn.user ?? null, + database_name: conn.database ?? "querya", + folder_id: conn.folderId ?? null, + sort_order: 0, + created_at: now, + updated_at: now, + }; + } return { id: conn.id, driver: "sqlite", @@ -344,6 +381,66 @@ export const useConnectionsStore = defineStore("connections", () => { } } + async function connectMongo(opts: { + name: string; + host: string; + port: number; + database: string; + username?: string | null; + password?: string | null; + authSource?: string | null; + folderId?: string | null; + id?: string | null; + }) { + const status = useShellStatusStore(); + const nav = useShellNavStore(); + const name = + opts.name.trim() || + `${opts.host}:${opts.port}/${opts.database}`; + status.setQueryMetrics({ + busy: true, + message: `Connecting Mongo ${opts.host}:${opts.port}…`, + }); + try { + const profile = await profilesConnectMongo({ + name, + host: opts.host, + port: opts.port, + database: opts.database, + username: opts.username ?? null, + password: opts.password ?? null, + authSource: opts.authSource ?? null, + folderId: opts.folderId ?? null, + id: opts.id ?? null, + }); + if (opts.password) sessionPasswords.set(profile.id, opts.password); + const conn: LiveConnection = { + id: profile.id, + driver: "mongo", + name: profile.name, + host: profile.host ?? opts.host, + port: profile.port != null ? Number(profile.port) : opts.port, + database: profile.database_name ?? opts.database, + user: profile.username ?? opts.username ?? undefined, + folderId: profile.folder_id ?? opts.folderId ?? null, + connected: true, + }; + upsert(conn); + nav.setExpanded(conn.id, true); + nav.selectConnection(conn.id); + status.setConnection(conn.name, endpointLabel(conn)); + status.setQueryMetrics({ + busy: false, + message: `Connected · ${conn.name}`, + }); + return conn; + } catch (err) { + const msg = formatConnectionError(err); + status.setQueryMetrics({ busy: false, message: msg }); + throw err; + } + } + async function connectServerDriver( driver: "postgres" | "mysql", opts: { @@ -442,6 +539,19 @@ export const useConnectionsStore = defineStore("connections", () => { id: conn.id, }); } + if (conn.driver === "mongo") { + return connectMongo({ + name: conn.name, + host: conn.host ?? "localhost", + port: conn.port ?? 27017, + database: conn.database ?? "querya", + username: conn.user ?? null, + password, + authSource: "admin", + folderId: conn.folderId, + id: conn.id, + }); + } const opts = { name: conn.name, host: conn.host ?? "localhost", @@ -574,6 +684,7 @@ export const useConnectionsStore = defineStore("connections", () => { connectPostgres, connectMysql, connectRedis, + connectMongo, ensureConnected, disconnect, removeConnection, diff --git a/apps/tauri/src/stores/objectTree.ts b/apps/tauri/src/stores/objectTree.ts index 1ab34e63..e5799c84 100644 --- a/apps/tauri/src/stores/objectTree.ts +++ b/apps/tauri/src/stores/objectTree.ts @@ -113,12 +113,20 @@ export const useObjectTreeStore = defineStore("objectTree", () => { return useConnectionsStore().byId.get(id)?.driver === "redis"; } + function isMongo(id: string): boolean { + return useConnectionsStore().byId.get(id)?.driver === "mongo"; + } + + function isNosqlBrowser(id: string): boolean { + return isRedis(id) || isMongo(id); + } + async function fetchChildren( connId: string, parentId: string | null, ): Promise { if (isMock(connId)) return demoListChildren(parentId); - if (isRedis(connId)) return []; + if (isNosqlBrowser(connId)) return []; return dbListChildren(connId, parentId); } @@ -144,8 +152,8 @@ export const useObjectTreeStore = defineStore("objectTree", () => { st.error = null; try { await useConnectionsStore().ensureConnected(connId); - // Redis uses RedisBrowserView — no SQL object tree. - if (isRedis(connId)) { + // Redis/Mongo use dedicated browser panes — no SQL object tree. + if (isNosqlBrowser(connId)) { st.roots = []; st.loaded = true; return; diff --git a/apps/tauri/src/ui/ConnectionHome.vue b/apps/tauri/src/ui/ConnectionHome.vue index 4bae6037..77b568a8 100644 --- a/apps/tauri/src/ui/ConnectionHome.vue +++ b/apps/tauri/src/ui/ConnectionHome.vue @@ -12,6 +12,7 @@ import { useObjectTreeStore } from "../stores/objectTree"; import QueryaButton from "./QueryaButton.vue"; import SqlWorkspace from "./SqlWorkspace.vue"; import RedisBrowserView from "./RedisBrowserView.vue"; +import MongoBrowserView from "./MongoBrowserView.vue"; import { QueryaIcon } from "./icons"; import ErdView from "./erd/ErdView.vue"; @@ -101,6 +102,8 @@ async function loadOverview() { const isSqlite = computed(() => conn.value?.driver === "sqlite"); const isRedis = computed(() => conn.value?.driver === "redis"); +const isMongo = computed(() => conn.value?.driver === "mongo"); +const isNosqlBrowser = computed(() => isRedis.value || isMongo.value); const isServerSql = computed( () => conn.value?.driver === "postgres" || conn.value?.driver === "mysql", @@ -111,6 +114,7 @@ const driverLabel = computed(() => { if (d === "postgres") return "PostgreSQL"; if (d === "mysql") return "MySQL"; if (d === "redis") return "Redis"; + if (d === "mongo") return "MongoDB"; return d ?? ""; }); const endpointLabel = computed(() => { @@ -119,7 +123,13 @@ const endpointLabel = computed(() => { if (c.driver === "sqlite") return c.path ?? ""; const port = c.port ?? - (c.driver === "mysql" ? 3306 : c.driver === "redis" ? 6379 : 5432); + (c.driver === "mysql" + ? 3306 + : c.driver === "redis" + ? 6379 + : c.driver === "mongo" + ? 27017 + : 5432); return `${c.host ?? "localhost"}:${port}/${c.database ?? (c.driver === "redis" ? "0" : "")}`; }); @@ -166,6 +176,21 @@ function openRelation(o: { id: string; name: string; kind: string; schema?: stri +
+
+

{{ conn.name }}

+ MongoDB +

{{ endpointLabel }}

+
+
+ +
+
+
@@ -354,7 +379,7 @@ function openRelation(o: { id: string; name: string; kind: string; schema?: stri
@@ -362,7 +387,7 @@ function openRelation(o: { id: string; name: string; kind: string; schema?: stri
diff --git a/apps/tauri/src/ui/ConnectionsPanel.vue b/apps/tauri/src/ui/ConnectionsPanel.vue index c19b5770..20511e70 100644 --- a/apps/tauri/src/ui/ConnectionsPanel.vue +++ b/apps/tauri/src/ui/ConnectionsPanel.vue @@ -377,6 +377,20 @@ onUnmounted(() => {
+
+
+ DATABASE +
+
    +
  • + {{ c.database ?? "querya" }} +
  • +
+
{
+
+
+ DATABASE +
+
    +
  • + {{ c.database ?? "querya" }} +
  • +
+
+import { computed, ref, watch } from "vue"; +import { storeToRefs } from "pinia"; +import { formatConnectionError } from "../api/connection"; +import { + mongoFind, + mongoListCollections, + mongoListDatabases, + type MongoFindResult, +} from "../api/mongo"; +import { useShellNavStore } from "../stores/shellNav"; +import { useConnectionsStore } from "../stores/connections"; + +const nav = useShellNavStore(); +const connections = useConnectionsStore(); +const { selectedConnectionId } = storeToRefs(nav); + +const databases = ref([]); +const collections = ref([]); +const selectedDb = ref(""); +const selectedColl = ref(null); +const filterJson = ref("{}"); +const limit = ref(50); +const result = ref(null); +const loadingDbs = ref(false); +const loadingCols = ref(false); +const loadingDocs = ref(false); +const error = ref(null); + +const prettyDocs = computed(() => { + if (!result.value) return ""; + return JSON.stringify(result.value.documents, null, 2); +}); + +async function loadDatabases() { + const id = selectedConnectionId.value; + if (!id) return; + const conn = connections.byId.get(id); + if (!conn || conn.driver !== "mongo") return; + + loadingDbs.value = true; + error.value = null; + try { + const names = await mongoListDatabases(id); + databases.value = names.filter((n) => !n.startsWith("system.")).sort(); + const preferred = conn.database?.trim(); + selectedDb.value = + preferred && databases.value.includes(preferred) + ? preferred + : (databases.value[0] ?? preferred ?? ""); + await loadCollections(); + } catch (err) { + error.value = formatConnectionError(err); + databases.value = []; + collections.value = []; + } finally { + loadingDbs.value = false; + } +} + +async function loadCollections() { + const id = selectedConnectionId.value; + if (!id || !selectedDb.value) { + collections.value = []; + return; + } + loadingCols.value = true; + error.value = null; + selectedColl.value = null; + result.value = null; + try { + collections.value = (await mongoListCollections(id, selectedDb.value)).sort(); + } catch (err) { + error.value = formatConnectionError(err); + collections.value = []; + } finally { + loadingCols.value = false; + } +} + +async function selectCollection(name: string) { + selectedColl.value = name; + await runFind(); +} + +async function runFind() { + const id = selectedConnectionId.value; + if (!id || !selectedDb.value || !selectedColl.value) return; + + loadingDocs.value = true; + error.value = null; + try { + const raw = filterJson.value.trim(); + result.value = await mongoFind(id, { + database: selectedDb.value, + collection: selectedColl.value, + filterJson: raw && raw !== "{}" ? raw : null, + limit: limit.value, + }); + } catch (err) { + result.value = null; + error.value = formatConnectionError(err); + } finally { + loadingDocs.value = false; + } +} + +watch( + selectedConnectionId, + () => { + void loadDatabases(); + }, + { immediate: true }, +); + + + + + diff --git a/apps/tauri/src/ui/NewConnectionDialog.vue b/apps/tauri/src/ui/NewConnectionDialog.vue index f7d08325..fa731679 100644 --- a/apps/tauri/src/ui/NewConnectionDialog.vue +++ b/apps/tauri/src/ui/NewConnectionDialog.vue @@ -49,6 +49,17 @@ export type RedisConnectPayload = { password: string; }; +export type MongoConnectPayload = { + driver: "mongodb"; + name: string; + host: string; + port: number; + database: string; + username: string; + password: string; + authSource: string; +}; + type Category = "all" | "sql" | "nosql"; type DriverChoice = { @@ -105,13 +116,21 @@ const emit = defineEmits<{ connectPostgres: [payload: PostgresConnectPayload]; connectMysql: [payload: MysqlConnectPayload]; connectRedis: [payload: RedisConnectPayload]; + connectMongo: [payload: MongoConnectPayload]; }>(); function dismiss() { open.value = false; } -type Step = "pick" | "sqlite" | "postgres" | "mysql" | "redis" | "stub"; +type Step = + | "pick" + | "sqlite" + | "postgres" + | "mysql" + | "redis" + | "mongo" + | "stub"; const step = ref("pick"); const category = ref("all"); @@ -128,6 +147,7 @@ const port = ref("5432"); const database = ref("querya"); const user = ref("querya"); const password = ref("querya"); +const authSource = ref("admin"); const error = ref(null); const stubDriver = ref(null); @@ -180,6 +200,18 @@ const canSaveRedis = computed(() => { ); }); +const canSaveMongo = computed(() => { + const p = Number(port.value); + return ( + name.value.trim().length > 0 && + host.value.trim().length > 0 && + database.value.trim().length > 0 && + Number.isFinite(p) && + p > 0 && + p <= 65535 + ); +}); + function reset() { step.value = "pick"; category.value = "all"; @@ -262,6 +294,18 @@ function goNext() { void nextTick(() => nameEl.value?.focus()); return; } + if (choice.id === "mongodb") { + step.value = "mongo"; + if (!name.value.trim()) name.value = "mongo"; + host.value = host.value.trim() || "127.0.0.1"; + port.value = "27017"; + database.value = "querya"; + user.value = "querya"; + password.value = "querya"; + authSource.value = "admin"; + void nextTick(() => nameEl.value?.focus()); + return; + } stubDriver.value = choice; step.value = "stub"; } @@ -365,6 +409,33 @@ function submitRedis() { }); } +function submitMongo() { + error.value = null; + const n = name.value.trim(); + const h = host.value.trim(); + const db = database.value.trim(); + const p = Number(port.value); + if (!n) { + error.value = "Connection name is required."; + return; + } + if (!h || !db || !Number.isFinite(p) || p <= 0 || p > 65535) { + error.value = "Host, port and database are required."; + return; + } + dismiss(); + emit("connectMongo", { + driver: "mongodb", + name: n, + host: h, + port: p, + database: db, + username: user.value.trim(), + password: password.value, + authSource: authSource.value.trim() || "admin", + }); +} + function submitMysql() { error.value = null; const n = name.value.trim(); @@ -407,6 +478,7 @@ function onKey(e: KeyboardEvent) { submitPostgres(); else if (step.value === "mysql" && canSaveMysql.value) submitMysql(); else if (step.value === "redis" && canSaveRedis.value) submitRedis(); + else if (step.value === "mongo" && canSaveMongo.value) submitMongo(); } } @@ -825,7 +897,78 @@ onUnmounted(() => window.removeEventListener("keydown", onKey, true));
- + + + +