Skip to main content

panproto_protocols/database/
sql.rs

1//! SQL protocol definition.
2//!
3//! SQL uses a constrained hypergraph schema theory
4//! (`colimit(ThHypergraph, ThConstraint)`) and a set-valued functor
5//! instance theory (`ThFunctor`).
6//!
7//! Tables are modeled as hyper-edges connecting column vertices.
8//! Foreign keys are hyper-edges connecting source columns to target columns.
9
10use std::collections::HashMap;
11
12use panproto_gat::{Sort, Theory, colimit};
13use panproto_schema::{EdgeRule, Protocol, Schema, SchemaBuilder};
14
15use crate::error::ProtocolError;
16use crate::theories;
17
18/// Returns the SQL protocol definition.
19///
20/// Schema theory: `colimit(ThHypergraph, ThConstraint)`.
21/// Instance theory: `ThFunctor`.
22#[must_use]
23pub fn protocol() -> Protocol {
24    Protocol {
25        name: "sql".into(),
26        schema_theory: "ThSQLSchema".into(),
27        instance_theory: "ThSQLInstance".into(),
28        edge_rules: edge_rules(),
29        obj_kinds: vec![
30            "table".into(),
31            "integer".into(),
32            "string".into(),
33            "boolean".into(),
34            "number".into(),
35            "bytes".into(),
36            "timestamp".into(),
37            "date".into(),
38            "uuid".into(),
39            "json".into(),
40        ],
41        constraint_sorts: vec![
42            "NOT NULL".into(),
43            "UNIQUE".into(),
44            "CHECK".into(),
45            "PRIMARY KEY".into(),
46            "DEFAULT".into(),
47            "FOREIGN KEY".into(),
48        ],
49    }
50}
51
52/// Register the component GATs for SQL with a theory registry.
53///
54/// Registers `ThHypergraph`, `ThConstraint`, `ThFunctor`, and the
55/// composed schema/instance theories.
56pub fn register_theories<S: ::std::hash::BuildHasher>(registry: &mut HashMap<String, Theory, S>) {
57    let th_hypergraph = theories::th_hypergraph();
58    let th_constraint = theories::th_constraint();
59    let th_functor = theories::th_functor();
60
61    registry.insert("ThHypergraph".into(), th_hypergraph.clone());
62    registry.insert("ThConstraint".into(), th_constraint.clone());
63    registry.insert("ThFunctor".into(), th_functor.clone());
64
65    // Compose schema theory: colimit(ThHypergraph, ThConstraint) over shared Vertex.
66    let shared_vertex = Theory::new("ThVertex", vec![Sort::simple("Vertex")], vec![], vec![]);
67
68    if let Ok(mut schema_theory) = colimit(&th_hypergraph, &th_constraint, &shared_vertex) {
69        schema_theory.name = "ThSQLSchema".into();
70        registry.insert("ThSQLSchema".into(), schema_theory);
71    }
72
73    // Instance theory is just ThFunctor (no colimit needed).
74    let mut inst = th_functor;
75    inst.name = "ThSQLInstance".into();
76    registry.insert("ThSQLInstance".into(), inst);
77}
78
79/// Parse a SQL DDL string into a [`Schema`].
80///
81/// Supports `CREATE TABLE`, `ALTER TABLE`, and `DROP TABLE` statements with
82/// column definitions, primary keys, foreign keys, `NOT NULL`, `UNIQUE`,
83/// `CHECK`, and `DEFAULT` constraints.
84///
85/// # Errors
86///
87/// Returns [`ProtocolError`] if the DDL cannot be parsed or
88/// schema construction fails.
89pub fn parse_ddl(ddl: &str) -> Result<Schema, ProtocolError> {
90    let proto = protocol();
91    let mut builder = SchemaBuilder::new(&proto);
92    let mut hyper_edge_counter: usize = 0;
93    let mut dropped_tables: std::collections::HashSet<String> = std::collections::HashSet::new();
94
95    // Simple line-based DDL parser.
96    let statements = split_statements(ddl);
97
98    // First pass: identify dropped tables.
99    for stmt in &statements {
100        let trimmed = stmt.trim();
101        let upper = trimmed.to_uppercase();
102        if upper.starts_with("DROP TABLE") {
103            if let Ok(name) = extract_drop_table_name(trimmed) {
104                dropped_tables.insert(name);
105            }
106        }
107    }
108
109    // Track tables and their columns for ALTER TABLE support.
110    let mut table_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
111
112    for stmt in &statements {
113        let trimmed = stmt.trim();
114        let upper = trimmed.to_uppercase();
115
116        if upper.starts_with("CREATE TABLE") {
117            let table_name = extract_table_name(trimmed)?;
118            if dropped_tables.contains(&table_name) {
119                continue;
120            }
121            let (new_builder, cols) =
122                parse_create_table(builder, trimmed, &mut hyper_edge_counter)?;
123            builder = new_builder;
124            table_columns.insert(table_name, cols);
125        } else if upper.starts_with("ALTER TABLE") {
126            builder = parse_alter_table(builder, trimmed, &mut table_columns)?;
127        }
128        // DROP TABLE already handled via dropped_tables set.
129    }
130
131    let schema = builder.build()?;
132    Ok(schema)
133}
134
135/// Split DDL text into individual statements by semicolons.
136fn split_statements(ddl: &str) -> Vec<String> {
137    ddl.split(';')
138        .map(|s| s.trim().to_string())
139        .filter(|s| !s.is_empty())
140        .collect()
141}
142
143/// Parse a single CREATE TABLE statement.
144///
145/// Returns the updated builder and a map of column names to vertex IDs.
146fn parse_create_table(
147    mut builder: SchemaBuilder,
148    stmt: &str,
149    hyper_edge_counter: &mut usize,
150) -> Result<(SchemaBuilder, HashMap<String, String>), ProtocolError> {
151    // Extract table name.
152    let table_name = extract_table_name(stmt)?;
153
154    // Create a vertex for the table.
155    builder = builder.vertex(&table_name, "table", None)?;
156
157    // Extract column block (content between outer parentheses).
158    let columns_block = extract_parenthesized(stmt)?;
159
160    // Parse each column definition.
161    let column_defs = split_column_defs(&columns_block);
162
163    let mut sig = HashMap::new();
164
165    for col_def in &column_defs {
166        let trimmed = col_def.trim();
167        if trimmed.is_empty() {
168            continue;
169        }
170
171        let upper = trimmed.to_uppercase();
172
173        // Handle table-level constraints.
174        if upper.starts_with("PRIMARY KEY") {
175            // PRIMARY KEY(col1, col2)
176            if let Some(cols) = extract_constraint_columns(trimmed) {
177                let constraint_val = cols.join(",");
178                builder = builder.constraint(&table_name, "PRIMARY KEY", &constraint_val);
179            }
180            continue;
181        }
182        if upper.starts_with("FOREIGN KEY") {
183            // FOREIGN KEY(col) REFERENCES other_table(col)
184            builder = parse_table_foreign_key(builder, trimmed, &table_name, &sig);
185            continue;
186        }
187        if upper.starts_with("UNIQUE") {
188            if let Some(cols) = extract_constraint_columns(trimmed) {
189                let constraint_val = cols.join(",");
190                builder = builder.constraint(&table_name, "UNIQUE", &constraint_val);
191            }
192            continue;
193        }
194        if upper.starts_with("CHECK") {
195            // Extract the expression inside parentheses.
196            if let Ok(expr) = extract_parenthesized(trimmed) {
197                builder = builder.constraint(&table_name, "CHECK", &expr);
198            }
199            continue;
200        }
201        if upper.starts_with("CONSTRAINT") {
202            // Named constraint: CONSTRAINT name PRIMARY KEY(...) / FOREIGN KEY(...) / etc.
203            // Parse the inner constraint type.
204            if upper.contains("PRIMARY KEY") {
205                if let Some(cols) = extract_constraint_columns(trimmed) {
206                    let constraint_val = cols.join(",");
207                    builder = builder.constraint(&table_name, "PRIMARY KEY", &constraint_val);
208                }
209            } else if upper.contains("FOREIGN KEY") {
210                builder = parse_table_foreign_key(builder, trimmed, &table_name, &sig);
211            } else if upper.contains("UNIQUE") {
212                if let Some(cols) = extract_constraint_columns(trimmed) {
213                    let constraint_val = cols.join(",");
214                    builder = builder.constraint(&table_name, "UNIQUE", &constraint_val);
215                }
216            } else if upper.contains("CHECK") {
217                if let Ok(expr) = extract_parenthesized(trimmed) {
218                    builder = builder.constraint(&table_name, "CHECK", &expr);
219                }
220            }
221            continue;
222        }
223
224        // Parse column: name type [constraints...]
225        let parts: Vec<&str> = trimmed.split_whitespace().collect();
226        if parts.len() < 2 {
227            continue;
228        }
229
230        let col_name = parts[0].trim_matches('"').trim_matches('`');
231        let col_type = parts[1];
232        let col_id = format!("{table_name}.{col_name}");
233
234        // Determine vertex kind from SQL type.
235        let kind = sql_type_to_kind(col_type);
236        builder = builder.vertex(&col_id, &kind, None)?;
237
238        // Parse inline constraints.
239        let rest = parts[2..].join(" ").to_uppercase();
240        if rest.contains("NOT NULL") {
241            builder = builder.constraint(&col_id, "NOT NULL", "true");
242        }
243        if rest.contains("PRIMARY KEY") {
244            builder = builder.constraint(&col_id, "PRIMARY KEY", "true");
245        }
246        if rest.contains("UNIQUE") {
247            builder = builder.constraint(&col_id, "UNIQUE", "true");
248        }
249        if let Some(default_val) = extract_default(&rest) {
250            builder = builder.constraint(&col_id, "DEFAULT", &default_val);
251        }
252
253        // Handle inline REFERENCES.
254        if let Some(ref_idx) = rest.find("REFERENCES") {
255            let ref_rest = &rest[ref_idx + "REFERENCES".len()..].trim().to_string();
256            let ref_table = ref_rest
257                .split(|c: char| c == '(' || c.is_whitespace())
258                .next()
259                .unwrap_or("")
260                .trim();
261            if !ref_table.is_empty() {
262                builder =
263                    builder.constraint(&col_id, "FOREIGN KEY", &format!("{ref_table}.{col_name}"));
264            }
265        }
266
267        // Add a prop edge from table to column.
268        builder = builder.edge(&table_name, &col_id, "prop", Some(col_name))?;
269
270        sig.insert(col_name.to_string(), col_id);
271    }
272
273    // Create a hyper-edge for the table (connecting all columns).
274    if !sig.is_empty() {
275        let he_id = format!("he_{hyper_edge_counter}");
276        *hyper_edge_counter += 1;
277        builder = builder.hyper_edge(&he_id, "table", sig.clone(), &table_name)?;
278    }
279
280    Ok((builder, sig))
281}
282
283/// Parse a table-level FOREIGN KEY constraint.
284fn parse_table_foreign_key(
285    mut builder: SchemaBuilder,
286    constraint_str: &str,
287    table_name: &str,
288    sig: &HashMap<String, String>,
289) -> SchemaBuilder {
290    let upper = constraint_str.to_uppercase();
291
292    // Extract the column(s) in the FOREIGN KEY clause.
293    let fk_cols = extract_constraint_columns_at(&upper, "FOREIGN KEY");
294
295    // Extract REFERENCES target.
296    if let Some(ref_idx) = upper.find("REFERENCES") {
297        let ref_rest = &constraint_str[ref_idx + "REFERENCES".len()..]
298            .trim()
299            .to_string();
300        let ref_table = ref_rest
301            .split(|c: char| c == '(' || c.is_whitespace())
302            .next()
303            .unwrap_or("")
304            .trim()
305            .to_string();
306
307        if !ref_table.is_empty() {
308            if let Some(fk_cols) = fk_cols {
309                for col in &fk_cols {
310                    let col_lower = col.to_lowercase();
311                    if let Some(col_id) = sig.get(&col_lower) {
312                        builder = builder.constraint(
313                            col_id,
314                            "FOREIGN KEY",
315                            &format!("{ref_table}.{col_lower}"),
316                        );
317                    } else {
318                        // Column may not exist yet; add constraint to table.
319                        builder = builder.constraint(
320                            table_name,
321                            "FOREIGN KEY",
322                            &format!("{col_lower}->{ref_table}"),
323                        );
324                    }
325                }
326            }
327        }
328    }
329
330    builder
331}
332
333/// Parse an ALTER TABLE statement.
334fn parse_alter_table(
335    mut builder: SchemaBuilder,
336    stmt: &str,
337    table_columns: &mut HashMap<String, HashMap<String, String>>,
338) -> Result<SchemaBuilder, ProtocolError> {
339    let upper = stmt.to_uppercase();
340
341    // Extract table name after ALTER TABLE.
342    let after_alter = upper
343        .find("ALTER TABLE")
344        .map(|i| i + "ALTER TABLE".len())
345        .ok_or_else(|| ProtocolError::Parse("no ALTER TABLE keyword found".into()))?;
346
347    let remainder = stmt[after_alter..].trim();
348    let table_end = remainder
349        .find(|c: char| c.is_whitespace())
350        .unwrap_or(remainder.len());
351    let table_name = remainder[..table_end]
352        .trim()
353        .trim_matches('"')
354        .trim_matches('`')
355        .to_string();
356
357    let after_table = remainder[table_end..].trim();
358    let after_table_upper = after_table.to_uppercase();
359
360    if after_table_upper.starts_with("ADD COLUMN") || after_table_upper.starts_with("ADD ") {
361        // ADD [COLUMN] name type [constraints...]
362        let col_def = if after_table_upper.starts_with("ADD COLUMN") {
363            after_table["ADD COLUMN".len()..].trim()
364        } else {
365            after_table["ADD".len()..].trim()
366        };
367
368        let parts: Vec<&str> = col_def.split_whitespace().collect();
369        if parts.len() >= 2 {
370            let col_name = parts[0].trim_matches('"').trim_matches('`');
371            let col_type = parts[1];
372            let col_id = format!("{table_name}.{col_name}");
373            let kind = sql_type_to_kind(col_type);
374            builder = builder.vertex(&col_id, &kind, None)?;
375            builder = builder.edge(&table_name, &col_id, "prop", Some(col_name))?;
376
377            let rest = parts[2..].join(" ").to_uppercase();
378            if rest.contains("NOT NULL") {
379                builder = builder.constraint(&col_id, "NOT NULL", "true");
380            }
381
382            if let Some(cols) = table_columns.get_mut(&table_name) {
383                cols.insert(col_name.to_string(), col_id);
384            }
385        }
386    } else if after_table_upper.starts_with("DROP COLUMN") || after_table_upper.starts_with("DROP ")
387    {
388        // DROP [COLUMN] name - we acknowledge but the column vertex remains.
389        // Full removal would require schema diffing, which is out of scope.
390    } else if after_table_upper.starts_with("MODIFY")
391        || after_table_upper.starts_with("ALTER COLUMN")
392    {
393        // MODIFY/ALTER COLUMN name type - acknowledged but column vertex already exists.
394        // Constraints could be updated, but column identity doesn't change.
395    }
396
397    Ok(builder)
398}
399
400/// Extract the table name from a CREATE TABLE statement.
401fn extract_table_name(stmt: &str) -> Result<String, ProtocolError> {
402    // "CREATE TABLE [IF NOT EXISTS] name (...)"
403    let upper = stmt.to_uppercase();
404    let start = if upper.contains("IF NOT EXISTS") {
405        upper
406            .find("IF NOT EXISTS")
407            .map(|i| i + "IF NOT EXISTS".len())
408    } else {
409        upper.find("TABLE").map(|i| i + "TABLE".len())
410    };
411
412    let start = start.ok_or_else(|| ProtocolError::Parse("no TABLE keyword found".into()))?;
413    let remainder = stmt[start..].trim();
414    let name_end = remainder
415        .find(|c: char| c == '(' || c.is_whitespace())
416        .unwrap_or(remainder.len());
417
418    let name = remainder[..name_end]
419        .trim()
420        .trim_matches('"')
421        .trim_matches('`')
422        .to_string();
423
424    if name.is_empty() {
425        return Err(ProtocolError::Parse("empty table name".into()));
426    }
427
428    Ok(name)
429}
430
431/// Extract the table name from a DROP TABLE statement.
432fn extract_drop_table_name(stmt: &str) -> Result<String, ProtocolError> {
433    let upper = stmt.to_uppercase();
434    let start = if upper.contains("IF EXISTS") {
435        upper.find("IF EXISTS").map(|i| i + "IF EXISTS".len())
436    } else {
437        upper.find("TABLE").map(|i| i + "TABLE".len())
438    };
439
440    let start = start.ok_or_else(|| ProtocolError::Parse("no TABLE keyword found".into()))?;
441    let remainder = stmt[start..].trim();
442    let name_end = remainder
443        .find(|c: char| c.is_whitespace() || c == ';')
444        .unwrap_or(remainder.len());
445
446    let name = remainder[..name_end]
447        .trim()
448        .trim_matches('"')
449        .trim_matches('`')
450        .to_string();
451
452    if name.is_empty() {
453        return Err(ProtocolError::Parse("empty table name".into()));
454    }
455
456    Ok(name)
457}
458
459/// Extract the parenthesized block from a statement.
460fn extract_parenthesized(stmt: &str) -> Result<String, ProtocolError> {
461    let open = stmt
462        .find('(')
463        .ok_or_else(|| ProtocolError::Parse("no opening parenthesis".into()))?;
464    let close = stmt
465        .rfind(')')
466        .ok_or_else(|| ProtocolError::Parse("no closing parenthesis".into()))?;
467    if close <= open {
468        return Err(ProtocolError::Parse("mismatched parentheses".into()));
469    }
470    Ok(stmt[open + 1..close].to_string())
471}
472
473/// Split column definitions by commas, respecting nested parentheses.
474fn split_column_defs(block: &str) -> Vec<String> {
475    let mut defs = Vec::new();
476    let mut current = String::new();
477    let mut depth = 0;
478
479    for ch in block.chars() {
480        match ch {
481            '(' => {
482                depth += 1;
483                current.push(ch);
484            }
485            ')' => {
486                depth -= 1;
487                current.push(ch);
488            }
489            ',' if depth == 0 => {
490                defs.push(current.trim().to_string());
491                current.clear();
492            }
493            _ => current.push(ch),
494        }
495    }
496    if !current.trim().is_empty() {
497        defs.push(current.trim().to_string());
498    }
499    defs
500}
501
502/// Map a SQL type name to a vertex kind.
503fn sql_type_to_kind(sql_type: &str) -> String {
504    let upper = sql_type.to_uppercase();
505    if upper.starts_with("INT")
506        || upper.starts_with("BIGINT")
507        || upper.starts_with("SMALLINT")
508        || upper.starts_with("TINYINT")
509        || upper.starts_with("SERIAL")
510    {
511        "integer".into()
512    } else if upper.starts_with("VARCHAR") || upper.starts_with("TEXT") || upper.starts_with("CHAR")
513    {
514        "string".into()
515    } else if upper.starts_with("BOOL") {
516        "boolean".into()
517    } else if upper.starts_with("FLOAT")
518        || upper.starts_with("DOUBLE")
519        || upper.starts_with("DECIMAL")
520        || upper.starts_with("NUMERIC")
521        || upper.starts_with("REAL")
522    {
523        "number".into()
524    } else if upper.starts_with("BYTEA") || upper.starts_with("BLOB") {
525        "bytes".into()
526    } else if upper.starts_with("TIMESTAMP") {
527        "timestamp".into()
528    } else if upper.starts_with("DATE") {
529        "date".into()
530    } else if upper.starts_with("UUID") {
531        "uuid".into()
532    } else if upper.starts_with("JSON") || upper.starts_with("JSONB") {
533        "json".into()
534    } else {
535        "string".into()
536    }
537}
538
539/// Extract a DEFAULT value from a constraint string.
540fn extract_default(constraint_str: &str) -> Option<String> {
541    let idx = constraint_str.find("DEFAULT")?;
542    let rest = constraint_str[idx + "DEFAULT".len()..].trim();
543    // Take the first token as the default value.
544    let end = rest
545        .find(|c: char| c.is_whitespace() || c == ',')
546        .unwrap_or(rest.len());
547    let val = rest[..end].trim().to_string();
548    if val.is_empty() { None } else { Some(val) }
549}
550
551/// Extract column names from a constraint like `PRIMARY KEY(col1, col2)`.
552fn extract_constraint_columns(constraint_str: &str) -> Option<Vec<String>> {
553    let open = constraint_str.find('(')?;
554    let close = constraint_str[open..].find(')')? + open;
555    let inner = &constraint_str[open + 1..close];
556    let cols: Vec<String> = inner
557        .split(',')
558        .map(|s| s.trim().trim_matches('"').trim_matches('`').to_string())
559        .filter(|s| !s.is_empty())
560        .collect();
561    if cols.is_empty() { None } else { Some(cols) }
562}
563
564/// Extract column names from a constraint starting at a specific keyword.
565fn extract_constraint_columns_at(upper_str: &str, keyword: &str) -> Option<Vec<String>> {
566    let idx = upper_str.find(keyword)?;
567    let after = &upper_str[idx + keyword.len()..];
568    let open = after.find('(')?;
569    let close = after[open..].find(')')? + open;
570    let inner = &after[open + 1..close];
571    let cols: Vec<String> = inner
572        .split(',')
573        .map(|s| s.trim().to_string())
574        .filter(|s| !s.is_empty())
575        .collect();
576    if cols.is_empty() { None } else { Some(cols) }
577}
578
579/// Map a vertex kind back to a SQL type name.
580fn kind_to_sql_type(kind: &str) -> &'static str {
581    match kind {
582        "integer" => "INTEGER",
583        "boolean" => "BOOLEAN",
584        "number" => "FLOAT",
585        "bytes" => "BYTEA",
586        "timestamp" => "TIMESTAMP",
587        "date" => "DATE",
588        "uuid" => "UUID",
589        "json" => "JSONB",
590        _ => "TEXT",
591    }
592}
593
594/// Emit a [`Schema`] as SQL DDL `CREATE TABLE` statements.
595///
596/// Reconstructs table definitions from the schema graph, including
597/// column types and constraints (`NOT NULL`, `PRIMARY KEY`, `UNIQUE`,
598/// `DEFAULT`).
599///
600/// # Errors
601///
602/// Returns [`ProtocolError::Emit`] if the schema cannot be serialized.
603pub fn emit_ddl(schema: &Schema) -> Result<String, ProtocolError> {
604    use std::fmt::Write;
605
606    use crate::emit::{children_by_edge, vertex_constraints};
607
608    let mut output = String::new();
609
610    // Find all table vertices.
611    let mut tables: Vec<&panproto_schema::Vertex> = schema
612        .vertices
613        .values()
614        .filter(|v| v.kind == "table")
615        .collect();
616    tables.sort_by(|a, b| a.id.cmp(&b.id));
617
618    for table in &tables {
619        let _ = writeln!(output, "CREATE TABLE {} (", table.id);
620
621        let columns = children_by_edge(schema, &table.id, "prop");
622        let col_count = columns.len();
623        for (i, (edge, col_vertex)) in columns.iter().enumerate() {
624            let col_name = edge.name.as_deref().unwrap_or(&col_vertex.id);
625            let sql_type = kind_to_sql_type(&col_vertex.kind);
626
627            let mut constraints_str = String::new();
628            let constraints = vertex_constraints(schema, &col_vertex.id);
629            for c in &constraints {
630                match c.sort.as_str() {
631                    "PRIMARY KEY" if c.value == "true" => {
632                        constraints_str.push_str(" PRIMARY KEY");
633                    }
634                    "NOT NULL" if c.value == "true" => {
635                        constraints_str.push_str(" NOT NULL");
636                    }
637                    "UNIQUE" if c.value == "true" => {
638                        constraints_str.push_str(" UNIQUE");
639                    }
640                    "DEFAULT" => {
641                        let _ = write!(constraints_str, " DEFAULT {}", c.value);
642                    }
643                    _ => {}
644                }
645            }
646
647            let comma = if i + 1 < col_count { "," } else { "" };
648            let _ = writeln!(output, "  {col_name} {sql_type}{constraints_str}{comma}");
649        }
650
651        output.push_str(");\n\n");
652    }
653
654    Ok(output)
655}
656
657/// Well-formedness rules for SQL edges.
658fn edge_rules() -> Vec<EdgeRule> {
659    vec![
660        EdgeRule {
661            edge_kind: "prop".into(),
662            src_kinds: vec!["table".into()],
663            tgt_kinds: vec![],
664        },
665        EdgeRule {
666            edge_kind: "foreign-key".into(),
667            src_kinds: vec![],
668            tgt_kinds: vec![],
669        },
670    ]
671}
672
673#[cfg(test)]
674#[allow(clippy::expect_used, clippy::unwrap_used)]
675mod tests {
676    use super::*;
677
678    #[test]
679    fn protocol_creates_valid_definition() {
680        let p = protocol();
681        assert_eq!(p.name, "sql");
682        assert_eq!(p.schema_theory, "ThSQLSchema");
683        assert_eq!(p.instance_theory, "ThSQLInstance");
684        assert!(p.find_edge_rule("prop").is_some());
685    }
686
687    #[test]
688    fn register_theories_adds_correct_theories() {
689        let mut registry = HashMap::new();
690        register_theories(&mut registry);
691
692        assert!(registry.contains_key("ThHypergraph"));
693        assert!(registry.contains_key("ThConstraint"));
694        assert!(registry.contains_key("ThFunctor"));
695        assert!(registry.contains_key("ThSQLSchema"));
696        assert!(registry.contains_key("ThSQLInstance"));
697
698        let schema_t = &registry["ThSQLSchema"];
699        assert!(schema_t.find_sort("Vertex").is_some());
700        assert!(schema_t.find_sort("HyperEdge").is_some());
701        assert!(schema_t.find_sort("Constraint").is_some());
702    }
703
704    #[test]
705    fn parse_simple_create_table() {
706        let ddl = r"
707            CREATE TABLE users (
708                id INTEGER PRIMARY KEY NOT NULL,
709                name VARCHAR(255) NOT NULL,
710                email TEXT UNIQUE,
711                active BOOLEAN DEFAULT true
712            );
713        ";
714
715        let schema = parse_ddl(ddl);
716        assert!(schema.is_ok(), "parse_ddl should succeed: {schema:?}");
717        let schema = schema.ok();
718        let schema = schema.as_ref();
719
720        assert!(schema.is_some_and(|s| s.has_vertex("users")));
721        assert!(schema.is_some_and(|s| s.has_vertex("users.id")));
722        assert!(schema.is_some_and(|s| s.has_vertex("users.name")));
723        assert!(schema.is_some_and(|s| s.has_vertex("users.email")));
724        assert!(schema.is_some_and(|s| s.has_vertex("users.active")));
725    }
726
727    #[test]
728    fn parse_multiple_tables() {
729        let ddl = r"
730            CREATE TABLE posts (
731                id INTEGER PRIMARY KEY,
732                title TEXT NOT NULL,
733                author_id INTEGER
734            );
735            CREATE TABLE comments (
736                id INTEGER PRIMARY KEY,
737                body TEXT,
738                post_id INTEGER
739            );
740        ";
741
742        let schema = parse_ddl(ddl);
743        assert!(schema.is_ok(), "parse_ddl should succeed: {schema:?}");
744        let schema = schema.ok();
745        let schema = schema.as_ref();
746
747        assert!(schema.is_some_and(|s| s.has_vertex("posts")));
748        assert!(schema.is_some_and(|s| s.has_vertex("comments")));
749        assert!(schema.is_some_and(|s| s.has_vertex("posts.title")));
750        assert!(schema.is_some_and(|s| s.has_vertex("comments.body")));
751    }
752
753    #[test]
754    fn parse_empty_ddl() {
755        let result = parse_ddl("");
756        // Empty DDL produces no vertices, which SchemaBuilder rejects.
757        assert!(result.is_err(), "empty DDL should fail with EmptySchema");
758    }
759
760    #[test]
761    fn parse_timestamp_and_uuid_types() {
762        let ddl = r"
763            CREATE TABLE events (
764                id UUID PRIMARY KEY,
765                created_at TIMESTAMP NOT NULL,
766                event_date DATE,
767                payload JSONB
768            );
769        ";
770        let schema = parse_ddl(ddl).expect("should parse");
771        assert_eq!(schema.vertices.get("events.id").unwrap().kind, "uuid");
772        assert_eq!(
773            schema.vertices.get("events.created_at").unwrap().kind,
774            "timestamp"
775        );
776        assert_eq!(
777            schema.vertices.get("events.event_date").unwrap().kind,
778            "date"
779        );
780        assert_eq!(schema.vertices.get("events.payload").unwrap().kind, "json");
781    }
782
783    #[test]
784    fn parse_float_double_types() {
785        let ddl = r"
786            CREATE TABLE measurements (
787                temp FLOAT,
788                pressure DOUBLE
789            );
790        ";
791        let schema = parse_ddl(ddl).expect("should parse");
792        assert_eq!(
793            schema.vertices.get("measurements.temp").unwrap().kind,
794            "number"
795        );
796        assert_eq!(
797            schema.vertices.get("measurements.pressure").unwrap().kind,
798            "number"
799        );
800    }
801
802    #[test]
803    fn parse_drop_table() {
804        let ddl = r"
805            CREATE TABLE temp (id INTEGER);
806            DROP TABLE temp;
807        ";
808        let result = parse_ddl(ddl);
809        // The table was dropped, so no vertices should be created.
810        assert!(result.is_err(), "dropped table should produce empty schema");
811    }
812
813    #[test]
814    fn parse_table_level_primary_key() {
815        let ddl = r"
816            CREATE TABLE orders (
817                order_id INTEGER NOT NULL,
818                product_id INTEGER NOT NULL,
819                PRIMARY KEY(order_id, product_id)
820            );
821        ";
822        let schema = parse_ddl(ddl).expect("should parse");
823        let constraints = schema.constraints.get("orders");
824        assert!(constraints.is_some());
825        assert!(constraints.unwrap().iter().any(|c| c.sort == "PRIMARY KEY"));
826    }
827
828    #[test]
829    fn emit_ddl_roundtrip() {
830        let ddl = r"
831            CREATE TABLE users (
832                id INTEGER PRIMARY KEY NOT NULL,
833                name TEXT NOT NULL,
834                active BOOLEAN DEFAULT true
835            );
836        ";
837
838        let schema1 = parse_ddl(ddl).expect("first parse should succeed");
839        let emitted = emit_ddl(&schema1).expect("emit should succeed");
840        let schema2 = parse_ddl(&emitted).expect("re-parse should succeed");
841
842        assert_eq!(
843            schema1.vertex_count(),
844            schema2.vertex_count(),
845            "vertex counts should match after round-trip"
846        );
847        assert_eq!(
848            schema1.edge_count(),
849            schema2.edge_count(),
850            "edge counts should match after round-trip"
851        );
852    }
853
854    #[test]
855    fn parse_alter_table_add_column() {
856        let ddl = r"
857            CREATE TABLE users (
858                id INTEGER PRIMARY KEY
859            );
860            ALTER TABLE users ADD COLUMN name TEXT NOT NULL;
861        ";
862        let schema = parse_ddl(ddl).expect("should parse");
863        assert!(schema.has_vertex("users.name"));
864    }
865}