use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct MaterializedView {
pub name: String,
pub sql: String,
pub base_table: String,
pub aggregate_columns: Vec<String>,
pub group_by_columns: Vec<String>,
pub created_at: Instant,
}
#[derive(Debug, Clone)]
pub struct MatchResult {
pub view: Option<MaterializedView>,
pub fallback_reason: Option<String>,
}
#[derive(Debug)]
pub struct MaterializedViewMatcher {
views: Vec<MaterializedView>,
ttl: Duration,
}
impl MaterializedViewMatcher {
pub fn new(ttl: Duration) -> Self {
Self {
views: Vec::new(),
ttl,
}
}
pub fn with_defaults() -> Self {
Self::new(Duration::from_secs(3600))
}
pub fn register(&mut self, view: MaterializedView) {
self.views.push(view);
}
pub fn match_view(
&self,
base_table: &str,
group_by: &[String],
aggregates: &[String],
) -> MatchResult {
for view in &self.views {
if view.base_table != base_table {
continue;
}
if view.group_by_columns != group_by {
continue;
}
let all_covered = aggregates
.iter()
.all(|a| view.aggregate_columns.contains(a));
if !all_covered {
continue;
}
if self.is_expired(view) {
return MatchResult {
view: None,
fallback_reason: Some(format!(
"MV {} expired, fallback to base table",
view.name
)),
};
}
return MatchResult {
view: Some(view.clone()),
fallback_reason: None,
};
}
MatchResult {
view: None,
fallback_reason: Some("no matching materialized view".into()),
}
}
pub fn is_expired(&self, view: &MaterializedView) -> bool {
view.created_at.elapsed() > self.ttl
}
pub fn len(&self) -> usize {
self.views.len()
}
pub fn is_empty(&self) -> bool {
self.views.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mv(name: &str, base: &str, agg: Vec<&str>, group: Vec<&str>) -> MaterializedView {
MaterializedView {
name: name.into(),
sql: format!(
"SELECT {} FROM {} GROUP BY {}",
agg.join(","),
base,
group.join(",")
),
base_table: base.into(),
aggregate_columns: agg.into_iter().map(String::from).collect(),
group_by_columns: group.into_iter().map(String::from).collect(),
created_at: Instant::now(),
}
}
#[test]
fn match_exact_view() {
let mut m = MaterializedViewMatcher::with_defaults();
m.register(mv(
"mv_sales_dept",
"sales",
vec!["SUM(amount)"],
vec!["dept"],
));
let result = m.match_view("sales", &["dept".into()], &["SUM(amount)".into()]);
assert!(result.view.is_some());
assert_eq!(result.view.unwrap().name, "mv_sales_dept");
}
#[test]
fn no_match_different_group_by() {
let mut m = MaterializedViewMatcher::with_defaults();
m.register(mv("mv1", "sales", vec!["SUM(amount)"], vec!["dept"]));
let result = m.match_view("sales", &["region".into()], &["SUM(amount)".into()]);
assert!(result.view.is_none());
}
#[test]
fn no_match_different_base_table() {
let mut m = MaterializedViewMatcher::with_defaults();
m.register(mv("mv1", "orders", vec!["SUM(qty)"], vec!["dept"]));
let result = m.match_view("sales", &["dept".into()], &["SUM(qty)".into()]);
assert!(result.view.is_none());
}
#[test]
fn expired_mv_fallback() {
let mut m = MaterializedViewMatcher::new(Duration::from_millis(0));
m.register(mv("mv1", "sales", vec!["SUM(amount)"], vec!["dept"]));
std::thread::sleep(Duration::from_millis(1));
let result = m.match_view("sales", &["dept".into()], &["SUM(amount)".into()]);
assert!(result.view.is_none());
assert!(result.fallback_reason.as_ref().unwrap().contains("expired"));
}
#[test]
fn aggregate_subset_matches() {
let mut m = MaterializedViewMatcher::with_defaults();
m.register(mv(
"mv1",
"sales",
vec!["SUM(amount)", "COUNT(*)"],
vec!["dept"],
));
let result = m.match_view("sales", &["dept".into()], &["SUM(amount)".into()]);
assert!(result.view.is_some());
}
}