use crate::api::{RedDBError, RedDBResult};
use crate::storage::unified::entity::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
use reddb_types::Value;
pub const CATALOG_COLLECTION: &str = "red.materialized_view.definitions";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MaterializedViewDescriptor {
pub name: String,
pub source_sql: String,
pub source_collections: Vec<String>,
pub refresh_every_ms: Option<u64>,
pub retention_duration_ms: Option<u64>,
}
impl MaterializedViewDescriptor {
fn to_row_entity(&self) -> UnifiedEntity {
let mut named = std::collections::HashMap::new();
named.insert("name".to_string(), Value::text(self.name.clone()));
named.insert(
"source_sql".to_string(),
Value::text(self.source_sql.clone()),
);
named.insert(
"source_collections".to_string(),
Value::Array(
self.source_collections
.iter()
.map(|s| Value::text(s.clone()))
.collect(),
),
);
named.insert(
"refresh_every_ms".to_string(),
match self.refresh_every_ms {
Some(ms) => Value::UnsignedInteger(ms),
None => Value::Null,
},
);
named.insert(
"retention_duration_ms".to_string(),
match self.retention_duration_ms {
Some(ms) => Value::UnsignedInteger(ms),
None => Value::Null,
},
);
UnifiedEntity::new(
EntityId::new(0),
EntityKind::TableRow {
table: std::sync::Arc::from(CATALOG_COLLECTION),
row_id: 0,
},
EntityData::Row(RowData {
columns: Vec::new(),
named: Some(named),
schema: None,
}),
)
}
}
#[cfg(test)]
fn decode_row(entity: &UnifiedEntity) -> Option<MaterializedViewDescriptor> {
decode_row_with_schema(entity, None)
}
fn decode_row_with_schema(
entity: &UnifiedEntity,
schema: Option<&Vec<String>>,
) -> Option<MaterializedViewDescriptor> {
let EntityData::Row(row) = &entity.data else {
return None;
};
let name = match field(row, schema, "name")? {
Value::Text(s) => s.to_string(),
_ => return None,
};
let source_sql = match field(row, schema, "source_sql")? {
Value::Text(s) => s.to_string(),
_ => return None,
};
let source_collections = match field(row, schema, "source_collections") {
Some(Value::Array(values)) => values
.iter()
.filter_map(|v| match v {
Value::Text(s) => Some(s.to_string()),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let refresh_every_ms = match field(row, schema, "refresh_every_ms") {
Some(Value::UnsignedInteger(ms)) => Some(*ms),
Some(Value::Integer(ms)) if *ms >= 0 => Some(*ms as u64),
_ => None,
};
let retention_duration_ms = match field(row, schema, "retention_duration_ms") {
Some(Value::UnsignedInteger(ms)) => Some(*ms),
Some(Value::Integer(ms)) if *ms >= 0 => Some(*ms as u64),
_ => None,
};
Some(MaterializedViewDescriptor {
name,
source_sql,
source_collections,
refresh_every_ms,
retention_duration_ms,
})
}
pub(crate) fn persist_descriptor(
store: &crate::storage::unified::UnifiedStore,
descriptor: &MaterializedViewDescriptor,
) -> RedDBResult<()> {
let _ = store.get_or_create_collection(CATALOG_COLLECTION);
remove_by_name(store, &descriptor.name)?;
let entity = descriptor.to_row_entity();
store
.insert_auto(CATALOG_COLLECTION, entity)
.map_err(|err| {
RedDBError::Internal(format!(
"persist materialized-view descriptor {}: {err}",
descriptor.name
))
})?;
Ok(())
}
pub(crate) fn remove_by_name(
store: &crate::storage::unified::UnifiedStore,
name: &str,
) -> RedDBResult<()> {
let Some(manager) = store.get_collection(CATALOG_COLLECTION) else {
return Ok(());
};
let mut to_delete: Vec<EntityId> = Vec::new();
let schema = manager.column_schema();
manager
.try_for_each_entity(usize::MAX, |entity| {
let EntityData::Row(row) = &entity.data else {
return true;
};
if let Some(Value::Text(stored)) = field(row, schema.as_deref(), "name") {
if stored.as_ref() == name {
to_delete.push(entity.id);
}
}
true
})
.map_err(|err| {
RedDBError::Internal(format!("scan materialized-view descriptors: {err}"))
})?;
for id in to_delete {
store.delete(CATALOG_COLLECTION, id).map_err(|err| {
RedDBError::Internal(format!(
"delete materialized-view descriptor row for {name}: {err}"
))
})?;
}
Ok(())
}
pub(crate) fn load_all(
store: &crate::storage::unified::UnifiedStore,
) -> RedDBResult<Vec<MaterializedViewDescriptor>> {
let Some(manager) = store.get_collection(CATALOG_COLLECTION) else {
return Ok(Vec::new());
};
let schema = manager.column_schema();
let mut descriptors = Vec::new();
manager
.try_for_each_entity(usize::MAX, |entity| {
if let Some(descriptor) = decode_row_with_schema(entity, schema.as_deref()) {
descriptors.push(descriptor);
}
true
})
.map_err(|err| {
RedDBError::Internal(format!("load materialized-view descriptors: {err}"))
})?;
Ok(descriptors)
}
fn field<'a>(row: &'a RowData, schema: Option<&Vec<String>>, name: &str) -> Option<&'a Value> {
if row.named.is_some() || row.schema.is_some() {
return row.get_field(name);
}
schema
.and_then(|columns| columns.iter().position(|column| column == name))
.and_then(|index| row.columns.get(index))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn row_entity_roundtrips_through_decode() {
let d = MaterializedViewDescriptor {
name: "v".into(),
source_sql: "CREATE MATERIALIZED VIEW v AS SELECT * FROM t".into(),
source_collections: vec!["t".into(), "u".into()],
refresh_every_ms: Some(60_000),
retention_duration_ms: Some(7 * 24 * 3_600_000),
};
let entity = d.to_row_entity();
let back = decode_row(&entity).expect("decode");
assert_eq!(d, back);
}
#[test]
fn null_options_decode_to_none() {
let d = MaterializedViewDescriptor {
name: "v".into(),
source_sql: "CREATE MATERIALIZED VIEW v AS SELECT 1".into(),
source_collections: vec![],
refresh_every_ms: None,
retention_duration_ms: None,
};
let entity = d.to_row_entity();
let back = decode_row(&entity).expect("decode");
assert!(back.refresh_every_ms.is_none());
assert!(back.retention_duration_ms.is_none());
}
}