feat(solr): add Apache Solr connection support
This commit is contained in:
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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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![]),
|
||||
|
||||
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user