use async_graphql::dynamic::ResolverContext;
use futures::StreamExt;
use mongodb::bson::doc;
use mongodb::Database;
use crate::error::GraphQLError;
use crate::helpers::serialization::document_to_graphql_value;
use crate::resolvers::pagination::{decode_cursor, encode_cursor, PaginationArgs};
use crate::schema::definition::CollectionDef;
pub async fn resolve_get(
ctx: ResolverContext<'_>,
collection_def: &CollectionDef,
db: &Database,
) -> Result<Option<serde_json::Value>, GraphQLError> {
let collection = db.collection::<mongodb::bson::Document>(&collection_def.collection);
let where_input: mongodb::bson::Document = ctx
.args
.try_get("where")?
.deserialize()
.map_err(|err| GraphQLError::Internal(err.message))?;
let filter = transform_id_filter(where_input)?;
let document = collection
.find_one(filter)
.await
?;
match document {
Some(d) => Ok(Some(document_to_graphql_value(&d, collection_def))),
None => Ok(None),
}
}
pub async fn resolve_list(
ctx: ResolverContext<'_>,
collection_def: &CollectionDef,
db: &Database,
max_page_size: usize,
) -> Result<serde_json::Value, GraphQLError> {
let collection = db.collection::<mongodb::bson::Document>(&collection_def.collection);
let first: Option<i64> = try_deserialize_optional(&ctx.args, "first")?;
let after: Option<String> = try_deserialize_optional(&ctx.args, "after")?;
let last: Option<i64> = try_deserialize_optional(&ctx.args, "last")?;
let before: Option<String> = try_deserialize_optional(&ctx.args, "before")?;
let pagination = PaginationArgs { first, after, last, before };
let limit = pagination.effective_limit(max_page_size)?;
let is_backward = last.is_some();
let raw_filter: mongodb::bson::Document =
try_deserialize_optional(&ctx.args, "where")?.unwrap_or_default();
let base_filter = transform_where_filter(raw_filter, collection_def)?;
let filter = if is_backward {
if let Some(before) = &pagination.before {
let cursor_id = decode_cursor(before)?;
doc! { "$and": [base_filter.clone(), doc! { "_id": { "$lt": cursor_id } }] }
} else {
base_filter.clone()
}
} else {
if let Some(after) = &pagination.after {
let cursor_id = decode_cursor(after)?;
doc! { "$and": [base_filter.clone(), doc! { "_id": { "$gt": cursor_id } }] }
} else {
base_filter.clone()
}
};
let sort_raw: mongodb::bson::Document =
try_deserialize_optional(&ctx.args, "sort")?.unwrap_or_else(|| doc! { "_id": if is_backward { -1 } else { 1 } });
let mut sort = mongodb::bson::Document::new();
for (gql_field, direction) in &sort_raw {
let mongo_name = graphql_to_mongo_field(gql_field, collection_def);
let dir = direction.as_i32().unwrap_or(1);
sort.insert(mongo_name, dir);
}
let sort_direction: i32 = if is_backward { -1 } else { 1 };
sort.insert("_id", sort_direction);
let mut cursor = collection
.find(filter)
.sort(sort)
.limit(limit + 1)
.await
?;
let mut docs: Vec<mongodb::bson::Document> = Vec::new();
while let Some(result) = cursor.next().await {
let document = result?;
docs.push(document);
}
if is_backward {
docs.reverse();
}
let has_more = docs.len() > limit as usize;
if has_more {
if is_backward {
docs.remove(0);
} else {
docs.pop();
}
}
let edges: Vec<serde_json::Value> = docs
.iter()
.map(|document| document_to_graphql_value(document, collection_def))
.collect();
let start_cursor = docs
.first()
.and_then(|document| document.get_object_id("_id").ok())
.map(|id| encode_cursor(&id));
let end_cursor = docs
.last()
.and_then(|document| document.get_object_id("_id").ok())
.map(|id| encode_cursor(&id));
let has_next = if is_backward {
pagination.before.is_some()
} else {
has_more
};
let has_previous = if is_backward {
has_more
} else {
pagination.after.is_some()
};
let total_count = collection.count_documents(base_filter).await?;
Ok(serde_json::json!({
"edges": edges,
"pageInfo": {
"hasNextPage": has_next,
"hasPreviousPage": has_previous,
"startCursor": start_cursor,
"endCursor": end_cursor,
},
"totalCount": total_count as i64
}))
}
pub(crate) fn try_deserialize_optional<T: serde::de::DeserializeOwned>(
args: &async_graphql::dynamic::ObjectAccessor<'_>,
name: &str,
) -> Result<Option<T>, GraphQLError> {
match args.get(name) {
Some(value) => value.deserialize().map(Some).map_err(|err| {
GraphQLError::Internal(format!(
"Failed to parse argument '{}': {}",
name, err.message
))
}),
None => Ok(None),
}
}
pub(crate) fn transform_id_filter(
mut filter: mongodb::bson::Document,
) -> Result<mongodb::bson::Document, GraphQLError> {
if let Some(mongodb::bson::Bson::String(hex)) = filter.remove("id") {
let oid = mongodb::bson::oid::ObjectId::parse_str(&hex).map_err(|_| {
GraphQLError::Internal(format!("Invalid ObjectId: {}", hex))
})?;
filter.insert("_id", oid);
}
Ok(filter)
}
fn transform_where_filter(
filter: mongodb::bson::Document,
collection_def: &CollectionDef,
) -> Result<mongodb::bson::Document, GraphQLError> {
let mut result = mongodb::bson::Document::new();
for (key, value) in filter {
let mongo_key = graphql_to_mongo_field(&key, collection_def);
let is_id_field = mongo_key == "_id";
match value {
mongodb::bson::Bson::Document(filter_doc) => {
for (op, val) in filter_doc {
if is_id_field {
let hex = val.as_str().ok_or_else(|| {
GraphQLError::Internal(format!(
"Id filter value must be a string for operator '{}'",
op
))
})?;
let oid = mongodb::bson::oid::ObjectId::parse_str(hex)
.map_err(|_| {
GraphQLError::Internal(format!(
"Invalid ObjectId in where filter: {}",
hex
))
})?;
match op.as_str() {
"ne" => {
result.insert(mongo_key.as_str(), doc! { "$ne": oid });
}
_ => {
result.insert(mongo_key.as_str(), oid);
}
}
continue;
}
let mongo_op = operator_to_mongo(&op);
if mongo_op == "$eq" {
result.insert(mongo_key.as_str(), val);
} else {
result.insert(mongo_key.as_str(), doc! { mongo_op.as_str(): val });
}
}
}
_ => {
result.insert(mongo_key.as_str(), value);
}
}
}
Ok(result)
}
fn graphql_to_mongo_field(gql_field: &str, collection_def: &CollectionDef) -> String {
if gql_field == "id" {
return "_id".to_string();
}
collection_def
.fields
.iter()
.find(|field| field.graphql_name() == gql_field)
.map(|field| field.name.clone())
.unwrap_or_else(|| gql_field.to_string())
}
fn operator_to_mongo(op: &str) -> String {
match op {
"eq" => "$eq".to_string(),
"ne" => "$ne".to_string(),
"gt" => "$gt".to_string(),
"gte" => "$gte".to_string(),
"lt" => "$lt".to_string(),
"lte" => "$lte".to_string(),
"contains" => "$regex".to_string(),
"startsWith" => "$regex".to_string(),
"endsWith" => "$regex".to_string(),
_ => "$eq".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mongodb::bson::{doc, oid::ObjectId, Bson};
#[test]
fn test_transform_id_filter_converts_hex_to_object_id() {
let oid = ObjectId::new();
let filter = doc! { "id": oid.to_hex() };
let result = transform_id_filter(filter).unwrap();
assert_eq!(result.get("_id"), Some(&Bson::ObjectId(oid)));
assert!(!result.contains_key("id"));
}
#[test]
fn test_transform_id_filter_preserves_other_fields() {
let oid = ObjectId::new();
let filter = doc! { "id": oid.to_hex(), "alias": "TestHero" };
let result = transform_id_filter(filter).unwrap();
assert_eq!(result.get("_id"), Some(&Bson::ObjectId(oid)));
assert_eq!(
result.get("alias"),
Some(&Bson::String("TestHero".into()))
);
}
#[test]
fn test_transform_id_filter_invalid_hex_returns_error() {
let filter = doc! { "id": "not-a-valid-hex" };
let result = transform_id_filter(filter);
assert!(result.is_err());
}
#[test]
fn test_transform_id_filter_no_id_field_unchanged() {
let filter = doc! { "alias": "TestHero", "active": true };
let result = transform_id_filter(filter.clone()).unwrap();
assert_eq!(result, filter);
}
#[test]
fn test_transform_id_filter_empty_document() {
let filter = doc! {};
let result = transform_id_filter(filter.clone()).unwrap();
assert_eq!(result, filter);
}
}