1use 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#[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
52pub 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 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 let mut inst = th_functor;
75 inst.name = "ThSQLInstance".into();
76 registry.insert("ThSQLInstance".into(), inst);
77}
78
79pub 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 let statements = split_statements(ddl);
97
98 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 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 }
130
131 let schema = builder.build()?;
132 Ok(schema)
133}
134
135fn 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
143fn parse_create_table(
147 mut builder: SchemaBuilder,
148 stmt: &str,
149 hyper_edge_counter: &mut usize,
150) -> Result<(SchemaBuilder, HashMap<String, String>), ProtocolError> {
151 let table_name = extract_table_name(stmt)?;
153
154 builder = builder.vertex(&table_name, "table", None)?;
156
157 let columns_block = extract_parenthesized(stmt)?;
159
160 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 if upper.starts_with("PRIMARY KEY") {
175 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 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 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 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 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 let kind = sql_type_to_kind(col_type);
236 builder = builder.vertex(&col_id, &kind, None)?;
237
238 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 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 builder = builder.edge(&table_name, &col_id, "prop", Some(col_name))?;
269
270 sig.insert(col_name.to_string(), col_id);
271 }
272
273 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
283fn 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 let fk_cols = extract_constraint_columns_at(&upper, "FOREIGN KEY");
294
295 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 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
333fn 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 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 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 } else if after_table_upper.starts_with("MODIFY")
391 || after_table_upper.starts_with("ALTER COLUMN")
392 {
393 }
396
397 Ok(builder)
398}
399
400fn extract_table_name(stmt: &str) -> Result<String, ProtocolError> {
402 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
431fn 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
459fn 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
473fn 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
502fn 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
539fn extract_default(constraint_str: &str) -> Option<String> {
541 let idx = constraint_str.find("DEFAULT")?;
542 let rest = constraint_str[idx + "DEFAULT".len()..].trim();
543 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
551fn 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
564fn 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
579fn 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
594pub 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 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
657fn 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 = ®istry["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 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 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}