1use 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#[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
56pub fn register_theories<S: BuildHasher>(registry: &mut HashMap<String, Theory, S>) {
61 theories::register_hypergraph_functor(registry, "ThSQLSchema", "ThSQLInstance");
62}
63
64pub 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 let statements = split_statements(ddl);
82
83 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 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 }
115
116 let schema = builder.build()?;
117 Ok(schema)
118}
119
120fn 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
128fn parse_create_table(
132 mut builder: SchemaBuilder,
133 stmt: &str,
134 hyper_edge_counter: &mut usize,
135) -> Result<(SchemaBuilder, HashMap<String, String>), ProtocolError> {
136 let table_name = extract_table_name(stmt)?;
138
139 builder = builder.vertex(&table_name, "table", None)?;
141
142 let columns_block = extract_parenthesized(stmt)?;
144
145 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 if upper.starts_with("PRIMARY KEY") {
160 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 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 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 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 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 let kind = sql_type_to_kind(col_type);
221 builder = builder.vertex(&col_id, &kind, None)?;
222
223 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 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 builder = builder.edge(&table_name, &col_id, "prop", Some(col_name))?;
254
255 sig.insert(col_name.to_string(), col_id);
256 }
257
258 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
268fn 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 let fk_cols = extract_constraint_columns_at(&upper, "FOREIGN KEY");
279
280 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 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
318fn 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 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 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 } else if after_table_upper.starts_with("MODIFY")
376 || after_table_upper.starts_with("ALTER COLUMN")
377 {
378 }
381
382 Ok(builder)
383}
384
385fn extract_table_name(stmt: &str) -> Result<String, ProtocolError> {
387 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
416fn 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
444fn 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
458fn 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
487fn 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
524fn extract_default(constraint_str: &str) -> Option<String> {
526 let idx = constraint_str.find("DEFAULT")?;
527 let rest = constraint_str[idx + "DEFAULT".len()..].trim();
528 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
536fn 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
549fn 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
564fn 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
579pub 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 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
642fn 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 = ®istry["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 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 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}