1use std::collections::HashMap;
45
46use crate::Emitter;
47use crate::ir;
48
49use super::case::to_snake_case;
50
51#[derive(Debug, Default, Clone, Copy)]
53pub struct SurrealQlEmitter;
54
55impl SurrealQlEmitter {
56 pub fn new() -> Self {
58 Self
59 }
60}
61
62impl Emitter for SurrealQlEmitter {
63 fn emit(&self, schema: &ir::Schema) -> String {
64 let enums: HashMap<&str, &[String]> = schema
66 .types
67 .iter()
68 .filter_map(|t| match t {
69 ir::TypeDef::Enum { name, variants, .. } => {
70 Some((name.as_str(), variants.as_slice()))
71 }
72 ir::TypeDef::Struct { .. } => None,
73 })
74 .collect();
75
76 let mut code = String::from(HEADER);
77 for ty in &schema.types {
79 if let ir::TypeDef::Struct {
80 name,
81 description,
82 fields,
83 } = ty
84 {
85 code.push('\n');
86 code.push_str(&render_table(
87 name,
88 description.as_deref(),
89 fields,
90 TableKind::Struct,
91 &enums,
92 ));
93 }
94 }
95 for record in &schema.records {
97 code.push('\n');
98 code.push_str(&render_table(
99 &record.name,
100 record.description.as_deref(),
101 &record.fields,
102 TableKind::Record,
103 &enums,
104 ));
105 }
106 for relation in &schema.relations {
108 code.push('\n');
109 code.push_str(&render_relation(relation, &enums));
110 }
111 code
112 }
113}
114
115#[derive(Clone, Copy)]
117enum TableKind {
118 Struct,
121 Record,
123}
124
125const HEADER: &str = "\
127-- Auto-generated SurrealDB schema
128-- DO NOT EDIT MANUALLY
129";
130
131fn comment_clause(description: Option<&str>) -> String {
134 match description {
135 Some(text) => {
136 let escaped = text.replace('\\', "\\\\").replace('\'', "\\'");
137 format!(" COMMENT '{escaped}'")
138 }
139 None => String::new(),
140 }
141}
142
143fn render_table(
145 name: &str,
146 description: Option<&str>,
147 fields: &[ir::Field],
148 kind: TableKind,
149 enums: &HashMap<&str, &[String]>,
150) -> String {
151 let table = to_snake_case(name);
152 let type_clause = match kind {
153 TableKind::Struct => "",
154 TableKind::Record => "TYPE NORMAL ",
155 };
156 let mut out = format!(
157 "DEFINE TABLE {table} {type_clause}SCHEMAFULL{};\n",
158 comment_clause(description)
159 );
160 for field in fields {
161 out.push_str(&render_field(&table, field, enums));
162 }
163 out
164}
165
166fn render_relation(relation: &ir::Relation, enums: &HashMap<&str, &[String]>) -> String {
170 let table = to_snake_case(&relation.name);
171 let in_t = to_snake_case(&relation.from);
172 let out_t = to_snake_case(&relation.to);
173 let mut out = format!(
174 "DEFINE TABLE {table} TYPE RELATION IN {in_t} OUT {out_t} SCHEMAFULL{};\n",
175 comment_clause(relation.description.as_deref())
176 );
177 for field in &relation.fields {
178 out.push_str(&render_field(&table, field, enums));
179 }
180 if relation.unique {
181 out.push_str(&format!(
182 "DEFINE INDEX {table}_unique_edge ON {table} FIELDS in, out UNIQUE;\n"
183 ));
184 }
185 out
186}
187
188fn render_field(table: &str, field: &ir::Field, enums: &HashMap<&str, &[String]>) -> String {
194 let (base, ty_assert) = ty_to_surql(&field.ty, enums);
195 let full = if field.required {
196 base
197 } else {
198 format!("option<{base}>")
199 };
200 let flexible = if field.flexible && is_object_ty(&field.ty) {
202 "FLEXIBLE "
203 } else {
204 ""
205 };
206 let mut line = format!(
207 "DEFINE FIELD {} ON {table} {flexible}TYPE {full}",
208 field.name
209 );
210 let mut conditions: Vec<String> = Vec::new();
213 conditions.extend(ty_assert);
214 conditions.extend(constraint_conditions(&field.ty, &field.constraints));
215 if !conditions.is_empty() {
216 let joined = conditions.join(" AND ");
217 let assert = if field.required {
222 joined
223 } else {
224 format!("$value = NONE OR {joined}")
225 };
226 line.push_str(&format!(" ASSERT {assert}"));
227 }
228 if let Some(default) = &field.default {
229 line.push_str(&format!(" DEFAULT {}", surql_default(&field.ty, default)));
230 }
231 line.push_str(&comment_clause(field.description.as_deref()));
232 line.push_str(";\n");
233 line
234}
235
236fn constraint_conditions(ty: &ir::Ty, c: &ir::Constraints) -> Vec<String> {
244 let mut out: Vec<String> = Vec::new();
245 if let Some(min) = c.min {
246 out.push(format!("$value >= {min}"));
247 }
248 if let Some(max) = c.max {
249 out.push(format!("$value <= {max}"));
250 }
251 let len_fn = match ty {
253 ir::Ty::Array(_) => Some("array::len"),
254 ir::Ty::Primitive(ir::Prim::String) => Some("string::len"),
255 _ => None,
256 };
257 if let Some(len_fn) = len_fn {
258 if let Some(min) = c.min_length {
259 out.push(format!("{len_fn}($value) >= {min}"));
260 }
261 if let Some(max) = c.max_length {
262 out.push(format!("{len_fn}($value) <= {max}"));
263 }
264 }
265 if let Some(pattern) = &c.pattern {
266 let escaped = pattern.replace('\\', "\\\\").replace('\'', "\\'");
267 out.push(format!("string::matches($value, '{escaped}')"));
268 }
269 out
270}
271
272fn is_object_ty(ty: &ir::Ty) -> bool {
274 matches!(ty, ir::Ty::Primitive(ir::Prim::Json))
275}
276
277fn surql_default(ty: &ir::Ty, raw: &str) -> String {
280 let quote = matches!(
281 ty,
282 ir::Ty::Primitive(ir::Prim::String)
283 | ir::Ty::Primitive(ir::Prim::Datetime)
284 | ir::Ty::Literal(_)
285 | ir::Ty::Named(_)
286 ) || matches!(ty, ir::Ty::Union(members)
287 if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))));
288 if quote {
289 format!("'{raw}'")
290 } else {
291 raw.to_string()
292 }
293}
294
295fn ty_to_surql(ty: &ir::Ty, enums: &HashMap<&str, &[String]>) -> (String, Option<String>) {
301 match ty {
302 ir::Ty::Primitive(p) => (prim_to_surql(*p).to_string(), None),
303 ir::Ty::Array(inner) => {
304 let (inner_ty, _) = ty_to_surql(inner, enums);
305 (format!("array<{inner_ty}>"), None)
306 }
307 ir::Ty::Named(name) => match enums.get(name.as_str()) {
308 Some(variants) => (
310 "string".to_string(),
311 Some(inside_condition(variants.iter().map(String::as_str))),
312 ),
313 None => (format!("record<{}>", to_snake_case(name)), None),
315 },
316 ir::Ty::Link(name) => (format!("record<{}>", to_snake_case(name)), None),
318 ir::Ty::Literal(value) => (
320 "string".to_string(),
321 Some(inside_condition(std::iter::once(value.as_str()))),
322 ),
323 ir::Ty::Union(members) => {
324 if let Some(values) = literal_union_values(members) {
326 (
327 "string".to_string(),
328 Some(inside_condition(values.iter().map(String::as_str))),
329 )
330 } else {
331 let mut parts: Vec<String> = Vec::new();
336 for m in members {
337 let (t, _) = ty_to_surql(m, enums);
338 if !parts.contains(&t) {
339 parts.push(t);
340 }
341 }
342 (parts.join(" | "), None)
343 }
344 }
345 }
346}
347
348fn literal_union_values(members: &[ir::Ty]) -> Option<Vec<String>> {
350 members
351 .iter()
352 .map(|m| match m {
353 ir::Ty::Literal(v) => Some(v.clone()),
354 _ => None,
355 })
356 .collect()
357}
358
359fn inside_condition<'a>(values: impl Iterator<Item = &'a str>) -> String {
363 let list: Vec<String> = values.map(|v| format!("'{v}'")).collect();
364 format!("$value INSIDE [{}]", list.join(", "))
365}
366
367fn prim_to_surql(p: ir::Prim) -> &'static str {
369 match p {
370 ir::Prim::String => "string",
371 ir::Prim::Int => "int",
372 ir::Prim::Float => "float",
373 ir::Prim::Bool => "bool",
374 ir::Prim::Datetime => "datetime",
375 ir::Prim::Json => "object",
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
384 ir::Field {
385 name: name.to_string(),
386 ty,
387 required,
388 flexible: false,
389 default: None,
390 description: None,
391 constraints: ir::Constraints::default(),
392 }
393 }
394
395 #[test]
396 fn emits_header() {
397 let out = SurrealQlEmitter::new().emit(&ir::Schema::default());
398 assert!(out.contains("-- Auto-generated SurrealDB schema"));
399 }
400
401 #[test]
402 fn struct_becomes_define_table_and_fields() {
403 let schema = ir::Schema {
404 types: vec![ir::TypeDef::Struct {
405 name: "User".to_string(),
406 description: None,
407 fields: vec![
408 field("id", ir::Ty::Primitive(ir::Prim::String), true),
409 field("age", ir::Ty::Primitive(ir::Prim::Int), true),
410 ],
411 }],
412 protocol: None,
413 ..Default::default()
414 };
415 let out = SurrealQlEmitter::new().emit(&schema);
416 assert!(out.contains("DEFINE TABLE user SCHEMAFULL;"));
417 assert!(out.contains("DEFINE FIELD id ON user TYPE string;"));
418 assert!(out.contains("DEFINE FIELD age ON user TYPE int;"));
419 }
420
421 #[test]
422 fn optional_field_becomes_option_type() {
423 let schema = ir::Schema {
424 types: vec![ir::TypeDef::Struct {
425 name: "User".to_string(),
426 description: None,
427 fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
428 }],
429 protocol: None,
430 ..Default::default()
431 };
432 let out = SurrealQlEmitter::new().emit(&schema);
433 assert!(out.contains("DEFINE FIELD nick ON user TYPE option<string>;"));
434 }
435
436 #[test]
437 fn enum_reference_becomes_string_with_assert() {
438 let schema = ir::Schema {
439 types: vec![
440 ir::TypeDef::Struct {
441 name: "User".to_string(),
442 description: None,
443 fields: vec![field("role", ir::Ty::Named("Role".to_string()), true)],
444 },
445 ir::TypeDef::Enum {
446 name: "Role".to_string(),
447 description: None,
448 variants: vec!["admin".to_string(), "member".to_string()],
449 },
450 ],
451 protocol: None,
452 ..Default::default()
453 };
454 let out = SurrealQlEmitter::new().emit(&schema);
455 assert!(out.contains(
456 "DEFINE FIELD role ON user TYPE string ASSERT $value INSIDE ['admin', 'member'];"
457 ));
458 }
459
460 #[test]
461 fn struct_reference_becomes_record_link() {
462 let schema = ir::Schema {
463 types: vec![
464 ir::TypeDef::Struct {
465 name: "Post".to_string(),
466 description: None,
467 fields: vec![field("author", ir::Ty::Named("User".to_string()), true)],
468 },
469 ir::TypeDef::Struct {
470 name: "User".to_string(),
471 description: None,
472 fields: vec![field("id", ir::Ty::Primitive(ir::Prim::String), true)],
473 },
474 ],
475 protocol: None,
476 ..Default::default()
477 };
478 let out = SurrealQlEmitter::new().emit(&schema);
479 assert!(out.contains("DEFINE FIELD author ON post TYPE record<user>;"));
480 }
481
482 #[test]
483 fn array_and_primitive_mapping() {
484 let schema = ir::Schema {
485 types: vec![ir::TypeDef::Struct {
486 name: "T".to_string(),
487 description: None,
488 fields: vec![
489 field("f", ir::Ty::Primitive(ir::Prim::Float), true),
490 field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
491 field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
492 field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
493 field(
494 "tags",
495 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
496 true,
497 ),
498 ],
499 }],
500 protocol: None,
501 ..Default::default()
502 };
503 let out = SurrealQlEmitter::new().emit(&schema);
504 assert!(out.contains("TYPE float;"));
505 assert!(out.contains("TYPE bool;"));
506 assert!(out.contains("TYPE datetime;"));
507 assert!(out.contains("TYPE object;"));
508 assert!(out.contains("TYPE array<string>;"));
509 }
510
511 #[test]
512 fn protocol_only_schema_yields_header_only() {
513 let schema = ir::Schema {
516 types: vec![],
517 records: vec![],
518 relations: vec![],
519 protocol: Some(ir::Protocol {
520 name: "p".to_string(),
521 version: "1.0.0".to_string(),
522 namespace: None,
523 description: None,
524 channels: vec![],
525 }),
526 };
527 let out = SurrealQlEmitter::new().emit(&schema);
528 assert!(out.contains("-- Auto-generated SurrealDB schema"));
529 assert!(!out.contains("DEFINE TABLE"));
530 }
531
532 #[test]
537 fn record_becomes_define_table_type_normal() {
538 let schema = ir::Schema {
539 records: vec![ir::Record {
540 name: "Atlas".to_string(),
541 description: None,
542 id_strategy: ir::IdStrategy::Uuidv7,
543 fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
544 }],
545 ..Default::default()
546 };
547 let out = SurrealQlEmitter::new().emit(&schema);
548 assert!(out.contains("DEFINE TABLE atlas TYPE NORMAL SCHEMAFULL;"));
549 assert!(out.contains("DEFINE FIELD name ON atlas TYPE string;"));
550 }
551
552 #[test]
553 fn struct_table_keeps_no_type_clause() {
554 let schema = ir::Schema {
557 types: vec![ir::TypeDef::Struct {
558 name: "GeoPoint".to_string(),
559 description: None,
560 fields: vec![field("lat", ir::Ty::Primitive(ir::Prim::Float), true)],
561 }],
562 ..Default::default()
563 };
564 let out = SurrealQlEmitter::new().emit(&schema);
565 assert!(out.contains("DEFINE TABLE geo_point SCHEMAFULL;"));
566 assert!(!out.contains("TYPE NORMAL"));
567 }
568
569 #[test]
570 fn relation_becomes_define_table_type_relation_with_index() {
571 let schema = ir::Schema {
572 relations: vec![ir::Relation {
573 name: "derivedFrom".to_string(),
574 description: None,
575 from: "Memory".to_string(),
576 to: "Memory".to_string(),
577 unique: true,
578 fields: vec![field(
579 "confidence",
580 ir::Ty::Primitive(ir::Prim::Float),
581 false,
582 )],
583 }],
584 ..Default::default()
585 };
586 let out = SurrealQlEmitter::new().emit(&schema);
587 assert!(
588 out.contains(
589 "DEFINE TABLE derived_from TYPE RELATION IN memory OUT memory SCHEMAFULL;"
590 )
591 );
592 assert!(out.contains("DEFINE FIELD confidence ON derived_from TYPE option<float>;"));
593 assert!(out.contains(
594 "DEFINE INDEX derived_from_unique_edge ON derived_from FIELDS in, out UNIQUE;"
595 ));
596 }
597
598 #[test]
599 fn non_unique_relation_omits_index() {
600 let schema = ir::Schema {
601 relations: vec![ir::Relation {
602 name: "tagged".to_string(),
603 description: None,
604 from: "Note".to_string(),
605 to: "Tag".to_string(),
606 unique: false,
607 fields: vec![],
608 }],
609 ..Default::default()
610 };
611 let out = SurrealQlEmitter::new().emit(&schema);
612 assert!(out.contains("TYPE RELATION IN note OUT tag"));
613 assert!(!out.contains("DEFINE INDEX"));
614 }
615
616 #[test]
617 fn link_field_becomes_record_link() {
618 let schema = ir::Schema {
619 records: vec![ir::Record {
620 name: "Atlas".to_string(),
621 description: None,
622 id_strategy: ir::IdStrategy::Uuidv7,
623 fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
624 }],
625 ..Default::default()
626 };
627 let out = SurrealQlEmitter::new().emit(&schema);
628 assert!(out.contains("DEFINE FIELD parent ON atlas TYPE option<record<atlas>>;"));
629 }
630
631 #[test]
632 fn literal_union_becomes_string_with_assert() {
633 let schema = ir::Schema {
634 records: vec![ir::Record {
635 name: "Doc".to_string(),
636 description: None,
637 id_strategy: ir::IdStrategy::Uuidv7,
638 fields: vec![field(
639 "visibility",
640 ir::Ty::Union(vec![
641 ir::Ty::Literal("public".to_string()),
642 ir::Ty::Literal("private".to_string()),
643 ]),
644 true,
645 )],
646 }],
647 ..Default::default()
648 };
649 let out = SurrealQlEmitter::new().emit(&schema);
650 assert!(out.contains(
651 "DEFINE FIELD visibility ON doc TYPE string ASSERT $value INSIDE ['public', 'private'];"
652 ));
653 }
654
655 #[test]
656 fn flexible_object_field_emits_flexible_keyword() {
657 let mut f = field("metadata", ir::Ty::Primitive(ir::Prim::Json), true);
658 f.flexible = true;
659 let schema = ir::Schema {
660 records: vec![ir::Record {
661 name: "Atlas".to_string(),
662 description: None,
663 id_strategy: ir::IdStrategy::Uuidv7,
664 fields: vec![f],
665 }],
666 ..Default::default()
667 };
668 let out = SurrealQlEmitter::new().emit(&schema);
669 assert!(out.contains("DEFINE FIELD metadata ON atlas FLEXIBLE TYPE object;"));
670 }
671
672 #[test]
673 fn default_value_is_quoted_for_string_types() {
674 let mut f = field("visibility", ir::Ty::Primitive(ir::Prim::String), true);
675 f.default = Some("private".to_string());
676 let mut g = field("count", ir::Ty::Primitive(ir::Prim::Int), true);
677 g.default = Some("0".to_string());
678 let schema = ir::Schema {
679 records: vec![ir::Record {
680 name: "Doc".to_string(),
681 description: None,
682 id_strategy: ir::IdStrategy::Uuidv7,
683 fields: vec![f, g],
684 }],
685 ..Default::default()
686 };
687 let out = SurrealQlEmitter::new().emit(&schema);
688 assert!(out.contains("DEFAULT 'private'"), "string default quoted");
689 assert!(out.contains("DEFAULT 0"), "numeric default unquoted");
690 }
691
692 #[test]
697 fn record_and_field_descriptions_become_comment_clauses() {
698 let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
699 content.description = Some("Memory content text".to_string());
700 let schema = ir::Schema {
701 records: vec![ir::Record {
702 name: "Memory".to_string(),
703 description: Some("User memory".to_string()),
704 id_strategy: ir::IdStrategy::Uuidv7,
705 fields: vec![content],
706 }],
707 ..Default::default()
708 };
709 let out = SurrealQlEmitter::new().emit(&schema);
710 assert!(
711 out.contains("DEFINE TABLE memory TYPE NORMAL SCHEMAFULL COMMENT 'User memory';"),
712 "table COMMENT; got: {out}"
713 );
714 assert!(
715 out.contains(
716 "DEFINE FIELD content ON memory TYPE string COMMENT 'Memory content text';"
717 ),
718 "field COMMENT; got: {out}"
719 );
720 }
721
722 #[test]
723 fn numeric_constraints_become_assert_range() {
724 let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
725 f.constraints = ir::Constraints {
726 min: Some(0),
727 max: Some(1),
728 ..Default::default()
729 };
730 let schema = ir::Schema {
731 types: vec![ir::TypeDef::Struct {
732 name: "T".to_string(),
733 description: None,
734 fields: vec![f],
735 }],
736 ..Default::default()
737 };
738 let out = SurrealQlEmitter::new().emit(&schema);
739 assert!(
740 out.contains(
741 "DEFINE FIELD confidence ON t TYPE float ASSERT $value >= 0 AND $value <= 1;"
742 ),
743 "got: {out}"
744 );
745 }
746
747 #[test]
748 fn string_length_and_pattern_constraints_become_assert() {
749 let mut f = field("name", ir::Ty::Primitive(ir::Prim::String), true);
750 f.constraints = ir::Constraints {
751 min_length: Some(1),
752 max_length: Some(32),
753 pattern: Some("^[a-z]+$".to_string()),
754 ..Default::default()
755 };
756 let schema = ir::Schema {
757 types: vec![ir::TypeDef::Struct {
758 name: "T".to_string(),
759 description: None,
760 fields: vec![f],
761 }],
762 ..Default::default()
763 };
764 let out = SurrealQlEmitter::new().emit(&schema);
765 assert!(
766 out.contains(
767 "ASSERT string::len($value) >= 1 AND string::len($value) <= 32 AND string::matches($value, '^[a-z]+$')"
768 ),
769 "string length + pattern ASSERT; got: {out}"
770 );
771 }
772
773 #[test]
774 fn array_length_constraint_uses_array_len() {
775 let mut f = field(
776 "tags",
777 ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
778 true,
779 );
780 f.constraints = ir::Constraints {
781 min_length: Some(2),
782 ..Default::default()
783 };
784 let schema = ir::Schema {
785 types: vec![ir::TypeDef::Struct {
786 name: "T".to_string(),
787 description: None,
788 fields: vec![f],
789 }],
790 ..Default::default()
791 };
792 let out = SurrealQlEmitter::new().emit(&schema);
793 assert!(out.contains("ASSERT array::len($value) >= 2"), "got: {out}");
794 }
795
796 #[test]
797 fn enum_value_set_and_constraint_assert_are_anded() {
798 let mut f = field("role", ir::Ty::Named("Role".to_string()), true);
801 f.constraints = ir::Constraints {
802 pattern: Some("^[a-z]+$".to_string()),
803 ..Default::default()
804 };
805 let schema = ir::Schema {
806 types: vec![
807 ir::TypeDef::Struct {
808 name: "User".to_string(),
809 description: None,
810 fields: vec![f],
811 },
812 ir::TypeDef::Enum {
813 name: "Role".to_string(),
814 description: None,
815 variants: vec!["admin".to_string(), "member".to_string()],
816 },
817 ],
818 ..Default::default()
819 };
820 let out = SurrealQlEmitter::new().emit(&schema);
821 assert!(
822 out.contains(
823 "ASSERT $value INSIDE ['admin', 'member'] AND string::matches($value, '^[a-z]+$')"
824 ),
825 "value-set AND constraint; got: {out}"
826 );
827 }
828
829 #[test]
830 fn assert_uses_inside_not_in() {
831 let schema = ir::Schema {
833 records: vec![ir::Record {
834 name: "Doc".to_string(),
835 description: None,
836 id_strategy: ir::IdStrategy::Uuidv7,
837 fields: vec![field(
838 "visibility",
839 ir::Ty::Union(vec![
840 ir::Ty::Literal("public".to_string()),
841 ir::Ty::Literal("private".to_string()),
842 ]),
843 true,
844 )],
845 }],
846 ..Default::default()
847 };
848 let out = SurrealQlEmitter::new().emit(&schema);
849 assert!(out.contains("ASSERT $value INSIDE ['public', 'private']"));
850 assert!(!out.contains("$value IN ["), "no legacy IN operator");
851 }
852}