use std::collections::HashMap;
use std::path::Path;
use crate::error::{Error, Result};
use crate::schema::{
cql_parser::{classify_statement, parse_create_type, split_cql_statements, StatementType},
parse_cql_schema,
};
use crate::types::UdtTypeDef;
use super::{ParsedSchema, SchemaAggregator};
pub(super) fn extract_use_keyspace(statement: &str) -> Option<String> {
let normalized = statement.trim().to_lowercase();
if !normalized.starts_with("use ") {
return None;
}
let after_use = statement.trim()[4..].trim();
let mut ks_name = after_use.trim_end_matches(';').trim();
if ks_name.starts_with('"') && ks_name.ends_with('"') && ks_name.len() > 1 {
ks_name = &ks_name[1..ks_name.len() - 1];
}
if ks_name.is_empty() {
None
} else {
Some(ks_name.to_string())
}
}
pub(super) fn extract_create_keyspace_name(statement: &str) -> Option<String> {
let normalized = statement.trim().to_lowercase();
if !normalized.starts_with("create keyspace") {
return None;
}
let words: Vec<&str> = statement.split_whitespace().collect();
let start_idx = if words.len() > 2 && words[2].eq_ignore_ascii_case("if") {
5 } else {
2 };
if words.len() > start_idx {
let mut ks_name = words[start_idx].trim();
if ks_name.starts_with('"') && ks_name.ends_with('"') && ks_name.len() > 1 {
ks_name = &ks_name[1..ks_name.len() - 1];
}
Some(ks_name.to_string())
} else {
None
}
}
impl SchemaAggregator {
pub(super) async fn parse_cql_file(&self, path: &Path) -> Result<Option<ParsedSchema>> {
let content = std::fs::read_to_string(path)?;
let statements = split_cql_statements(&content);
if statements.is_empty() {
return Ok(None);
}
let mut keyspace: Option<String> = None;
let mut tables = HashMap::new();
let mut udts = HashMap::new();
let mut errors = Vec::new();
let mut create_type_stmts = Vec::new();
let mut create_table_stmts = Vec::new();
for statement in &statements {
match classify_statement(statement) {
StatementType::CreateType => create_type_stmts.push(statement.as_str()),
StatementType::CreateTable => create_table_stmts.push(statement.as_str()),
StatementType::Other(ref kind) if kind == "use" => {
if let Some(ks_name) = extract_use_keyspace(statement) {
keyspace = Some(ks_name);
}
}
StatementType::Other(ref kind) if kind == "create" => {
if let Some(ks_name) = extract_create_keyspace_name(statement) {
if keyspace.is_none() {
keyspace = Some(ks_name);
}
}
}
StatementType::Other(_kind) => {
}
}
}
for stmt in create_type_stmts {
match parse_create_type(stmt) {
Ok((_, (type_name, type_keyspace, fields))) => {
let udt_keyspace = type_keyspace.unwrap_or_else(|| {
keyspace.clone().unwrap_or_else(|| "default".to_string())
});
if keyspace.is_none() {
keyspace = Some(udt_keyspace.clone());
}
let mut udt_def = UdtTypeDef::new(udt_keyspace.clone(), type_name.clone());
for (field_name, field_type_str) in fields {
let field_type = crate::schema::CqlType::parse(&field_type_str)?;
udt_def = udt_def.with_field(field_name, field_type, true);
}
let qualified_name = format!("{}.{}", udt_keyspace, type_name);
udts.insert(qualified_name, udt_def);
}
Err(e) => {
errors.push(format!(
"Failed to parse CREATE TYPE in {}: {:?}",
path.display(),
e
));
}
}
}
for stmt in create_table_stmts {
match parse_cql_schema(stmt) {
Ok(mut table_schema) => {
if table_schema.keyspace == "default" {
if let Some(ref active_keyspace) = keyspace {
table_schema.keyspace = active_keyspace.clone();
}
}
if keyspace.is_none() {
keyspace = Some(table_schema.keyspace.clone());
}
let qualified_name =
format!("{}.{}", table_schema.keyspace, table_schema.table);
tables.insert(qualified_name, table_schema);
}
Err(e) => {
errors.push(format!(
"Failed to parse CREATE TABLE in {}: {}",
path.display(),
e
));
}
}
}
if !errors.is_empty() && tables.is_empty() && udts.is_empty() {
return Err(Error::CqlParse(format!(
"Failed to parse CQL file {}: {}",
path.display(),
errors.join("; ")
)));
}
if tables.is_empty() && udts.is_empty() && !statements.is_empty() {
let legitimate_keywords = [
"use", "create", "alter", "drop", "grant", "revoke", "truncate",
];
let has_invalid_statement = statements.iter().any(|stmt| {
let normalized = stmt.trim().to_lowercase();
let first_word = normalized.split_whitespace().next().unwrap_or("");
if normalized.starts_with("create ") {
return true;
}
!legitimate_keywords.contains(&first_word)
});
if has_invalid_statement {
return Err(Error::CqlParse(format!(
"Failed to parse CQL file {}: No valid CREATE TABLE or CREATE TYPE statements found",
path.display()
)));
}
}
let final_keyspace = keyspace.unwrap_or_else(|| "default".to_string());
Ok(Some(ParsedSchema {
keyspace: final_keyspace,
tables,
udts,
}))
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::*;
use super::super::{AggregatorConfig, SchemaAggregator};
use crate::platform::Platform;
use crate::schema::registry::{SchemaRegistry, SchemaRegistryConfig};
use crate::schema::UdtRegistry;
use crate::Config;
use std::sync::Arc;
use tokio::sync::RwLock;
#[tokio::test]
async fn test_load_single_cql_file() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TABLE test_ks.products (
id uuid PRIMARY KEY,
name text,
price decimal
);
"#;
let cql_path = write_file(temp_dir.path(), "products.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(result.schemas_loaded, 1);
assert_eq!(result.udts_loaded, 0);
assert!(result.errors.is_empty());
}
#[tokio::test]
async fn test_multi_statement_cql_file_with_create_type_and_create_table() {
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 mut aggregator = SchemaAggregator::new(
registry,
udt_registry,
AggregatorConfig {
graceful_degradation: true,
validate_udt_dependencies: false, },
);
let cql_content = r#"
-- Test schema with UDTs
CREATE TYPE test_ks.address (
street text,
city text,
zip_code int
);
CREATE TYPE test_ks.contact_info (
email text,
phone text,
address address
);
CREATE TABLE test_ks.users (
id uuid PRIMARY KEY,
name text,
contact contact_info
);
"#;
let cql_path = write_file(temp_dir.path(), "schema.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(result.udts_loaded, 2, "Expected 2 UDTs to be loaded");
assert_eq!(result.schemas_loaded, 1, "Expected 1 table to be loaded");
assert!(
result.errors.is_empty(),
"Expected no errors, got: {:?}",
result.errors
);
let udt_registry = aggregator.udt_registry.read().await;
assert!(
udt_registry.contains_udt("test_ks", "address"),
"address UDT should be registered"
);
assert!(
udt_registry.contains_udt("test_ks", "contact_info"),
"contact_info UDT should be registered"
);
let registry = aggregator.registry.read().await;
let schema = registry.get_schema("test_ks", "users").await.unwrap();
assert_eq!(schema.table, "users");
assert_eq!(schema.columns.len(), 3);
}
#[tokio::test]
async fn test_table_referencing_undefined_udt_top_level_fails() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TABLE test_ks.users (
id uuid PRIMARY KEY,
name text,
contact ContactInfo
);
"#;
let cql_path = write_file(temp_dir.path(), "schema.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(
result.schemas_loaded, 0,
"table referencing undefined UDT must not load"
);
assert!(
result
.errors
.iter()
.any(|e| e.message.contains("ContactInfo")),
"an error must name the missing UDT, got: {:?}",
result.errors
);
}
#[tokio::test]
async fn test_table_referencing_undefined_udt_nested_fails() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TABLE test_ks.users (
id uuid PRIMARY KEY,
contacts list<frozen<ContactInfo>>
);
"#;
let cql_path = write_file(temp_dir.path(), "schema.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(
result.schemas_loaded, 0,
"table referencing undefined nested UDT must not load"
);
assert!(
result
.errors
.iter()
.any(|e| e.message.contains("ContactInfo")),
"an error must name the missing nested UDT, got: {:?}",
result.errors
);
}
#[tokio::test]
async fn test_table_referencing_defined_udt_loads() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TYPE test_ks.contact_info (
email text,
phone text
);
CREATE TABLE test_ks.users (
id uuid PRIMARY KEY,
contact contact_info
);
"#;
let cql_path = write_file(temp_dir.path(), "schema.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert!(
result.errors.is_empty(),
"defined UDT reference should load without errors, got: {:?}",
result.errors
);
assert_eq!(result.udts_loaded, 1);
assert_eq!(result.schemas_loaded, 1);
}
#[tokio::test]
#[ignore = "Test fails due to UDT dependency validation not implemented - see Issue #117 review"]
async fn test_cql_file_with_comments_and_semicolons() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
-- This is a comment with ; semicolon
CREATE TYPE test_ks.metadata (
key text,
value text
);
/* Multi-line comment
with ; semicolon */
CREATE TABLE test_ks.data (
id uuid PRIMARY KEY,
info metadata
);
"#;
let cql_path = write_file(temp_dir.path(), "edge_cases.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(result.udts_loaded, 1);
assert_eq!(result.schemas_loaded, 1);
assert!(result.errors.is_empty());
}
#[tokio::test]
async fn test_backward_compat_single_create_table() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TABLE test_ks.simple (
id uuid PRIMARY KEY,
data text
);
"#;
let cql_path = write_file(temp_dir.path(), "simple.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(result.schemas_loaded, 1);
assert_eq!(result.udts_loaded, 0);
assert!(result.errors.is_empty());
}
#[tokio::test]
async fn test_multi_keyspace_cql_file_no_collision() {
let (mut aggregator, temp_dir) = setup_test_aggregator().await;
let cql_content = r#"
CREATE TYPE ks_a.address (
street text,
city text
);
CREATE TYPE ks_b.address (
country text,
postal_code text
);
CREATE TABLE ks_a.users (
id uuid PRIMARY KEY,
addr frozen<address>
);
CREATE TABLE ks_b.customers (
id uuid PRIMARY KEY,
location frozen<address>
);
"#;
let cql_path = write_file(temp_dir.path(), "multi_ks.cql", cql_content);
let result = aggregator.load_from_paths(&[cql_path]).await.unwrap();
assert_eq!(
result.udts_loaded, 2,
"Expected 2 UDTs from different keyspaces"
);
assert_eq!(
result.schemas_loaded, 2,
"Expected 2 tables from different keyspaces"
);
assert!(
result.errors.is_empty(),
"Expected no errors, got: {:?}",
result.errors
);
let udt_registry = aggregator.udt_registry.read().await;
assert!(
udt_registry.contains_udt("ks_a", "address"),
"ks_a.address should be registered"
);
assert!(
udt_registry.contains_udt("ks_b", "address"),
"ks_b.address should be registered"
);
let registry = aggregator.registry.read().await;
let schema_a = registry.get_schema("ks_a", "users").await.unwrap();
assert_eq!(schema_a.keyspace, "ks_a");
assert_eq!(schema_a.table, "users");
let schema_b = registry.get_schema("ks_b", "customers").await.unwrap();
assert_eq!(schema_b.keyspace, "ks_b");
assert_eq!(schema_b.table, "customers");
}
}