Skip to main content

cortex_runtime/compiler/
codegen_graphql.rs

1//! GraphQL schema generator.
2//!
3//! Generates a GraphQL schema with types, queries, and mutations from compiled models.
4
5use crate::compiler::models::*;
6
7/// Generate a complete GraphQL schema from a compiled schema.
8pub fn generate_graphql(schema: &CompiledSchema) -> String {
9    let mut out = String::new();
10
11    out.push_str(&format!(
12        "# Auto-generated Cortex GraphQL schema for {}\n",
13        schema.domain
14    ));
15    out.push_str(&format!(
16        "# {} models, {} actions, {} relationships\n\n",
17        schema.models.len(),
18        schema.actions.len(),
19        schema.relationships.len()
20    ));
21
22    // Generate types
23    for model in &schema.models {
24        generate_graphql_type(&mut out, model, schema);
25    }
26
27    // Generate Query type
28    out.push_str("type Query {\n");
29    for model in &schema.models {
30        if model.instance_count <= 1 {
31            // Singleton type — return single object
32            let name_lower = model.name.to_lowercase();
33            out.push_str(&format!("  {name_lower}: {name}\n", name = model.name));
34        } else {
35            // Collection type — search and get by ID
36            let name_lower = pluralize_lower(&model.name);
37            out.push_str(&format!(
38                "  {name_lower}(query: String, limit: Int = 20): [{name}!]!\n",
39                name = model.name
40            ));
41            out.push_str(&format!(
42                "  {single}(nodeId: Int!): {name}\n",
43                single = model.name.to_lowercase(),
44                name = model.name
45            ));
46        }
47    }
48    out.push_str("}\n\n");
49
50    // Generate Mutation type
51    let mutations: Vec<&CompiledAction> = schema
52        .actions
53        .iter()
54        .filter(|a| a.http_method == "POST")
55        .collect();
56
57    if !mutations.is_empty() {
58        out.push_str("type Mutation {\n");
59        for action in &mutations {
60            let fn_name = to_camel_case(&action.name);
61            let mut params: Vec<String> = Vec::new();
62
63            if action.is_instance_method {
64                params.push("nodeId: Int!".to_string());
65            }
66
67            for param in &action.params {
68                if param.name == "node_id" {
69                    continue;
70                }
71                let gql_type = param.param_type.to_graphql_type();
72                let required = if param.required { "!" } else { "" };
73                let default = if let Some(ref d) = param.default_value {
74                    format!(" = {}", graphql_default(d, &param.param_type))
75                } else {
76                    String::new()
77                };
78                params.push(format!(
79                    "{}: {gql_type}{required}{default}",
80                    to_camel_case(&param.name)
81                ));
82            }
83
84            let params_str = if params.is_empty() {
85                String::new()
86            } else {
87                format!("({})", params.join(", "))
88            };
89
90            out.push_str(&format!("  {fn_name}{params_str}: Boolean!\n"));
91        }
92        out.push_str("}\n\n");
93    }
94
95    out
96}
97
98/// Generate a GraphQL type definition for a model.
99fn generate_graphql_type(out: &mut String, model: &DataModel, schema: &CompiledSchema) {
100    out.push_str(&format!("type {} {{\n", model.name));
101
102    for field in &model.fields {
103        let gql_type = field.field_type.to_graphql_type();
104        let required = if !field.nullable { "!" } else { "" };
105        out.push_str(&format!(
106            "  {}: {gql_type}{required}\n",
107            to_camel_case(&field.name)
108        ));
109    }
110
111    // Relationship fields
112    for rel in &schema.relationships {
113        if rel.from_model == model.name {
114            match rel.cardinality {
115                Cardinality::BelongsTo | Cardinality::HasOne => {
116                    out.push_str(&format!(
117                        "  {}: {}\n",
118                        to_camel_case(&rel.name),
119                        rel.to_model
120                    ));
121                }
122                Cardinality::HasMany | Cardinality::ManyToMany => {
123                    out.push_str(&format!(
124                        "  {}(limit: Int = 10): [{}!]!\n",
125                        to_camel_case(&rel.name),
126                        rel.to_model
127                    ));
128                }
129            }
130        }
131    }
132
133    out.push_str("}\n\n");
134}
135
136/// Convert snake_case to camelCase.
137fn to_camel_case(s: &str) -> String {
138    let parts: Vec<&str> = s.split('_').collect();
139    if parts.is_empty() {
140        return s.to_string();
141    }
142    let mut result = parts[0].to_string();
143    for part in &parts[1..] {
144        if let Some(first) = part.chars().next() {
145            result.push(first.to_uppercase().next().unwrap_or(first));
146            result.push_str(&part[first.len_utf8()..]);
147        }
148    }
149    result
150}
151
152/// Generate a lowercase plural form for queries.
153fn pluralize_lower(name: &str) -> String {
154    let lower = name.to_lowercase();
155    if lower.ends_with('s') {
156        format!("{lower}es")
157    } else if lower.ends_with('y') {
158        format!("{}ies", &lower[..lower.len() - 1])
159    } else {
160        format!("{lower}s")
161    }
162}
163
164/// Convert a default value to GraphQL literal.
165fn graphql_default(value: &str, field_type: &FieldType) -> String {
166    match field_type {
167        FieldType::Integer | FieldType::Float | FieldType::Bool => value.to_string(),
168        FieldType::String | FieldType::Url => format!("\"{value}\""),
169        _ => format!("\"{value}\""),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use chrono::Utc;
177
178    fn test_schema() -> CompiledSchema {
179        CompiledSchema {
180            domain: "test.com".to_string(),
181            compiled_at: Utc::now(),
182            models: vec![DataModel {
183                name: "Product".to_string(),
184                schema_org_type: "Product".to_string(),
185                fields: vec![
186                    ModelField {
187                        name: "url".to_string(),
188                        field_type: FieldType::Url,
189                        source: FieldSource::Inferred,
190                        confidence: 1.0,
191                        nullable: false,
192                        example_values: vec![],
193                        feature_dim: None,
194                    },
195                    ModelField {
196                        name: "name".to_string(),
197                        field_type: FieldType::String,
198                        source: FieldSource::JsonLd,
199                        confidence: 0.99,
200                        nullable: false,
201                        example_values: vec![],
202                        feature_dim: None,
203                    },
204                    ModelField {
205                        name: "price".to_string(),
206                        field_type: FieldType::Float,
207                        source: FieldSource::JsonLd,
208                        confidence: 0.99,
209                        nullable: true,
210                        example_values: vec![],
211                        feature_dim: Some(48),
212                    },
213                ],
214                instance_count: 50,
215                example_urls: vec![],
216                search_action: None,
217                list_url: None,
218            }],
219            actions: vec![CompiledAction {
220                name: "add_to_cart".to_string(),
221                belongs_to: "Product".to_string(),
222                is_instance_method: true,
223                http_method: "POST".to_string(),
224                endpoint_template: "/cart/add".to_string(),
225                params: vec![ActionParam {
226                    name: "quantity".to_string(),
227                    param_type: FieldType::Integer,
228                    required: false,
229                    default_value: Some("1".to_string()),
230                    source: "json_body".to_string(),
231                }],
232                requires_auth: false,
233                execution_path: "http".to_string(),
234                confidence: 0.9,
235            }],
236            relationships: vec![],
237            stats: SchemaStats {
238                total_models: 1,
239                total_fields: 3,
240                total_instances: 50,
241                avg_confidence: 0.99,
242            },
243        }
244    }
245
246    #[test]
247    fn test_generate_graphql_has_type() {
248        let gql = generate_graphql(&test_schema());
249        assert!(gql.contains("type Product {"));
250        assert!(gql.contains("url: String!"));
251        assert!(gql.contains("price: Float"));
252    }
253
254    #[test]
255    fn test_generate_graphql_has_query() {
256        let gql = generate_graphql(&test_schema());
257        assert!(gql.contains("type Query {"));
258        assert!(gql.contains("products(query: String"));
259    }
260
261    #[test]
262    fn test_generate_graphql_has_mutation() {
263        let gql = generate_graphql(&test_schema());
264        assert!(gql.contains("type Mutation {"));
265        assert!(gql.contains("addToCart("));
266    }
267
268    #[test]
269    fn test_pluralize_lower() {
270        assert_eq!(pluralize_lower("Product"), "products");
271        assert_eq!(pluralize_lower("Category"), "categories");
272        assert_eq!(pluralize_lower("Address"), "addresses");
273    }
274}