// src/table_definition/handlers/post_table_definition.rs use tonic::Status; use sqlx::PgPool; use serde_json::json; use time::OffsetDateTime; use common::proto::multieko2::table_definition::{PostTableDefinitionRequest, TableDefinitionResponse}; const VALID_DATA_TYPES: &[&str] = &["TEXT", "INTEGER", "BIGINT", "BOOLEAN", "TIMESTAMPTZ", "NUMERIC"]; fn is_valid_data_type(dt: &str) -> bool { VALID_DATA_TYPES.contains(&dt.to_uppercase().as_str()) } fn is_valid_identifier(s: &str) -> bool { let parts: Vec<&str> = s.split('_').collect(); parts.len() >= 3 && parts[0] == "ud" && parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit()) && parts[2..].iter().all(|p| !p.is_empty()) && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') } fn sanitize_identifier(s: &str) -> String { let year = OffsetDateTime::now_utc().year(); let cleaned = s.replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "") .trim() .to_lowercase(); format!("ud_{}_{}", year, cleaned) } pub async fn post_table_definition( db_pool: &PgPool, mut request: PostTableDefinitionRequest, ) -> Result { // Validate and sanitize inputs let table_name = sanitize_identifier(&request.table_name); if !is_valid_identifier(&table_name) { return Err(Status::invalid_argument("Invalid table name")); } // Lookup or create profile let profile = sqlx::query!( "INSERT INTO profiles (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id", request.profile_name ) .fetch_one(db_pool) .await .map_err(|e| Status::internal(format!("Profile error: {}", e)))?; // Validate linked table if provided let linked_table_id; let linked_table_name = request.linked_table_name .as_ref() .map(|lt| sanitize_identifier(lt)); if let Some(lt_name) = &linked_table_name { let lt_record = sqlx::query!( "SELECT id FROM table_definitions WHERE profile_id = $1 AND table_name = $2", profile.id, lt_name ) .fetch_optional(db_pool) .await .map_err(|e| Status::internal(format!("Linked table lookup failed: {}", e)))?; linked_table_id = match lt_record { Some(r) => Some(r.id), None => return Err(Status::not_found("Linked table not found in profile")), }; } else { linked_table_id = None; } // Validate columns and indexes let mut columns = Vec::new(); for col_def in request.columns.drain(..) { let col_name = sanitize_identifier(&col_def.name); if !is_valid_identifier(&col_name) { return Err(Status::invalid_argument("Invalid column name")); } if !is_valid_data_type(&col_def.data_type) { return Err(Status::invalid_argument("Invalid data type")); } columns.push(format!("\"{}\" {}", col_name, col_def.data_type)); } let mut indexes = Vec::new(); for idx in request.indexes.drain(..) { let idx_name = sanitize_identifier(&idx); if !is_valid_identifier(&idx_name) { return Err(Status::invalid_argument(format!("Invalid index name: {}", idx))); } indexes.push(idx_name); } // Generate SQL let (create_sql, index_sql) = generate_table_sql( &table_name, &columns, &indexes, linked_table_name.as_deref() ); // Store definition sqlx::query!( r#"INSERT INTO table_definitions (profile_id, table_name, columns, indexes, linked_table_id) VALUES ($1, $2, $3, $4, $5)"#, profile.id, &table_name, json!(columns), json!(indexes), linked_table_id ) .execute(db_pool) .await .map_err(|e| { if let Some(db_err) = e.as_database_error() { if db_err.constraint() == Some("idx_table_definitions_profile_table") { return Status::already_exists("Table already exists in this profile"); } } Status::internal(format!("Database error: {}", e)) })?; // Execute generated SQL sqlx::query(&create_sql) .execute(db_pool) .await .map_err(|e| Status::internal(format!("Table creation failed: {}", e)))?; for sql in &index_sql { sqlx::query(&sql) .execute(db_pool) .await .map_err(|e| Status::internal(format!("Index creation failed: {}", e)))?; } Ok(TableDefinitionResponse { success: true, sql: format!("{}\n{}", create_sql, index_sql.join("\n")), }) } fn generate_table_sql( table_name: &str, columns: &[String], indexes: &[String], linked_table: Option<&str>, ) -> (String, Vec) { let mut system_columns = vec![ "id BIGSERIAL PRIMARY KEY".to_string(), "deleted BOOLEAN NOT NULL DEFAULT FALSE".to_string(), "firma TEXT NOT NULL".to_string(), ]; if let Some(linked) = linked_table { // Extract base name without prefix for relationship let parts: Vec<&str> = linked.splitn(3, '_').collect(); let base_name = parts.get(2).unwrap_or(&linked); system_columns.push( format!("\"{}_id\" BIGINT NOT NULL REFERENCES \"{}\"(id)", base_name, linked ) ); } let all_columns = system_columns .iter() .chain(columns.iter()) .cloned() .collect::>(); let create_sql = format!( "CREATE TABLE \"{}\" (\n {},\n created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP\n)", table_name, all_columns.join(",\n ") ); let mut system_indexes = vec![ format!("CREATE INDEX idx_{}_firma ON \"{}\" (firma)", table_name, table_name), ]; if let Some(linked) = linked_table { system_indexes.push(format!( "CREATE INDEX idx_{}_{}_id ON \"{}\" (\"{}_id\")", table_name, linked, table_name, linked )); } let all_indexes = system_indexes .into_iter() .chain(indexes.iter().map(|idx| { format!("CREATE INDEX idx_{}_{} ON \"{}\" (\"{}\")", table_name, idx, table_name, idx) })) .collect::>(); (create_sql, all_indexes) }