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