");
+ expect(dialogSource).toContain("
");
// Switching to the Spanner profile clears host and credentials.
expect(dialogSource).toContain('if (profile.type === "spanner") {');
// Save/Test gating and submit-time normalization.
diff --git a/apps/desktop/src/lib/__tests__/sql/sqlRisk.solr.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlRisk.solr.spec.ts
new file mode 100644
index 0000000000..785800948b
--- /dev/null
+++ b/apps/desktop/src/lib/__tests__/sql/sqlRisk.solr.spec.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { classifySqlRisk, classifySqlStatementRisk } from "@/lib/sql/sqlRisk";
+import { sqlLooksLikeMutation } from "@/lib/database/readOnlyWriteAccess";
+
+const SEARCH_REQUEST = 'POST /solr/mycore/select\n{\n "query": "*:*",\n "limit": 100\n}';
+
+describe("Solr request risk", () => {
+ it("treats queries and other retrieval requests as reads", () => {
+ expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/mycore/select?q=*:*", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/admin/cores?action=STATUS", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("HEAD /solr/mycore/admin/ping", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk('POST /solr/mycore/query\n{"query":"*:*"}', { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk('POST /mycore/suggest\n{"suggest":{"q":"sol"}}', { dialect: "solr" }).risk).toBe("read");
+ });
+
+ it("treats document updates as writes and admin/schema changes as dangerous", () => {
+ expect(classifySqlRisk('POST /solr/mycore/update\n{"add":{"doc":{"id":"1"}}}', { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk('POST /mycore/update/json/docs\n{"a":1}', { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk('POST /solr/mycore/update?commit=true\n{"delete":{"query":"*:*"}}', { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk("POST /solr/admin/cores?action=CREATE&name=x", { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk('POST /solr/mycore/schema\n{"add-field":{"name":"f","type":"string"}}', { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk("DELETE /solr/mycore/update", { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk("DELETE /solr/mycore/config", { dialect: "solr" }).risk).toBe("ddl");
+ });
+
+ it("treats GET-driven update and admin actions as mutations", () => {
+ // Solr update handlers answer GET (commit/optimize/stream.body) and
+ // CoreAdmin actions ride on GET as well, so the method alone cannot mark
+ // these paths read-only.
+ expect(classifySqlRisk("GET /solr/mycore/update?commit=true", { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk("GET /solr/admin/cores?action=CREATE&name=x", { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk("GET /solr/admin/cores?action=UNLOAD&core=mycore", { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk("GET /solr/admin/cores?action=REQUESTSTATUS&requestid=1", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/admin/info/system", { dialect: "solr" }).risk).toBe("read");
+ });
+
+ it("treats GET replication commands outside the read whitelist as dangerous", () => {
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=details", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=restorestatus", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=filelist", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/mycore/replication", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=disablereplication", { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=enablereplication", { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlRisk("GET /solr/mycore/replication?command=fetchindex", { dialect: "solr" }).risk).toBe("ddl");
+ });
+
+ it("reports the highest risk across every request in the editor text", () => {
+ const source = `${SEARCH_REQUEST}\n\nPOST /solr/admin/cores?action=UNLOAD&core=mycore`;
+ expect(classifySqlRisk(source, { dialect: "solr" }).risk).toBe("ddl");
+ expect(classifySqlStatementRisk(SEARCH_REQUEST, { dialect: "solr" }).risk).toBe("read");
+ });
+
+ it("reads the request line through query strings, trailing slashes and CRLF endings", () => {
+ expect(classifySqlRisk("GET /solr/mycore/select?q=*:*&rows=1", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("post /solr/mycore/select/\r\n{}", { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk('POST /solr/mycore/update?commitWithin=500\n{"delete":{"id":"1"}}', { dialect: "solr" }).risk).toBe("write");
+ expect(classifySqlRisk(" GET /solr/mycore/select // inline comment", { dialect: "solr" }).risk).toBe("read");
+ });
+
+ it("ignores commented-out requests", () => {
+ expect(classifySqlRisk(`# DELETE /solr/mycore/config\n// DELETE /solr/mycore/config\n/* DELETE /solr/mycore/config */\n${SEARCH_REQUEST}`, { dialect: "solr" }).risk).toBe("read");
+ expect(classifySqlRisk("/*\nDELETE /solr/mycore/config\n*/\nGET /solr/mycore/select", { dialect: "solr" }).risk).toBe("read");
+ });
+
+ it("leaves text that does not start with a request line to the SQL rules", () => {
+ // The backend anchors on the first line too, so a stray request line further
+ // down must not turn unparseable text into a read.
+ expect(classifySqlRisk('{"query":"*:*"}\nGET /solr/mycore/select', { dialect: "solr" }).risk).toBe("unknown");
+ });
+
+ it("keeps REST requests classified as SQL for other database types", () => {
+ expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "mysql" }).risk).toBe("unknown");
+ expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "elasticsearch" }).risk).not.toBe("read");
+ });
+
+ it("does not ask a read-only connection to unlock writes for a search", () => {
+ expect(sqlLooksLikeMutation(SEARCH_REQUEST, "solr")).toBe(false);
+ expect(sqlLooksLikeMutation("GET /solr/mycore/select?q=*:*", "solr")).toBe(false);
+ expect(sqlLooksLikeMutation('POST /solr/mycore/update\n{"delete":{"query":"*:*"}}', "solr")).toBe(true);
+ expect(sqlLooksLikeMutation("POST /solr/admin/cores?action=CREATE&name=x", "solr")).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/lib/ai/ai.ts b/apps/desktop/src/lib/ai/ai.ts
index 375a63c639..f5a1a52aa2 100644
--- a/apps/desktop/src/lib/ai/ai.ts
+++ b/apps/desktop/src/lib/ai/ai.ts
@@ -32,6 +32,7 @@ function dbLabel(dbType: DatabaseType): string {
milvus: "Milvus",
weaviate: "Weaviate",
chromadb: "ChromaDB",
+ solr: "Apache Solr",
};
return labels[dbType] || dbType;
}
@@ -318,6 +319,9 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
if (context.databaseType === "redis") {
return buildRedisSystemPrompt(context, mode, custom);
}
+ if (context.databaseType === "solr") {
+ return buildSolrSystemPrompt(context, mode, custom);
+ }
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
@@ -397,6 +401,59 @@ function buildRedisSystemPrompt(context: AiContext, mode: AiAssistantMode, custo
return lines.filter(Boolean).join("\n");
}
+/**
+ * Solr prompt: Solr speaks REST, not SQL, so the generic dialect instructions
+ * would push the model toward SELECT statements that the Solr driver rejects.
+ * The query surface is the DBX REST console (`METHOD /path` + optional JSON
+ * body); schema context entries are Solr cores whose "columns" are schema
+ * fields (the PK-flagged field is the core's uniqueKey).
+ */
+function buildSolrSystemPrompt(context: AiContext, mode: AiAssistantMode, custom?: CustomPromptContext): string {
+ const isZh = isChineseLocale(currentLocale());
+ const schema = formatSchema(context);
+ const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
+ const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
+ const referencedSqlFiles = formatReferencedSqlFiles(context);
+ const lines: string[] = [
+ isZh ? "你是 DBX 内置的 Apache Solr 助手。用中文回复。" : "You are DBX's built-in Apache Solr assistant. Reply in English.",
+ isZh
+ ? 'Solr 不使用 SQL。查询一律使用 DBX REST 控制台格式:首行 `METHOD /path`,后续可选 JSON 请求体,例如 `GET /{core}/select?q=*:*&rows=20`、`POST /{core}/query` 加 {"query":"..."} JSON body。路径可以省略开头的 /solr 段,wt=json 会自动补上。'
+ : 'Solr does not use SQL. Always express queries in the DBX REST-console format: `METHOD /path` on the first line plus an optional JSON body, e.g. `GET /{core}/select?q=*:*&rows=20` or `POST /{core}/query` with a body such as {"query":"..."}. A leading /solr path segment may be omitted and wt=json is appended automatically.',
+ isZh
+ ? "Schema 上下文中每个条目是一个 Solr core(类型标注为 CORE);其下列出的是 schema fields,标记为 PK 的字段是该 core 的唯一键(uniqueKey)。用真实字段名构造 q/fq 参数,不要编造不存在的字段。"
+ : "Each entry in the schema context is a Solr core (marked CORE); the listed items are its schema fields and the PK-flagged field is the core's uniqueKey. Build q/fq parameters from real field names and never invent fields that are not listed.",
+ ...buildModePromptLines(mode, isZh, context.databaseType),
+ ...buildRichContentPromptLines(isZh),
+ ...buildCustomInstructionLines(custom, isZh),
+ attachmentSafetyInstruction(isZh),
+ // The ```sql fence is only a transport convention: the editor treats the
+ // block content as a Solr REST request, never as SQL.
+ isZh ? "返回请求时放在 ```sql 代码块中,块内容是 Solr REST 请求文本而非 SQL。额外说明简短实用。" : "Put the request in a fenced ```sql code block; the block holds Solr REST request text, not SQL. Keep extra explanation short and practical.",
+ "",
+ "Database type: solr",
+ `Connection: ${context.connectionName}`,
+ `Database: ${context.database}`,
+ context.schema ? `Selected schema: ${context.schema}` : "",
+ schemaCoverageLine(context, isZh),
+ "",
+ `Current request:\n${context.currentSql.trim() || "(empty)"}`,
+ referencedSqlFiles,
+ lastError,
+ resultPreview,
+ `Schema:\n${schema}`,
+ ];
+
+ if (context.schemaScope === "focused_table") {
+ lines.push(
+ isZh
+ ? "Schema 上下文只覆盖当前打开的 core;连接中可能还有其他 core。用户询问有哪些 core 或提到上下文中不存在的 core 时,不要直接断言不存在,先用 list_tables 确认。"
+ : "Schema context covers only the currently opened core; the connection may contain other cores. When the user asks what cores exist or mentions a core absent from context, do not conclude it is missing; use list_tables to verify first.",
+ );
+ }
+
+ return lines.filter(Boolean).join("\n");
+}
+
function buildBasePromptLines(isZh: boolean): string[] {
return [
isZh ? "你是 DBX 内置的数据库助手。用中文回复。" : "You are DBX's built-in database assistant. Reply in English.",
@@ -554,6 +611,28 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean, databaseType
currentTimeGuidance,
];
}
+ if (databaseType === "solr") {
+ if (mode === "agent") {
+ return [
+ isZh
+ ? "你处于 Solr Agent 模式。你有以下工具可用:list_tables(列出 Solr cores)、get_columns(返回某个 core 的 schema fields)、execute_query、get_current_time。"
+ : "You are in Solr Agent mode. You have the following tools available: list_tables (lists Solr cores), get_columns (returns a core's schema fields), execute_query, get_current_time.",
+ isZh
+ ? "execute_query 接受 DBX REST 控制台格式的 Solr 请求而不是 SQL:首行 `METHOD /path`,后续可选 JSON body,例如 `GET /{core}/select?q=*:*&rows=20` 或 `POST /{core}/query` 加 JSON body。用户提出数据查询意图时,必须调用该工具获取真实结果后再回答。"
+ : "execute_query accepts Solr REST-console requests, not SQL: `METHOD /path` on the first line plus an optional JSON body, for example `GET /{core}/select?q=*:*&rows=20` or `POST /{core}/query` with a JSON body. For data queries, call the tool and answer from its actual results.",
+ currentTimeGuidance,
+ isZh
+ ? "禁止不经确认直接执行 Solr 写请求(`/{core}/update`、commit、schema/admin 变更);如果安全执行条件不满足,先说明原因,再给出只读替代方案。"
+ : "Never execute Solr write requests (`/{core}/update`, commits, schema/admin changes) without confirmation. If safe execution requirements are not met, explain why and provide a read-only alternative.",
+ ];
+ }
+ return [
+ isZh
+ ? "你处于 Solr Ask 模式。只生成 Solr REST 请求(`METHOD /path` + 可选 JSON body)和说明,不要生成 SQL,也不要暗示已经执行或即将自动执行。"
+ : "You are in Solr Ask mode. Generate Solr REST requests (`METHOD /path` plus optional JSON body) and explanations only, not SQL, and do not imply that anything has run or will auto-run.",
+ currentTimeGuidance,
+ ];
+ }
if (mode === "agent") {
return [
isZh ? "你处于 Agent 模式。你有以下工具可用:list_tables、get_columns、execute_query、get_sample_data、get_current_time。" : "You are in Agent mode. You have the following tools available: list_tables, get_columns, execute_query, get_sample_data, get_current_time.",
diff --git a/apps/desktop/src/lib/app/documentJsonValues.ts b/apps/desktop/src/lib/app/documentJsonValues.ts
index 295973bd45..f5417eb46b 100644
--- a/apps/desktop/src/lib/app/documentJsonValues.ts
+++ b/apps/desktop/src/lib/app/documentJsonValues.ts
@@ -41,6 +41,9 @@ export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKin
export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string {
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
if (kind === "elasticsearch") return String(value);
+ // Solr document ids are the uniqueKey field value verbatim — no quoting or
+ // sentinel encoding like Meilisearch's string-id marker.
+ if (kind === "solr") return String(value);
if (kind === "meilisearch") return typeof value === "string" ? `__dbx_meilisearch_string_id__${JSON.stringify(value)}` : String(value);
if (kind === "dynamodb") return stringifyJsonPreservingLargeNumbers(value);
return serializeMongoDocumentId(value);
diff --git a/apps/desktop/src/lib/app/documentStoreProvider.ts b/apps/desktop/src/lib/app/documentStoreProvider.ts
index d04948a227..d06cb233f6 100644
--- a/apps/desktop/src/lib/app/documentStoreProvider.ts
+++ b/apps/desktop/src/lib/app/documentStoreProvider.ts
@@ -1,10 +1,10 @@
import type { ComposerTranslation } from "vue-i18n";
import { normalizeJsonArgument } from "@dbx-app/mongo-shell";
-import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
-export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch";
+export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch" | "solr";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "begins-with" | "ends-with" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "in" | "not-in" | "between" | "not-between" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
@@ -80,7 +80,9 @@ const MONGO_ONLY_DOCUMENT_FILTER_MODES = new Set(["begins-wi
export function documentFilterModeOptionsFor(kind: DocumentStoreKind): Array<{ value: DocumentFilterMode; labelKey: string }> {
if (kind === "meilisearch") return documentFilterModeOptions.filter((option) => option.value !== "like" && option.value !== "not-like" && !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
- if (kind === "mongodb") return documentFilterModeOptions;
+ // Solr's driver translates every mode — including $in/$nin and range pairs —
+ // into fq clauses, so the full Mongo-style operator set is available.
+ if (kind === "mongodb" || kind === "solr") return documentFilterModeOptions;
return documentFilterModeOptions.filter((option) => !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
}
@@ -166,6 +168,22 @@ const meilisearchDocumentProvider: DocumentStoreProvider = {
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
+const solrDocumentProvider: DocumentStoreProvider = {
+ kind: "solr",
+ filterInputLabel: "filter",
+ sortInputLabel: "sort",
+ documentsLabel: ({ total }) => `${total} Documents`,
+ queryPreview: ({ collection, filterJson, sortJson, skip, limit }) => {
+ const lines = ["DBX SOLR QUERY DOCUMENTS", `core: ${JSON.stringify(collection)}`, `offset: ${skip}`, `limit: ${limit}`];
+ const filter = documentStorePreviewJson(filterJson);
+ if (filter) lines.push("filter:", filter);
+ const sort = documentStorePreviewJson(sortJson);
+ if (sort) lines.push("sort:", sort);
+ return lines.join("\n");
+ },
+ sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
+};
+
const dynamodbDocumentProvider: DocumentStoreProvider = {
kind: "dynamodb",
filterInputLabel: "filter",
@@ -197,6 +215,7 @@ export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentS
if (databaseType === "dynamodb") return dynamodbDocumentProvider;
if (isElasticsearchCompatibleDatabaseType(databaseType)) return elasticsearchDocumentProvider;
if (isMeilisearchDatabaseType(databaseType)) return meilisearchDocumentProvider;
+ if (isSolrDatabaseType(databaseType)) return solrDocumentProvider;
return mongoDocumentProvider;
}
@@ -590,7 +609,10 @@ function escapeRegexLiteral(value: string): string {
}
function mongoDocumentFilterKind(options: DocumentFilterParseOptions): boolean {
- return options.kind === undefined || options.kind === "mongodb";
+ // Solr shares the Mongo-style filter document — the driver translates $in,
+ // $gte/$lte and anchored $regex into fq clauses — so it uses the same
+ // operator-rich condition shapes.
+ return options.kind === undefined || options.kind === "mongodb" || options.kind === "solr";
}
/** Splits a comma/newline separated filter value list, keeping quoted values intact. */
@@ -824,6 +846,7 @@ const documentQueryInputNormalizers: Record }): string {
+ const lines = [`DBX SOLR ${options.action.toUpperCase()} DOCUMENT`, `core: ${JSON.stringify(options.core)}`];
+ if (options.id !== undefined) lines.push(`id: ${stringifyDocumentStoreValue(options.id, "solr")}`);
+ if (options.document) lines.push("document:", stringifyDocumentStoreValue(options.document, "solr", 2));
+ return lines.join("\n");
+}
+
/**
* Write a document body under a known identity.
* - `put`: Elasticsearch index-by-id / Mongo update-by-id (identity via path, not body).
diff --git a/apps/desktop/src/lib/connection/connectionPresentation.ts b/apps/desktop/src/lib/connection/connectionPresentation.ts
index 86b50a632f..1eb5ad075e 100644
--- a/apps/desktop/src/lib/connection/connectionPresentation.ts
+++ b/apps/desktop/src/lib/connection/connectionPresentation.ts
@@ -142,6 +142,7 @@ export function connectionDisplayUrlScheme(connection: Pick = {
elasticsearch: { type: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", defaultPort: 9200 },
easysearch: { type: "easysearch", profile: "easysearch", label: "Easysearch", defaultPort: 9200 },
meilisearch: { type: "meilisearch", profile: "meilisearch", label: "Meilisearch", defaultPort: 7700 },
+ solr: { type: "solr", profile: "solr", label: "Apache Solr", defaultPort: 8983 },
qdrant: { type: "qdrant", profile: "qdrant", label: "Qdrant", defaultPort: 6333 },
milvus: { type: "milvus", profile: "milvus", label: "Milvus", defaultPort: 19530 },
weaviate: { type: "weaviate", profile: "weaviate", label: "Weaviate", defaultPort: 8080 },
@@ -97,6 +98,7 @@ const HTTP_SELECTED_PROFILES: Record = {
elasticsearch: SCHEME_PROFILES.elasticsearch,
easysearch: SCHEME_PROFILES.easysearch,
meilisearch: SCHEME_PROFILES.meilisearch,
+ solr: SCHEME_PROFILES.solr,
qdrant: SCHEME_PROFILES.qdrant,
milvus: SCHEME_PROFILES.milvus,
weaviate: SCHEME_PROFILES.weaviate,
@@ -795,7 +797,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: jdbcCredentials?.username ?? decodeUrlPart(parsed.username),
password: jdbcCredentials?.password ?? decodeUrlPart(parsed.password),
- database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : pathDatabase,
+ database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch || profile.type === "solr" ? undefined : pathDatabase,
urlParams: effectiveUrlParams,
ssl: scheme === "rediss" || scheme === "https" || (redisPath?.tls ?? false) || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)),
...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}),
diff --git a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts
index 25f8458f77..bd9cd1dc61 100644
--- a/apps/desktop/src/lib/connection/connectionUrlBuilder.ts
+++ b/apps/desktop/src/lib/connection/connectionUrlBuilder.ts
@@ -74,7 +74,7 @@ const STANDARD_URL_SCHEMES: Partial> = {
};
/** Schemes that are plain HTTP(S) endpoints. */
-const HTTP_URL_DB_TYPES = new Set(["elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
+const HTTP_URL_DB_TYPES = new Set(["elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
/**
* Generic `prefix://host:port/db` JDBC dialects. Dialects with a different URL
diff --git a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
index 5b8627cc9f..93eebbcd72 100644
--- a/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
+++ b/apps/desktop/src/lib/connection/connectionVisibleDatabases.ts
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
// non-database services expose their own root objects rather than database namespaces.
-const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
+const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
index 701a523228..fe9d9f244a 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnNameCopy.ts
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
}
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
-const NON_SQL_DATABASE_TYPES = new Set(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
+const NON_SQL_DATABASE_TYPES = new Set(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
const UNQUOTABLE_DATABASE_TYPES = new Set(["jdbc", "iotdb"]);
diff --git a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts
index 0a65bd4abb..6f720ed92b 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridColumnType.ts
@@ -1,4 +1,4 @@
-import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
/**
* Resolve the data type to display in a data-grid column header.
@@ -207,6 +207,13 @@ const INTEGER_COLUMN_TYPE_BASES = new Set([
// BYTE is binary), so they only apply to Elasticsearch-compatible databases.
const ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES = new Set(["byte", "short", "long"]);
+// Solr schema field types: point/trie numerics and dates carry a `p`/`t`
+// prefix, and analyzed text fields are named `text_*` — none of those names
+// exist in the SQL type palette, so they resolve only under a Solr connection.
+const SOLR_INTEGER_COLUMN_TYPE_BASES = new Set(["pint", "plong", "tint", "tlong", "sint", "slong"]);
+const SOLR_NUMERIC_COLUMN_TYPE_BASES = new Set(["pfloat", "pdouble", "tfloat", "tdouble", "sfloat", "sdouble", "pcurrency", "currency"]);
+const SOLR_TEMPORAL_COLUMN_TYPE_BASES = new Set(["pdate", "tdate"]);
+
const STRING_COLUMN_TYPE_BASES = new Set([
"varchar",
"varchar2",
@@ -300,8 +307,14 @@ export function resolveDataGridTypeVisualKind(dataType: string | undefined, data
if (array) return "structured";
if (databaseType === "sqlserver" && (base === "timestamp" || base === "rowversion")) return "binary";
if (databaseType === "postgres" && (base === "bit" || base === "bit varying")) return "binary";
- if (isElasticsearchCompatibleDatabaseType(databaseType) && ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
+ if ((isElasticsearchCompatibleDatabaseType(databaseType) || isSolrDatabaseType(databaseType)) && ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if (INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
+ if (isSolrDatabaseType(databaseType)) {
+ if (SOLR_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
+ if (SOLR_NUMERIC_COLUMN_TYPE_BASES.has(base)) return "numeric";
+ if (SOLR_TEMPORAL_COLUMN_TYPE_BASES.has(base)) return "temporal";
+ if (base.startsWith("text")) return "string";
+ }
if (isNumericColumnType(dataType)) return "numeric";
if (BOOLEAN_COLUMN_TYPE_BASES.has(base)) return "boolean";
if (TEMPORAL_COLUMN_TYPE_BASES.has(base) || base.startsWith("timestamp_")) return "temporal";
diff --git a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
index 92122a9ee5..a8e39cef8a 100644
--- a/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
+++ b/apps/desktop/src/lib/dataGrid/dataGridSaveUi.ts
@@ -1,6 +1,6 @@
import type { DatabaseType } from "@/types/database";
-const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch"]);
+const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr"]);
export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" {
return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql";
diff --git a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
index 82b53a31d3..e2d5599bbd 100644
--- a/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
+++ b/apps/desktop/src/lib/database/databaseNamespaceCreation.ts
@@ -33,6 +33,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
elasticsearch: { deferred: "index creation is not modeled as database creation" },
easysearch: { deferred: "index creation is not modeled as database creation" },
meilisearch: { deferred: "index creation is not modeled as database creation" },
+ solr: { deferred: "core creation is not modeled as database creation" },
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
qdrant: { deferred: "collection creation is separate from database creation" },
milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" },
diff --git a/apps/desktop/src/lib/database/databasePropertyEditing.ts b/apps/desktop/src/lib/database/databasePropertyEditing.ts
index ad0efa218d..0f3a92331a 100644
--- a/apps/desktop/src/lib/database/databasePropertyEditing.ts
+++ b/apps/desktop/src/lib/database/databasePropertyEditing.ts
@@ -33,6 +33,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
elasticsearch: { deferred: "index settings are not database properties" },
easysearch: { deferred: "index settings are not database properties" },
meilisearch: { deferred: "index settings are not database properties" },
+ solr: { deferred: "core settings are not database properties" },
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
qdrant: { deferred: "collection settings are not database properties" },
milvus: { deferred: "collection/database settings need a dedicated vector workflow" },
diff --git a/apps/desktop/src/lib/database/sqlExecutionTargetRegistry.ts b/apps/desktop/src/lib/database/sqlExecutionTargetRegistry.ts
index 80a36d850a..6832b1a78c 100644
--- a/apps/desktop/src/lib/database/sqlExecutionTargetRegistry.ts
+++ b/apps/desktop/src/lib/database/sqlExecutionTargetRegistry.ts
@@ -110,6 +110,7 @@ const SQL_EXECUTION_TARGET_REGISTRY: Partial, child
const regularChildren = normalized.filter((child) => child.type !== "user-admin" && child.type !== "dameng-users" && child.type !== "dameng-roles" && child.type !== "saved-sql-root");
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
- if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
+ if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
const meilisearchSystem = regularChildren.filter((child) => child.type === "meilisearch-system");
const databaseObjects = regularChildren.filter((child) => child.type !== "meilisearch-system");
return withConnectionUtilityOrder([...sortByLabel(databaseObjects), ...meilisearchSystem]);
diff --git a/apps/desktop/src/lib/solr/solrAdmin.ts b/apps/desktop/src/lib/solr/solrAdmin.ts
new file mode 100644
index 0000000000..e3fb508541
--- /dev/null
+++ b/apps/desktop/src/lib/solr/solrAdmin.ts
@@ -0,0 +1,572 @@
+import { executeQuery } from "@/lib/backend/api";
+import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
+
+/**
+ * Solr Admin 面板的数据层:所有请求复用 REST 控制台链路
+ * (execute_query → solr_driver::execute_rest_query),后端零新增命令。
+ * 非表格化 JSON 响应由驱动包装为 [status, response] 单行返回;
+ * /response/docs 形态走 elasticsearch_raw_body 透出原文。
+ */
+
+export interface SolrAdminResponse {
+ status: number;
+ /** 解析后的 JSON;非 JSON 时为原始文本;空响应为 null。 */
+ body: unknown;
+ rawBody: string;
+ elapsedMs: number;
+}
+
+export type SolrAdminMethod = "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
+
+export async function solrAdminRequest(connectionId: string, method: SolrAdminMethod, path: string, body?: string): Promise {
+ const sql = body ? `${method} ${path}\n${body}` : `${method} ${path}`;
+ const result = await executeQuery(connectionId, "", sql);
+ const status = typeof result.rows?.[0]?.[0] === "number" ? result.rows[0][0] : 200;
+ const rawBody = result.elasticsearch_raw_body ?? (typeof result.rows?.[0]?.[1] === "string" ? result.rows[0][1] : "");
+ let parsed: unknown = null;
+ if (rawBody) {
+ try {
+ parsed = parseJsonPreservingLargeNumbers(rawBody);
+ } catch {
+ parsed = rawBody;
+ }
+ }
+ return { status, body: parsed, rawBody, elapsedMs: result.execution_time_ms };
+}
+
+export function solrAdminGet(connectionId: string, path: string): Promise {
+ return solrAdminRequest(connectionId, "GET", path);
+}
+
+/** Solr 错误体 {"error":{"msg","code"}} → 可读消息;用于写操作的状态判定。 */
+export function solrAdminErrorMessage(res: SolrAdminResponse): string | null {
+ if (res.status < 400) return null;
+ const body = res.body as Record | null;
+ const msg = body?.error && typeof body.error === "object" ? (body.error as Record).msg : undefined;
+ return `Solr error (${res.status}): ${typeof msg === "string" ? msg : res.rawBody.slice(0, 500)}`;
+}
+
+/** 服务端点(server scope)与 core 端点(core scope,{core} 占位)。 */
+export interface SolrAdminViewDef {
+ id: SolrAdminViewId;
+ scope: "server" | "core";
+ /** REST 路径模板;core scope 中 {core} 会被替换。 */
+ path: string;
+ /** 需要专用渲染而非通用 JSON 树。 */
+ view: "generic" | "dashboard" | "properties" | "logging" | "coreAdmin" | "overview" | "ping" | "schema" | "analysis" | "segments" | "queryForm" | "replication" | "action";
+}
+
+export type SolrAdminViewId = "dashboard" | "logging" | "javaProps" | "threads" | "coreAdmin" | "metrics" | "overview" | "analysis" | "documents" | "paramsets" | "files" | "ping" | "plugins" | "query" | "replication" | "schema" | "segments";
+
+export const SOLR_ADMIN_SERVER_VIEWS: readonly SolrAdminViewDef[] = [
+ { id: "dashboard", scope: "server", path: "/admin/info/system", view: "dashboard" },
+ { id: "logging", scope: "server", path: "/admin/info/logging", view: "logging" },
+ { id: "javaProps", scope: "server", path: "/admin/info/properties", view: "properties" },
+ { id: "threads", scope: "server", path: "/admin/info/threads", view: "generic" },
+ { id: "coreAdmin", scope: "server", path: "/admin/cores?action=STATUS", view: "coreAdmin" },
+ { id: "metrics", scope: "server", path: "/admin/metrics", view: "generic" },
+];
+
+export const SOLR_ADMIN_CORE_VIEWS: readonly SolrAdminViewDef[] = [
+ { id: "overview", scope: "core", path: "/{core}/admin/luke?show=index", view: "overview" },
+ { id: "analysis", scope: "core", path: "/{core}/analysis/field", view: "analysis" },
+ { id: "documents", scope: "core", path: "", view: "action" },
+ { id: "paramsets", scope: "core", path: "/{core}/config/params", view: "generic" },
+ { id: "files", scope: "core", path: "/{core}/admin/file", view: "generic" },
+ { id: "ping", scope: "core", path: "/{core}/admin/ping", view: "ping" },
+ { id: "plugins", scope: "core", path: "/{core}/admin/mbeans?stats=true", view: "generic" },
+ { id: "query", scope: "core", path: "", view: "queryForm" },
+ { id: "replication", scope: "core", path: "/{core}/replication?command=details", view: "replication" },
+ { id: "schema", scope: "core", path: "/{core}/schema", view: "schema" },
+ { id: "segments", scope: "core", path: "/{core}/admin/segments", view: "segments" },
+];
+
+export function solrAdminViewById(id: SolrAdminViewId): SolrAdminViewDef | undefined {
+ return [...SOLR_ADMIN_SERVER_VIEWS, ...SOLR_ADMIN_CORE_VIEWS].find((view) => view.id === id);
+}
+
+export function solrAdminPath(view: SolrAdminViewDef, core: string): string {
+ return view.path.replace("{core}", encodeURIComponent(core));
+}
+
+/* ---------- Core Admin ---------- */
+
+export interface SolrCoreStatus {
+ name: string;
+ instanceDir?: string;
+ dataDir?: string;
+ config?: string;
+ schema?: string;
+ startTime?: string;
+ uptime?: number;
+ numDocs?: number;
+ maxDoc?: number;
+ deletedDocs?: number;
+ sizeInBytes?: number;
+ version?: number;
+ segmentCount?: number;
+}
+
+export function parseCoreStatus(body: unknown): SolrCoreStatus[] {
+ const status = (body as Record | null)?.status;
+ if (!status || typeof status !== "object" || Array.isArray(status)) return [];
+ return Object.entries(status as Record>).map(([name, info]) => {
+ const index = (info?.index ?? {}) as Record;
+ return {
+ name: typeof info?.name === "string" ? info.name : name,
+ instanceDir: info?.instanceDir as string | undefined,
+ dataDir: info?.dataDir as string | undefined,
+ config: info?.config as string | undefined,
+ schema: info?.schema as string | undefined,
+ startTime: info?.startTime as string | undefined,
+ uptime: typeof info?.uptime === "number" ? info.uptime : undefined,
+ numDocs: typeof index.numDocs === "number" ? index.numDocs : undefined,
+ maxDoc: typeof index.maxDoc === "number" ? index.maxDoc : undefined,
+ deletedDocs: typeof index.deletedDocs === "number" ? index.deletedDocs : undefined,
+ sizeInBytes: typeof index.sizeInBytes === "number" ? index.sizeInBytes : typeof index.size === "number" ? index.size : undefined,
+ version: typeof index.version === "number" ? index.version : undefined,
+ segmentCount: typeof index.segmentCount === "number" ? index.segmentCount : undefined,
+ };
+ });
+}
+
+export type SolrCoreAction = "CREATE" | "RELOAD" | "UNLOAD" | "RENAME" | "SWAP";
+
+/** CoreAdmin 写操作统一走 GET /admin/cores(Solr 官方形态),参数全部由 UI 收集。 */
+export function solrCoreAdminRequest(connectionId: string, params: Record): Promise {
+ const query = new URLSearchParams(params).toString();
+ return solrAdminRequest(connectionId, "GET", `/admin/cores?${query}`);
+}
+
+/* ---------- Schema ---------- */
+
+export interface SolrSchemaField {
+ name: string;
+ type?: string;
+ stored?: boolean;
+ indexed?: boolean;
+ required?: boolean;
+ multiValued?: boolean;
+ docValues?: boolean;
+ uninvertible?: boolean;
+}
+
+export interface SolrSchemaInfo {
+ name?: string;
+ version?: string;
+ uniqueKey?: string;
+ fields: SolrSchemaField[];
+ dynamicFields: SolrSchemaField[];
+ copyFields: Array<{ source: string; dest: string; maxChars?: number }>;
+ fieldTypeCount: number;
+}
+
+export function parseSolrSchema(body: unknown): SolrSchemaInfo {
+ const root = (body as Record | null)?.schema ?? body;
+ const schema = (root ?? {}) as Record;
+ const fields = Array.isArray(schema.fields) ? (schema.fields as SolrSchemaField[]) : [];
+ const dynamicFields = Array.isArray(schema.dynamicFields) ? (schema.dynamicFields as SolrSchemaField[]) : [];
+ const copyFields = Array.isArray(schema.copyFields) ? (schema.copyFields as Array<{ source: string; dest: string; maxChars?: number }>) : [];
+ const fieldTypes = Array.isArray(schema.fieldTypes) ? schema.fieldTypes : [];
+ return {
+ name: schema.name as string | undefined,
+ version: schema.version != null ? String(schema.version) : undefined,
+ uniqueKey: schema.uniqueKey as string | undefined,
+ fields,
+ dynamicFields,
+ copyFields,
+ fieldTypeCount: fieldTypes.length,
+ };
+}
+
+/* ---------- Analysis ---------- */
+
+export interface SolrAnalysisStage {
+ /** 分析器类名,如 org.apache.lucene.analysis.standard.StandardTokenizer */
+ name: string;
+ tokens: string[];
+}
+
+export interface SolrAnalysisResult {
+ /** index 侧阶段链 */
+ index: SolrAnalysisStage[];
+ /** query 侧阶段链 */
+ query: SolrAnalysisStage[];
+}
+
+/**
+ * /analysis/field 响应:analysis.field_names.{f}.index 是
+ * [ [stageName, [tokenObj...]], ... ] 交替数组;query 侧结构相同。
+ */
+export function parseAnalysisResponse(body: unknown, fieldName: string): SolrAnalysisResult {
+ const analysis = (body as Record | null)?.analysis as Record | undefined;
+ const fieldNames = analysis?.field_names as Record | undefined;
+ const entry = (fieldName ? fieldNames?.[fieldName] : undefined) ?? (fieldNames ? Object.values(fieldNames)[0] : undefined);
+ const stages = (entry ?? {}) as Record;
+ const parseStages = (raw: unknown): SolrAnalysisStage[] => {
+ if (!Array.isArray(raw)) return [];
+ const stages: SolrAnalysisStage[] = [];
+ for (const item of raw) {
+ if (!Array.isArray(item) || item.length < 2) continue;
+ const name = typeof item[0] === "string" ? item[0] : String(item[0]);
+ const tokenObjs = Array.isArray(item[1]) ? item[1] : [];
+ const tokens = tokenObjs.map((token) => {
+ if (token && typeof token === "object" && !Array.isArray(token)) {
+ const text = (token as Record).text;
+ return text != null ? String(text) : JSON.stringify(token);
+ }
+ return String(token);
+ });
+ stages.push({ name, tokens });
+ }
+ return stages;
+ };
+ return { index: parseStages(stages.index), query: parseStages(stages.query) };
+}
+
+export function solrAnalysisPath(core: string, fieldName: string, fieldValue: string, queryValue?: string): string {
+ const params = new URLSearchParams();
+ params.set("analysis.fieldname", fieldName);
+ params.set("analysis.fieldvalue", fieldValue);
+ if (queryValue) params.set("analysis.query", queryValue);
+ return `/${encodeURIComponent(core)}/analysis/field?${params.toString()}`;
+}
+
+/* ---------- Query Builder(对标官方 Admin UI Query 表单) ---------- */
+
+export interface SolrQueryParam {
+ key: string;
+ value: string;
+}
+
+export interface SolrQueryFormState {
+ /** Request-Handler,如 /select、/query */
+ handler: string;
+ q: string;
+ qop: "" | "AND" | "OR";
+ fq: string[];
+ sort: string;
+ start: string;
+ rows: string;
+ fl: string;
+ df: string;
+ /** paramset 名(useParams 参数,逗号拼接) */
+ useParams: string[];
+ wt: string;
+ indent: boolean;
+ debugQuery: boolean;
+ defType: string;
+ hl: { enabled: boolean; fl: string; snippets: string; fragsize: string; simplePre: string; simplePost: string; requireFieldMatch: boolean; mergeContiguous: boolean };
+ facet: { enabled: boolean; queries: string[]; fields: string[]; prefix: string; sort: string; limit: string; offset: string; mincount: string; missing: boolean };
+ spatial: { enabled: boolean; pt: string; sfield: string; d: string; geofilt: boolean; bbox: boolean };
+ spellcheck: { enabled: boolean; q: string; build: boolean; collate: boolean; count: string; dictionary: string; onlyMorePopular: boolean };
+ rawParams: SolrQueryParam[];
+ /** 非空时改用 JSON Request DSL:POST {handler} + body,与参数表单互斥 */
+ jsonQuery: string;
+}
+
+export function defaultSolrQueryForm(): SolrQueryFormState {
+ return {
+ handler: "/select",
+ q: "*:*",
+ qop: "",
+ fq: [],
+ sort: "",
+ start: "0",
+ rows: "10",
+ fl: "",
+ df: "",
+ useParams: [],
+ wt: "json",
+ indent: true,
+ debugQuery: false,
+ defType: "",
+ hl: { enabled: false, fl: "", snippets: "", fragsize: "", simplePre: "", simplePost: "", requireFieldMatch: false, mergeContiguous: false },
+ facet: { enabled: false, queries: [], fields: [], prefix: "", sort: "", limit: "", offset: "", mincount: "", missing: false },
+ spatial: { enabled: false, pt: "", sfield: "", d: "", geofilt: false, bbox: false },
+ spellcheck: { enabled: false, q: "", build: false, collate: false, count: "", dictionary: "", onlyMorePopular: false },
+ rawParams: [],
+ jsonQuery: "",
+ };
+}
+
+export interface SolrBuiltQuery {
+ /** REST 控制台可执行文本:"GET /path?qs" 或 "POST /path\n{json}" */
+ text: string;
+ jsonMode: boolean;
+}
+
+const nonEmpty = (v: string) => v.trim() !== "";
+
+/** 表单 → REST 文本。重复参数(fq/facet.field…)经 URLSearchParams.append 保留多值。 */
+export function buildSolrQuery(core: string, state: SolrQueryFormState): SolrBuiltQuery {
+ const handler = state.handler.trim() || "/select";
+ const base = `/${encodeURIComponent(core)}${handler.startsWith("/") ? handler : `/${handler}`}`;
+
+ if (nonEmpty(state.jsonQuery)) {
+ return { text: `POST ${base}\n${state.jsonQuery.trim()}`, jsonMode: true };
+ }
+
+ const params = new URLSearchParams();
+ const set = (key: string, value: string | undefined) => {
+ if (value != null && nonEmpty(value)) params.set(key, value.trim());
+ };
+ const appendEach = (key: string, values: string[]) => {
+ for (const v of values) if (nonEmpty(v)) params.append(key, v.trim());
+ };
+
+ set("q", state.q || "*:*");
+ set("q.op", state.qop);
+ appendEach("fq", state.fq);
+ set("sort", state.sort);
+ set("start", state.start);
+ set("rows", state.rows);
+ set("fl", state.fl);
+ set("df", state.df);
+ if (state.useParams.length) params.set("useParams", state.useParams.join(","));
+ set("wt", state.wt);
+ if (state.indent) params.set("indent", "on");
+ if (state.debugQuery) params.set("debugQuery", "true");
+ set("defType", state.defType);
+
+ const hl = state.hl;
+ if (hl.enabled) {
+ params.set("hl", "true");
+ set("hl.fl", hl.fl);
+ set("hl.snippets", hl.snippets);
+ set("hl.fragsize", hl.fragsize);
+ set("hl.simple.pre", hl.simplePre);
+ set("hl.simple.post", hl.simplePost);
+ if (hl.requireFieldMatch) params.set("hl.requireFieldMatch", "true");
+ if (hl.mergeContiguous) params.set("hl.mergeContiguous", "true");
+ }
+
+ const facet = state.facet;
+ if (facet.enabled) {
+ params.set("facet", "true");
+ appendEach("facet.query", facet.queries);
+ appendEach("facet.field", facet.fields);
+ set("facet.prefix", facet.prefix);
+ set("facet.sort", facet.sort);
+ set("facet.limit", facet.limit);
+ set("facet.offset", facet.offset);
+ set("facet.mincount", facet.mincount);
+ if (facet.missing) params.set("facet.missing", "true");
+ }
+
+ const spatial = state.spatial;
+ if (spatial.enabled) {
+ set("pt", spatial.pt);
+ set("sfield", spatial.sfield);
+ set("d", spatial.d);
+ // 官方 UI 的 geofilt/bbox 以 fq 函数查询注入
+ if (spatial.geofilt) params.append("fq", "{!geofilt}");
+ if (spatial.bbox) params.append("fq", "{!bbox}");
+ }
+
+ const sc = state.spellcheck;
+ if (sc.enabled) {
+ params.set("spellcheck", "true");
+ set("spellcheck.q", sc.q);
+ if (sc.build) params.set("spellcheck.build", "true");
+ if (sc.collate) params.set("spellcheck.collate", "true");
+ set("spellcheck.count", sc.count);
+ set("spellcheck.dictionary", sc.dictionary);
+ if (sc.onlyMorePopular) params.set("spellcheck.onlyMorePopular", "true");
+ }
+
+ for (const rp of state.rawParams) {
+ if (nonEmpty(rp.key) && nonEmpty(rp.value)) params.append(rp.key.trim(), rp.value.trim());
+ }
+
+ return { text: `GET ${base}?${params.toString()}`, jsonMode: false };
+}
+
+/** /{core}/config/params 响应 → paramset 名列表,供 useParams 选择。 */
+export function parseParamsetNames(body: unknown): string[] {
+ const params = (body as Record | null)?.response as Record | undefined;
+ const named = params?.params;
+ if (!named || typeof named !== "object" || Array.isArray(named)) return [];
+ return Object.keys(named as Record).sort();
+}
+
+/** select 响应体 → numFound;非 select/出错为 null。 */
+export function parseSolrNumFound(rawBody: string): number | null {
+ try {
+ const body = parseJsonPreservingLargeNumbers(rawBody) as Record | null;
+ const num = (body?.response as Record | undefined)?.numFound;
+ return typeof num === "number" ? num : null;
+ } catch {
+ return null;
+ }
+}
+
+/* ---------- Overview(对标官方 UI:Statistics / Instance / Replication / Healthcheck 组合) ---------- */
+
+/** luke ?show=index 的 index 块 → Statistics 行 */
+export interface SolrIndexStats {
+ lastModified?: string;
+ numDocs?: number;
+ maxDoc?: number;
+ deletedDocs?: number;
+ version?: number;
+ segmentCount?: number;
+ current?: boolean;
+ /** Lucene Directory 实现类(org.apache.lucene.store.NRTCachingDirectory 等) */
+ directoryImpl?: string;
+}
+
+export function parseIndexStats(body: unknown): SolrIndexStats {
+ const index = (body as Record | null)?.index as Record | undefined;
+ if (!index) return {};
+ const directory = typeof index.directory === "string" ? index.directory : undefined;
+ return {
+ lastModified: index.lastModified as string | undefined,
+ numDocs: typeof index.numDocs === "number" ? index.numDocs : undefined,
+ maxDoc: typeof index.maxDoc === "number" ? index.maxDoc : undefined,
+ deletedDocs: typeof index.deletedDocs === "number" ? index.deletedDocs : undefined,
+ version: typeof index.version === "number" ? index.version : undefined,
+ segmentCount: typeof index.segmentCount === "number" ? index.segmentCount : undefined,
+ current: typeof index.current === "boolean" ? index.current : undefined,
+ directoryImpl: directory?.split(":")[0],
+ };
+}
+
+/** replication?command=details 的 details 块 */
+export interface SolrReplicationDetails {
+ isLeader: boolean;
+ isFollower: boolean;
+ indexSize?: string;
+ indexPath?: string;
+ indexVersion?: string;
+ generation?: number;
+ replicableVersion?: number;
+ replicableGeneration?: number;
+ replicationEnabled?: boolean;
+ replicateAfter: string[];
+ /** follower 侧信息(isLeader=false 时展示) */
+ follower?: Record;
+}
+
+export function parseReplicationDetails(body: unknown): SolrReplicationDetails | null {
+ const details = (body as Record | null)?.details as Record | undefined;
+ if (!details) return null;
+ const leader = (details.leader ?? {}) as Record;
+ const follower = details.follower as Record | undefined;
+ const num = (v: unknown) => (typeof v === "number" ? v : v != null ? Number(v) : undefined);
+ return {
+ isLeader: details.isLeader === true || details.isLeader === "true",
+ isFollower: details.isFollower === true || details.isFollower === "true",
+ indexSize: details.indexSize as string | undefined,
+ indexPath: details.indexPath as string | undefined,
+ indexVersion: details.indexVersion != null ? String(details.indexVersion) : undefined,
+ generation: num(details.generation),
+ replicableVersion: num(leader.replicableVersion),
+ replicableGeneration: num(leader.replicableGeneration),
+ replicationEnabled: leader.replicationEnabled === true || leader.replicationEnabled === "true",
+ replicateAfter: Array.isArray(leader.replicateAfter) ? leader.replicateAfter.map(String) : [],
+ follower,
+ };
+}
+
+/** leader 侧启停复制:/{core}/replication?command=enablereplication|disablereplication */
+export function solrReplicationCommand(connectionId: string, core: string, command: "enablereplication" | "disablereplication"): Promise {
+ return solrAdminRequest(connectionId, "GET", `/${encodeURIComponent(core)}/replication?command=${command}`);
+}
+
+/** /admin/info/properties 响应 → JVM 工作目录(官方 Instance 框的 CWD)。 */
+export function parseServerCwd(body: unknown): string | undefined {
+ const props = (body as Record | null)?.["system.properties"] as Record | undefined;
+ return typeof props?.["user.dir"] === "string" ? props["user.dir"] : undefined;
+}
+
+/* ---------- Dashboard(官方布局:Instance / Versions / JVM + System 进度条) ---------- */
+
+export interface SolrSystemInfo {
+ mode?: string;
+ solrHome?: string;
+ coreRoot?: string;
+ /** jvm.jmx.startTime(ISO) */
+ startTime?: string;
+ uptimeMs?: number;
+ solrSpec?: string;
+ solrImpl?: string;
+ luceneSpec?: string;
+ luceneImpl?: string;
+ /** name + version 拼接,如 "Eclipse Adoptium OpenJDK 64-Bit Server VM 17.0.15 17.0.15+6" */
+ jvmRuntime?: string;
+ processors?: number;
+ args: string[];
+ jvmMemUsedPct?: number;
+ jvmMemUsed?: string;
+ jvmMemTotal?: string;
+ /** 物理内存:bytes */
+ physMemUsedPct?: number;
+ physMemUsed?: number;
+ physMemTotal?: number;
+ swapUsedPct?: number;
+ swapUsed?: number;
+ swapTotal?: number;
+ fdUsed?: number;
+ fdMax?: number;
+}
+
+export function parseSolrSystemInfo(body: unknown): SolrSystemInfo {
+ const root = (body ?? {}) as Record;
+ const jvm = (root.jvm ?? {}) as Record;
+ const jmx = (jvm.jmx ?? {}) as Record;
+ const lucene = (root.lucene ?? {}) as Record;
+ const system = (root.system ?? {}) as Record;
+ const memory = (jvm.memory ?? {}) as Record;
+ const raw = (memory.raw ?? {}) as Record;
+
+ const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
+ const pct = (used: number | undefined, total: number | undefined) => (used != null && total != null && total > 0 ? (used / total) * 100 : undefined);
+
+ const physTotal = num(system.totalPhysicalMemorySize);
+ const physFree = num(system.freePhysicalMemorySize);
+ const physUsed = physTotal != null && physFree != null ? physTotal - physFree : undefined;
+ const swapTotal = num(system.totalSwapSpaceSize);
+ const swapFree = num(system.freeSwapSpaceSize);
+ const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : undefined;
+
+ const jvmName = str(jvm.name);
+ const jvmVersion = str(jvm.version);
+
+ return {
+ mode: str(root.mode),
+ solrHome: str(root.solr_home),
+ coreRoot: str(root.core_root),
+ startTime: str(jmx.startTime),
+ uptimeMs: num(jmx.upTimeMS),
+ solrSpec: str(lucene["solr-spec-version"]),
+ solrImpl: str(lucene["solr-impl-version"]),
+ luceneSpec: str(lucene["lucene-spec-version"]),
+ luceneImpl: str(lucene["lucene-impl-version"]),
+ jvmRuntime: [jvmName, jvmVersion].filter(Boolean).join(" ") || undefined,
+ processors: num(jvm.processors),
+ args: Array.isArray(jmx.commandLineArgs) ? jmx.commandLineArgs.map(String) : [],
+ jvmMemUsedPct: num(raw["used%"]),
+ jvmMemUsed: str(memory.used),
+ jvmMemTotal: str(memory.total),
+ physMemUsedPct: pct(physUsed, physTotal),
+ physMemUsed: physUsed,
+ physMemTotal: physTotal,
+ swapUsedPct: pct(swapUsed, swapTotal),
+ swapUsed,
+ swapTotal,
+ fdUsed: num(system.openFileDescriptorCount),
+ fdMax: num(system.maxFileDescriptorCount),
+ };
+}
+
+/** solr-impl-version 里带构建日期("9.8.1 dab835... - houston - 2025-03-06 13:59:17"),>1 年提示升级。 */
+export function solrReleaseAgeDays(implVersion?: string): number | null {
+ if (!implVersion) return null;
+ const match = implVersion.match(/(\d{4})-(\d{2})-(\d{2})/);
+ if (!match) return null;
+ const ts = Date.parse(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
+ if (!Number.isFinite(ts)) return null;
+ return Math.floor((Date.now() - ts) / 86400000);
+}
diff --git a/apps/desktop/src/lib/solr/solrRequestRisk.ts b/apps/desktop/src/lib/solr/solrRequestRisk.ts
new file mode 100644
index 0000000000..9f57da19f9
--- /dev/null
+++ b/apps/desktop/src/lib/solr/solrRequestRisk.ts
@@ -0,0 +1,173 @@
+/**
+ * Read/write classification for Apache Solr REST requests.
+ *
+ * Mirrors `classify_solr_query_risk` in `crates/dbx-sql/src/query_execution_sql.rs`
+ * so the desktop guards (read-only unlock, production safety) agree with the
+ * backend read-only gate instead of treating every request as unrecognized.
+ *
+ * Solr write traffic funnels through `/{core}/update*` (plus `commit`) — for
+ * every HTTP method, since Solr update handlers also answer GET commits.
+ * CoreAdmin/Collections actions and ReplicationHandler commands ride on GET
+ * too, so `action=`/`command=` values outside the read whitelists are
+ * dangerous. Read-only POST handlers are the query
+ * endpoints (select/query/get/export/...). Any other mutating method/path —
+ * `/admin/*`, `/schema`, `/config`, core management — is classified dangerous.
+ */
+
+export type SolrRequestRisk = "read" | "write" | "dangerous";
+
+const REQUEST_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)/i;
+const READ_ONLY_POST_ENDPOINTS = new Set(["select", "query", "get", "export", "terms", "suggest", "spell", "mlt", "sql", "graph", "clustering", "tvrh", "luke", "elevate", "browse", "debug"]);
+const READ_ONLY_REPLICATION_COMMANDS = new Set(["details", "restorestatus", "filelist", "filecontent", "filedownload", "filemtime", "indexversion", "showversion"]);
+const RISK_ORDER: Record = { read: 0, write: 1, dangerous: 2 };
+
+function parseRequestLine(line: string): { method: string; rawPath: string; endpoint: string; isUpdatePath: boolean; segments: string[] } | null {
+ const match = line.trim().match(REQUEST_LINE);
+ if (!match) return null;
+ const rawPath = match[2];
+ const path = (rawPath.split("?", 1)[0] ?? "").replace(/\/+$/, "");
+ const segments = path.split("/").filter(Boolean);
+ // Users may paste absolute Solr paths ("/solr/{core}/select") or relative
+ // ones ("{core}/select"); drop the fixed /solr context segment if present.
+ if (segments[0]?.toLowerCase() === "solr") segments.shift();
+ // Update handler variants (/update/json, /update/json/docs, /update/csv)
+ // nest below the update segment, so the last segment alone cannot spot them.
+ const isUpdatePath = segments.some((segment) => segment.startsWith("update") || segment === "commit");
+ return { method: match[1].toUpperCase(), rawPath, endpoint: segments[segments.length - 1]?.toLowerCase() ?? "", isUpdatePath, segments };
+}
+
+/**
+ * CoreAdmin/Collections endpoints accept GET-triggered mutations
+ * (`GET /admin/cores?action=CREATE&name=x` really creates a core). Only the
+ * explicitly read-only actions stay "read"; any other action is dangerous.
+ */
+function solrAdminRequestIsMutation(rawPath: string, segments: string[]): boolean {
+ const isAdminTarget = segments.some((segment, index) => segment.toLowerCase() === "admin" && ["cores", "collections"].includes(segments[index + 1]?.toLowerCase() ?? ""));
+ if (!isAdminTarget) return false;
+ const query = rawPath.split("?")[1];
+ if (!query) return false;
+ const action = query
+ .split("&")
+ .map((param) => param.split("=", 2))
+ .find(([key]) => key.toLowerCase() === "action")?.[1];
+ if (action === undefined) return false;
+ return !["STATUS", "REQUESTSTATUS", "LIST"].includes(action.toUpperCase());
+}
+
+/**
+ * The replication handler also takes GET-triggered mutations
+ * (`GET /{core}/replication?command=disablereplication` really stops
+ * replication). Only the explicitly read-only commands stay "read"; a missing
+ * command falls back to the handler's default details response.
+ */
+function solrReplicationRequestIsMutation(rawPath: string, segments: string[]): boolean {
+ if (segments[segments.length - 1]?.toLowerCase() !== "replication") return false;
+ const query = rawPath.split("?")[1];
+ if (!query) return false;
+ const command = query
+ .split("&")
+ .map((param) => param.split("=", 2))
+ .find(([key]) => key.toLowerCase() === "command")?.[1];
+ if (command === undefined) return false;
+ return !READ_ONLY_REPLICATION_COMMANDS.has(command.toLowerCase());
+}
+
+function classifyParsedRequest({ method, rawPath, endpoint, isUpdatePath, segments }: { method: string; rawPath: string; endpoint: string; isUpdatePath: boolean; segments: string[] }): SolrRequestRisk {
+ // Solr update handlers also answer GET (commit/optimize/stream.body all
+ // mutate), so update paths count as writes for every method, not just POST.
+ if (isUpdatePath) return "write";
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
+ return solrAdminRequestIsMutation(rawPath, segments) || solrReplicationRequestIsMutation(rawPath, segments) ? "dangerous" : "read";
+ }
+ if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
+ return method === "POST" && READ_ONLY_POST_ENDPOINTS.has(endpoint) ? "read" : "dangerous";
+ }
+ return "dangerous";
+}
+
+/**
+ * Classifies a single request: the request line is the first non-comment line,
+ * everything after it is the JSON body. Returns null when the text is not a
+ * REST request.
+ */
+export function classifySolrRequestRisk(request: string): SolrRequestRisk | null {
+ const parsed = parseRequestLine(uncommentedLines(request)[0] ?? "");
+ return parsed ? classifyParsedRequest(parsed) : null;
+}
+
+/**
+ * Classifies every request in the editor text and returns the highest risk, so
+ * a read request followed by a write is still guarded. Like the backend, the
+ * text only counts as REST when its first non-comment line is a request line.
+ */
+export function classifySolrSourceRisk(source: string): SolrRequestRisk | null {
+ const lines = uncommentedLines(source);
+ if (!parseRequestLine(lines[0] ?? "")) return null;
+
+ let highest: SolrRequestRisk = "read";
+ for (const line of lines) {
+ const parsed = parseRequestLine(line);
+ if (!parsed) continue;
+ const risk = classifyParsedRequest(parsed);
+ if (RISK_ORDER[risk] > RISK_ORDER[highest]) highest = risk;
+ }
+ return highest;
+}
+
+/**
+ * Whether a parsed request line targets a mutating path that is not a document
+ * update — used by the dangerous-execution confirmation gate.
+ */
+export function isDangerousSolrRequest(method: string, path: string): boolean {
+ const upperMethod = method.toUpperCase();
+ const pathname = path.split("?", 1)[0].replace(/\/+$/, "");
+ const segments = pathname.split("/").filter(Boolean);
+ if (segments[0]?.toLowerCase() === "solr") segments.shift();
+ const endpoint = segments[segments.length - 1]?.toLowerCase() ?? "";
+ const isUpdatePath = segments.some((segment) => segment.startsWith("update") || segment === "commit");
+ // Document writes are guarded by the write confirmation, not the dangerous
+ // gate — for every method, including GET-driven commits.
+ if (isUpdatePath) return false;
+ if (upperMethod === "GET" || upperMethod === "HEAD" || upperMethod === "OPTIONS") {
+ return solrAdminRequestIsMutation(path, segments) || solrReplicationRequestIsMutation(path, segments);
+ }
+ if (upperMethod === "POST" || upperMethod === "PUT" || upperMethod === "PATCH" || upperMethod === "DELETE") {
+ return !(upperMethod === "POST" && READ_ONLY_POST_ENDPOINTS.has(endpoint));
+ }
+ return true;
+}
+
+/**
+ * Returns each line with its leading comments removed, dropping lines that hold
+ * nothing else. Block comments spanning several lines are skipped as a whole so
+ * a commented-out request line is never classified.
+ */
+function uncommentedLines(source: string): string[] {
+ const lines: string[] = [];
+ let inBlockComment = false;
+
+ for (const rawLine of source.split("\n")) {
+ let offset = 0;
+ while (offset < rawLine.length) {
+ if (inBlockComment) {
+ const close = rawLine.indexOf("*/", offset);
+ if (close < 0) break;
+ inBlockComment = false;
+ offset = close + 2;
+ continue;
+ }
+ while (offset < rawLine.length && /\s/.test(rawLine[offset] ?? "")) offset += 1;
+ if (offset >= rawLine.length) break;
+ if (rawLine.startsWith("/*", offset)) {
+ inBlockComment = true;
+ offset += 2;
+ continue;
+ }
+ if (rawLine[offset] === "#" || rawLine.startsWith("//", offset)) break;
+ lines.push(rawLine.slice(offset));
+ break;
+ }
+ }
+
+ return lines;
+}
diff --git a/apps/desktop/src/lib/sql/semantic/diagnostics.ts b/apps/desktop/src/lib/sql/semantic/diagnostics.ts
index bb9297ade7..a44aad2582 100644
--- a/apps/desktop/src/lib/sql/semantic/diagnostics.ts
+++ b/apps/desktop/src/lib/sql/semantic/diagnostics.ts
@@ -461,6 +461,7 @@ export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, opt
options.databaseType === "elasticsearch" ||
options.databaseType === "easysearch" ||
options.databaseType === "meilisearch" ||
+ options.databaseType === "solr" ||
options.databaseType === "qdrant" ||
options.databaseType === "milvus" ||
options.databaseType === "weaviate" ||
@@ -481,6 +482,7 @@ export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number,
options.databaseType === "elasticsearch" ||
options.databaseType === "easysearch" ||
options.databaseType === "meilisearch" ||
+ options.databaseType === "solr" ||
options.databaseType === "qdrant" ||
options.databaseType === "milvus" ||
options.databaseType === "weaviate" ||
diff --git a/apps/desktop/src/lib/sql/sqlRisk.ts b/apps/desktop/src/lib/sql/sqlRisk.ts
index 20d8f74355..930fc402e7 100644
--- a/apps/desktop/src/lib/sql/sqlRisk.ts
+++ b/apps/desktop/src/lib/sql/sqlRisk.ts
@@ -1,6 +1,7 @@
import { classifyElasticsearchRequestRisk, classifyElasticsearchSourceRisk, type ElasticsearchRequestRisk } from "@/lib/elasticsearch/elasticsearchRequestRisk";
+import { classifySolrRequestRisk, classifySolrSourceRisk } from "@/lib/solr/solrRequestRisk";
import { mongoAggregateWriteStage, splitMongoCommandRanges, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
-import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
@@ -54,7 +55,7 @@ export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlR
// REST requests carry a JSON body that must not be split on semicolons, and
// every request in the text is classified so the highest risk wins.
- const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchSourceRisk);
+ const searchEngineRisk = searchEngineAssessment(sql, options.dialect, { elasticsearch: classifyElasticsearchSourceRisk, solr: classifySolrSourceRisk });
if (searchEngineRisk) return { ...searchEngineRisk, statements: [searchEngineRisk] };
const statements = splitSqlStatementsForSafety(sql).map((statement) => classifySqlStatementRisk(statement, options));
@@ -69,7 +70,7 @@ export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions =
const dynamodbRisk = classifyDynamoDbStatementRisk(sql, options.dialect);
if (dynamodbRisk) return dynamodbRisk;
- const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchRequestRisk);
+ const searchEngineRisk = searchEngineAssessment(sql, options.dialect, { elasticsearch: classifyElasticsearchRequestRisk, solr: classifySolrRequestRisk });
if (searchEngineRisk) return searchEngineRisk;
return classifyTokens(tokenizeSqlForRisk(sql));
}
@@ -85,9 +86,11 @@ export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions =
* is a schema change, ...), which a request path does not carry. Reporting
* `rest` keeps REST mutations as opaque as they were before this branch existed.
*/
-function searchEngineAssessment(sql: string, dialect: DatabaseType | string | undefined, classify: (value: string) => ElasticsearchRequestRisk | null): SqlRiskStatementAssessment | null {
- if (!isElasticsearchCompatibleDatabaseType(dialect as DatabaseType | undefined)) return null;
- const risk = classify(sql);
+function searchEngineAssessment(sql: string, dialect: DatabaseType | string | undefined, classify: { elasticsearch: (value: string) => ElasticsearchRequestRisk | null; solr: (value: string) => ElasticsearchRequestRisk | null }): SqlRiskStatementAssessment | null {
+ const databaseType = dialect as DatabaseType | undefined;
+ const classifier = isSolrDatabaseType(databaseType) ? classify.solr : isElasticsearchCompatibleDatabaseType(databaseType) ? classify.elasticsearch : null;
+ if (!classifier) return null;
+ const risk = classifier(sql);
if (!risk) return null;
return { risk: risk === "read" ? "read" : risk === "write" ? "write" : "ddl", firstKeyword: "rest" };
}
diff --git a/apps/desktop/src/lib/sql/sqlStatementRanges.ts b/apps/desktop/src/lib/sql/sqlStatementRanges.ts
index 74995760a9..0c3692a113 100644
--- a/apps/desktop/src/lib/sql/sqlStatementRanges.ts
+++ b/apps/desktop/src/lib/sql/sqlStatementRanges.ts
@@ -3,7 +3,7 @@ import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDe
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { isRedisCommentLine } from "@/lib/redis/redisCommandTokenizer";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
-import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
+import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
@@ -18,7 +18,7 @@ export interface SqlTextRange {
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
function isHttpJsonRestDatabaseType(databaseType?: DatabaseType): boolean {
- return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType);
+ return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType) || isSolrDatabaseType(databaseType);
}
export function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
@@ -27,7 +27,7 @@ export function elasticsearchRestRequestRanges(sql: string, databaseType?: Datab
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
}
-const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
+const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
@@ -270,6 +270,7 @@ const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial {
elasticsearch: "Elasticsearch",
easysearch: "Easysearch",
meilisearch: "Meilisearch",
+ solr: "Apache Solr",
qdrant: "Qdrant",
milvus: "Milvus",
weaviate: "Weaviate",
@@ -4165,8 +4166,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadMongoDatabases(connectionId);
} else if (config.db_type === "dynamodb") {
await loadDynamoDbTables(connectionId);
- } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
- // Reload: list indices.
+ } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr") {
+ // Reload: list indices/cores.
await loadElasticsearchIndices(connectionId);
} else if (config.db_type === "milvus") {
await loadMilvusDatabases(connectionId);
@@ -5041,7 +5042,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
- if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
+ if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
@@ -5464,9 +5465,10 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const isMeilisearch = getConfig(connectionId)?.db_type === "meilisearch";
+ const isSolr = getConfig(connectionId)?.db_type === "solr";
const collections = isMeilisearch
? sortSidebarNames(await withMetadataLoadTimeout(connectionId, api.meilisearchListIndexes(connectionId), "Meilisearch indexes")).map((name) => ({ name, aliases: [] as string[] }))
- : [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
+ : [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), isSolr ? "Solr cores" : "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
const indexNodes = collections.map((collection) => {
const aliases = collection.aliases?.filter((alias) => alias.trim());
return {
@@ -7214,7 +7216,7 @@ export const useConnectionStore = defineStore("connection", () => {
await loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await loadDynamoDbTables(node.connectionId);
- } else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
+ } else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
await loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
await loadMilvusDatabases(node.connectionId);
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index 34f4bde198..0ccda52b8c 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -3278,6 +3278,29 @@ export const useQueryStore = defineStore("query", () => {
return registerOpenTab(tab);
}
+ function openSolrAdmin(connectionId: string) {
+ const existing = tabs.value.find((tab) => tab.mode === "solr-admin" && tab.connectionId === connectionId);
+ if (existing) {
+ switchTab(existing.id);
+ return existing.id;
+ }
+
+ const conn = useConnectionStore().getConfig(connectionId);
+ const id = uuid();
+ const tab: QueryTab = {
+ id,
+ title: conn?.name ? `${conn.name} - ${t("solrAdmin.title")}` : t("solrAdmin.title"),
+ connectionId,
+ database: "",
+ sql: "",
+ isExecuting: false,
+ isCancelling: false,
+ isExplaining: false,
+ mode: "solr-admin",
+ };
+ return registerOpenTab(tab);
+ }
+
function openDamengJobAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId);
if (existing) {
@@ -8997,6 +9020,7 @@ export const useQueryStore = defineStore("query", () => {
openPostgresDashboard,
openXuguDashboard,
openNacosDashboard,
+ openSolrAdmin,
openDamengUsers,
openDamengRoles,
openDamengJobAdmin,
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index 4cce4f6bb2..b664d4fe19 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -13,6 +13,10 @@ export function isMeilisearchDatabaseType(dbType?: DatabaseType): boolean {
return dbType === "meilisearch";
}
+export function isSolrDatabaseType(dbType?: DatabaseType): boolean {
+ return dbType === "solr";
+}
+
export interface SqlSnippet {
id: string;
label: string;
@@ -1796,6 +1800,7 @@ export interface QueryTab {
| "mysql-dashboard"
| "postgres-dashboard"
| "xugu-dashboard"
+ | "solr-admin"
| "dolt-version-control"
| "plugin-workbench"
| "plugin-filesystem";
diff --git a/apps/desktop/src/types/generated/connectionProfiles.ts b/apps/desktop/src/types/generated/connectionProfiles.ts
index 3842dbd283..2c73d1ca17 100644
--- a/apps/desktop/src/types/generated/connectionProfiles.ts
+++ b/apps/desktop/src/types/generated/connectionProfiles.ts
@@ -39,6 +39,7 @@ export const CONNECTION_PROFILES = {
oracle: { type: "oracle", port: 1521, user: "system", label: "Oracle", icon: "oracle" },
elasticsearch: { type: "elasticsearch", port: 9200, user: "", label: "Elasticsearch", icon: "elasticsearch" },
easysearch: { type: "easysearch", port: 9200, user: "", label: "Easysearch", icon: "easysearch" },
+ solr: { type: "solr", port: 8983, user: "", label: "Apache Solr", icon: "solr" },
meilisearch: { type: "meilisearch", port: 7700, user: "", label: "Meilisearch", icon: "meilisearch" },
hbase: { type: "hbase", port: 8080, user: "", label: "Apache HBase", icon: "hbase" },
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
@@ -146,6 +147,7 @@ export const CONNECTION_PROFILE_ICONS = {
oracle: "oracle",
elasticsearch: "elasticsearch",
easysearch: "easysearch",
+ solr: "solr",
meilisearch: "meilisearch",
hbase: "hbase",
qdrant: "qdrant",
@@ -252,6 +254,7 @@ export const CONNECTION_PICKER_OPTIONS = [
{ value: "oracle", label: "Oracle", category: "sql" },
{ value: "elasticsearch", label: "Elasticsearch", category: "document" },
{ value: "easysearch", label: "Easysearch", category: "document" },
+ { value: "solr", label: "Apache Solr", category: "document" },
{ value: "meilisearch", label: "Meilisearch", category: "document" },
{ value: "hbase", label: "Apache HBase", category: "document" },
{ value: "qdrant", label: "Qdrant", category: "graph_ai" },
diff --git a/apps/desktop/src/types/generated/databaseTypes.ts b/apps/desktop/src/types/generated/databaseTypes.ts
index 445c65551d..170e1839f7 100644
--- a/apps/desktop/src/types/generated/databaseTypes.ts
+++ b/apps/desktop/src/types/generated/databaseTypes.ts
@@ -16,6 +16,7 @@ export const DATABASE_TYPES = [
"elasticsearch",
"easysearch",
"meilisearch",
+ "solr",
"hbase",
"qdrant",
"chromadb",
diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json
index 35b8776ce3..f25462b452 100644
--- a/crates/dbx-core/assets/database-drivers.manifest.json
+++ b/crates/dbx-core/assets/database-drivers.manifest.json
@@ -567,6 +567,35 @@
"driverManagement": false
}
},
+ {
+ "dbType": "solr",
+ "label": "Apache Solr",
+ "runtimeMode": "native",
+ "mcpMode": "bridge",
+ "singleConnectionPool": false,
+ "metadataConnectionScoped": false,
+ "skipTcpProbe": false,
+ "defaultPort": 8983,
+ "supportLevel": "browse",
+ "capabilities": {
+ "queryExecution": true,
+ "metadataBrowse": true,
+ "objectBrowser": false,
+ "objectSource": false,
+ "schemaSearch": false,
+ "diagram": false,
+ "tableDataEdit": false,
+ "tableStructureEdit": false,
+ "tableImport": false,
+ "dataTransfer": false,
+ "sqlFileExecution": false,
+ "databaseCreate": false,
+ "fieldLineage": false,
+ "sqlExplain": false,
+ "userAdmin": false,
+ "driverManagement": false
+ }
+ },
{
"dbType": "hbase",
"label": "Apache HBase",
diff --git a/crates/dbx-core/src/ai/agent_tools.rs b/crates/dbx-core/src/ai/agent_tools.rs
index 03483d1990..0661e32342 100644
--- a/crates/dbx-core/src/ai/agent_tools.rs
+++ b/crates/dbx-core/src/ai/agent_tools.rs
@@ -358,6 +358,11 @@ pub fn all_tools(db_type: DatabaseType, sql_permissions: AgentSqlPermissions) ->
let mut tools = vec![list_databases_tool(), list_tables_tool(), get_columns_tool(db_type), get_current_time_tool()];
if db_type == DatabaseType::MongoDb {
tools.push(mongo_execute_query_tool(sql_permissions));
+ } else if db_type == DatabaseType::Solr {
+ // Solr has no SQL surface; the agent drives the same `METHOD /path`
+ // REST requests the query console accepts. Writes (`/{core}/update`)
+ // still flow through the shared risk classifier and confirmation gate.
+ tools.push(solr_execute_query_tool(sql_permissions));
} else if supports_sql_query(db_type) {
tools.push(execute_query_tool(sql_permissions));
tools.push(get_sample_data_tool());
@@ -401,6 +406,58 @@ fn mongo_execute_query_tool(_sql_permissions: AgentSqlPermissions) -> ToolDefini
}
}
+/// Solr `execute_query` tool — the `sql` argument carries a DBX REST-console
+/// request (`METHOD /path` plus an optional JSON body), not SQL.
+fn solr_execute_query_tool(sql_permissions: AgentSqlPermissions) -> ToolDefinition {
+ let description = if sql_permissions.allow_dangerous {
+ "Execute a Solr REST request after the user explicitly confirmed this operation. Read, write, and admin requests are allowed for this run."
+ } else if sql_permissions.allow_writes {
+ "Execute a Solr REST request after the user explicitly confirmed this operation. Read requests and document writes are allowed for this run."
+ } else {
+ "Execute a read-only Solr REST request and return results (default 50 rows, up to 1000 with the limit argument). \
+ The request uses the DBX REST-console format: `METHOD /path` on the first line, then an optional JSON body. \
+ Examples: `GET /{core}/select?q=*:*&rows=20`, `GET /{core}/schema/fields`, `POST /{core}/query` with a JSON body \
+ such as {\"query\":\"name:foo\",\"limit\":10}. The leading /solr segment may be omitted — paths are resolved \
+ against the Solr server root. This run cannot execute writes (`/{core}/update`) or admin/schema requests \
+ because no specific request has been confirmed yet; when the user requests a write, first propose the exact \
+ request in one ```sql code block and ask for confirmation."
+ };
+ ToolDefinition {
+ name: "execute_query",
+ description,
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "sql": {
+ "type": "string",
+ "description": "The Solr REST request to execute (`METHOD /path` plus optional JSON body)"
+ },
+ "limit": {
+ "type": "number",
+ "minimum": 1,
+ "maximum": MAX_EXECUTE_QUERY_ROWS,
+ "description": format!("Max rows to return (default {EXECUTE_QUERY_LIMIT}, max {MAX_EXECUTE_QUERY_ROWS})")
+ },
+ "cell_char_offset": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 1000000,
+ "description": "Start character offset for every string cell (default 0). Use the next offset reported by a truncated result to slide through long values. Narrow the request to the target document and field before expanding."
+ },
+ "cell_char_limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 4000,
+ "description": "Maximum characters returned per string cell (default 200, max 4000). Increase only for an explicit long-value expansion."
+ }
+ },
+ "required": ["sql"]
+ }),
+ read_only: true,
+ parallel_ok: false,
+ }
+}
+
/// list_tables tool definition.
fn list_tables_tool() -> ToolDefinition {
ToolDefinition {
@@ -1657,6 +1714,34 @@ for line in sys.stdin:
assert!(names.contains(&"get_current_time"));
}
+ #[test]
+ fn solr_agent_registers_rest_execute_query_tool() {
+ let tools = all_tools(DatabaseType::Solr, AgentSqlPermissions::default());
+ let names = tools.iter().map(|tool| tool.name).collect::>();
+ // Solr gets the shared metadata tools plus a REST `execute_query`; SQL
+ // sample/explain helpers are not meaningful for a non-SQL backend.
+ assert!(names.contains(&"list_tables"));
+ assert!(names.contains(&"get_columns"));
+ assert!(names.contains(&"execute_query"));
+ assert!(!names.contains(&"get_sample_data"));
+ assert!(!names.contains(&"explain_query"));
+
+ let execute_query = tools.iter().find(|tool| tool.name == "execute_query").unwrap();
+ assert!(execute_query.description.contains("METHOD /path"));
+ assert!(!execute_query.description.contains("SQL query to execute"));
+
+ let confirmed_tools = all_tools(
+ DatabaseType::Solr,
+ AgentSqlPermissions {
+ allow_writes: true,
+ allow_dangerous: false,
+ confirmed_write_sql: Some("POST /mycore/update\n{\"add\":{\"doc\":{\"id\":\"1\"}}}".to_string()),
+ },
+ );
+ let confirmed_execute_query = confirmed_tools.iter().find(|tool| tool.name == "execute_query").unwrap();
+ assert!(confirmed_execute_query.description.contains("confirmed"));
+ }
+
#[cfg(unix)]
#[tokio::test]
async fn mongodb_agent_registers_shell_query_tool_and_routes_find_one_as_read_only() {
diff --git a/crates/dbx-core/src/connection/mod.rs b/crates/dbx-core/src/connection/mod.rs
index 9005c7dc34..db67f4b661 100644
--- a/crates/dbx-core/src/connection/mod.rs
+++ b/crates/dbx-core/src/connection/mod.rs
@@ -110,6 +110,7 @@ pub enum PoolKind {
SqlServer(Arc>),
Elasticsearch(db::elasticsearch_driver::EsClient),
Easysearch(db::easysearch_driver::EasysearchClient),
+ Solr(db::solr_driver::SolrClient),
Meilisearch(db::meilisearch_driver::MeilisearchClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
@@ -2742,6 +2743,22 @@ impl AppState {
db::easysearch_driver::test_connection(&mut client, connect_timeout).await?;
PoolKind::Easysearch(client)
}
+ DatabaseType::Solr => {
+ let mut client = db::solr_driver::SolrClient::from_config(
+ &url,
+ Some(&db_config.username),
+ Some(&db_config.password),
+ db_config.ssl,
+ db_config.url_params.as_deref(),
+ db_config.external_config.as_ref(),
+ connect_timeout,
+ Some(db_config.ca_cert_path.as_str()),
+ Some(db_config.client_cert_path.as_str()),
+ Some(db_config.client_key_path.as_str()),
+ )?;
+ db::solr_driver::test_connection(&mut client, connect_timeout).await?;
+ PoolKind::Solr(client)
+ }
DatabaseType::Meilisearch => {
let client = db::meilisearch_driver::MeilisearchClient::new_for_config(
&url,
@@ -4084,6 +4101,17 @@ impl AppState {
}
}
}
+ PoolKind::Solr(client) => {
+ let mut client = client.clone();
+ let timeout = crate::db::connection_timeout();
+ match db::solr_driver::test_connection(&mut client, timeout).await {
+ Ok(()) => false,
+ Err(err) => {
+ log::warn!("Solr connection pool '{pool_key}' is stale: {err}");
+ true
+ }
+ }
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
let timeout = crate::db::connection_timeout();
@@ -5198,6 +5226,16 @@ impl AppState {
}
}
}
+ PoolKind::Solr(client) => {
+ let mut client = client.clone();
+ match db::solr_driver::test_connection(&mut client, timeout).await {
+ Ok(()) => true,
+ Err(e) => {
+ log::warn!("Solr connection pool '{key}' is unhealthy: {e}");
+ false
+ }
+ }
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
match db::meilisearch_driver::test_connection(&client, timeout).await {
@@ -5599,6 +5637,7 @@ enum KeepaliveTarget {
SqlServer(Arc>),
Elasticsearch(db::elasticsearch_driver::EsClient),
Easysearch(db::easysearch_driver::EasysearchClient),
+ Solr(db::solr_driver::SolrClient),
HBase(db::hbase_driver::HBaseClient),
VectorDb(db::vector_driver::VectorClient),
InfluxDb(db::influxdb_driver::InfluxdbClient),
@@ -5700,6 +5739,7 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
PoolKind::SqlServer(client) => Some(KeepaliveTarget::SqlServer(client.clone())),
PoolKind::Elasticsearch(client) => Some(KeepaliveTarget::Elasticsearch(client.clone())),
PoolKind::Easysearch(client) => Some(KeepaliveTarget::Easysearch(client.clone())),
+ PoolKind::Solr(client) => Some(KeepaliveTarget::Solr(client.clone())),
PoolKind::HBase(client) => Some(KeepaliveTarget::HBase(client.clone())),
PoolKind::VectorDb(client) => Some(KeepaliveTarget::VectorDb(client.clone())),
PoolKind::InfluxDb(client) => Some(KeepaliveTarget::InfluxDb(client.clone())),
@@ -5742,6 +5782,7 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
KeepaliveTarget::Easysearch(client) => {
db::easysearch_driver::test_connection(client, timeout).await.map_err(Into::into)
}
+ KeepaliveTarget::Solr(client) => db::solr_driver::test_connection(client, timeout).await.map_err(Into::into),
KeepaliveTarget::HBase(client) => {
db::hbase_driver::test_connection(client, timeout).await.map(|_| ()).map_err(Into::into)
}
@@ -6162,6 +6203,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
+ PoolKind::Solr(client) => PoolKind::Solr(client.clone()),
PoolKind::Meilisearch(client) => PoolKind::Meilisearch(client.clone()),
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
@@ -6221,6 +6263,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
PoolKind::Easysearch(client) => {
drop(client);
}
+ PoolKind::Solr(client) => {
+ drop(client);
+ }
PoolKind::Meilisearch(client) => {
drop(client);
}
@@ -6319,6 +6364,7 @@ fn base_pool_key_for_with_catalog(
db_type,
DatabaseType::Elasticsearch
| DatabaseType::Easysearch
+ | DatabaseType::Solr
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
diff --git a/crates/dbx-core/src/query/document_ops.rs b/crates/dbx-core/src/query/document_ops.rs
index f5db665ceb..81050a02b7 100644
--- a/crates/dbx-core/src/query/document_ops.rs
+++ b/crates/dbx-core/src/query/document_ops.rs
@@ -1,7 +1,7 @@
use crate::connection::{AppState, PoolKind};
use crate::db::agent_driver::mongo_document_id_params;
use crate::db::document_result::DocumentQueryResult;
-use crate::db::{dynamodb_driver, easysearch_driver, elasticsearch_driver, mongo_driver, vector_driver};
+use crate::db::{dynamodb_driver, easysearch_driver, elasticsearch_driver, mongo_driver, solr_driver, vector_driver};
pub use crate::db::vector_driver::CollectionInfo;
@@ -61,6 +61,7 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
PoolKind::DynamoDb(client) => Ok(vec![client.region.clone()]),
PoolKind::Elasticsearch(_) => Ok(vec!["default".to_string()]),
PoolKind::Easysearch(_) => Ok(vec!["default".to_string()]),
+ PoolKind::Solr(_) => Ok(vec!["default".to_string()]),
PoolKind::Meilisearch(_) => Ok(vec!["default".to_string()]),
PoolKind::VectorDb(client) => vector_driver::list_databases(client).await,
PoolKind::Agent(client) => {
@@ -270,6 +271,18 @@ pub async fn list_collections_core(
PoolKind::Easysearch(client) => {
Ok(elasticsearch_collection_infos(easysearch_driver::list_indices_with_aliases(client).await?))
}
+ PoolKind::Solr(client) => {
+ let names = sort_names(solr_driver::list_cores(client).await?);
+ Ok(names
+ .into_iter()
+ .map(|n| CollectionInfo {
+ name: n.clone(),
+ id: n,
+ kind: Some("core".to_string()),
+ ..Default::default()
+ })
+ .collect())
+ }
PoolKind::Meilisearch(client) => {
let names = sort_names(crate::db::meilisearch_driver::list_indexes(client).await?);
Ok(names.into_iter().map(|n| CollectionInfo { name: n.clone(), id: n, ..Default::default() }).collect())
@@ -459,6 +472,15 @@ pub async fn find_documents_core(
easysearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
}
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ let _ = projection;
+ if cursor_pagination {
+ solr_driver::find_documents_with_cursor(&client, collection, limit, filter, sort, cursor).await
+ } else {
+ solr_driver::find_documents(&client, collection, skip, limit, filter, sort).await
+ }
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
crate::db::meilisearch_driver::find_documents(&client, collection, skip, limit, filter, sort).await
@@ -517,6 +539,10 @@ pub async fn count_document_store_documents_core(
let client = client.clone();
easysearch_driver::count_documents(&client, collection, filter).await
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ solr_driver::count_documents(&client, collection, filter).await
+ }
_ => Err("Document count is not supported for this connection".to_string()),
}
}
@@ -643,6 +669,11 @@ pub async fn insert_document_core(
let client = client.clone();
easysearch_driver::insert_document(&client, collection, doc_json, routing).await
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ let _ = routing;
+ solr_driver::insert_document(&client, collection, doc_json).await
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
crate::db::meilisearch_driver::insert_document(&client, collection, doc_json).await
@@ -707,6 +738,11 @@ pub async fn update_document_core(
let client = client.clone();
easysearch_driver::update_document(&client, collection, id, doc_json, routing).await
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ let _ = routing;
+ solr_driver::update_document(&client, collection, id, doc_json).await
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
crate::db::meilisearch_driver::update_document(&client, collection, id, doc_json).await
@@ -765,6 +801,11 @@ pub async fn delete_document_core_with_type(
let client = client.clone();
easysearch_driver::delete_document(&client, collection, id, document_type, routing).await
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ let _ = (document_type, routing);
+ solr_driver::delete_document(&client, collection, id).await
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
crate::db::meilisearch_driver::delete_document(&client, collection, id).await
diff --git a/crates/dbx-core/src/query/mod.rs b/crates/dbx-core/src/query/mod.rs
index 50c6848777..157905d134 100644
--- a/crates/dbx-core/src/query/mod.rs
+++ b/crates/dbx-core/src/query/mod.rs
@@ -1963,6 +1963,20 @@ async fn do_execute_typed(
}
result
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ let sql = sql.to_string();
+ let max_rows = options.max_rows;
+ // cursorMark 分页只服务文档浏览器;REST 查询是一次性请求,不需要 session 游标。
+ let result =
+ wait_for_query_opt(cancel_token, query_timeout, db::solr_driver::execute_rest_query(&client, &sql))
+ .await
+ .map(|result| truncate_result_with_max_rows(result, max_rows));
+ if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(pool_db_type, err)) {
+ state.remove_pool_by_key(pool_key).await;
+ }
+ result
+ }
PoolKind::Meilisearch(client) => {
let client = client.clone();
let sql = sql.to_string();
@@ -2926,6 +2940,11 @@ pub async fn close_query_session(
db::easysearch_driver::close_cursor(&client, session_id).await?;
Ok(true)
}
+ PoolKind::Solr(client) => {
+ let client = client.clone();
+ db::solr_driver::close_cursor(&client, session_id).await?;
+ Ok(true)
+ }
_ => Ok(false),
}
}
@@ -4301,6 +4320,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::DynamoDb(_)
| PoolKind::Elasticsearch(_)
| PoolKind::Easysearch(_)
+ | PoolKind::Solr(_)
| PoolKind::Meilisearch(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
@@ -4704,6 +4724,7 @@ fn batch_transaction_path(pool: &PoolKind) -> BatchTransactionPath {
| PoolKind::CloudflareD1(_)
| PoolKind::Elasticsearch(_)
| PoolKind::Easysearch(_)
+ | PoolKind::Solr(_)
| PoolKind::Meilisearch(_)
| PoolKind::VectorDb(_)
| PoolKind::InfluxDb(_)
diff --git a/crates/dbx-core/src/schema/providers/native.rs b/crates/dbx-core/src/schema/providers/native.rs
index ee8a627670..eb576e05b1 100644
--- a/crates/dbx-core/src/schema/providers/native.rs
+++ b/crates/dbx-core/src/schema/providers/native.rs
@@ -64,6 +64,9 @@ pub(in crate::schema) async fn list_tables(
PoolKind::Easysearch(client) => {
db::easysearch_driver::list_indices(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
}
+ PoolKind::Solr(client) => {
+ db::solr_driver::list_cores(client).await.map(|names| collection_names_to_tables(names, "CORE"))
+ }
PoolKind::Meilisearch(client) => {
db::meilisearch_driver::list_indexes(client).await.map(|names| collection_names_to_tables(names, "INDEX"))
}
@@ -158,6 +161,7 @@ pub(in crate::schema) async fn get_columns(
PoolKind::Turso(client) => db::turso_driver::get_columns(client, schema, table).await,
PoolKind::Elasticsearch(client) => db::elasticsearch_driver::get_columns(client, table).await,
PoolKind::Easysearch(client) => db::easysearch_driver::get_columns(client, table).await,
+ PoolKind::Solr(client) => db::solr_driver::get_columns(client, table).await,
PoolKind::Meilisearch(client) => db::meilisearch_driver::get_columns(client, table).await,
PoolKind::HBase(client) => db::hbase_driver::get_columns(client, database, table).await,
PoolKind::VectorDb(_) => Ok(vec![]),
diff --git a/crates/dbx-drivers/src/db/mod.rs b/crates/dbx-drivers/src/db/mod.rs
index 7bb5c604b4..e9ed1e3f21 100644
--- a/crates/dbx-drivers/src/db/mod.rs
+++ b/crates/dbx-drivers/src/db/mod.rs
@@ -39,6 +39,7 @@ pub mod proxy_tunnel;
pub mod questdb;
pub mod redis_driver;
pub mod rqlite_driver;
+pub mod solr_driver;
pub mod sqlite;
pub mod sqlite_worker;
pub mod sqlserver;
diff --git a/crates/dbx-drivers/src/db/solr_driver.rs b/crates/dbx-drivers/src/db/solr_driver.rs
new file mode 100644
index 0000000000..7e953922b2
--- /dev/null
+++ b/crates/dbx-drivers/src/db/solr_driver.rs
@@ -0,0 +1,1143 @@
+use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+use reqwest::{Client as HttpClient, Method};
+use serde::Deserialize;
+use serde_json::Value;
+use std::collections::{BTreeSet, HashMap, HashSet};
+use std::error::Error;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use super::{apply_tls_certificates, http_client_builder, with_connection_timeout};
+use crate::db::document_result::DocumentQueryResult;
+use crate::types::QueryResult;
+
+const SOLR_PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
+ .add(b' ')
+ .add(b'"')
+ .add(b'#')
+ .add(b'%')
+ .add(b'/')
+ .add(b'<')
+ .add(b'>')
+ .add(b'?')
+ .add(b'[')
+ .add(b'\\')
+ .add(b']')
+ .add(b'^')
+ .add(b'`')
+ .add(b'{')
+ .add(b'|')
+ .add(b'}');
+
+const SOLR_REST_TABLE_MAX_BODY_BYTES: usize = 8 * 1024 * 1024;
+const SOLR_REST_TABLE_MAX_ROWS: usize = 2_000;
+const SOLR_REST_TABLE_MAX_CELLS: usize = 200_000;
+/// `get_columns` 在无显式 schema 字段(schemaless core 只有 dynamicFields)时
+/// 抽样文档数量,用于推断列。
+const SOLR_SCHEMA_SAMPLE_LIMIT: usize = 50;
+
+#[derive(Clone)]
+pub struct SolrClient {
+ http: HttpClient,
+ /// Base URL including the Solr context path, e.g. `http://host:8983/solr`.
+ base_url: String,
+ auth: Option<(String, String)>,
+ /// `/{core}/schema/uniquekey` 结果缓存:文档的 `_id` 别名、cursorMark 排序
+ /// 兜底字段和文档更新都要靠它定位唯一键。
+ unique_keys: Arc>>>,
+}
+
+impl SolrClient {
+ #[allow(clippy::too_many_arguments)]
+ pub fn from_config(
+ url: &str,
+ username: Option<&str>,
+ password: Option<&str>,
+ tls_enabled: bool,
+ url_params: Option<&str>,
+ _external_config: Option<&Value>,
+ timeout: Duration,
+ ca_cert_path: Option<&str>,
+ client_cert_path: Option<&str>,
+ client_key_path: Option<&str>,
+ ) -> Result {
+ let base_url = normalize_solr_base_url(url);
+ let auth = match (username, password) {
+ (Some(u), Some(p)) if !u.is_empty() => Some((u.to_string(), p.to_string())),
+ _ => None,
+ };
+ let mut builder = http_client_builder(timeout)
+ .danger_accept_invalid_certs(solr_accept_invalid_certs(tls_enabled, url_params));
+ builder = apply_tls_certificates(builder, ca_cert_path, client_cert_path, client_key_path)?;
+ let http = builder.build().map_err(|e| format!("Failed to initialize Solr HTTP client: {e}"))?;
+ Ok(Self { http, base_url, auth, unique_keys: Arc::new(Mutex::new(HashMap::new())) })
+ }
+
+ fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
+ let req = self.http.request(method, format!("{}{}", self.base_url, path));
+ if let Some((ref user, ref pass)) = self.auth {
+ req.basic_auth(user, Some(pass))
+ } else {
+ req
+ }
+ }
+
+ fn get(&self, path: &str) -> reqwest::RequestBuilder {
+ self.request(Method::GET, path)
+ }
+
+ fn post(&self, path: &str) -> reqwest::RequestBuilder {
+ self.request(Method::POST, path)
+ }
+
+ fn core_path(&self, core: &str, endpoint: &str) -> String {
+ format!("/{}/{}", solr_path_segment(core), endpoint.trim_start_matches('/'))
+ }
+
+ /// `/schema/uniquekey` 返回 core 的唯一键字段名(通常 `id`)。结果按连接缓存:
+ /// 每个分页请求都会用到它,逐页重查浪费一次 HTTP 往返。
+ async fn unique_key(&self, core: &str) -> Option {
+ if let Some(cached) = self.unique_keys.lock().ok().and_then(|keys| keys.get(core).cloned()) {
+ return cached;
+ }
+ let resolved = self.fetch_unique_key(core).await.unwrap_or(None);
+ if let Ok(mut keys) = self.unique_keys.lock() {
+ keys.insert(core.to_string(), resolved.clone());
+ }
+ resolved
+ }
+
+ async fn fetch_unique_key(&self, core: &str) -> Result