use mongodb::bson::{Bson, Document};
use serde_json::Value as Json;
use crate::config::QueryConfig;
use crate::query::bulk::{BulkOutcome, ItemOutcome};
use crate::query::error::QueryError;
use crate::query::ir::{CmpOp, Cond, FieldRef, MongoStorage, Quant, TextOp, Value};
use crate::query::spec::QuerySpec;
use crate::query::write::{ResolvedConflict, ResolvedWrite, WriteError};
#[derive(Debug, Clone, PartialEq)]
pub struct MongoQuery {
pub collection: String,
pub filter: Document,
pub projection: Option<Document>,
pub sort: Option<Document>,
pub skip: Option<u64>,
pub limit: u64,
}
pub fn render(
spec: &QuerySpec,
cond: &Cond,
collection: &str,
limits: &QueryConfig,
) -> Result<MongoQuery, QueryError> {
super::reject_include(spec, "mongodb")?;
let limit = super::resolve_limit(spec.limit, limits)?;
let skip = super::resolve_skip(spec.skip, limits)?;
let filter = match cond {
Cond::True => Document::new(),
other => match_doc(other)?,
};
let projection = super::plan_projection(&spec.fields).map(|fields| {
let mut p = Document::new();
for f in fields {
p.insert(f.as_str(), 1_i32);
}
if !p.contains_key("_id") {
p.insert("_id", 0_i32);
}
p
});
let plans = super::plan_sort(&spec.sort);
let sort = if plans.is_empty() {
None
} else {
let mut s = Document::new();
for p in &plans {
s.insert(p.field, if p.ascending { 1_i32 } else { -1_i32 });
}
Some(s)
};
Ok(MongoQuery {
collection: collection.to_string(),
filter,
projection,
sort,
skip,
limit,
})
}
fn match_doc(cond: &Cond) -> Result<Document, QueryError> {
Ok(match cond {
Cond::True => Document::new(),
Cond::False => doc_kv("$expr", Bson::Boolean(false)),
Cond::And(cs) => doc_kv("$and", bson_docs(cs)?),
Cond::Or(cs) => doc_kv("$or", bson_docs(cs)?),
Cond::Not(inner) => doc_kv("$nor", Bson::Array(vec![Bson::Document(match_doc(inner)?)])),
Cond::Compare { field, op, value } => field_op(field, cmp_key(*op), to_bson(value)),
Cond::In {
field,
values,
negated,
} => {
let key = if *negated { "$nin" } else { "$in" };
field_op(
field,
key,
Bson::Array(values.iter().map(to_bson).collect()),
)
}
Cond::IsNull { field, negated } => {
let key = if *negated { "$ne" } else { "$eq" };
field_op(field, key, Bson::Null)
}
Cond::Between {
field,
low,
high,
low_incl,
high_incl,
negated,
} => {
let mut inner = Document::new();
inner.insert(if *low_incl { "$gte" } else { "$gt" }, to_bson(low));
inner.insert(if *high_incl { "$lte" } else { "$lt" }, to_bson(high));
let d = doc_kv(field.physical.as_str(), Bson::Document(inner));
if *negated {
doc_kv("$nor", Bson::Array(vec![Bson::Document(d)]))
} else {
d
}
}
Cond::Text { field, op, pattern } => {
let escaped = regex_escape(pattern);
let regex = match op {
TextOp::StartsWith => format!("^{escaped}"),
TextOp::EndsWith => format!("{escaped}$"),
TextOp::Contains => escaped,
};
let mut inner = Document::new();
inner.insert("$regex", Bson::String(regex));
doc_kv(field.physical.as_str(), Bson::Document(inner))
}
Cond::Rel { quant, rel, cond } => {
super::reject_many_to_many(rel, "mongodb")?;
rel_doc(*quant, &rel.name, rel.mongo, cond)?
}
})
}
fn rel_doc(
quant: Quant,
field: &str,
storage: MongoStorage,
inner: &Cond,
) -> Result<Document, QueryError> {
if storage == MongoStorage::Referenced {
return Err(QueryError::FeatureUnsupportedByTarget {
feature: format!("referenced relation '{field}' ($lookup)"),
target: "mongodb".to_string(),
});
}
let inner_doc = match_doc(inner)?;
Ok(match quant {
Quant::Any => doc_kv(
field,
Bson::Document(doc_kv("$elemMatch", Bson::Document(inner_doc))),
),
Quant::None => doc_kv(
field,
Bson::Document(doc_kv(
"$not",
Bson::Document(doc_kv("$elemMatch", Bson::Document(inner_doc))),
)),
),
Quant::All => {
let nonempty = doc_kv(
field,
Bson::Document(doc_kv("$elemMatch", Bson::Document(Document::new()))),
);
let violates = doc_kv("$nor", Bson::Array(vec![Bson::Document(inner_doc)]));
let no_violation = doc_kv(
field,
Bson::Document(doc_kv(
"$not",
Bson::Document(doc_kv("$elemMatch", Bson::Document(violates))),
)),
);
doc_kv(
"$and",
Bson::Array(vec![Bson::Document(nonempty), Bson::Document(no_violation)]),
)
}
})
}
fn bson_docs(cs: &[Cond]) -> Result<Bson, QueryError> {
let mut out = Vec::with_capacity(cs.len());
for c in cs {
out.push(Bson::Document(match_doc(c)?));
}
Ok(Bson::Array(out))
}
fn field_op(field: &FieldRef, op: &str, value: Bson) -> Document {
doc_kv(field.physical.as_str(), Bson::Document(doc_kv(op, value)))
}
fn doc_kv(key: impl Into<String>, value: Bson) -> Document {
let mut d = Document::new();
d.insert(key.into(), value);
d
}
fn cmp_key(op: CmpOp) -> &'static str {
match op {
CmpOp::Eq => "$eq",
CmpOp::Ne => "$ne",
CmpOp::Lt => "$lt",
CmpOp::Le => "$lte",
CmpOp::Gt => "$gt",
CmpOp::Ge => "$gte",
}
}
fn to_bson(v: &Value) -> Bson {
match v {
Value::Null => Bson::Null,
Value::Bool(b) => Bson::Boolean(*b),
Value::Int(i) => Bson::Int64(*i),
Value::Float(f) => Bson::Double(*f),
Value::Str(s) => Bson::String(s.clone()),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum MongoWrite {
Insert {
collection: String,
docs: Vec<Document>,
},
Update {
collection: String,
filter: Document,
update: Document,
upsert: bool,
multi: bool,
},
Delete {
collection: String,
filter: Document,
},
}
pub fn insert_outcome(sent: usize, failed: &[(usize, Json)]) -> BulkOutcome {
let first_failure = failed.iter().map(|(i, _)| *i).min();
let items = (0..sent)
.map(|i| match failed.iter().find(|(idx, _)| *idx == i) {
Some((_, detail)) => ItemOutcome::error(i, detail.clone()),
None if first_failure.is_none_or(|f| i < f) => ItemOutcome::ok(i, None),
None => ItemOutcome::skipped(i),
})
.collect();
BulkOutcome { items }
}
pub fn render_write(w: &ResolvedWrite) -> Result<MongoWrite, WriteError> {
Ok(match w {
ResolvedWrite::Insert {
table,
columns,
rows,
..
} => MongoWrite::Insert {
collection: table.clone(),
docs: build_docs(columns, rows),
},
ResolvedWrite::Update {
table, set, cond, ..
} => MongoWrite::Update {
collection: table.clone(),
filter: cond_to_doc(cond)?,
update: doc_kv("$set", Bson::Document(set_to_doc(set))),
upsert: false,
multi: true,
},
ResolvedWrite::Delete { table, cond, .. } => MongoWrite::Delete {
collection: table.clone(),
filter: cond_to_doc(cond)?,
},
ResolvedWrite::Upsert {
table,
columns,
rows,
set,
conflict,
..
} => render_upsert(table, columns, rows, set, conflict)?,
})
}
fn render_upsert(
table: &str,
columns: &[String],
rows: &[Vec<Value>],
w_set: &[(String, Value)],
conflict: &ResolvedConflict,
) -> Result<MongoWrite, WriteError> {
let plan = super::plan_upsert(columns, rows, w_set, conflict, "mongodb")?;
let mut filter = Document::new();
for t in &conflict.targets {
let idx = columns.iter().position(|c| c == t).ok_or_else(|| {
WriteError::Query(QueryError::InvalidEnvelope(format!(
"on_conflict target '{t}' must be one of the inserted columns"
)))
})?;
filter.insert(t.as_str(), to_bson(&plan.row[idx]));
}
let mut set = Document::new();
for (col, v) in &plan.on_conflict {
set.insert(*col, to_bson(v));
}
let mut set_on_insert = Document::new();
for (col, v) in &plan.insert_only {
set_on_insert.insert(*col, to_bson(v));
}
let mut update = Document::new();
if !set.is_empty() {
update.insert("$set", Bson::Document(set));
}
if !set_on_insert.is_empty() {
update.insert("$setOnInsert", Bson::Document(set_on_insert));
}
if update.is_empty() {
update.insert("$setOnInsert", Bson::Document(filter.clone()));
}
Ok(MongoWrite::Update {
collection: table.to_string(),
filter,
update,
upsert: true,
multi: false,
})
}
fn build_docs(columns: &[String], rows: &[Vec<Value>]) -> Vec<Document> {
rows.iter()
.map(|row| {
let mut d = Document::new();
for (col, v) in columns.iter().zip(row) {
d.insert(col.as_str(), to_bson(v));
}
d
})
.collect()
}
fn set_to_doc(set: &[(String, Value)]) -> Document {
let mut d = Document::new();
for (col, v) in set {
d.insert(col.as_str(), to_bson(v));
}
d
}
fn cond_to_doc(cond: &Option<Cond>) -> Result<Document, WriteError> {
Ok(match cond {
None | Some(Cond::True) => Document::new(),
Some(c) => match_doc(c).map_err(WriteError::from)?,
})
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(
c,
'.' | '^' | '$' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\'
) {
out.push('\\');
}
out.push(c);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::{EntityRegistry, translate_mongo};
use mongodb::bson::doc;
use serde_json::json;
fn limits() -> QueryConfig {
QueryConfig::default()
}
fn mongo(query: serde_json::Value) -> MongoQuery {
translate_mongo(
&query,
&serde_json::Map::new(),
&EntityRegistry::identity(),
&limits(),
)
.expect("translation should succeed")
}
fn mongo_schema(query: serde_json::Value, schema: serde_json::Value) -> MongoQuery {
let reg = EntityRegistry::from_json(&schema).expect("schema");
translate_mongo(&query, &serde_json::Map::new(), ®, &limits()).expect("ok")
}
#[test]
fn test_scalar_match() {
let q = mongo(json!({
"source": "users",
"filter": { "and": [
{ ">": [{"field": "age"}, 18] },
{ "==": [{"field": "status"}, "active"] }
] }
}));
assert_eq!(q.collection, "users");
assert_eq!(
q.filter,
doc! { "$and": [ { "age": { "$gt": 18_i64 } }, { "status": { "$eq": "active" } } ] }
);
assert_eq!(q.limit, 100);
}
#[test]
fn test_id_is_not_silently_rewritten_to_underscore_id() {
let q = mongo(json!({ "source": "users", "filter": { "==": [{"field": "id"}, "u1"] } }));
assert_eq!(q.filter, doc! { "id": { "$eq": "u1" } });
}
#[test]
fn test_the_document_key_is_an_explicit_schema_rename() {
let q = mongo_schema(
json!({ "source": "users", "filter": { "==": [{"field": "id"}, "u1"] } }),
json!({ "entities": { "users": { "columns": { "id": { "name": "_id" } } } } }),
);
assert_eq!(q.filter, doc! { "_id": { "$eq": "u1" } });
}
#[test]
fn test_membership_and_range() {
let q = mongo(json!({
"source": "t",
"filter": { "and": [
{ "in": [{"field": "status"}, ["a", "b"]] },
{ "<=": [1, {"field": "x"}, 10] }
] }
}));
assert_eq!(
q.filter,
doc! { "$and": [
{ "status": { "$in": ["a", "b"] } },
{ "x": { "$gte": 1_i64, "$lte": 10_i64 } }
] }
);
}
#[test]
fn test_is_null() {
let q = mongo(json!({ "source": "t", "filter": { "==": [{"field": "email"}, null] } }));
assert_eq!(q.filter, doc! { "email": { "$eq": Bson::Null } });
}
#[test]
fn test_contains_regex_escaped() {
let q = mongo(json!({ "source": "t", "filter": { "in": ["a.b", {"field": "name"}] } }));
assert_eq!(q.filter, doc! { "name": { "$regex": "a\\.b" } });
}
#[test]
fn test_projection_and_sort() {
let q = mongo_schema(
json!({
"source": "users",
"fields": ["id", "name"],
"sort": [{ "name": "asc" }, { "age": "desc" }]
}),
json!({
"unmapped": "identity",
"entities": { "users": { "columns": { "id": { "name": "_id" } } } }
}),
);
assert_eq!(q.projection, Some(doc! { "_id": 1_i32, "name": 1_i32 }));
assert_eq!(q.sort, Some(doc! { "name": 1_i32, "age": -1_i32 }));
}
#[test]
fn test_sort_null_ordering_matches_the_other_backends() {
let q = mongo(json!({ "source": "t", "sort": [{ "a": "asc" }, { "b": "desc" }] }));
assert_eq!(q.sort, Some(doc! { "a": 1_i32, "b": -1_i32 }));
}
#[test]
fn test_projection_suppresses_id_unless_requested() {
let q = mongo(json!({ "source": "users", "fields": ["name"] }));
assert_eq!(
q.projection,
Some(doc! { "name": 1_i32, "_id": 0_i32 }),
"an unrequested _id must be suppressed"
);
}
#[test]
fn test_embedded_relation_elemmatch() {
let q = mongo_schema(
json!({
"source": "users",
"filter": { "some": [{"field": "orders"}, {">": [{"field": "total"}, 100]}] }
}),
json!({ "unmapped": "identity", "entities": { "users": { "relations": {
"orders": { "to": "orders", "kind": "has_many", "local": "id", "foreign": "user_id", "mongo": "embedded" }
} } } }),
);
assert_eq!(
q.filter,
doc! { "orders": { "$elemMatch": { "total": { "$gt": 100_i64 } } } }
);
}
#[test]
fn test_referenced_relation_is_capability_error() {
let err = translate_mongo(
&json!({
"source": "users",
"filter": { "some": [{"field": "orders"}, {">": [{"field": "total"}, 100]}] }
}),
&serde_json::Map::new(),
&EntityRegistry::from_json(&json!({ "unmapped": "identity", "entities": { "users": { "relations": {
"orders": { "to": "orders", "kind": "has_many", "local": "id", "foreign": "user_id", "mongo": "referenced" }
} } } }))
.expect("schema"),
&limits(),
)
.expect_err("referenced not supported yet");
assert!(matches!(err, QueryError::FeatureUnsupportedByTarget { .. }));
}
#[test]
fn test_many_to_many_relation_filter_is_capability_error() {
let err = translate_mongo(
&json!({
"source": "users",
"filter": { "some": [{"field": "tags"}, {"==": [{"field": "label"}, "vip"]}] }
}),
&serde_json::Map::new(),
&EntityRegistry::from_json(
&json!({ "unmapped": "identity", "entities": { "users": { "relations": {
"tags": {
"to": "tags", "kind": "many_to_many", "local": "id", "foreign": "id",
"through": { "table": "user_tags", "local": "user_id", "foreign": "tag_id" }
}
} } } }),
)
.expect("schema"),
&limits(),
)
.expect_err("m2m filter must be gated, not approximated");
assert!(
matches!(err, QueryError::FeatureUnsupportedByTarget { .. }),
"{err}"
);
assert!(err.to_string().contains("tags"), "{err}");
}
#[test]
fn test_include_is_capability_error() {
for selection in [
json!({ "sort": [{ "id": "asc" }], "limit": 5 }),
json!({ "limit": 5 }),
] {
let err = translate_mongo(
&json!({ "source": "users", "include": { "orders": selection } }),
&serde_json::Map::new(),
&EntityRegistry::from_json(&json!({ "unmapped": "identity", "entities": { "users": { "relations": {
"orders": { "to": "orders", "kind": "has_many", "local": "id", "foreign": "user_id" }
} } } }))
.expect("schema"),
&limits(),
)
.expect_err("include must be gated on mongo");
assert!(
matches!(err, QueryError::FeatureUnsupportedByTarget { .. }),
"{err}"
);
assert!(err.to_string().contains("include 'orders'"), "{err}");
}
}
#[test]
fn test_limit_exceeds_max_rejected() {
let err = translate_mongo(
&json!({ "source": "t", "limit": 9999 }),
&serde_json::Map::new(),
&EntityRegistry::identity(),
&limits(),
)
.expect_err("over cap");
assert!(matches!(err, QueryError::LimitExceeded { .. }));
}
#[test]
fn test_skip_exceeds_max_rejected() {
let err = translate_mongo(
&json!({ "source": "t", "skip": 10_001 }),
&serde_json::Map::new(),
&EntityRegistry::identity(),
&limits(),
)
.expect_err("over the skip cap");
assert!(matches!(err, QueryError::SkipExceeded { .. }), "{err}");
}
fn resolve(input: serde_json::Value) -> crate::query::write::ResolvedWrite {
crate::query::write::resolve_write(
&input,
&serde_json::Map::new(),
&EntityRegistry::identity(),
&crate::config::WriteConfig {
max_rows: 1000,
allow_unfiltered: true,
},
)
.expect("resolve_write should succeed")
}
#[test]
fn test_mongo_insert_docs() {
let mw = render_write(&resolve(json!({
"op": "insert", "target": "users",
"values": [ { "id": "u1", "name": "Ada" }, { "id": "u2", "name": "Bob" } ]
})))
.expect("render");
assert_eq!(
mw,
MongoWrite::Insert {
collection: "users".to_string(),
docs: vec![
doc! { "id": "u1", "name": "Ada" },
doc! { "id": "u2", "name": "Bob" },
],
}
);
}
#[test]
fn test_mongo_update_uses_set_and_filter() {
let mw = render_write(&resolve(json!({
"op": "update", "target": "users",
"set": { "status": "inactive" },
"filter": { "==": [{ "field": "id" }, "u1"] }
})))
.expect("render");
assert_eq!(
mw,
MongoWrite::Update {
collection: "users".to_string(),
filter: doc! { "id": { "$eq": "u1" } },
update: doc! { "$set": { "status": "inactive" } },
upsert: false,
multi: true,
}
);
}
#[test]
fn test_mongo_delete_filter() {
let mw = render_write(&resolve(json!({
"op": "delete", "target": "sessions",
"filter": { "<": [{ "field": "age" }, 0] }
})))
.expect("render");
assert_eq!(
mw,
MongoWrite::Delete {
collection: "sessions".to_string(),
filter: doc! { "age": { "$lt": 0_i64 } },
}
);
}
#[test]
fn test_mongo_upsert_is_update_one_with_upsert() {
let mw = render_write(&resolve(json!({
"op": "upsert", "target": "users",
"values": { "email": "a@x.io", "name": "Ada" },
"on_conflict": { "target": ["email"], "action": "update" }
})))
.expect("render");
assert_eq!(
mw,
MongoWrite::Update {
collection: "users".to_string(),
filter: doc! { "email": "a@x.io" },
update: doc! { "$set": { "name": "Ada" } },
upsert: true,
multi: false,
}
);
}
fn dup(index: usize) -> (usize, Json) {
(
index,
json!({ "code": 11000, "message": "duplicate key error" }),
)
}
#[test]
fn an_ordered_failure_splits_the_batch_into_applied_failed_and_untried() {
let out = insert_outcome(5, &[dup(2)]);
assert!(out.is_partial(), "{:?}", out);
let j = out.to_json();
assert_eq!(j["status"], "partial", "{j}");
assert_eq!(j["inserted"], 2, "0 and 1 committed: {j}");
assert_eq!(j["failed"], 1, "{j}");
assert_eq!(j["skipped"], 2, "3 and 4 were never attempted: {j}");
let items = j["items"].as_array().expect("items");
assert_eq!(items[0]["status"], "ok");
assert_eq!(items[1]["status"], "ok");
assert_eq!(items[2]["status"], "error");
assert_eq!(items[2]["error"]["code"], 11000);
assert_eq!(items[3]["status"], "skipped");
assert_eq!(items[4]["status"], "skipped");
}
#[test]
fn a_failure_at_index_zero_applies_nothing() {
let out = insert_outcome(3, &[dup(0)]);
assert!(out.nothing_applied(), "{:?}", out);
assert!(!out.is_partial());
assert_eq!(out.to_json()["skipped"], 2);
}
#[test]
fn no_reported_errors_means_every_document_landed() {
let out = insert_outcome(3, &[]);
assert_eq!(out.inserted(), 3);
assert!(!out.is_partial());
}
}