qail_core/
schema.rs

1//! Schema introspection and Rust struct generation.
2//!
3//! This module queries database schema and generates Rust structs.
4
5use crate::error::QailError;
6use sqlx::postgres::PgPool;
7use sqlx::Row;
8
9/// Column information from database schema.
10#[derive(Debug, Clone)]
11pub struct ColumnInfo {
12    pub name: String,
13    pub data_type: String,
14    pub is_nullable: bool,
15    pub column_default: Option<String>,
16}
17
18/// Query the schema for a table and return column information.
19pub async fn get_table_schema(pool: &PgPool, table_name: &str) -> Result<Vec<ColumnInfo>, QailError> {
20    let rows = sqlx::query(
21        r#"
22        SELECT 
23            column_name,
24            data_type,
25            is_nullable,
26            column_default
27        FROM information_schema.columns 
28        WHERE table_name = $1 
29        ORDER BY ordinal_position
30        "#,
31    )
32    .bind(table_name)
33    .fetch_all(pool)
34    .await
35    .map_err(|e| QailError::Execution(e.to_string()))?;
36
37    let columns: Vec<ColumnInfo> = rows
38        .iter()
39        .map(|row| ColumnInfo {
40            name: row.get::<String, _>("column_name"),
41            data_type: row.get::<String, _>("data_type"),
42            is_nullable: row.get::<String, _>("is_nullable") == "YES",
43            column_default: row.try_get::<String, _>("column_default").ok(),
44        })
45        .collect();
46
47    if columns.is_empty() {
48        return Err(QailError::Execution(format!(
49            "Table '{}' not found or has no columns",
50            table_name
51        )));
52    }
53
54    Ok(columns)
55}
56
57/// Map PostgreSQL type to Rust type.
58fn pg_to_rust_type(pg_type: &str, nullable: bool) -> String {
59    let base_type = match pg_type.to_lowercase().as_str() {
60        "uuid" => "Uuid",
61        "text" | "varchar" | "character varying" | "char" | "character" | "name" => "String",
62        "int2" | "smallint" => "i16",
63        "int4" | "integer" | "int" => "i32",
64        "int8" | "bigint" => "i64",
65        "float4" | "real" => "f32",
66        "float8" | "double precision" => "f64",
67        "numeric" | "decimal" => "rust_decimal::Decimal",
68        "bool" | "boolean" => "bool",
69        "timestamp without time zone" | "timestamp" => "chrono::NaiveDateTime",
70        "timestamp with time zone" | "timestamptz" => "chrono::DateTime<chrono::Utc>",
71        "date" => "chrono::NaiveDate",
72        "time" | "time without time zone" => "chrono::NaiveTime",
73        "jsonb" | "json" => "serde_json::Value",
74        "bytea" => "Vec<u8>",
75        t if t.ends_with("[]") => {
76            let inner = pg_to_rust_type(&t[..t.len() - 2], false);
77            return format!("Vec<{}>", inner);
78        }
79        _ => "String", // Fallback for unknown types
80    };
81
82    if nullable {
83        format!("Option<{}>", base_type)
84    } else {
85        base_type.to_string()
86    }
87}
88
89/// Convert snake_case to PascalCase for struct name.
90fn to_pascal_case(s: &str) -> String {
91    s.split('_')
92        .map(|word| {
93            let mut chars = word.chars();
94            match chars.next() {
95                None => String::new(),
96                Some(first) => first.to_uppercase().chain(chars).collect(),
97            }
98        })
99        .collect()
100}
101
102/// Generate a Rust struct from table schema.
103pub fn generate_struct(table_name: &str, columns: &[ColumnInfo]) -> String {
104    let struct_name = to_pascal_case(table_name);
105    
106    let mut output = String::new();
107    
108    // Add derives
109    output.push_str("use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};\n");
110    output.push_str("use serde::{Deserialize, Serialize};\n");
111    output.push_str("use sqlx::FromRow;\n");
112    output.push_str("use uuid::Uuid;\n");
113    output.push_str("\n");
114    output.push_str("#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]\n");
115    output.push_str(&format!("pub struct {} {{\n", struct_name));
116    
117    for col in columns {
118        let rust_type = pg_to_rust_type(&col.data_type, col.is_nullable);
119        output.push_str(&format!("    pub {}: {},\n", col.name, rust_type));
120    }
121    
122    output.push_str("}\n");
123    
124    output
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_to_pascal_case() {
133        assert_eq!(to_pascal_case("users"), "Users");
134        assert_eq!(to_pascal_case("ai_knowledge_base"), "AiKnowledgeBase");
135        assert_eq!(to_pascal_case("order_items"), "OrderItems");
136    }
137
138    #[test]
139    fn test_pg_to_rust_type() {
140        assert_eq!(pg_to_rust_type("uuid", false), "Uuid");
141        assert_eq!(pg_to_rust_type("text", false), "String");
142        assert_eq!(pg_to_rust_type("integer", true), "Option<i32>");
143        assert_eq!(pg_to_rust_type("timestamp with time zone", false), "chrono::DateTime<chrono::Utc>");
144        assert_eq!(pg_to_rust_type("text[]", false), "Vec<String>");
145    }
146}