use bson::Document;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone)]
pub enum ConversionError {
ResumeTokenConversion(String),
}
impl fmt::Display for ConversionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ResumeTokenConversion(msg) => {
write!(f, "Failed to convert resume token: {msg}")
}
}
}
}
impl std::error::Error for ConversionError {}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum OperationType {
Insert,
Update,
Delete,
Replace,
Invalidate,
Drop,
#[serde(rename = "dropdatabase")]
DropDatabase,
Rename,
#[serde(untagged)]
Unknown(String),
}
impl OperationType {
#[inline]
#[must_use]
pub const fn is_data_modification(&self) -> bool {
matches!(self, Self::Insert | Self::Update | Self::Replace)
}
#[inline]
#[must_use]
pub const fn is_data_removal(&self) -> bool {
matches!(self, Self::Delete | Self::Drop | Self::DropDatabase)
}
#[inline]
#[must_use]
pub const fn is_ddl(&self) -> bool {
matches!(self, Self::Drop | Self::DropDatabase | Self::Rename)
}
#[inline]
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown(_))
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Insert => "insert",
Self::Update => "update",
Self::Delete => "delete",
Self::Replace => "replace",
Self::Invalidate => "invalidate",
Self::Drop => "drop",
Self::DropDatabase => "dropdatabase",
Self::Rename => "rename",
Self::Unknown(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Namespace {
pub database: String,
pub collection: String,
}
impl Namespace {
pub fn new(database: impl Into<String>, collection: impl Into<String>) -> Self {
Self {
database: database.into(),
collection: collection.into(),
}
}
#[must_use]
pub fn full_name(&self) -> String {
format!("{}.{}", self.database, self.collection)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdateDescription {
#[serde(rename = "updatedFields")]
pub updated_fields: Document,
#[serde(rename = "removedFields")]
pub removed_fields: Vec<String>,
#[serde(rename = "truncatedArrays", skip_serializing_if = "Option::is_none")]
pub truncated_arrays: Option<Vec<TruncatedArray>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TruncatedArray {
pub field: String,
#[serde(rename = "newSize")]
pub new_size: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChangeEvent {
#[serde(rename = "operationType")]
pub operation: OperationType,
#[serde(rename = "ns")]
pub namespace: Namespace,
#[serde(rename = "documentKey", skip_serializing_if = "Option::is_none")]
pub document_key: Option<Document>,
#[serde(rename = "fullDocument", skip_serializing_if = "Option::is_none")]
pub full_document: Option<Document>,
#[serde(rename = "updateDescription", skip_serializing_if = "Option::is_none")]
pub update_description: Option<UpdateDescription>,
#[serde(rename = "clusterTime")]
pub cluster_time: DateTime<Utc>,
#[serde(rename = "_id")]
pub resume_token: Document,
}
impl ChangeEvent {
#[inline]
#[must_use]
pub fn is_insert(&self) -> bool {
self.operation == OperationType::Insert
}
#[inline]
#[must_use]
pub fn is_update(&self) -> bool {
self.operation == OperationType::Update
}
#[inline]
#[must_use]
pub fn is_delete(&self) -> bool {
self.operation == OperationType::Delete
}
#[inline]
#[must_use]
pub fn is_replace(&self) -> bool {
self.operation == OperationType::Replace
}
#[inline]
#[must_use]
pub fn is_invalidate(&self) -> bool {
self.operation == OperationType::Invalidate
}
#[inline]
#[must_use]
pub fn collection_name(&self) -> &str {
&self.namespace.collection
}
#[inline]
#[must_use]
pub fn database_name(&self) -> &str {
&self.namespace.database
}
#[inline]
#[must_use]
pub fn full_namespace(&self) -> String {
self.namespace.full_name()
}
#[must_use]
pub fn document_id(&self) -> Option<&bson::Bson> {
self.document_key.as_ref()?.get("_id")
}
#[inline]
#[must_use]
pub const fn has_full_document(&self) -> bool {
self.full_document.is_some()
}
#[inline]
#[must_use]
pub const fn has_update_description(&self) -> bool {
self.update_description.is_some()
}
#[must_use]
pub fn estimated_size_bytes(&self) -> usize {
let mut size = std::mem::size_of::<Self>();
if let Some(doc) = &self.full_document {
size += estimate_document_size(doc);
}
if let Some(update_desc) = &self.update_description {
size += estimate_document_size(&update_desc.updated_fields);
size += update_desc
.removed_fields
.iter()
.map(String::len)
.sum::<usize>();
}
if let Some(doc_key) = &self.document_key {
size += estimate_document_size(doc_key);
}
size += estimate_document_size(&self.resume_token);
size
}
}
fn estimate_document_size(doc: &Document) -> usize {
doc.len() * 50
}
impl TryFrom<mongodb::change_stream::event::ChangeStreamEvent<Document>> for ChangeEvent {
type Error = ConversionError;
fn try_from(
event: mongodb::change_stream::event::ChangeStreamEvent<Document>,
) -> Result<Self, Self::Error> {
use mongodb::change_stream::event::OperationType as MongoOpType;
let operation = match event.operation_type {
MongoOpType::Insert => OperationType::Insert,
MongoOpType::Update => OperationType::Update,
MongoOpType::Delete => OperationType::Delete,
MongoOpType::Replace => OperationType::Replace,
MongoOpType::Invalidate => OperationType::Invalidate,
MongoOpType::Drop => OperationType::Drop,
MongoOpType::DropDatabase => OperationType::DropDatabase,
MongoOpType::Rename => OperationType::Rename,
_ => {
let op_str = format!("{:?}", event.operation_type);
eprintln!(
"Warning: Unknown MongoDB operation type encountered: {op_str}. \
This may indicate a newer MongoDB version than supported."
);
OperationType::Unknown(op_str)
}
};
let namespace = event.ns.map_or_else(
|| Namespace {
database: String::new(),
collection: String::new(),
},
|ns| Namespace {
database: ns.db,
collection: ns.coll.unwrap_or_default(),
},
);
let update_description = event.update_description.map(|ud| UpdateDescription {
updated_fields: ud.updated_fields,
removed_fields: ud.removed_fields,
truncated_arrays: ud.truncated_arrays.map(|arrays| {
arrays
.into_iter()
.map(|ta| TruncatedArray {
field: ta.field,
new_size: u32::try_from(ta.new_size).unwrap_or(0),
})
.collect()
}),
});
let cluster_time = event
.cluster_time
.map_or_else(|| {
eprintln!("Warning: Missing cluster_time in ChangeStreamEvent, using current time");
Utc::now()
}, |ts| {
let seconds = i64::from(ts.time);
let nanos = ts.increment * 1_000_000; DateTime::from_timestamp(seconds, nanos)
.unwrap_or_else(|| {
eprintln!(
"Warning: Invalid MongoDB timestamp (time={}, increment={}), using current time",
ts.time, ts.increment
);
Utc::now()
})
});
let resume_token = bson::to_document(&event.id).map_err(|e| {
ConversionError::ResumeTokenConversion(format!(
"Failed to serialize resume token to BSON document: {e}"
))
})?;
Ok(Self {
operation,
namespace,
document_key: event.document_key,
full_document: event.full_document,
update_description,
cluster_time,
resume_token,
})
}
}