mod cql;
mod json;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::error::{Error, Result};
use crate::schema::{TableSchema, UdtRegistry};
use crate::types::UdtTypeDef;
#[allow(unused_imports)]
use crate::schema::cql_parser;
#[derive(Debug, Clone)]
pub struct AggregatorConfig {
pub graceful_degradation: bool,
pub validate_udt_dependencies: bool,
}
impl Default for AggregatorConfig {
fn default() -> Self {
Self {
graceful_degradation: true,
validate_udt_dependencies: true,
}
}
}
pub struct SchemaAggregator {
registry: Arc<RwLock<crate::schema::registry::SchemaRegistry>>,
udt_registry: Arc<RwLock<UdtRegistry>>,
config: AggregatorConfig,
errors: Vec<SchemaLoadError>,
warnings: Vec<SchemaLoadWarning>,
}
#[derive(Debug, Clone)]
pub struct LoadResult {
pub schemas_loaded: usize,
pub udts_loaded: usize,
pub errors: Vec<SchemaLoadError>,
pub warnings: Vec<SchemaLoadWarning>,
}
#[derive(Debug, Clone)]
pub struct SchemaLoadError {
pub file_path: Option<PathBuf>,
pub error_type: LoadErrorType,
pub message: String,
}
#[derive(Debug, Clone)]
pub enum LoadErrorType {
FileRead,
InvalidJson,
InvalidCql,
MissingUdtDependency,
CircularUdtDependency,
ValidationFailed,
InvalidFileFormat,
}
#[derive(Debug, Clone)]
pub struct SchemaLoadWarning {
pub file_path: Option<PathBuf>,
pub message: String,
}
#[derive(Debug, Clone)]
struct ParsedSchema {
#[allow(dead_code)]
keyspace: String,
tables: HashMap<String, TableSchema>,
udts: HashMap<String, UdtTypeDef>,
}
impl SchemaAggregator {
pub fn new(
registry: Arc<RwLock<crate::schema::registry::SchemaRegistry>>,
udt_registry: Arc<RwLock<UdtRegistry>>,
config: AggregatorConfig,
) -> Self {
Self {
registry,
udt_registry,
config,
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub async fn load_from_paths(&mut self, paths: &[PathBuf]) -> Result<LoadResult> {
self.errors.clear();
self.warnings.clear();
let mut all_files = Vec::new();
for path in paths {
if let Err(e) = self.discover_files(path, &mut all_files) {
self.errors.push(SchemaLoadError {
file_path: Some(path.clone()),
error_type: LoadErrorType::FileRead,
message: format!("Failed to discover files: {}", e),
});
}
}
if all_files.is_empty() && !self.errors.is_empty() {
return Ok(self.build_result(0, 0));
}
let mut parsed_schemas = Vec::new();
for file_path in &all_files {
match self.parse_file(file_path).await {
Ok(Some(schema)) => parsed_schemas.push(schema),
Ok(None) => {} Err(e) => {
let error_type = match &e {
Error::Io(_) => LoadErrorType::FileRead,
Error::CqlParse(_) => LoadErrorType::InvalidCql,
Error::Schema(_) => {
let msg = e.to_string();
if msg.contains("Invalid JSON")
|| msg.contains("JSON")
|| msg.contains("json")
{
LoadErrorType::InvalidJson
} else {
LoadErrorType::ValidationFailed
}
}
_ => {
let msg = e.to_string();
if msg.contains("JSON") || msg.contains("json") {
LoadErrorType::InvalidJson
} else if msg.contains("CQL") || msg.contains("parse") {
LoadErrorType::InvalidCql
} else {
LoadErrorType::ValidationFailed
}
}
};
self.errors.push(SchemaLoadError {
file_path: Some(file_path.clone()),
error_type,
message: format!("Failed to parse file: {}", e),
});
if !self.config.graceful_degradation {
return Ok(self.build_result(0, 0));
}
}
}
}
if !self.config.graceful_degradation && !self.errors.is_empty() {
return Ok(self.build_result(0, 0));
}
let (udts_loaded, tables_loaded) = self.apply_schemas(parsed_schemas).await;
Ok(self.build_result(tables_loaded, udts_loaded))
}
fn discover_files(&mut self, path: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !path.exists() {
return Err(Error::InvalidPath(format!(
"Path does not exist: {}",
path.display()
)));
}
if path.is_file() {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if ext_str == "cql" || ext_str == "json" {
files.push(path.to_path_buf());
} else {
self.warnings.push(SchemaLoadWarning {
file_path: Some(path.to_path_buf()),
message: format!("Skipping file with unsupported extension: {}", ext_str),
});
}
}
} else if path.is_dir() {
self.scan_directory_recursive(path, files)?;
}
Ok(())
}
#[allow(clippy::only_used_in_recursion)]
fn scan_directory_recursive(&mut self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(Error::Io)?
.filter_map(|entry| entry.ok().map(|e| e.path()))
.collect();
entries.sort();
for entry in entries {
if entry.is_file() {
if let Some(ext) = entry.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if ext_str == "cql" || ext_str == "json" {
files.push(entry);
}
}
} else if entry.is_dir() {
self.scan_directory_recursive(&entry, files)?;
}
}
Ok(())
}
async fn parse_file(&self, path: &Path) -> Result<Option<ParsedSchema>> {
let ext = path
.extension()
.ok_or_else(|| Error::InvalidPath("File has no extension".to_string()))?;
let ext_str = ext.to_string_lossy().to_lowercase();
match ext_str.as_str() {
"cql" => self.parse_cql_file(path).await,
"json" => self.parse_json_file(path).await,
_ => Err(Error::InvalidPath(format!(
"Unsupported file extension: {}",
ext_str
))),
}
}
async fn apply_schemas(&mut self, parsed_schemas: Vec<ParsedSchema>) -> (usize, usize) {
let mut udt_map: HashMap<String, (String, UdtTypeDef)> = HashMap::new();
for parsed in &parsed_schemas {
for (qualified_name, udt_def) in &parsed.udts {
udt_map.insert(
qualified_name.clone(),
(udt_def.keyspace.clone(), udt_def.clone()),
);
}
}
let mut udts_loaded = 0;
{
let mut udt_registry = self.udt_registry.write().await;
for (_key, (_keyspace, udt_def)) in udt_map {
if self.config.validate_udt_dependencies {
if let Err(e) = udt_registry.register_udt_with_validation(udt_def.clone()) {
self.errors.push(SchemaLoadError {
file_path: None,
error_type: LoadErrorType::CircularUdtDependency,
message: format!("UDT validation failed: {}", e),
});
if !self.config.graceful_degradation {
return (udts_loaded, 0);
}
continue;
}
} else {
udt_registry.register_udt(udt_def);
}
udts_loaded += 1;
}
}
if !self.config.graceful_degradation && !self.errors.is_empty() {
return (udts_loaded, 0);
}
let mut table_map: HashMap<String, TableSchema> = HashMap::new();
for parsed in &parsed_schemas {
for (qualified_name, table_schema) in &parsed.tables {
table_map.insert(qualified_name.clone(), table_schema.clone());
}
}
let mut tables_loaded = 0;
{
let registry = self.registry.write().await;
for (_key, table_schema) in table_map {
if self.config.validate_udt_dependencies {
let udt_registry = self.udt_registry.read().await;
if let Err(e) = table_schema.validate_udt_references(&udt_registry) {
self.errors.push(SchemaLoadError {
file_path: None,
error_type: LoadErrorType::ValidationFailed,
message: format!(
"Failed to register table '{}.{}': {}",
table_schema.keyspace, table_schema.table, e
),
});
if !self.config.graceful_degradation {
return (udts_loaded, tables_loaded);
}
continue;
}
}
match registry
.register_schema(
table_schema.clone(),
crate::schema::registry::SchemaSource::Manual,
)
.await
{
Ok(_) => tables_loaded += 1,
Err(e) => {
self.errors.push(SchemaLoadError {
file_path: None,
error_type: LoadErrorType::ValidationFailed,
message: format!(
"Failed to register table '{}.{}': {}",
table_schema.keyspace, table_schema.table, e
),
});
if !self.config.graceful_degradation {
return (udts_loaded, tables_loaded);
}
}
}
}
}
(udts_loaded, tables_loaded)
}
fn build_result(&self, schemas_loaded: usize, udts_loaded: usize) -> LoadResult {
LoadResult {
schemas_loaded,
udts_loaded,
errors: self.errors.clone(),
warnings: self.warnings.clone(),
}
}
}
#[cfg(test)]
pub(super) mod test_support {
use super::*;
use crate::platform::Platform;
use crate::schema::registry::{SchemaRegistry, SchemaRegistryConfig};
use crate::Config;
use std::io::Write;
pub(crate) use tempfile::TempDir;
pub(crate) async fn setup_test_aggregator() -> (SchemaAggregator, TempDir) {
let temp_dir = TempDir::new().unwrap();
let config = Config::default();
let platform = Arc::new(Platform::new(&config).await.unwrap());
let registry_config = SchemaRegistryConfig::default();
let registry = Arc::new(RwLock::new(
SchemaRegistry::new(registry_config, platform, config)
.await
.unwrap(),
));
let udt_registry = Arc::new(RwLock::new(UdtRegistry::new()));
let aggregator = SchemaAggregator::new(registry, udt_registry, AggregatorConfig::default());
(aggregator, temp_dir)
}
pub(crate) fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
path
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
#[tokio::test]
async fn test_error_type_mapping_io_error() {
let (mut aggregator, _temp_dir) = setup_test_aggregator().await;
let non_existent_path = PathBuf::from("/nonexistent/path/schema.json");
let result = aggregator
.load_from_paths(std::slice::from_ref(&non_existent_path))
.await
.unwrap();
assert_eq!(result.schemas_loaded, 0);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0].error_type,
LoadErrorType::FileRead
));
assert!(result.errors[0]
.message
.contains("Failed to discover files"));
}
#[tokio::test]
async fn test_error_type_mapping_invalid_json() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let invalid_json = r#"{"keyspace": "ks", "table": "broken", invalid}"#;
let path = write_file(temp_dir.path(), "invalid.json", invalid_json);
let result = aggregator.load_from_paths(&[path]).await.unwrap();
assert_eq!(result.schemas_loaded, 0);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0].error_type,
LoadErrorType::InvalidJson
));
assert!(result.errors[0].message.contains("Failed to parse file"));
assert!(result.errors[0].message.contains("Invalid JSON"));
}
#[tokio::test]
async fn test_error_type_mapping_invalid_cql() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let invalid_cql = r#"
CREATE INVALID SYNTAX HERE
id uuid PRIMARY KEY
"#;
let path = write_file(temp_dir.path(), "invalid.cql", invalid_cql);
let result = aggregator.load_from_paths(&[path]).await.unwrap();
assert_eq!(result.schemas_loaded, 0);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0].error_type,
LoadErrorType::InvalidCql
));
assert!(result.errors[0].message.contains("Failed to parse file"));
}
#[tokio::test]
async fn test_error_message_preservation() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let invalid_json = r#"{"keyspace": "ks""#; let path = write_file(temp_dir.path(), "broken.json", invalid_json);
let result = aggregator
.load_from_paths(std::slice::from_ref(&path))
.await
.unwrap();
assert_eq!(result.errors.len(), 1);
assert!(result.errors[0].message.contains("Failed to parse file"));
assert!(result.errors[0].message.contains("Invalid JSON"));
assert_eq!(result.errors[0].file_path, Some(path));
}
#[tokio::test]
async fn test_multiple_error_types_in_batch() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let invalid_json = r#"{"invalid json"#;
let invalid_cql = r#"INVALID CQL SYNTAX"#;
let json_path = write_file(temp_dir.path(), "bad.json", invalid_json);
let cql_path = write_file(temp_dir.path(), "bad.cql", invalid_cql);
let result = aggregator
.load_from_paths(&[json_path, cql_path])
.await
.unwrap();
assert_eq!(result.schemas_loaded, 0);
assert_eq!(result.errors.len(), 2);
let json_error = result
.errors
.iter()
.find(|e| {
e.file_path
.as_ref()
.unwrap()
.to_str()
.unwrap()
.ends_with(".json")
})
.unwrap();
let cql_error = result
.errors
.iter()
.find(|e| {
e.file_path
.as_ref()
.unwrap()
.to_str()
.unwrap()
.ends_with(".cql")
})
.unwrap();
assert!(matches!(json_error.error_type, LoadErrorType::InvalidJson));
assert!(matches!(cql_error.error_type, LoadErrorType::InvalidCql));
}
#[tokio::test]
#[cfg(unix)]
async fn test_file_read_error_from_parse_file() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let json_content =
r#"{"keyspace": "ks", "table": "test", "columns": [], "partition_keys": ["id"]}"#;
let path = write_file(temp_dir.path(), "unreadable.json", json_content);
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&path, perms).unwrap();
if fs::File::open(&path).is_ok() {
return;
}
let result = aggregator
.load_from_paths(std::slice::from_ref(&path))
.await
.unwrap();
let mut perms = fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o644);
let _ = fs::set_permissions(&path, perms);
assert_eq!(result.schemas_loaded, 0);
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0].error_type,
LoadErrorType::FileRead
));
}
}