feat(solr): add Apache Solr connection support

This commit is contained in:
Terry Yao authored and GitHub committed 2026-09-21 18:07:44 +08:00
1 parent 7b407ccbfc
commit 3687a9cbfb
75 files changed
+5596 -82

No files matched your search

@@ -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",
+85
View File
@@ -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::<Vec<_>>();
// 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() {
+46
View File
@@ -110,6 +110,7 @@ pub enum PoolKind {
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
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<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
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
+42 -1
View File
@@ -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
+21
View File
@@ -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(_)
@@ -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![]),
+1
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+23
View File
@@ -2663,9 +2663,32 @@ mod tests {
fn parses_database_type_using_dbx_protocol_names() {
assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres);
assert_eq!(parse_database_type("mongodb").unwrap(), DatabaseType::MongoDb);
assert_eq!(parse_database_type("Solr").unwrap(), DatabaseType::Solr);
assert_eq!(parse_database_type("solr").unwrap(), DatabaseType::Solr);
assert!(parse_database_type("unknown").is_err());
}
#[test]
fn new_connection_config_accepts_solr() {
// Solr connections are created through the generic descriptor path; the
// REST core lives in the query text, not a database field.
let connection = new_connection_config(
"solr".to_string(),
"solr".to_string(),
DatabaseType::Solr,
"localhost".to_string(),
8983,
String::new(),
String::new(),
None,
false,
None,
)
.unwrap();
assert_eq!(connection.db_type, DatabaseType::Solr);
assert_eq!(connection.port, 8983);
}
#[test]
fn native_mysql_transaction_gate_accepts_the_builtin_mysql_profile_only() {
let mut connection: ConnectionConfig = serde_json::from_value(json!({
+14
View File
@@ -6127,6 +6127,20 @@ mod tests {
assert!(!sql_requires_batch_execution("SELECT 1", DatabaseType::Mysql));
}
#[test]
fn solr_rest_requests_stay_on_the_single_request_path() {
// Solr speaks the DBX REST-console format (`METHOD /path` + optional
// JSON body), not SQL. A single request must not be routed through the
// `;`-splitting batch executor.
assert!(!sql_requires_batch_execution("GET /mycore/select?q=*:*&rows=20", DatabaseType::Solr));
assert!(!sql_requires_batch_execution(
"POST /mycore/update\n{\"add\":{\"doc\":{\"id\":\"1\",\"title\":\"x\"}}}",
DatabaseType::Solr
));
// A REST request is never a forbidden database switch.
assert!(!mcp_sql_has_forbidden_database_switch("GET /mycore/select?q=*:*", DatabaseType::Solr));
}
#[test]
fn format_batch_results_renders_failed_statement() {
let results = vec![crate::backend::BatchStatementResult {
+191
View File
@@ -200,6 +200,7 @@ pub fn supports_sql_query(database_type: DatabaseType) -> bool {
| DatabaseType::MongoDb
| DatabaseType::Elasticsearch
| DatabaseType::Easysearch
| DatabaseType::Solr
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
@@ -334,6 +335,9 @@ pub enum SearchEngineQueryRisk {
}
pub fn classify_search_engine_query_risk(source: &str, database_type: DatabaseType) -> Option<SearchEngineQueryRisk> {
if database_type == DatabaseType::Solr {
return classify_solr_query_risk(source);
}
if !matches!(database_type, DatabaseType::Elasticsearch | DatabaseType::Easysearch) {
return None;
}
@@ -382,6 +386,113 @@ pub fn classify_search_engine_query_risk(source: &str, database_type: DatabaseTy
}
}
/// Solr REST 写请求集中在 `/{core}/update`(含 JSON/XML/CSV update handlers);
/// 只读 POST handler 名单之外的写路径(`/admin/*`、`/schema`、`/config`、
/// collection/core 管理)一律按 Dangerous 处理。
fn classify_solr_query_risk(source: &str) -> Option<SearchEngineQueryRisk> {
const READ_ONLY_POST_ENDPOINTS: &[&str] = &[
"select",
"query",
"get",
"export",
"terms",
"suggest",
"spell",
"mlt",
"sql",
"graph",
"clustering",
"tvrh",
"luke",
"elevate",
"browse",
"debug",
];
let source = strip_leading_search_engine_comments(source);
let request_line = source.lines().next()?.trim();
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let raw_path = parts.next()?;
let path = raw_path.split('?').next().unwrap_or(raw_path).trim_end_matches('/');
let mut segments: Vec<&str> = path.split('/').filter(|segment| !segment.is_empty()).collect();
if segments.first().is_some_and(|segment| segment.eq_ignore_ascii_case("solr")) {
segments.remove(0);
}
let endpoint = segments.last().copied().unwrap_or("");
// update handler 的变体(/update/json、/update/json/docs、/update/csv)在
// update 段之后还有子路径,所以不能只检查末段。
let is_update_path = segments.iter().any(|segment| segment.starts_with("update") || *segment == "commit");
match method.as_str() {
// Solr 的 update handler 也响应 GET(commit/optimize/stream.body 都能
// 改数据),所以 update 路径对任何方法都算写,不能只放行 POST。
_ if is_update_path => Some(SearchEngineQueryRisk::Write),
"GET" | "HEAD" | "OPTIONS" => {
if solr_admin_request_is_mutation(raw_path, &segments)
|| solr_replication_request_is_mutation(raw_path, &segments)
{
Some(SearchEngineQueryRisk::Dangerous)
} else {
Some(SearchEngineQueryRisk::ReadOnly)
}
}
"POST" if READ_ONLY_POST_ENDPOINTS.contains(&endpoint) => Some(SearchEngineQueryRisk::ReadOnly),
"POST" | "PUT" | "PATCH" | "DELETE" => Some(SearchEngineQueryRisk::Dangerous),
_ => None,
}
}
/// CoreAdmin/Collections 管理端点接受 GET 触发的变更动作
///(`GET /admin/cores?action=CREATE&name=x` 会真实建 core),只有显式只读的
/// action 才能算 ReadOnly;其余带 action 的一律按 Dangerous 处理。
fn solr_admin_request_is_mutation(raw_path: &str, segments: &[&str]) -> bool {
let is_admin_target = segments.windows(2).any(|pair| {
pair[0].eq_ignore_ascii_case("admin")
&& matches!(pair[1].to_ascii_lowercase().as_str(), "cores" | "collections")
});
if !is_admin_target {
return false;
}
let Some(query) = raw_path.split('?').nth(1) else { return false };
let action = query.split('&').find_map(|param| {
param.split_once('=').filter(|(key, _)| key.eq_ignore_ascii_case("action")).map(|(_, value)| value)
});
match action {
None => false,
Some(action) => !matches!(action.to_ascii_uppercase().as_str(), "STATUS" | "REQUESTSTATUS" | "LIST"),
}
}
/// ReplicationHandler 也接受 GET 触发的变更
///(`GET /{core}/replication?command=disablereplication` 会真实停用复制),
/// 只有显式只读的 command 才算 ReadOnly;不带 command 时 handler 默认返回
/// 详情,按只读处理。
fn solr_replication_request_is_mutation(raw_path: &str, segments: &[&str]) -> bool {
if !segments.last().is_some_and(|segment| segment.eq_ignore_ascii_case("replication")) {
return false;
}
const READ_ONLY_COMMANDS: &[&str] = &[
"details",
"restorestatus",
"filelist",
"filecontent",
"filedownload",
"filemtime",
"indexversion",
"showversion",
];
let Some(query) = raw_path.split('?').nth(1) else {
return false;
};
let command = query.split('&').find_map(|param| {
param.split_once('=').filter(|(key, _)| key.eq_ignore_ascii_case("command")).map(|(_, value)| value)
});
match command {
None => false,
Some(command) => !READ_ONLY_COMMANDS.iter().any(|readonly| readonly.eq_ignore_ascii_case(command)),
}
}
fn strip_leading_search_engine_comments(input: &str) -> &str {
let mut rest = input;
loop {
@@ -2015,6 +2126,86 @@ mod tests {
));
}
#[test]
fn classifies_solr_rest_requests() {
// Read-only requests: select/query/get and other read handlers.
assert!(!is_write_sql_for_database("GET /mycore/select?q=*:*&rows=20", DatabaseType::Solr));
assert!(!is_write_sql_for_database(
"POST /mycore/query\n{\"query\":\"name:foo\",\"limit\":10}",
DatabaseType::Solr
));
assert!(!is_write_sql_for_database("GET /mycore/schema/fields", DatabaseType::Solr));
// Document writes hit the update handlers, including the `/update/...`
// variants whose last path segment is not literally `update`.
assert!(is_write_sql_for_database(
"POST /mycore/update\n{\"add\":{\"doc\":{\"id\":\"1\"}}}",
DatabaseType::Solr
));
assert!(is_write_sql_for_database("POST /mycore/update/json/docs\n{\"id\":\"1\"}", DatabaseType::Solr));
assert!(is_write_sql_for_database("GET /mycore/update?commit=true", DatabaseType::Solr));
// CoreAdmin actions mutate over GET too; only the read whitelist stays safe.
assert_eq!(
classify_search_engine_query_risk("GET /admin/cores?action=CREATE&name=x", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /admin/cores?action=STATUS", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /admin/info/system", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
// Replication commands mutate over GET too; only the read whitelist stays safe.
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=details", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=restorestatus", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=filelist", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=disablereplication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=enablereplication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=fetchindex", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
// Core/schema/admin operations are dangerous.
assert_eq!(
classify_search_engine_query_risk("POST /admin/cores?action=CREATE&name=x", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk(
"POST /mycore/schema\n{\"add-field\":{\"name\":\"x\",\"type\":\"string\"}}",
DatabaseType::Solr
),
Some(SearchEngineQueryRisk::Dangerous)
);
// Non-Solr types never take this classifier.
assert!(classify_search_engine_query_risk("POST /mycore/update", DatabaseType::Postgres).is_none());
}
#[test]
fn excludes_victoriametrics_from_sql_query_paths() {
assert!(!supports_sql_query(DatabaseType::VictoriaMetrics));
+4 -1
View File
@@ -538,7 +538,10 @@ fn err(reason: &str) -> QuerySqlBuildResult {
}
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
matches!(database_type, Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis))
matches!(
database_type,
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Solr)
)
}
fn find_query_result_statement_at_cursor(sql: &str, cursor_pos: usize, database_type: Option<DatabaseType>) -> String {
@@ -821,7 +821,7 @@ pub fn profile_for(db_type: DatabaseType) -> DdlDialectProfile {
Plugin => conservative_ansi(db_type),
// Non-tabular / not applicable for relational CREATE TABLE
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Meilisearch | Qdrant | Milvus | Weaviate
Redis | MongoDb | DynamoDb | Elasticsearch | Easysearch | Solr | Meilisearch | Qdrant | Milvus | Weaviate
| ChromaDb | Neo4j | Cassandra | Etcd | ZooKeeper | Nacos | Consul | InfluxDb3 | VictoriaMetrics
| MessageQueue | Mqtt | Hbase => conservative_ansi(db_type),
}
@@ -1115,6 +1115,7 @@ impl ConnectionConfig {
DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"),
DatabaseType::Elasticsearch
| DatabaseType::Easysearch
| DatabaseType::Solr
| DatabaseType::Meilisearch
| DatabaseType::Hbase
| DatabaseType::Qdrant
@@ -1299,6 +1300,7 @@ impl ConnectionConfig {
}
DatabaseType::Elasticsearch
| DatabaseType::Easysearch
| DatabaseType::Solr
| DatabaseType::Meilisearch
| DatabaseType::Hbase
| DatabaseType::Qdrant