766 lines
30 KiB
Rust
766 lines
30 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
|
use reqwest::Method;
|
|
use serde_json::Value;
|
|
|
|
use super::document_result::DocumentQueryResult;
|
|
use super::{apply_tls_certificates, http_client_builder, with_connection_timeout};
|
|
use crate::db::ColumnInfo;
|
|
use crate::models::connection::DatabaseConnectionInfo;
|
|
use crate::types::QueryResult;
|
|
|
|
const COUCHDB_PATH_ENCODE_SET: &AsciiSet =
|
|
&CONTROLS.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>').add(b'`').add(b'?').add(b'{').add(b'}').add(b'/');
|
|
|
|
fn encode_path_segment(value: &str) -> String {
|
|
utf8_percent_encode(value, COUCHDB_PATH_ENCODE_SET).to_string()
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CouchDbClient {
|
|
http: reqwest::Client,
|
|
base_url: String,
|
|
auth: Option<(String, String)>,
|
|
}
|
|
|
|
impl CouchDbClient {
|
|
#[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<Self, String> {
|
|
let base_url = normalize_couchdb_base_url(url, tls_enabled);
|
|
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(couchdb_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 CouchDB HTTP client: {e}"))?;
|
|
Ok(Self { http, base_url, auth })
|
|
}
|
|
|
|
pub fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
|
|
let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
|
|
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
|
|
}
|
|
}
|
|
|
|
pub fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
|
self.request(Method::GET, path)
|
|
}
|
|
|
|
pub fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
|
self.request(Method::POST, path)
|
|
}
|
|
|
|
pub fn put(&self, path: &str) -> reqwest::RequestBuilder {
|
|
self.request(Method::PUT, path)
|
|
}
|
|
|
|
pub fn delete(&self, path: &str) -> reqwest::RequestBuilder {
|
|
self.request(Method::DELETE, path)
|
|
}
|
|
|
|
pub fn head(&self, path: &str) -> reqwest::RequestBuilder {
|
|
self.request(Method::HEAD, path)
|
|
}
|
|
}
|
|
|
|
pub fn normalize_couchdb_base_url(url: &str, tls_enabled: bool) -> String {
|
|
let trimmed = url.trim().trim_end_matches('/');
|
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
|
trimmed.to_string()
|
|
} else if let Some(stripped) = trimmed.strip_prefix("couchdb://") {
|
|
let scheme = if tls_enabled { "https" } else { "http" };
|
|
format!("{scheme}://{stripped}")
|
|
} else {
|
|
let scheme = if tls_enabled { "https" } else { "http" };
|
|
format!("{scheme}://{trimmed}")
|
|
}
|
|
}
|
|
|
|
pub fn couchdb_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> bool {
|
|
tls_enabled
|
|
|| couchdb_url_params_flag(url_params, "sslmode", &["disable", "allow"])
|
|
|| couchdb_url_params_flag(url_params, "tlsverify", &["false", "0", "no", "off"])
|
|
|| couchdb_url_params_flag(url_params, "verify", &["false", "0", "no", "off"])
|
|
|| couchdb_url_params_flag(url_params, "insecure", &["true", "1", "yes", "on"])
|
|
|| couchdb_url_params_flag(url_params, "accept_invalid_certs", &["true", "1", "yes", "on"])
|
|
}
|
|
|
|
fn couchdb_url_params_flag(params: Option<&str>, key: &str, expected_values: &[&str]) -> bool {
|
|
params.unwrap_or("").trim().trim_start_matches('?').split('&').filter_map(|p| p.split_once('=')).any(|(k, v)| {
|
|
k.eq_ignore_ascii_case(key) && expected_values.iter().any(|expected| v.eq_ignore_ascii_case(expected))
|
|
})
|
|
}
|
|
|
|
pub async fn test_connection(client: &CouchDbClient, timeout: Duration) -> Result<(), String> {
|
|
with_connection_timeout("CouchDB", timeout, async {
|
|
let resp = client.get("/").send().await.map_err(|e| format!("CouchDB connection failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB HTTP {status}: {body}"));
|
|
}
|
|
Ok(())
|
|
})
|
|
.await
|
|
}
|
|
|
|
pub async fn database_connection_info(client: &CouchDbClient) -> Result<DatabaseConnectionInfo, String> {
|
|
let resp = client.get("/").send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
return Err(format!("CouchDB HTTP {}", resp.status()));
|
|
}
|
|
let body: Value = resp.json().await.map_err(|e| format!("CouchDB parse error: {e}"))?;
|
|
let version = body.get("version").and_then(Value::as_str).unwrap_or("unknown").to_string();
|
|
Ok(DatabaseConnectionInfo {
|
|
product_name: Some("Apache CouchDB".to_string()),
|
|
product_version: Some(version),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
pub async fn list_databases(client: &CouchDbClient) -> Result<Vec<String>, String> {
|
|
let resp = client.get("/_all_dbs").send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB error {status}: {body}"));
|
|
}
|
|
let dbs: Vec<String> = resp.json().await.map_err(|e| format!("Invalid CouchDB JSON: {e}"))?;
|
|
Ok(dbs)
|
|
}
|
|
|
|
pub async fn get_columns(client: &CouchDbClient, database: &str) -> Result<Vec<ColumnInfo>, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let path = format!("/{encoded_db}/_all_docs?include_docs=true&limit=50");
|
|
let mut columns = vec![
|
|
ColumnInfo {
|
|
name: "_id".to_string(),
|
|
data_type: "string".to_string(),
|
|
is_primary_key: true,
|
|
is_nullable: false,
|
|
..Default::default()
|
|
},
|
|
ColumnInfo {
|
|
name: "_rev".to_string(),
|
|
data_type: "string".to_string(),
|
|
is_primary_key: false,
|
|
is_nullable: false,
|
|
..Default::default()
|
|
},
|
|
];
|
|
|
|
let resp = match client.get(&path).send().await {
|
|
Ok(r) => r,
|
|
Err(_) => return Ok(columns),
|
|
};
|
|
|
|
if resp.status().is_success() {
|
|
if let Ok(body) = resp.json::<Value>().await {
|
|
if let Some(rows) = body.get("rows").and_then(Value::as_array) {
|
|
let mut seen: HashSet<String> = ["_id".to_string(), "_rev".to_string()].into_iter().collect();
|
|
for row in rows {
|
|
if let Some(doc) = row.get("doc").and_then(Value::as_object) {
|
|
for (k, v) in doc {
|
|
if seen.insert(k.clone()) {
|
|
let type_str = match v {
|
|
Value::Null => "null",
|
|
Value::Bool(_) => "boolean",
|
|
Value::Number(_) => "number",
|
|
Value::String(_) => "string",
|
|
Value::Array(_) => "array",
|
|
Value::Object(_) => "object",
|
|
};
|
|
columns.push(ColumnInfo {
|
|
name: k.clone(),
|
|
data_type: type_str.to_string(),
|
|
is_primary_key: false,
|
|
is_nullable: true,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(columns)
|
|
}
|
|
|
|
fn parse_couchdb_sort(sort: Option<&str>) -> Result<Option<Value>, String> {
|
|
let sort = match sort {
|
|
Some(s) if !s.trim().is_empty() => s.trim(),
|
|
_ => return Ok(None),
|
|
};
|
|
let parsed = serde_json::from_str::<Value>(sort).map_err(|e| format!("Invalid sort JSON: {e}"))?;
|
|
match parsed {
|
|
Value::Array(val) => Ok(Some(Value::Array(val))),
|
|
Value::Object(map) => {
|
|
let mut list = Vec::new();
|
|
for (k, v) in map {
|
|
let dir = match v {
|
|
Value::Number(n) if n.as_i64() == Some(-1) => "desc",
|
|
Value::String(ref s) if s.eq_ignore_ascii_case("desc") => "desc",
|
|
_ => "asc",
|
|
};
|
|
list.push(serde_json::json!({ k: dir }));
|
|
}
|
|
if !list.is_empty() {
|
|
Ok(Some(Value::Array(list)))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
_ => Err(format!("CouchDB sort must be a JSON array or object, got: {sort}")),
|
|
}
|
|
}
|
|
|
|
fn parse_couchdb_selector(filter: Option<&str>) -> Result<Value, String> {
|
|
let filter = match filter {
|
|
Some(f) if !f.trim().is_empty() => f.trim(),
|
|
_ => return Ok(serde_json::json!({ "_id": { "$gt": null } })),
|
|
};
|
|
|
|
let parsed = serde_json::from_str::<Value>(filter).map_err(|e| format!("Invalid filter JSON: {e}"))?;
|
|
match parsed {
|
|
Value::Object(ref map) => {
|
|
if let Some(selector) = map.get("selector") {
|
|
Ok(selector.clone())
|
|
} else {
|
|
Ok(parsed)
|
|
}
|
|
}
|
|
_ => Err(format!("CouchDB selector must be a JSON object, got: {filter}")),
|
|
}
|
|
}
|
|
|
|
async fn find_documents_impl(
|
|
client: &CouchDbClient,
|
|
database: &str,
|
|
skip: Option<u64>,
|
|
limit: i64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
cursor: Option<&str>,
|
|
) -> Result<DocumentQueryResult, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let has_filter = filter.is_some_and(|f| !f.trim().is_empty());
|
|
let parsed_sort = parse_couchdb_sort(sort)?;
|
|
|
|
if !has_filter && parsed_sort.is_none() && cursor.is_none() {
|
|
// Fast path: use _all_docs
|
|
let skip_val = skip.unwrap_or(0);
|
|
let path = format!("/{encoded_db}/_all_docs?include_docs=true&limit={limit}&skip={skip_val}");
|
|
let resp = client.get(&path).send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB query error {status}: {body}"));
|
|
}
|
|
let body: Value = resp.json().await.map_err(|e| format!("Invalid CouchDB JSON: {e}"))?;
|
|
let total = body.get("total_rows").and_then(Value::as_u64).unwrap_or(0);
|
|
let docs = body
|
|
.get("rows")
|
|
.and_then(Value::as_array)
|
|
.map(|rows| rows.iter().filter_map(|r| r.get("doc").cloned()).collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
let raw_documents = docs
|
|
.iter()
|
|
.map(serde_json::to_string)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("CouchDB document serialization failed: {e}"))?;
|
|
return Ok(DocumentQueryResult {
|
|
documents: docs,
|
|
raw_documents: Some(raw_documents),
|
|
extended_documents: None,
|
|
total,
|
|
total_is_exact: true,
|
|
next_cursor: None,
|
|
});
|
|
}
|
|
|
|
// Mango query: POST /{db}/_find
|
|
let selector = parse_couchdb_selector(filter)?;
|
|
let mut payload = serde_json::json!({
|
|
"selector": selector,
|
|
"limit": limit
|
|
});
|
|
if let Some(s) = skip {
|
|
if cursor.is_none() && s > 0 {
|
|
payload["skip"] = Value::Number(s.into());
|
|
}
|
|
}
|
|
if let Some(c) = cursor {
|
|
payload["bookmark"] = Value::String(c.to_string());
|
|
}
|
|
if let Some(s) = parsed_sort {
|
|
payload["sort"] = s;
|
|
}
|
|
|
|
let resp = client
|
|
.post(&format!("/{encoded_db}/_find"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("CouchDB _find request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB _find error {status}: {body}"));
|
|
}
|
|
|
|
let body: Value = resp.json().await.map_err(|e| format!("Invalid CouchDB _find JSON: {e}"))?;
|
|
let docs = body.get("docs").and_then(Value::as_array).cloned().unwrap_or_default();
|
|
let next_cursor = if docs.len() == limit as usize {
|
|
body.get("bookmark").and_then(Value::as_str).map(str::to_string)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let total = count_documents(client, database, None).await.unwrap_or(docs.len() as u64);
|
|
let raw_documents = docs
|
|
.iter()
|
|
.map(serde_json::to_string)
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("CouchDB document serialization failed: {e}"))?;
|
|
Ok(DocumentQueryResult {
|
|
documents: docs,
|
|
raw_documents: Some(raw_documents),
|
|
extended_documents: None,
|
|
total,
|
|
// count_documents 忽略 selector,返回全库 doc_count;带过滤时不是精确总数。
|
|
total_is_exact: !has_filter,
|
|
next_cursor,
|
|
})
|
|
}
|
|
|
|
pub async fn find_documents(
|
|
client: &CouchDbClient,
|
|
database: &str,
|
|
skip: u64,
|
|
limit: i64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
) -> Result<DocumentQueryResult, String> {
|
|
find_documents_impl(client, database, Some(skip), limit, filter, sort, None).await
|
|
}
|
|
|
|
pub async fn find_documents_with_cursor(
|
|
client: &CouchDbClient,
|
|
database: &str,
|
|
limit: i64,
|
|
filter: Option<&str>,
|
|
sort: Option<&str>,
|
|
cursor: Option<&str>,
|
|
) -> Result<DocumentQueryResult, String> {
|
|
find_documents_impl(client, database, None, limit, filter, sort, cursor).await
|
|
}
|
|
|
|
pub async fn close_cursor(_client: &CouchDbClient, _cursor: &str) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn count_documents(client: &CouchDbClient, database: &str, _filter: Option<&str>) -> Result<u64, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let resp =
|
|
client.get(&format!("/{encoded_db}")).send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB error {status}: {body}"));
|
|
}
|
|
let body: Value = resp.json().await.map_err(|e| format!("Invalid CouchDB JSON: {e}"))?;
|
|
let doc_count = body.get("doc_count").and_then(Value::as_u64).unwrap_or(0);
|
|
Ok(doc_count)
|
|
}
|
|
|
|
pub async fn insert_document(client: &CouchDbClient, database: &str, doc_json: &str) -> Result<String, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let doc: Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
|
let id_opt = doc.get("_id").and_then(Value::as_str).map(str::to_string);
|
|
|
|
let resp = if let Some(ref id) = id_opt {
|
|
let encoded_id = encode_path_segment(id);
|
|
client.put(&format!("/{encoded_db}/{encoded_id}")).json(&doc).send().await
|
|
} else {
|
|
client.post(&format!("/{encoded_db}")).json(&doc).send().await
|
|
}
|
|
.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB insert error {status}: {body}"));
|
|
}
|
|
let body: Value = resp.json().await.map_err(|e| format!("Invalid CouchDB response: {e}"))?;
|
|
let id = body.get("id").and_then(Value::as_str).unwrap_or("").to_string();
|
|
Ok(id)
|
|
}
|
|
|
|
async fn fetch_doc_rev(client: &CouchDbClient, database: &str, id: &str) -> Result<Option<String>, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let encoded_id = encode_path_segment(id);
|
|
let resp = client
|
|
.head(&format!("/{encoded_db}/{encoded_id}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
return Ok(None);
|
|
}
|
|
if let Some(etag) = resp.headers().get(reqwest::header::ETAG).and_then(|h| h.to_str().ok()) {
|
|
let rev = etag.trim().trim_matches('"');
|
|
return Ok(Some(rev.to_string()));
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
pub async fn update_document(client: &CouchDbClient, database: &str, id: &str, doc_json: &str) -> Result<u64, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let encoded_id = encode_path_segment(id);
|
|
let mut doc: Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
|
if let Value::Object(ref mut map) = doc {
|
|
if !map.contains_key("_rev") {
|
|
if let Some(rev) = fetch_doc_rev(client, database, id).await? {
|
|
map.insert("_rev".to_string(), Value::String(rev));
|
|
}
|
|
}
|
|
if !map.contains_key("_id") {
|
|
map.insert("_id".to_string(), Value::String(id.to_string()));
|
|
}
|
|
}
|
|
let resp = client
|
|
.put(&format!("/{encoded_db}/{encoded_id}"))
|
|
.json(&doc)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB update error {status}: {body}"));
|
|
}
|
|
Ok(1)
|
|
}
|
|
|
|
pub async fn delete_document(client: &CouchDbClient, database: &str, id: &str) -> Result<u64, String> {
|
|
let encoded_db = encode_path_segment(database);
|
|
let encoded_id = encode_path_segment(id);
|
|
let rev = fetch_doc_rev(client, database, id).await?.ok_or_else(|| format!("Document '{id}' not found"))?;
|
|
let path = format!("/{encoded_db}/{encoded_id}?rev={rev}");
|
|
let resp = client.delete(&path).send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(format!("CouchDB delete error {status}: {body}"));
|
|
}
|
|
Ok(1)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REST console execution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct CouchDbRestRequest {
|
|
method: Method,
|
|
path: String,
|
|
body: Option<String>,
|
|
}
|
|
|
|
fn strip_leading_couchdb_comments(input: &str) -> &str {
|
|
let mut rest = input;
|
|
loop {
|
|
rest = rest.trim_start();
|
|
if let Some(comment) = rest.strip_prefix('#').or_else(|| rest.strip_prefix("//")) {
|
|
rest = comment.split_once('\n').map_or("", |(_, remaining)| remaining);
|
|
continue;
|
|
}
|
|
if let Some(comment) = rest.strip_prefix("/*") {
|
|
rest = comment.split_once("*/").map_or("", |(_, remaining)| remaining);
|
|
continue;
|
|
}
|
|
return rest.trim();
|
|
}
|
|
}
|
|
|
|
fn parse_couchdb_rest_request(input: &str) -> Result<CouchDbRestRequest, String> {
|
|
let input = strip_leading_couchdb_comments(input);
|
|
if input.is_empty() {
|
|
return Err("Invalid query: expected METHOD /path".to_string());
|
|
}
|
|
let (request_line, body) = input.split_once('\n').map_or((input, None), |(line, body)| {
|
|
let body = body.trim();
|
|
(line, (!body.is_empty()).then(|| body.to_string()))
|
|
});
|
|
let (method, path) =
|
|
request_line.trim().split_once(char::is_whitespace).ok_or("Invalid query: expected METHOD /path")?;
|
|
let method = method.to_ascii_uppercase();
|
|
let method = Method::from_bytes(method.as_bytes())
|
|
.map_err(|_| format!("Unsupported HTTP method: {method}. Use GET, POST, PUT, DELETE, or HEAD."))?;
|
|
if !matches!(method, Method::GET | Method::POST | Method::PUT | Method::DELETE | Method::HEAD) {
|
|
return Err(format!("Unsupported HTTP method: {}. Use GET, POST, PUT, DELETE, or HEAD.", method.as_str()));
|
|
}
|
|
|
|
let path = path.trim().to_string();
|
|
if path.is_empty() {
|
|
return Err("Invalid query: expected METHOD /path".to_string());
|
|
}
|
|
let path = if path.starts_with('/') { path } else { format!("/{path}") };
|
|
Ok(CouchDbRestRequest { method, path, body })
|
|
}
|
|
|
|
fn couchdb_docs_to_table(docs: &[Value]) -> (Vec<String>, Vec<String>, Vec<Vec<Value>>) {
|
|
let mut columns = Vec::<String>::new();
|
|
let mut column_indexes = HashMap::<String, usize>::new();
|
|
let mut json_column_indexes = HashSet::<usize>::new();
|
|
let mut rows = Vec::<Vec<Value>>::with_capacity(docs.len());
|
|
|
|
for doc in docs {
|
|
let mut row = vec![Value::Null; columns.len()];
|
|
if let Some(object) = doc.as_object() {
|
|
for (key, value) in object {
|
|
let is_json_cell = matches!(value, Value::Array(_) | Value::Object(_));
|
|
let value = if is_json_cell { Value::String(value.to_string()) } else { value.clone() };
|
|
match column_indexes.get(key).copied() {
|
|
Some(index) => {
|
|
if is_json_cell {
|
|
json_column_indexes.insert(index);
|
|
}
|
|
row[index] = value;
|
|
}
|
|
None => {
|
|
let index = columns.len();
|
|
if is_json_cell {
|
|
json_column_indexes.insert(index);
|
|
}
|
|
column_indexes.insert(key.clone(), index);
|
|
columns.push(key.clone());
|
|
for previous_row in rows.iter_mut() {
|
|
previous_row.push(Value::Null);
|
|
}
|
|
row.push(value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
rows.push(row);
|
|
}
|
|
|
|
let column_types = columns
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, _)| {
|
|
if json_column_indexes.contains(&index) {
|
|
return "json".to_string();
|
|
}
|
|
let mut inferred = None;
|
|
for value in rows.iter().filter_map(|row| row.get(index)) {
|
|
let value_type = match value {
|
|
Value::Null => continue,
|
|
Value::Bool(_) => "boolean",
|
|
Value::Number(_) => "number",
|
|
Value::String(_) => "text",
|
|
Value::Array(_) | Value::Object(_) => "json",
|
|
};
|
|
inferred = match inferred {
|
|
None => Some(value_type),
|
|
Some(existing) if existing == value_type => Some(existing),
|
|
Some(_) => Some("json"),
|
|
};
|
|
}
|
|
inferred.unwrap_or("unknown").to_string()
|
|
})
|
|
.collect();
|
|
(columns, column_types, rows)
|
|
}
|
|
|
|
fn couchdb_table_result(
|
|
columns: Vec<String>,
|
|
column_types: Vec<String>,
|
|
rows: Vec<Vec<Value>>,
|
|
start: Instant,
|
|
) -> QueryResult {
|
|
let row_count = rows.len() as u64;
|
|
QueryResult {
|
|
columns,
|
|
column_types,
|
|
deferred_large_value_cells: Vec::new(),
|
|
column_sortables: vec![],
|
|
spatial_columns: vec![],
|
|
spatial_values: vec![],
|
|
rows,
|
|
affected_rows: row_count,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
server_execute_time_us: None,
|
|
query_timings_ms: None,
|
|
truncated: false,
|
|
session_id: None,
|
|
has_more: false,
|
|
elasticsearch_raw_body: None,
|
|
messages: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn couchdb_raw_json_response_result(status: u16, body_text: impl Into<String>, start: Instant) -> QueryResult {
|
|
QueryResult {
|
|
columns: vec!["status".to_string(), "response".to_string()],
|
|
column_types: Vec::new(),
|
|
deferred_large_value_cells: Vec::new(),
|
|
column_sortables: vec![],
|
|
spatial_columns: vec![],
|
|
spatial_values: vec![],
|
|
rows: vec![vec![Value::Number(status.into()), Value::String(body_text.into())]],
|
|
affected_rows: 0,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
server_execute_time_us: None,
|
|
query_timings_ms: None,
|
|
truncated: false,
|
|
session_id: None,
|
|
has_more: false,
|
|
elasticsearch_raw_body: None,
|
|
messages: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn parse_couchdb_rest_response(status: u16, body_text: &str, start: Instant) -> Result<QueryResult, String> {
|
|
if status >= 400 {
|
|
return Ok(couchdb_raw_json_response_result(status, body_text, start));
|
|
}
|
|
match serde_json::from_str::<Value>(body_text) {
|
|
Ok(body) => {
|
|
// Case 1: Array of database names, e.g. /_all_dbs
|
|
if let Some(arr) = body.as_array() {
|
|
let is_string_list = arr.iter().all(|item| item.is_string());
|
|
if is_string_list {
|
|
let rows: Vec<Vec<Value>> = arr.iter().map(|item| vec![item.clone()]).collect();
|
|
let mut result =
|
|
couchdb_table_result(vec!["database".to_string()], vec!["text".to_string()], rows, start);
|
|
result.elasticsearch_raw_body = Some(body_text.to_string());
|
|
return Ok(result);
|
|
}
|
|
}
|
|
|
|
// Case 2: Document array in "docs" from _find
|
|
if let Some(docs) = body.get("docs").and_then(Value::as_array) {
|
|
let (columns, column_types, rows) = couchdb_docs_to_table(docs);
|
|
let mut result = couchdb_table_result(columns, column_types, rows, start);
|
|
result.elasticsearch_raw_body = Some(body_text.to_string());
|
|
return Ok(result);
|
|
}
|
|
|
|
// Case 3: Rows array from _all_docs
|
|
if let Some(rows) = body.get("rows").and_then(Value::as_array) {
|
|
let has_doc = rows.iter().any(|r| r.get("doc").is_some());
|
|
if has_doc {
|
|
let docs: Vec<Value> = rows.iter().filter_map(|r| r.get("doc").cloned()).collect();
|
|
let (columns, column_types, rows) = couchdb_docs_to_table(&docs);
|
|
let mut result = couchdb_table_result(columns, column_types, rows, start);
|
|
result.elasticsearch_raw_body = Some(body_text.to_string());
|
|
return Ok(result);
|
|
}
|
|
}
|
|
|
|
// Default: formatted JSON
|
|
Ok(couchdb_raw_json_response_result(
|
|
status,
|
|
serde_json::to_string_pretty(&body).unwrap_or_else(|_| body_text.to_string()),
|
|
start,
|
|
))
|
|
}
|
|
Err(_) => {
|
|
let rows: Vec<Vec<Value>> = body_text.lines().map(|line| vec![Value::String(line.to_string())]).collect();
|
|
let mut result = couchdb_table_result(vec!["response".to_string()], Vec::new(), rows, start);
|
|
result.affected_rows = result.rows.len() as u64;
|
|
Ok(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn execute_rest_query(client: &CouchDbClient, input: &str) -> Result<QueryResult, String> {
|
|
let start = Instant::now();
|
|
let request = parse_couchdb_rest_request(input)?;
|
|
let mut builder = client.request(request.method, &request.path);
|
|
if let Some(body) = request.body {
|
|
let json: Value = serde_json::from_str(&body).map_err(|e| format!("Invalid JSON body: {e}"))?;
|
|
builder = builder.json(&json);
|
|
}
|
|
let resp = builder.send().await.map_err(|e| format!("CouchDB request failed: {e}"))?;
|
|
let status = resp.status().as_u16();
|
|
let body = resp.text().await.map_err(|e| format!("CouchDB response read failed: {e}"))?;
|
|
parse_couchdb_rest_response(status, &body, start)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_normalize_couchdb_base_url() {
|
|
assert_eq!(normalize_couchdb_base_url("http://localhost:5984", false), "http://localhost:5984");
|
|
assert_eq!(normalize_couchdb_base_url("http://localhost:5984/", false), "http://localhost:5984");
|
|
assert_eq!(normalize_couchdb_base_url("couchdb://localhost:5984", false), "http://localhost:5984");
|
|
assert_eq!(normalize_couchdb_base_url("couchdb://localhost:5984", true), "https://localhost:5984");
|
|
assert_eq!(normalize_couchdb_base_url("localhost:5984", false), "http://localhost:5984");
|
|
assert_eq!(normalize_couchdb_base_url("localhost:5984", true), "https://localhost:5984");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_couchdb_rest_request() {
|
|
let req = parse_couchdb_rest_request("GET /_all_dbs").unwrap();
|
|
assert_eq!(req.method, Method::GET);
|
|
assert_eq!(req.path, "/_all_dbs");
|
|
assert!(req.body.is_none());
|
|
|
|
let req = parse_couchdb_rest_request("POST /mydb/_find\n{\"selector\":{}}").unwrap();
|
|
assert_eq!(req.method, Method::POST);
|
|
assert_eq!(req.path, "/mydb/_find");
|
|
assert_eq!(req.body.as_deref(), Some("{\"selector\":{}}"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_couchdb_sort() {
|
|
assert_eq!(parse_couchdb_sort(None).unwrap(), None);
|
|
assert_eq!(parse_couchdb_sort(Some("")).unwrap(), None);
|
|
let sort_obj = parse_couchdb_sort(Some(r#"{"age": -1, "name": "asc"}"#)).unwrap().expect("sort should parse");
|
|
assert!(sort_obj.is_array());
|
|
assert!(parse_couchdb_sort(Some("not json")).is_err());
|
|
assert!(parse_couchdb_sort(Some("42")).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_couchdb_selector() {
|
|
let sel = parse_couchdb_selector(None).unwrap();
|
|
assert_eq!(sel, serde_json::json!({ "_id": { "$gt": null } }));
|
|
|
|
let sel = parse_couchdb_selector(Some(r#"{"name": "Alice"}"#)).unwrap();
|
|
assert_eq!(sel, serde_json::json!({ "name": "Alice" }));
|
|
|
|
let sel = parse_couchdb_selector(Some(r#"{"selector": {"name": "Alice"}}"#)).unwrap();
|
|
assert_eq!(sel, serde_json::json!({ "name": "Alice" }));
|
|
|
|
assert!(parse_couchdb_selector(Some("not json")).is_err());
|
|
assert!(parse_couchdb_selector(Some(r#"["not","an","object"]"#)).is_err());
|
|
}
|
|
}
|