use std::collections::{BTreeMap, BTreeSet};
use mongreldb_core::Database;
use mongreldb_protocol::prepared::{PreparedStatementBinding, StatementId};
use mongreldb_types::ids::{MetadataVersion, SchemaVersion, TableId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CatalogState {
pub(crate) catalog_version: MetadataVersion,
pub(crate) schema_versions: BTreeMap<TableId, SchemaVersion>,
}
impl CatalogState {
pub(crate) fn capture(db: &Database) -> Self {
let catalog = db.catalog_snapshot();
let schema_versions = catalog
.tables
.iter()
.filter(|entry| matches!(entry.state, mongreldb_core::catalog::TableState::Live))
.map(|entry| {
(
TableId::new(entry.table_id),
schema_version_of(
entry.table_id,
&entry.name,
entry.created_epoch,
&entry.schema,
),
)
})
.collect();
Self {
catalog_version: MetadataVersion::new(catalog.db_epoch),
schema_versions,
}
}
pub(crate) fn is_compatible(&self, binding: &PreparedStatementBinding) -> bool {
binding.is_compatible(self.catalog_version, &self.schema_versions)
}
}
pub(crate) fn build_binding(
statement_id: StatementId,
sql: String,
parameter_types: Vec<String>,
state: &CatalogState,
) -> PreparedStatementBinding {
PreparedStatementBinding {
statement_id,
sql,
parameter_types,
catalog_version: state.catalog_version,
schema_versions: state.schema_versions.clone(),
feature_set: BTreeSet::new(),
}
}
fn schema_version_of(
table_id: u64,
name: &str,
created_epoch: u64,
schema: &mongreldb_core::schema::Schema,
) -> SchemaVersion {
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET_BASIS;
let mut absorb = |bytes: &[u8]| {
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
}
};
absorb(&table_id.to_le_bytes());
absorb(name.as_bytes());
absorb(&created_epoch.to_le_bytes());
if let Ok(encoded) = bincode::serialize(schema) {
absorb(&encoded);
}
SchemaVersion::new(hash)
}
pub(crate) fn parameter_type_names(params: &[serde_json::Value]) -> Vec<String> {
params
.iter()
.map(|value| {
match value {
serde_json::Value::Null => "NULL",
serde_json::Value::Bool(_) => "BOOL",
serde_json::Value::Number(number) => {
if number.is_i64() || number.is_u64() {
"INT64"
} else {
"FLOAT64"
}
}
serde_json::Value::String(_) => "TEXT",
serde_json::Value::Array(_) | serde_json::Value::Object(_) => "BYTES",
}
.to_owned()
})
.collect()
}
pub(crate) fn validate_parameter_type_names(names: &[String]) -> Result<(), String> {
const CANONICAL: [&str; 6] = ["NULL", "BOOL", "INT64", "FLOAT64", "TEXT", "BYTES"];
for name in names {
if !CANONICAL.contains(&name.as_str()) {
return Err(format!(
"unknown parameter type {name:?}; expected one of {CANONICAL:?}"
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mongreldb_core::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
use std::sync::Arc;
use tempfile::tempdir;
fn int64_pk_schema(id: u16, name: &str) -> Schema {
Schema {
columns: vec![ColumnDef {
id,
name: name.into(),
ty: TypeId::Int64,
flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
default_value: None,
embedding_source: None,
}],
..Schema::default()
}
}
#[test]
fn catalog_state_changes_on_ddl() {
let dir = tempdir().unwrap();
let db = Arc::new(Database::create(dir.path()).unwrap());
let empty = CatalogState::capture(&db);
assert!(empty.schema_versions.is_empty());
db.create_table("items", int64_pk_schema(1, "id")).unwrap();
let with_table = CatalogState::capture(&db);
assert_ne!(empty.catalog_version, with_table.catalog_version);
assert_eq!(with_table.schema_versions.len(), 1);
db.create_table("more", int64_pk_schema(1, "id")).unwrap();
let with_two = CatalogState::capture(&db);
assert_ne!(with_table.catalog_version, with_two.catalog_version);
assert_eq!(with_two.schema_versions.len(), 2);
let (first_id, first_version) = with_two
.schema_versions
.iter()
.next()
.map(|(id, version)| (*id, *version))
.unwrap();
db.drop_table("items").unwrap();
let after_drop = CatalogState::capture(&db);
assert_eq!(after_drop.schema_versions.len(), 1);
assert!(!after_drop.schema_versions.contains_key(&first_id));
db.create_table("items", int64_pk_schema(1, "id")).unwrap();
let recreated = CatalogState::capture(&db);
assert!(
!recreated.schema_versions.contains_key(&first_id)
|| recreated.schema_versions.get(&first_id) != Some(&first_version),
"drop+recreate must not resurrect the old binding entry"
);
}
#[test]
fn binding_compatibility_follows_catalog_state() {
let dir = tempdir().unwrap();
let db = Arc::new(Database::create(dir.path()).unwrap());
db.create_table("items", int64_pk_schema(1, "id")).unwrap();
let planned = CatalogState::capture(&db);
let binding = build_binding(
StatementId::new(1),
"SELECT id FROM items".to_owned(),
vec![],
&planned,
);
assert!(planned.is_compatible(&binding));
db.create_table("other", int64_pk_schema(1, "id")).unwrap();
let changed = CatalogState::capture(&db);
assert!(
!changed.is_compatible(&binding),
"any catalog change invalidates the binding (catalog version moves)"
);
}
#[test]
fn json_parameters_map_to_canonical_type_names() {
let params = vec![
serde_json::Value::Null,
serde_json::json!(true),
serde_json::json!(-42),
serde_json::json!(2.5),
serde_json::json!("text"),
serde_json::json!([1, 2]),
];
assert_eq!(
parameter_type_names(¶ms),
vec!["NULL", "BOOL", "INT64", "FLOAT64", "TEXT", "BYTES"]
);
assert!(parameter_type_names(&[]).is_empty());
}
#[test]
fn declared_parameter_types_are_validated() {
assert!(validate_parameter_type_names(&[]).is_ok());
assert!(validate_parameter_type_names(&["INT64".to_owned(), "TEXT".to_owned()]).is_ok());
assert!(validate_parameter_type_names(&["int64".to_owned()]).is_err());
assert!(validate_parameter_type_names(&["DATE".to_owned()]).is_err());
}
}