feat(mongodb): improve document quick filters
This commit is contained in:
1 parent
c417e54698
commit
681da22c33
32 files changed
+1639
-116
No files matched your search
@@ -678,7 +678,7 @@ pub async fn count_documents(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find MongoDB documents as relaxed Extended JSON for MongoDB transfer paths.
|
||||
/// Find MongoDB documents in a browser-friendly representation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn find_documents_extended_json(
|
||||
client: &Client,
|
||||
@@ -728,7 +728,7 @@ pub async fn find_documents_extended_json(
|
||||
let mut documents = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| e.to_string())? {
|
||||
let doc = cursor.deserialize_current().map_err(|e| e.to_string())?;
|
||||
documents.push(Bson::Document(doc).into_relaxed_extjson());
|
||||
documents.push(bson_to_browser_json(&Bson::Document(doc)));
|
||||
}
|
||||
|
||||
Ok(MongoDocumentResult { extended_documents: Some(documents.clone()), documents, raw_documents: None, total })
|
||||
@@ -1077,7 +1077,7 @@ pub async fn update_document(
|
||||
) -> Result<u64, String> {
|
||||
let value: serde_json::Value = serde_json::from_str(doc_json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
||||
let col = client.database(database).collection::<Document>(collection);
|
||||
let update_doc = json_object_to_document(&value).map_err(|e| format!("Invalid document: {e}"))?;
|
||||
let update_doc = json_object_to_document_for_update(&value, None).map_err(|e| format!("Invalid document: {e}"))?;
|
||||
if is_update_operator_document(&update_doc) {
|
||||
for filter in document_id_filters(id) {
|
||||
let result = col.update_one(filter, update_doc.clone()).await.map_err(|e| e.to_string())?;
|
||||
@@ -1090,7 +1090,7 @@ pub async fn update_document(
|
||||
|
||||
for filter in document_id_filters(id) {
|
||||
let current = col.find_one(filter.clone()).await.map_err(|e| e.to_string())?;
|
||||
let mut new_doc = json_object_to_document_preserving_existing(&value, current.as_ref())
|
||||
let mut new_doc = json_object_to_document_for_update(&value, current.as_ref())
|
||||
.map_err(|e| format!("Invalid document: {e}"))?;
|
||||
new_doc.remove("_id");
|
||||
let result = col.replace_one(filter, new_doc.clone()).await.map_err(|e| e.to_string())?;
|
||||
@@ -1468,6 +1468,20 @@ fn bson_document_field_to_json(key: &str, bson: &Bson) -> serde_json::Value {
|
||||
bson_to_json(bson)
|
||||
}
|
||||
|
||||
fn bson_to_browser_json(bson: &Bson) -> serde_json::Value {
|
||||
match bson {
|
||||
Bson::Int64(value) if !(-9_007_199_254_740_991..=9_007_199_254_740_991).contains(value) => {
|
||||
serde_json::json!({ "$numberLong": value.to_string() })
|
||||
}
|
||||
Bson::ObjectId(oid) => serde_json::json!({ "$oid": oid.to_hex() }),
|
||||
Bson::Array(values) => serde_json::Value::Array(values.iter().map(bson_to_browser_json).collect()),
|
||||
Bson::Document(document) => serde_json::Value::Object(
|
||||
document.iter().map(|(key, value)| (key.clone(), bson_to_browser_json(value))).collect(),
|
||||
),
|
||||
_ => bson_to_json(bson),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `serde_json::Value` (JSON object) to a BSON `Document`,
|
||||
/// handling MongoDB extended JSON conventions such as `{"$oid":"..."}`.
|
||||
pub fn json_object_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
||||
@@ -1504,21 +1518,27 @@ fn json_object_to_document_preserving_existing(
|
||||
value: &serde_json::Value,
|
||||
existing: Option<&Document>,
|
||||
) -> Result<Document, String> {
|
||||
match (value, existing) {
|
||||
(serde_json::Value::Object(obj), Some(existing)) => Ok(obj
|
||||
match value {
|
||||
serde_json::Value::Object(obj) => obj
|
||||
.iter()
|
||||
.map(|(key, value)| {
|
||||
let bson = existing
|
||||
.get(key)
|
||||
.map(|existing_bson| json_value_to_bson_preserving_existing(value, existing_bson))
|
||||
.unwrap_or_else(|| json_value_to_bson(value));
|
||||
(key.clone(), bson)
|
||||
json_value_to_bson_preserving_existing(value, existing.and_then(|document| document.get(key)))
|
||||
.map(|bson| (key.clone(), bson))
|
||||
})
|
||||
.collect()),
|
||||
.collect(),
|
||||
_ => json_object_to_document(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse document-editor input value by value so mixed browser representations
|
||||
/// retain their BSON types.
|
||||
fn json_object_to_document_for_update(
|
||||
value: &serde_json::Value,
|
||||
existing: Option<&Document>,
|
||||
) -> Result<Document, String> {
|
||||
json_object_to_document_preserving_existing(value, existing)
|
||||
}
|
||||
|
||||
pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, String> {
|
||||
match json_filter_value_to_bson(value, None) {
|
||||
Bson::Document(doc) => Ok(doc),
|
||||
@@ -1526,36 +1546,49 @@ pub fn json_filter_to_document(value: &serde_json::Value) -> Result<Document, St
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: &Bson) -> Bson {
|
||||
if &bson_to_json(existing) == value {
|
||||
return existing.clone();
|
||||
fn json_value_to_bson_preserving_existing(value: &serde_json::Value, existing: Option<&Bson>) -> Result<Bson, String> {
|
||||
if let Some(existing) = existing {
|
||||
if &bson_to_json(existing) == value {
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match (value, existing) {
|
||||
(serde_json::Value::Array(values), Bson::Array(existing_values)) => Bson::Array(
|
||||
match value {
|
||||
serde_json::Value::Array(values) => {
|
||||
let existing_values = match existing {
|
||||
Some(Bson::Array(values)) => Some(values),
|
||||
_ => None,
|
||||
};
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
existing_values
|
||||
.get(index)
|
||||
.map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item))
|
||||
.unwrap_or_else(|| json_value_to_bson(item))
|
||||
json_value_to_bson_preserving_existing(item, existing_values.and_then(|values| values.get(index)))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
(serde_json::Value::Object(obj), Bson::Document(existing_doc)) => Bson::Document(
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Bson::Array)
|
||||
}
|
||||
serde_json::Value::Object(obj) => {
|
||||
if let Some(value) = parse_extended_json_value(obj)? {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let existing_document = match existing {
|
||||
Some(Bson::Document(document)) => Some(document),
|
||||
_ => None,
|
||||
};
|
||||
obj.iter()
|
||||
.map(|(key, item)| {
|
||||
let bson = existing_doc
|
||||
.get(key)
|
||||
.map(|existing_item| json_value_to_bson_preserving_existing(item, existing_item))
|
||||
.unwrap_or_else(|| json_value_to_bson(item));
|
||||
(key.clone(), bson)
|
||||
json_value_to_bson_preserving_existing(
|
||||
item,
|
||||
existing_document.and_then(|document| document.get(key)),
|
||||
)
|
||||
.map(|bson| (key.clone(), bson))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => json_value_to_bson(value),
|
||||
.collect::<Result<Document, _>>()
|
||||
.map(Bson::Document)
|
||||
}
|
||||
_ => Ok(json_value_to_bson(value)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1577,19 +1610,8 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson {
|
||||
}
|
||||
serde_json::Value::Array(arr) => Bson::Array(arr.iter().map(json_value_to_bson).collect()),
|
||||
serde_json::Value::Object(obj) => {
|
||||
// Extended JSON: {"$oid":"..."} → BSON ObjectId
|
||||
if obj.len() == 1 {
|
||||
if let Some(serde_json::Value::String(hex)) = obj.get("$oid") {
|
||||
if let Ok(oid) = ObjectId::parse_str(hex) {
|
||||
return Bson::ObjectId(oid);
|
||||
}
|
||||
}
|
||||
if let Some(value) = parse_extended_json_int64(obj) {
|
||||
return Bson::Int64(value);
|
||||
}
|
||||
if let Some(date) = parse_extended_json_date(obj) {
|
||||
return Bson::DateTime(date);
|
||||
}
|
||||
if let Ok(Some(value)) = parse_extended_json_value(obj) {
|
||||
return value;
|
||||
}
|
||||
let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_value_to_bson(v))).collect();
|
||||
Bson::Document(doc)
|
||||
@@ -1597,6 +1619,40 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_extended_json_value(obj: &serde_json::Map<String, serde_json::Value>) -> Result<Option<Bson>, String> {
|
||||
let is_wrapper = match obj.len() {
|
||||
1 => obj.keys().next().is_some_and(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"$oid"
|
||||
| "$date"
|
||||
| "$numberInt"
|
||||
| "$numberLong"
|
||||
| "$numberDouble"
|
||||
| "$numberDecimal"
|
||||
| "$binary"
|
||||
| "$regularExpression"
|
||||
| "$timestamp"
|
||||
| "$minKey"
|
||||
| "$maxKey"
|
||||
| "$undefined"
|
||||
| "$symbol"
|
||||
| "$code"
|
||||
| "$dbPointer"
|
||||
| "$uuid"
|
||||
)
|
||||
}),
|
||||
2 => obj.contains_key("$code") && obj.contains_key("$scope"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_wrapper {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Bson::try_from(serde_json::Value::Object(obj.clone())).map(Some).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn parse_mongo_shell_date(value: &str) -> Option<DateTime> {
|
||||
let trimmed = value.trim();
|
||||
if let Some(inner) = trimmed.strip_prefix("ISODate(").or_else(|| trimmed.strip_prefix("new Date(")) {
|
||||
@@ -2150,7 +2206,124 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bson_to_extended_json_preserves_nested_object_ids() {
|
||||
fn browser_json_keeps_normal_scalars_readable_and_unsafe_int64_typed() {
|
||||
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
|
||||
let value = bson_to_browser_json(&Bson::Document(doc! {
|
||||
"int32": Bson::Int32(42), "double": Bson::Double(3.5), "date": Bson::DateTime(date),
|
||||
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
|
||||
}));
|
||||
assert_eq!(value["int32"], serde_json::json!(42));
|
||||
assert_eq!(value["double"], serde_json::json!(3.5));
|
||||
assert_eq!(value["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")"));
|
||||
assert_eq!(value["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_json_round_trips_normal_scalars_and_unsafe_int64_on_edit() {
|
||||
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
|
||||
let existing = doc! {
|
||||
"name": "before",
|
||||
"int32": Bson::Int32(42),
|
||||
"double": Bson::Double(3.5),
|
||||
"date": Bson::DateTime(date),
|
||||
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
|
||||
};
|
||||
let mut edited = bson_to_browser_json(&Bson::Document(existing.clone()));
|
||||
edited["name"] = serde_json::json!("after");
|
||||
let round_tripped = json_object_to_document_for_update(&edited, Some(&existing)).unwrap();
|
||||
|
||||
assert_eq!(round_tripped.get_str("name").unwrap(), "after");
|
||||
assert!(matches!(round_tripped.get("int32"), Some(Bson::Int32(42))));
|
||||
assert!(matches!(round_tripped.get("double"), Some(Bson::Double(value)) if *value == 3.5));
|
||||
assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date));
|
||||
assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_json_round_trips_mixed_bson_types_when_editing_an_unrelated_field() {
|
||||
let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
|
||||
let date = DateTime::parse_rfc3339_str("2026-06-10T13:59:31.287Z").unwrap();
|
||||
let existing = doc! {
|
||||
"_id": Bson::ObjectId(object_id),
|
||||
"name": "before",
|
||||
"date": Bson::DateTime(date),
|
||||
"unsafe": Bson::Int64(2_326_645_729_978_441_729),
|
||||
};
|
||||
let mut edited = bson_to_browser_json(&Bson::Document(existing.clone()));
|
||||
assert_eq!(edited["_id"], serde_json::json!({ "$oid": "507f1f77bcf86cd799439011" }));
|
||||
assert_eq!(edited["date"], serde_json::json!("ISODate(\"2026-06-10T13:59:31.287Z\")"));
|
||||
assert_eq!(edited["unsafe"], serde_json::json!({ "$numberLong": "2326645729978441729" }));
|
||||
|
||||
edited["name"] = serde_json::json!("after");
|
||||
let round_tripped = json_object_to_document_for_update(&edited, None).unwrap();
|
||||
|
||||
assert_eq!(round_tripped.get_str("name").unwrap(), "after");
|
||||
assert!(matches!(
|
||||
round_tripped.get("_id"),
|
||||
Some(Bson::ObjectId(value)) if *value == object_id
|
||||
));
|
||||
assert!(matches!(round_tripped.get("date"), Some(Bson::DateTime(value)) if *value == date));
|
||||
assert!(matches!(round_tripped.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_update_rejects_invalid_extended_json_wrapper() {
|
||||
let edited = serde_json::json!({
|
||||
"unsafe": { "$numberLong": "not-an-integer" },
|
||||
});
|
||||
|
||||
assert!(json_object_to_document_for_update(&edited, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_update_parses_wrapper_when_replacing_an_existing_document_value() {
|
||||
let object_id = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
|
||||
let existing = doc! {
|
||||
"value": { "kind": "plain" },
|
||||
};
|
||||
let edited = serde_json::json!({
|
||||
"value": { "$oid": "507f1f77bcf86cd799439011" },
|
||||
});
|
||||
|
||||
let updated = json_object_to_document_for_update(&edited, Some(&existing)).unwrap();
|
||||
|
||||
assert!(matches!(updated.get("value"), Some(Bson::ObjectId(value)) if *value == object_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_update_decodes_uuid_wrapper_per_value() {
|
||||
let uuid = serde_json::json!({ "$uuid": "00112233-4455-6677-8899-aabbccddeeff" });
|
||||
let expected = Bson::try_from(uuid.clone()).unwrap();
|
||||
let edited = serde_json::json!({
|
||||
"name": "after",
|
||||
"uuid": uuid,
|
||||
});
|
||||
|
||||
let updated = json_object_to_document_for_update(&edited, None).unwrap();
|
||||
|
||||
assert_eq!(updated.get("uuid"), Some(&expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_json_filters_decode_normal_scalars_and_unsafe_int64() {
|
||||
let filter = serde_json::json!({
|
||||
"int32": 42,
|
||||
"double": 3.5,
|
||||
"date": "ISODate(\"2026-06-10T13:59:31.287Z\")",
|
||||
"unsafe": { "$numberLong": "2326645729978441729" },
|
||||
});
|
||||
let document = json_filter_to_document(&filter).unwrap();
|
||||
|
||||
assert!(matches!(document.get("int32"), Some(Bson::Int64(42))));
|
||||
assert!(matches!(document.get("double"), Some(Bson::Double(value)) if *value == 3.5));
|
||||
assert!(
|
||||
matches!(document.get("date"), Some(Bson::DateTime(value)) if value.timestamp_millis() == 1_781_099_971_287)
|
||||
);
|
||||
assert!(matches!(document.get("unsafe"), Some(Bson::Int64(2_326_645_729_978_441_729))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_extended_json_preserves_nested_object_ids() {
|
||||
let oid = ObjectId::parse_str("507f1f77bcf86cd799439011").unwrap();
|
||||
let nested_oid = ObjectId::parse_str("507f191e810c19729de860ea").unwrap();
|
||||
let value = Bson::Document(doc! {
|
||||
@@ -2158,7 +2331,7 @@ mod tests {
|
||||
"owner": { "id": Bson::ObjectId(nested_oid) },
|
||||
"tags": [Bson::ObjectId(nested_oid)],
|
||||
})
|
||||
.into_relaxed_extjson();
|
||||
.into_canonical_extjson();
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
@@ -2170,6 +2343,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_extended_json_round_trips_unsafe_int64() {
|
||||
let original = doc! {
|
||||
"counter": Bson::Int64(2_326_645_729_978_441_729),
|
||||
};
|
||||
let extended_json = Bson::Document(original.clone()).into_canonical_extjson();
|
||||
|
||||
assert_eq!(extended_json, serde_json::json!({ "counter": { "$numberLong": "2326645729978441729" } }));
|
||||
|
||||
let round_tripped = json_object_to_document_extended_json(&extended_json).unwrap();
|
||||
assert_eq!(round_tripped, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_info_from_model_maps_mongodb_index_metadata() {
|
||||
let model = IndexModel::builder()
|
||||
|
||||
@@ -367,7 +367,12 @@ pub async fn find_documents_core(
|
||||
let connections = state.connections.read().await;
|
||||
match connections.get(connection_id).ok_or("Not found")? {
|
||||
PoolKind::MongoDb(client) => {
|
||||
mongo_driver::find_documents(client, database, collection, skip, limit, filter, projection, sort).await
|
||||
// Document browser responses must retain BSON type metadata so nested filters
|
||||
// can round-trip ObjectId, Date, and int64 values through Extended JSON.
|
||||
mongo_driver::find_documents_extended_json(
|
||||
client, database, collection, skip, limit, filter, projection, sort,
|
||||
)
|
||||
.await
|
||||
}
|
||||
PoolKind::Elasticsearch(client) => {
|
||||
let client = client.clone();
|
||||
@@ -393,12 +398,23 @@ pub async fn find_documents_core(
|
||||
if let Some(projection) = projection {
|
||||
params["projection"] = serde_json::json!(projection);
|
||||
}
|
||||
client.mongo_find_documents(params).await
|
||||
match client.mongo_find_documents_extended_json(params.clone()).await {
|
||||
Ok(result) => Ok(result),
|
||||
Err(error) if is_unknown_agent_method_error(&error, "find_documents_extended_json") => {
|
||||
client.mongo_find_documents(params).await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
_ => Err("Not a MongoDB/Elasticsearch/vector connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unknown_agent_method_error(error: &str, method: &str) -> bool {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
lower.contains(method) && (lower.contains("unknown method") || lower.contains("method not found"))
|
||||
}
|
||||
|
||||
pub async fn insert_document_core(
|
||||
state: &AppState,
|
||||
connection_id: &str,
|
||||
|
||||
Reference in new issue
Block a user