pub mod aggregator;
pub mod cql_parser;
pub mod discovery;
#[cfg(feature = "experimental")]
pub mod json_exporter;
pub mod parser;
pub mod registry;
mod cql_type_parser;
mod key_ordering;
mod schema_comparator;
mod udt_registry;
pub use udt_registry::UdtRegistry;
#[cfg(test)]
pub(crate) mod work_counters {
use std::cell::Cell;
thread_local! {
static PARSE_CALLS: Cell<usize> = const { Cell::new(0) };
static SCHEMA_CLONES: Cell<usize> = const { Cell::new(0) };
}
pub(crate) fn record_parse_call() {
PARSE_CALLS.with(|c| c.set(c.get() + 1));
}
pub(crate) fn record_schema_clone() {
SCHEMA_CLONES.with(|c| c.set(c.get() + 1));
}
pub(crate) fn reset() {
PARSE_CALLS.with(|c| c.set(0));
SCHEMA_CLONES.with(|c| c.set(0));
}
pub(crate) fn parse_calls() -> usize {
PARSE_CALLS.with(|c| c.get())
}
pub(crate) fn schema_clones() -> usize {
SCHEMA_CLONES.with(|c| c.get())
}
}
pub use aggregator::{
AggregatorConfig, LoadErrorType, LoadResult, SchemaAggregator, SchemaLoadError,
SchemaLoadWarning,
};
pub use cql_parser::{
cql_type_to_type_id, extract_table_name, parse_cql_schema, parse_cql_schema_with_visitor,
parse_create_table, table_name_matches,
};
pub use discovery::{
ColumnDefinition, DiscoveryMethod, IndexDefinition, SchemaDiscoveryConfig,
SchemaDiscoveryEngine, SchemaInfo, SchemaMetadata, TableOptions, TypeInfo, UDTDefinition,
ValidationError, ValidationResults, ValidationStatus, ValidationWarning,
};
pub use registry::{
ParsingContext, RegistryStatistics, SchemaChange, SchemaChangeType, SchemaQuery,
SchemaRegistry, SchemaRegistryConfig, SchemaSource, SchemaValidationStatus, SchemaValidator,
SchemaVersion, ValidationReport,
};
#[cfg(test)]
thread_local! {
pub(crate) static TABLE_SCHEMA_CLONES: std::cell::Cell<usize> =
const { std::cell::Cell::new(0) };
}
pub use parser::SchemaParser;
#[cfg(feature = "experimental")]
pub use json_exporter::{
JsonClusteringKey, JsonColumn, JsonExportConfig, JsonExporter, JsonFormat, JsonIndex,
JsonMetadata, JsonPerformanceMetrics, JsonPrimaryKey, JsonSchema, JsonTable, JsonTableOptions,
JsonUDT, JsonValidationResults,
};
pub type ColumnSpec = Column;
use crate::error::{Error, Result};
use crate::parser::header::SSTableHeader;
use crate::parser::types::CqlTypeId;
use crate::storage::StorageEngine;
use crate::types::UdtTypeDef;
use crate::Config;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableSchema {
pub keyspace: String,
pub table: String,
pub partition_keys: Vec<KeyColumn>,
pub clustering_keys: Vec<ClusteringColumn>,
pub columns: Vec<Column>,
#[serde(default)]
pub comments: HashMap<String, String>,
#[serde(default)]
pub dropped_columns: HashMap<String, i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyColumn {
pub name: String,
#[serde(rename = "type")]
pub data_type: String,
pub position: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusteringColumn {
pub name: String,
#[serde(rename = "type")]
pub data_type: String,
pub position: usize,
#[serde(default)]
pub order: ClusteringOrder,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ClusteringOrder {
#[default]
Asc,
Desc,
}
impl From<&str> for ClusteringOrder {
fn from(s: &str) -> Self {
match s.to_uppercase().as_str() {
"DESC" => ClusteringOrder::Desc,
_ => ClusteringOrder::Asc,
}
}
}
impl std::fmt::Display for ClusteringOrder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ClusteringOrder::Asc => write!(f, "ASC"),
ClusteringOrder::Desc => write!(f, "DESC"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
pub name: String,
#[serde(rename = "type")]
pub data_type: String,
#[serde(default)]
pub nullable: bool,
#[serde(default)]
pub default: Option<serde_json::Value>,
#[serde(default)]
pub is_static: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum CqlType {
Boolean,
TinyInt,
SmallInt,
Int,
BigInt,
Counter,
Float,
Double,
Decimal,
Text,
Ascii,
Varchar,
Blob,
Timestamp,
Date,
Time,
Uuid,
TimeUuid,
Inet,
Duration,
Varint,
List(Box<CqlType>),
Set(Box<CqlType>),
Map(Box<CqlType>, Box<CqlType>),
Tuple(Vec<CqlType>),
Udt(String, Vec<(String, CqlType)>), Frozen(Box<CqlType>),
Custom(String),
}
pub(crate) fn is_udt_identifier(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
}
impl TableSchema {
pub fn from_sstable_header(header: &SSTableHeader) -> Result<Self> {
let mut partition_keys = Vec::new();
let mut clustering_keys = Vec::new();
let mut regular_columns = Vec::new();
for col_info in &header.columns {
if col_info.is_primary_key {
if col_info.is_clustering {
clustering_keys.push(col_info);
} else {
partition_keys.push(col_info);
}
} else {
regular_columns.push(col_info);
}
}
for col_info in &partition_keys {
if col_info.key_position.is_none() {
return Err(Error::schema(format!(
"Partition key column '{}' missing key_position in SSTable header",
col_info.name
)));
}
}
for col_info in &clustering_keys {
if col_info.key_position.is_none() {
return Err(Error::schema(format!(
"Clustering key column '{}' missing key_position in SSTable header",
col_info.name
)));
}
}
partition_keys.sort_by_key(|c| c.key_position.unwrap());
clustering_keys.sort_by_key(|c| c.key_position.unwrap());
let partition_keys: Vec<KeyColumn> = partition_keys
.iter()
.enumerate()
.map(|(pos, col)| KeyColumn {
name: col.name.clone(),
data_type: col.column_type.clone(),
position: pos, })
.collect();
let clustering_keys: Vec<ClusteringColumn> = clustering_keys
.iter()
.enumerate()
.map(|(pos, col)| ClusteringColumn {
name: col.name.clone(),
data_type: col.column_type.clone(),
position: pos, order: if col.clustering_reversed {
ClusteringOrder::Desc
} else {
ClusteringOrder::Asc
},
})
.collect();
let columns: Vec<Column> = header
.columns
.iter()
.map(|col| Column {
name: col.name.clone(),
data_type: col.column_type.clone(),
nullable: !col.is_primary_key, default: None,
is_static: col.is_static,
})
.collect();
if partition_keys.is_empty() {
return Err(Error::schema(
"No partition keys found in SSTable header".to_string(),
));
}
let schema = TableSchema {
keyspace: header.keyspace.clone(),
table: header.table_name.clone(),
partition_keys,
clustering_keys,
columns,
comments: HashMap::new(),
dropped_columns: HashMap::new(),
};
schema.validate()?;
Ok(schema)
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path)
.map_err(|e| Error::schema(format!("Failed to read schema file: {}", e)))?;
Self::from_json(&content)
}
pub fn from_json(json: &str) -> Result<Self> {
let schema: TableSchema = serde_json::from_str(json)
.map_err(|e| Error::schema(format!("Invalid JSON schema: {}", e)))?;
schema.validate()?;
Ok(schema)
}
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| Error::serialization(format!("Failed to serialize schema: {}", e)))?;
fs::write(path, json)
.map_err(|e| Error::schema(format!("Failed to write schema file: {}", e)))?;
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.keyspace.is_empty() {
return Err(Error::schema("Keyspace name cannot be empty".to_string()));
}
if self.table.is_empty() {
return Err(Error::schema("Table name cannot be empty".to_string()));
}
if self.partition_keys.is_empty() {
return Err(Error::schema(
"Table must have at least one partition key".to_string(),
));
}
let mut positions: Vec<_> = self.partition_keys.iter().map(|k| k.position).collect();
positions.sort();
for (i, &pos) in positions.iter().enumerate() {
if pos != i {
return Err(Error::schema(format!(
"Partition key positions must be contiguous starting from 0, found gap at position {}",
i
)));
}
}
if !self.clustering_keys.is_empty() {
let mut positions: Vec<_> = self.clustering_keys.iter().map(|k| k.position).collect();
positions.sort();
for (i, &pos) in positions.iter().enumerate() {
if pos != i {
return Err(Error::schema(format!(
"Clustering key positions must be contiguous starting from 0, found gap at position {}",
i
)));
}
}
}
for column in &self.columns {
CqlType::parse(&column.data_type).map_err(|e| {
Error::schema(format!(
"Invalid data type '{}' for column '{}': {}",
column.data_type, column.name, e
))
})?;
}
for key in &self.partition_keys {
if !self.columns.iter().any(|c| c.name == key.name) {
return Err(Error::schema(format!(
"Partition key '{}' not found in columns list",
key.name
)));
}
}
for key in &self.clustering_keys {
if !self.columns.iter().any(|c| c.name == key.name) {
return Err(Error::schema(format!(
"Clustering key '{}' not found in columns list",
key.name
)));
}
}
self.validate_dropped_columns()?;
Ok(())
}
pub fn for_compaction_output(
&self,
retained: &std::collections::HashSet<String>,
) -> TableSchema {
TableSchema {
keyspace: self.keyspace.clone(),
table: self.table.clone(),
partition_keys: self.partition_keys.clone(),
clustering_keys: self.clustering_keys.clone(),
columns: self
.columns
.iter()
.filter(|c| {
!self.dropped_columns.contains_key(&c.name) || retained.contains(&c.name)
})
.cloned()
.collect(),
comments: self.comments.clone(),
dropped_columns: HashMap::new(),
}
}
pub fn validate_dropped_columns(&self) -> Result<()> {
for name in self.dropped_columns.keys() {
if !self.columns.iter().any(|c| &c.name == name) {
return Err(Error::schema(format!(
"dropped column '{}' must remain declared in `columns` (with its type) so \
its cells can be decoded and purged during compaction; a dropped column \
present only in `dropped_columns` cannot be decoded (see #904/#847)",
name
)));
}
}
Ok(())
}
pub fn validate_udt_references(&self, registry: &UdtRegistry) -> Result<()> {
for column in &self.columns {
if let Ok(cql_type) = CqlType::parse(&column.data_type) {
self.check_type_udt_references(&cql_type, &column.name, registry)?;
}
}
Ok(())
}
fn check_type_udt_references(
&self,
cql_type: &CqlType,
column_name: &str,
registry: &UdtRegistry,
) -> Result<()> {
match cql_type {
CqlType::Udt(name, _) => {
self.ensure_udt_exists(name, column_name, registry)?;
}
CqlType::Custom(name) => {
let udt_name = name.strip_prefix("udt:").unwrap_or(name);
if is_udt_identifier(udt_name) {
self.ensure_udt_exists(udt_name, column_name, registry)?;
}
}
CqlType::List(inner) | CqlType::Set(inner) | CqlType::Frozen(inner) => {
self.check_type_udt_references(inner, column_name, registry)?;
}
CqlType::Map(key_type, value_type) => {
self.check_type_udt_references(key_type, column_name, registry)?;
self.check_type_udt_references(value_type, column_name, registry)?;
}
CqlType::Tuple(field_types) => {
for field_type in field_types {
self.check_type_udt_references(field_type, column_name, registry)?;
}
}
_ => {} }
Ok(())
}
fn ensure_udt_exists(
&self,
udt_name: &str,
column_name: &str,
registry: &UdtRegistry,
) -> Result<()> {
let (lookup_keyspace, bare_name) = match udt_name.split_once('.') {
Some((ks, name)) => (ks, name),
None => (self.keyspace.as_str(), udt_name),
};
if registry.contains_udt(lookup_keyspace, bare_name)
|| registry.contains_udt("system", bare_name)
{
return Ok(());
}
Err(Error::schema(format!(
"Column '{}' references undefined UDT '{}' in keyspace '{}'",
column_name, udt_name, lookup_keyspace
)))
}
pub fn get_column(&self, name: &str) -> Option<&Column> {
self.columns.iter().find(|c| c.name == name)
}
pub fn is_partition_key(&self, name: &str) -> bool {
self.partition_keys.iter().any(|k| k.name == name)
}
pub fn is_clustering_key(&self, name: &str) -> bool {
self.clustering_keys.iter().any(|k| k.name == name)
}
#[cfg(test)]
pub fn new_for_testing(keyspace: &str, table: &str) -> Self {
Self {
keyspace: keyspace.to_string(),
table: table.to_string(),
partition_keys: vec![KeyColumn {
name: "id".to_string(),
data_type: "int".to_string(),
position: 0,
}],
clustering_keys: vec![],
columns: vec![Column {
name: "id".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_static: false,
}],
comments: HashMap::new(),
dropped_columns: HashMap::new(),
}
}
}
#[derive(Debug)]
pub struct SchemaManager {
#[allow(dead_code)]
storage: Arc<StorageEngine>,
registry: Arc<RwLock<registry::SchemaRegistry>>,
}
impl SchemaManager {
async fn default_registry(
platform: Arc<crate::platform::Platform>,
config: &Config,
) -> Result<Arc<RwLock<registry::SchemaRegistry>>> {
let registry = registry::SchemaRegistry::new(
registry::SchemaRegistryConfig::default(),
platform,
config.clone(),
)
.await?;
Ok(Arc::new(RwLock::new(registry)))
}
pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let config = Config::default();
let platform = Arc::new(crate::platform::Platform::new(&config).await?);
let storage = Arc::new(
StorageEngine::open(
path.as_ref(),
&config,
platform.clone(),
#[cfg(feature = "state_machine")]
None,
)
.await?,
);
let registry = Self::default_registry(platform, &config).await?;
Ok(Self { storage, registry })
}
pub async fn new_with_storage(storage: Arc<StorageEngine>, config: &Config) -> Result<Self> {
let platform = Arc::new(crate::platform::Platform::new(config).await?);
let registry = Self::default_registry(platform, config).await?;
let manager = Self { storage, registry };
manager.load_default_udts().await;
Ok(manager)
}
pub async fn new_with_registry(
storage: Arc<StorageEngine>,
registry: Arc<tokio::sync::RwLock<registry::SchemaRegistry>>,
_config: &Config,
) -> Result<Self> {
Ok(Self { storage, registry })
}
#[cfg(test)]
pub(crate) fn registry(&self) -> Arc<RwLock<registry::SchemaRegistry>> {
self.registry.clone()
}
async fn load_default_udts(&self) {
let address_udt = UdtTypeDef::new("test_keyspace".to_string(), "address".to_string())
.with_field("street".to_string(), CqlType::Text, true)
.with_field("city".to_string(), CqlType::Text, true)
.with_field("state".to_string(), CqlType::Text, true)
.with_field("zip_code".to_string(), CqlType::Text, true)
.with_field("country".to_string(), CqlType::Text, true);
self.register_udt(address_udt).await;
let person_udt = UdtTypeDef::new("test_keyspace".to_string(), "person".to_string())
.with_field("name".to_string(), CqlType::Text, true)
.with_field("age".to_string(), CqlType::Int, true)
.with_field("email".to_string(), CqlType::Text, true)
.with_field(
"addresses".to_string(),
CqlType::List(Box::new(CqlType::Udt(
"address".to_string(),
vec![
("street".to_string(), CqlType::Text),
("city".to_string(), CqlType::Text),
("state".to_string(), CqlType::Text),
("zip_code".to_string(), CqlType::Text),
("country".to_string(), CqlType::Text),
],
))),
true,
)
.with_field(
"contact_info".to_string(),
CqlType::Map(Box::new(CqlType::Text), Box::new(CqlType::Text)),
true,
);
self.register_udt(person_udt).await;
let company_udt = UdtTypeDef::new("test_keyspace".to_string(), "company".to_string())
.with_field("name".to_string(), CqlType::Text, false)
.with_field(
"headquarters".to_string(),
CqlType::Udt(
"address".to_string(),
vec![
("street".to_string(), CqlType::Text),
("city".to_string(), CqlType::Text),
("state".to_string(), CqlType::Text),
("zip_code".to_string(), CqlType::Text),
("country".to_string(), CqlType::Text),
],
),
true,
)
.with_field(
"employees".to_string(),
CqlType::Set(Box::new(CqlType::Udt("person".to_string(), vec![]))),
true,
)
.with_field("founded_year".to_string(), CqlType::Int, true);
self.register_udt(company_udt).await;
}
pub async fn register_udt(&self, udt_def: UdtTypeDef) {
let _ = self.registry.read().await.register_udt(udt_def).await;
}
pub async fn get_udt(&self, keyspace: &str, name: &str) -> Option<UdtTypeDef> {
self.registry
.read()
.await
.get_udt(keyspace, name)
.await
.ok()
.flatten()
}
pub async fn load_schema(&self, table_name: &str) -> Result<TableSchema> {
let (keyspace, table) = match table_name.split_once('.') {
Some((ks, tbl)) => (Some(ks.to_string()), tbl),
None => (None, table_name),
};
self.find_schema_by_table(&keyspace, table)
.await?
.ok_or_else(|| {
Error::schema(format!(
"unknown table {}; no schema registered or discovered",
table_name
))
})
}
pub async fn parse_and_register_cql_schema(&self, cql: &str) -> Result<TableSchema> {
let schema = cql_parser::parse_cql_schema(cql)?;
self.registry
.read()
.await
.register_schema(schema.clone(), registry::SchemaSource::Cql(cql.to_string()))
.await?;
Ok(schema)
}
pub async fn find_schema_by_table(
&self,
keyspace: &Option<String>,
table: &str,
) -> Result<Option<TableSchema>> {
let found = self
.registry
.read()
.await
.find_schema_by_table(keyspace, table)
.await?;
#[cfg(test)]
if found.is_some() {
TABLE_SCHEMA_CLONES.with(|c| c.set(c.get() + 1));
}
Ok(found)
}
pub fn extract_table_info(&self, cql: &str) -> Result<(Option<String>, String)> {
cql_parser::extract_table_name(cql)
}
pub fn cql_type_to_internal(&self, cql_type: &str) -> Result<CqlTypeId> {
cql_parser::cql_type_to_type_id(cql_type)
}
pub async fn get_table_schema(&self, table_name: &str) -> Result<TableSchema> {
if let Some(schema) = self.find_schema_by_table(&None, table_name).await? {
Ok(schema)
} else {
Err(Error::Schema(format!(
"Table schema not found: {}",
table_name
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_validation() {
let schema_json = r#"
{
"keyspace": "test",
"table": "users",
"partition_keys": [
{"name": "id", "type": "bigint", "position": 0}
],
"clustering_keys": [],
"columns": [
{"name": "id", "type": "bigint", "nullable": false},
{"name": "name", "type": "text", "nullable": true}
]
}
"#;
let schema = TableSchema::from_json(schema_json).unwrap();
assert_eq!(schema.keyspace, "test");
assert_eq!(schema.table, "users");
assert_eq!(schema.partition_keys.len(), 1);
assert_eq!(schema.columns.len(), 2);
}
#[test]
fn test_schema_validation_failures() {
let invalid_schema = r#"
{
"keyspace": "test",
"table": "users",
"partition_keys": [],
"clustering_keys": [],
"columns": []
}
"#;
assert!(TableSchema::from_json(invalid_schema).is_err());
let invalid_type = r#"
{
"keyspace": "test",
"table": "users",
"partition_keys": [
{"name": "id", "type": "invalid_type", "position": 0}
],
"clustering_keys": [],
"columns": [
{"name": "id", "type": "invalid_type", "nullable": false}
]
}
"#;
assert!(TableSchema::from_json(invalid_type).is_ok());
}
fn udt_schema(column_type: &str) -> TableSchema {
TableSchema {
keyspace: "test_ks".to_string(),
table: "t".to_string(),
partition_keys: vec![KeyColumn {
name: "id".to_string(),
data_type: "uuid".to_string(),
position: 0,
}],
clustering_keys: vec![],
columns: vec![
Column {
name: "id".to_string(),
data_type: "uuid".to_string(),
nullable: false,
default: None,
is_static: false,
},
Column {
name: "value".to_string(),
data_type: column_type.to_string(),
nullable: true,
default: None,
is_static: false,
},
],
comments: HashMap::new(),
dropped_columns: HashMap::new(),
}
}
#[test]
fn test_udt_reference_undefined_top_level_errors() {
let registry = UdtRegistry::new();
let schema = udt_schema("MyMissingType");
let err = schema
.validate_udt_references(®istry)
.expect_err("undefined UDT reference must fail validation");
let msg = err.to_string();
assert!(
matches!(err, Error::Schema(_)),
"expected schema-category error, got {err:?}"
);
assert!(
msg.contains("MyMissingType"),
"error must name the missing UDT, got: {msg}"
);
}
#[test]
fn test_udt_reference_undefined_lowercase_errors() {
let registry = UdtRegistry::new();
for col_type in ["address", "list<frozen<address>>"] {
let schema = udt_schema(col_type);
let err = schema
.validate_udt_references(®istry)
.expect_err("undefined lowercase UDT must fail validation");
assert!(matches!(err, Error::Schema(_)), "got {err:?}");
assert!(
err.to_string().contains("address"),
"error must name the missing UDT, got: {err}"
);
}
}
#[test]
fn test_uppercase_collection_of_primitives_is_not_a_udt() {
let registry = UdtRegistry::new();
for col_type in [
"SET<TEXT>",
"LIST<INT>",
"MAP<TEXT, TEXT>",
"FROZEN<LIST<INT>>",
] {
let schema = udt_schema(col_type);
schema
.validate_udt_references(®istry)
.unwrap_or_else(|e| panic!("'{col_type}' must not be flagged as a UDT: {e}"));
}
}
#[test]
fn test_uppercase_collection_with_undefined_udt_errors() {
let registry = UdtRegistry::new();
for col_type in [
"LIST<MissingType>",
"MAP<TEXT, MissingType>",
"FROZEN<SET<MissingType>>",
] {
let schema = udt_schema(col_type);
let err = schema
.validate_udt_references(®istry)
.expect_err("undefined UDT in uppercase collection must fail");
assert!(matches!(err, Error::Schema(_)), "got {err:?}");
assert!(
err.to_string().contains("MissingType"),
"error must name the missing UDT, got: {err}"
);
}
}
#[test]
fn test_udt_reference_undefined_nested_in_collection_errors() {
let registry = UdtRegistry::new();
let schema = udt_schema("list<frozen<NestedMissing>>");
let err = schema
.validate_udt_references(®istry)
.expect_err("nested undefined UDT reference must fail validation");
let msg = err.to_string();
assert!(matches!(err, Error::Schema(_)));
assert!(
msg.contains("NestedMissing"),
"error must name the nested missing UDT, got: {msg}"
);
}
#[test]
fn test_udt_reference_undefined_nested_in_map_errors() {
let registry = UdtRegistry::new();
let schema = udt_schema("map<text, MapValueMissing>");
let err = schema
.validate_udt_references(®istry)
.expect_err("undefined UDT in map value must fail validation");
assert!(matches!(err, Error::Schema(_)));
assert!(err.to_string().contains("MapValueMissing"));
}
#[test]
fn test_udt_reference_defined_top_level_ok() {
let mut registry = UdtRegistry::new();
registry.register_udt(
UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
"a".to_string(),
CqlType::Text,
true,
),
);
let schema = udt_schema("MyType");
schema
.validate_udt_references(®istry)
.expect("defined UDT should validate");
}
#[test]
fn test_udt_reference_defined_nested_ok() {
let mut registry = UdtRegistry::new();
registry.register_udt(
UdtTypeDef::new("test_ks".to_string(), "MyType".to_string()).with_field(
"a".to_string(),
CqlType::Text,
true,
),
);
let schema = udt_schema("list<frozen<MyType>>");
schema
.validate_udt_references(®istry)
.expect("defined nested UDT should validate");
}
#[test]
fn test_validate_udt_references_no_udts_ok() {
let registry = UdtRegistry::new();
let schema = udt_schema("map<text, list<int>>");
schema
.validate_udt_references(®istry)
.expect("primitive/collection-only schema should validate");
}
async fn build_storage() -> Arc<StorageEngine> {
let config = Config::default();
let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
let temp_dir = tempfile::tempdir().unwrap();
Arc::new(
StorageEngine::open(
temp_dir.path(),
&config,
platform,
#[cfg(feature = "state_machine")]
None,
)
.await
.unwrap(),
)
}
async fn build_shared_registry() -> Arc<RwLock<registry::SchemaRegistry>> {
let config = Config::default();
let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
Arc::new(RwLock::new(
registry::SchemaRegistry::new(
registry::SchemaRegistryConfig::default(),
platform,
config,
)
.await
.unwrap(),
))
}
async fn test_manager() -> Arc<SchemaManager> {
let config = Config::default();
let storage = build_storage().await;
Arc::new(
SchemaManager::new_with_storage(storage, &config)
.await
.unwrap(),
)
}
fn table_schema_named(table_name: &str) -> TableSchema {
let mut schema = udt_schema("text");
schema.table = table_name.to_string();
schema
}
#[tokio::test]
async fn test_load_schema_unknown_table_errs() {
let manager = test_manager().await;
let err = manager
.load_schema("never_defined_table")
.await
.expect_err("unknown table must error, not fabricate a schema");
assert!(matches!(err, Error::Schema(_)), "got {err:?}");
assert!(
err.to_string().contains("never_defined_table"),
"error must name the unknown table, got: {err}"
);
let stats = manager
.registry()
.read()
.await
.get_statistics()
.await
.unwrap();
assert_eq!(
stats.total_schemas, 0,
"unknown-table lookup must not mutate the registry"
);
}
#[tokio::test]
async fn test_concurrent_schema_access() {
let manager = test_manager().await;
for i in 0..3 {
let name = format!("table_{}", i);
manager
.registry()
.read()
.await
.register_schema(table_schema_named(&name), registry::SchemaSource::Manual)
.await
.unwrap();
}
let mut handles = vec![];
for i in 0..10 {
let m = Arc::clone(&manager);
let handle = tokio::spawn(async move {
let table = format!("table_{}", i % 3);
let schema = m.load_schema(&table).await.expect("registered table loads");
assert_eq!(schema.table, table);
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
let stats = manager
.registry()
.read()
.await
.get_statistics()
.await
.unwrap();
assert_eq!(stats.total_schemas, 3);
for i in 0..3 {
assert!(manager.load_schema(&format!("table_{}", i)).await.is_ok());
}
}
#[tokio::test]
async fn test_manager_reflects_registry_updates_no_stale_snapshot() {
let registry = build_shared_registry().await;
let v1 = table_schema_named("evolving");
assert_eq!(v1.columns.len(), 2);
registry
.read()
.await
.register_schema(v1.clone(), registry::SchemaSource::Manual)
.await
.unwrap();
let storage = build_storage().await;
let config = Config::default();
let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
.await
.unwrap();
let got_v1 = manager.get_table_schema("evolving").await.unwrap();
assert_eq!(got_v1.columns.len(), 2, "manager must see v1");
let mut v2 = v1.clone();
v2.columns.push(Column {
name: "extra".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_static: false,
});
registry
.read()
.await
.register_schema(v2, registry::SchemaSource::Manual)
.await
.unwrap();
let got_v2 = manager.get_table_schema("evolving").await.unwrap();
assert_eq!(
got_v2.columns.len(),
3,
"manager must resolve THROUGH the registry, not a stale by-value snapshot"
);
}
#[tokio::test]
async fn test_manager_does_not_serve_ttl_expired_schema() {
let config = Config::default();
let platform = Arc::new(crate::platform::Platform::new(&config).await.unwrap());
let mut reg_config = registry::SchemaRegistryConfig::default();
reg_config.cache_ttl_seconds = 0; let registry = Arc::new(RwLock::new(
registry::SchemaRegistry::new(reg_config, platform, config.clone())
.await
.unwrap(),
));
registry
.read()
.await
.register_schema(
table_schema_named("stale_tbl"),
registry::SchemaSource::Manual,
)
.await
.unwrap();
let storage = build_storage().await;
let manager = SchemaManager::new_with_registry(storage, registry.clone(), &config)
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let loaded = manager.load_schema("stale_tbl").await;
assert!(
loaded.is_err(),
"manager must delegate TTL freshness to the registry, not serve a stale schema; got {loaded:?}"
);
let got = manager.get_table_schema("stale_tbl").await;
assert!(
got.is_err(),
"get_table_schema must also honor registry expiry; got {got:?}"
);
assert!(
manager.load_schema("never_defined_here").await.is_err(),
"unknown table still errors (no fabrication)"
);
}
#[tokio::test]
async fn test_all_constructors_share_udt_registry() {
let temp = tempfile::tempdir().unwrap();
let via_new = SchemaManager::new(temp.path()).await.unwrap();
let config = Config::default();
let via_storage = {
let storage = build_storage().await;
SchemaManager::new_with_storage(storage, &config)
.await
.unwrap()
};
let via_registry = {
let registry = build_shared_registry().await;
let storage = build_storage().await;
SchemaManager::new_with_registry(storage, registry, &config)
.await
.unwrap()
};
for (label, manager) in [
("new", &via_new),
("new_with_storage", &via_storage),
("new_with_registry", &via_registry),
] {
let point = UdtTypeDef::new("ks_share".to_string(), "point".to_string()).with_field(
"x".to_string(),
CqlType::Int,
true,
);
manager
.registry()
.read()
.await
.register_udt(point)
.await
.unwrap();
assert!(
manager.get_udt("ks_share", "point").await.is_some(),
"[{label}] UDT registered in the shared registry must be visible via the manager"
);
let line = UdtTypeDef::new("ks_share".to_string(), "line".to_string()).with_field(
"len".to_string(),
CqlType::Int,
true,
);
manager.register_udt(line).await;
let seen = manager
.registry()
.read()
.await
.get_udt("ks_share", "line")
.await
.unwrap();
assert!(
seen.is_some(),
"[{label}] UDT registered via the manager must be visible in the shared registry"
);
}
}
#[test]
fn test_schema_from_sstable_header() {
use crate::parser::header::{
CassandraVersion, ColumnInfo, CompressionInfo, SSTableHeader, SSTableStats,
};
use std::collections::HashMap;
let columns = vec![
ColumnInfo {
name: "id".to_string(),
column_type: "int".to_string(),
is_primary_key: true,
key_position: Some(0),
is_static: false,
is_clustering: false,
clustering_reversed: false,
},
ColumnInfo {
name: "name".to_string(),
column_type: "text".to_string(),
is_primary_key: false,
key_position: None,
is_static: false,
is_clustering: false,
clustering_reversed: false,
},
];
let header = SSTableHeader {
cassandra_version: CassandraVersion::V5_0Bti,
version: 1,
table_id: [0; 16],
keyspace: "test_ks".to_string(),
table_name: "test_table".to_string(),
generation: 1,
compression: CompressionInfo {
algorithm: "NONE".to_string(),
chunk_size: 0,
parameters: HashMap::new(),
},
stats: SSTableStats::default(),
columns,
properties: HashMap::new(),
};
let schema = TableSchema::from_sstable_header(&header).unwrap();
assert_eq!(schema.keyspace, "test_ks");
assert_eq!(schema.table, "test_table");
assert_eq!(schema.partition_keys.len(), 1);
assert_eq!(schema.partition_keys[0].name, "id");
assert_eq!(schema.columns.len(), 2);
}
}