use std::collections::HashMap;
use crate::Emitter;
use crate::ir;
use super::case::to_snake_case;
#[derive(Debug, Default, Clone, Copy)]
pub struct SurrealQlEmitter;
impl SurrealQlEmitter {
pub fn new() -> Self {
Self
}
}
impl Emitter for SurrealQlEmitter {
fn emit(&self, schema: &ir::Schema) -> String {
let enums: HashMap<&str, &[String]> = schema
.types
.iter()
.filter_map(|t| match t {
ir::TypeDef::Enum { name, variants, .. } => {
Some((name.as_str(), variants.as_slice()))
}
ir::TypeDef::Struct { .. } => None,
})
.collect();
let mut code = String::from(HEADER);
for ty in &schema.types {
if let ir::TypeDef::Struct {
name,
description,
fields,
} = ty
{
code.push('\n');
code.push_str(&render_table(
name,
description.as_deref(),
fields,
TableKind::Struct,
&enums,
));
}
}
for record in &schema.records {
code.push('\n');
code.push_str(&render_table(
&record.name,
record.description.as_deref(),
&record.fields,
TableKind::Record,
&enums,
));
}
for relation in &schema.relations {
code.push('\n');
code.push_str(&render_relation(relation, &enums));
}
code
}
}
#[derive(Clone, Copy)]
enum TableKind {
Struct,
Record,
}
const HEADER: &str = "\
-- Auto-generated SurrealDB schema
-- DO NOT EDIT MANUALLY
";
fn comment_clause(description: Option<&str>) -> String {
match description {
Some(text) => {
let escaped = text.replace('\\', "\\\\").replace('\'', "\\'");
format!(" COMMENT '{escaped}'")
}
None => String::new(),
}
}
fn render_table(
name: &str,
description: Option<&str>,
fields: &[ir::Field],
kind: TableKind,
enums: &HashMap<&str, &[String]>,
) -> String {
let table = to_snake_case(name);
let type_clause = match kind {
TableKind::Struct => "",
TableKind::Record => "TYPE NORMAL ",
};
let mut out = format!(
"DEFINE TABLE {table} {type_clause}SCHEMAFULL{};\n",
comment_clause(description)
);
for field in fields {
out.push_str(&render_field(&table, field, enums));
}
out
}
fn render_relation(relation: &ir::Relation, enums: &HashMap<&str, &[String]>) -> String {
let table = to_snake_case(&relation.name);
let in_t = to_snake_case(&relation.from);
let out_t = to_snake_case(&relation.to);
let mut out = format!(
"DEFINE TABLE {table} TYPE RELATION IN {in_t} OUT {out_t} SCHEMAFULL{};\n",
comment_clause(relation.description.as_deref())
);
for field in &relation.fields {
out.push_str(&render_field(&table, field, enums));
}
if relation.unique {
out.push_str(&format!(
"DEFINE INDEX {table}_unique_edge ON {table} FIELDS in, out UNIQUE;\n"
));
}
out
}
fn render_field(table: &str, field: &ir::Field, enums: &HashMap<&str, &[String]>) -> String {
let (base, ty_assert) = ty_to_surql(&field.ty, enums);
let full = if field.required {
base
} else {
format!("option<{base}>")
};
let flexible = if field.flexible && is_object_ty(&field.ty) {
"FLEXIBLE "
} else {
""
};
let mut line = format!(
"DEFINE FIELD {} ON {table} {flexible}TYPE {full}",
field.name
);
let mut conditions: Vec<String> = Vec::new();
conditions.extend(ty_assert);
conditions.extend(constraint_conditions(&field.ty, &field.constraints));
if !conditions.is_empty() {
let joined = conditions.join(" AND ");
let assert = if field.required {
joined
} else {
format!("$value = NONE OR {joined}")
};
line.push_str(&format!(" ASSERT {assert}"));
}
if let Some(default) = &field.default {
line.push_str(&format!(" DEFAULT {}", surql_default(&field.ty, default)));
}
line.push_str(&comment_clause(field.description.as_deref()));
line.push_str(";\n");
line
}
fn constraint_conditions(ty: &ir::Ty, c: &ir::Constraints) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if let Some(min) = c.min {
out.push(format!("$value >= {min}"));
}
if let Some(max) = c.max {
out.push(format!("$value <= {max}"));
}
let len_fn = match ty {
ir::Ty::Array(_) => Some("array::len"),
ir::Ty::Primitive(ir::Prim::String) => Some("string::len"),
_ => None,
};
if let Some(len_fn) = len_fn {
if let Some(min) = c.min_length {
out.push(format!("{len_fn}($value) >= {min}"));
}
if let Some(max) = c.max_length {
out.push(format!("{len_fn}($value) <= {max}"));
}
}
if let Some(pattern) = &c.pattern {
let escaped = pattern.replace('\\', "\\\\").replace('\'', "\\'");
out.push(format!("string::matches($value, '{escaped}')"));
}
out
}
fn is_object_ty(ty: &ir::Ty) -> bool {
matches!(ty, ir::Ty::Primitive(ir::Prim::Json))
}
fn surql_default(ty: &ir::Ty, raw: &str) -> String {
let quote = matches!(
ty,
ir::Ty::Primitive(ir::Prim::String)
| ir::Ty::Primitive(ir::Prim::Datetime)
| ir::Ty::Literal(_)
| ir::Ty::Named(_)
) || matches!(ty, ir::Ty::Union(members)
if members.iter().all(|m| matches!(m, ir::Ty::Literal(_))));
if quote {
format!("'{raw}'")
} else {
raw.to_string()
}
}
fn ty_to_surql(ty: &ir::Ty, enums: &HashMap<&str, &[String]>) -> (String, Option<String>) {
match ty {
ir::Ty::Primitive(p) => (prim_to_surql(*p).to_string(), None),
ir::Ty::Array(inner) => {
let (inner_ty, _) = ty_to_surql(inner, enums);
(format!("array<{inner_ty}>"), None)
}
ir::Ty::Named(name) => match enums.get(name.as_str()) {
Some(variants) => (
"string".to_string(),
Some(inside_condition(variants.iter().map(String::as_str))),
),
None => (format!("record<{}>", to_snake_case(name)), None),
},
ir::Ty::Link(name) => (format!("record<{}>", to_snake_case(name)), None),
ir::Ty::Literal(value) => (
"string".to_string(),
Some(inside_condition(std::iter::once(value.as_str()))),
),
ir::Ty::Union(members) => {
if let Some(values) = literal_union_values(members) {
(
"string".to_string(),
Some(inside_condition(values.iter().map(String::as_str))),
)
} else {
let mut parts: Vec<String> = Vec::new();
for m in members {
let (t, _) = ty_to_surql(m, enums);
if !parts.contains(&t) {
parts.push(t);
}
}
(parts.join(" | "), None)
}
}
}
}
fn literal_union_values(members: &[ir::Ty]) -> Option<Vec<String>> {
members
.iter()
.map(|m| match m {
ir::Ty::Literal(v) => Some(v.clone()),
_ => None,
})
.collect()
}
fn inside_condition<'a>(values: impl Iterator<Item = &'a str>) -> String {
let list: Vec<String> = values.map(|v| format!("'{v}'")).collect();
format!("$value INSIDE [{}]", list.join(", "))
}
fn prim_to_surql(p: ir::Prim) -> &'static str {
match p {
ir::Prim::String => "string",
ir::Prim::Int => "int",
ir::Prim::Float => "float",
ir::Prim::Bool => "bool",
ir::Prim::Datetime => "datetime",
ir::Prim::Json => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str, ty: ir::Ty, required: bool) -> ir::Field {
ir::Field {
name: name.to_string(),
ty,
required,
flexible: false,
default: None,
description: None,
constraints: ir::Constraints::default(),
}
}
#[test]
fn emits_header() {
let out = SurrealQlEmitter::new().emit(&ir::Schema::default());
assert!(out.contains("-- Auto-generated SurrealDB schema"));
}
#[test]
fn struct_becomes_define_table_and_fields() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![
field("id", ir::Ty::Primitive(ir::Prim::String), true),
field("age", ir::Ty::Primitive(ir::Prim::Int), true),
],
}],
protocol: None,
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE TABLE user SCHEMAFULL;"));
assert!(out.contains("DEFINE FIELD id ON user TYPE string;"));
assert!(out.contains("DEFINE FIELD age ON user TYPE int;"));
}
#[test]
fn optional_field_becomes_option_type() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("nick", ir::Ty::Primitive(ir::Prim::String), false)],
}],
protocol: None,
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE FIELD nick ON user TYPE option<string>;"));
}
#[test]
fn enum_reference_becomes_string_with_assert() {
let schema = ir::Schema {
types: vec![
ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("role", ir::Ty::Named("Role".to_string()), true)],
},
ir::TypeDef::Enum {
name: "Role".to_string(),
description: None,
variants: vec!["admin".to_string(), "member".to_string()],
},
],
protocol: None,
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains(
"DEFINE FIELD role ON user TYPE string ASSERT $value INSIDE ['admin', 'member'];"
));
}
#[test]
fn struct_reference_becomes_record_link() {
let schema = ir::Schema {
types: vec![
ir::TypeDef::Struct {
name: "Post".to_string(),
description: None,
fields: vec![field("author", ir::Ty::Named("User".to_string()), true)],
},
ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![field("id", ir::Ty::Primitive(ir::Prim::String), true)],
},
],
protocol: None,
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE FIELD author ON post TYPE record<user>;"));
}
#[test]
fn array_and_primitive_mapping() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![
field("f", ir::Ty::Primitive(ir::Prim::Float), true),
field("b", ir::Ty::Primitive(ir::Prim::Bool), true),
field("at", ir::Ty::Primitive(ir::Prim::Datetime), true),
field("blob", ir::Ty::Primitive(ir::Prim::Json), true),
field(
"tags",
ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
true,
),
],
}],
protocol: None,
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("TYPE float;"));
assert!(out.contains("TYPE bool;"));
assert!(out.contains("TYPE datetime;"));
assert!(out.contains("TYPE object;"));
assert!(out.contains("TYPE array<string>;"));
}
#[test]
fn protocol_only_schema_yields_header_only() {
let schema = ir::Schema {
types: vec![],
records: vec![],
relations: vec![],
protocol: Some(ir::Protocol {
name: "p".to_string(),
version: "1.0.0".to_string(),
namespace: None,
description: None,
channels: vec![],
}),
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("-- Auto-generated SurrealDB schema"));
assert!(!out.contains("DEFINE TABLE"));
}
#[test]
fn record_becomes_define_table_type_normal() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("name", ir::Ty::Primitive(ir::Prim::String), true)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE TABLE atlas TYPE NORMAL SCHEMAFULL;"));
assert!(out.contains("DEFINE FIELD name ON atlas TYPE string;"));
}
#[test]
fn struct_table_keeps_no_type_clause() {
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "GeoPoint".to_string(),
description: None,
fields: vec![field("lat", ir::Ty::Primitive(ir::Prim::Float), true)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE TABLE geo_point SCHEMAFULL;"));
assert!(!out.contains("TYPE NORMAL"));
}
#[test]
fn relation_becomes_define_table_type_relation_with_index() {
let schema = ir::Schema {
relations: vec![ir::Relation {
name: "derivedFrom".to_string(),
description: None,
from: "Memory".to_string(),
to: "Memory".to_string(),
unique: true,
fields: vec![field(
"confidence",
ir::Ty::Primitive(ir::Prim::Float),
false,
)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(
out.contains(
"DEFINE TABLE derived_from TYPE RELATION IN memory OUT memory SCHEMAFULL;"
)
);
assert!(out.contains("DEFINE FIELD confidence ON derived_from TYPE option<float>;"));
assert!(out.contains(
"DEFINE INDEX derived_from_unique_edge ON derived_from FIELDS in, out UNIQUE;"
));
}
#[test]
fn non_unique_relation_omits_index() {
let schema = ir::Schema {
relations: vec![ir::Relation {
name: "tagged".to_string(),
description: None,
from: "Note".to_string(),
to: "Tag".to_string(),
unique: false,
fields: vec![],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("TYPE RELATION IN note OUT tag"));
assert!(!out.contains("DEFINE INDEX"));
}
#[test]
fn link_field_becomes_record_link() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field("parent", ir::Ty::Link("Atlas".to_string()), false)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE FIELD parent ON atlas TYPE option<record<atlas>>;"));
}
#[test]
fn literal_union_becomes_string_with_assert() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Doc".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field(
"visibility",
ir::Ty::Union(vec![
ir::Ty::Literal("public".to_string()),
ir::Ty::Literal("private".to_string()),
]),
true,
)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains(
"DEFINE FIELD visibility ON doc TYPE string ASSERT $value INSIDE ['public', 'private'];"
));
}
#[test]
fn flexible_object_field_emits_flexible_keyword() {
let mut f = field("metadata", ir::Ty::Primitive(ir::Prim::Json), true);
f.flexible = true;
let schema = ir::Schema {
records: vec![ir::Record {
name: "Atlas".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![f],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFINE FIELD metadata ON atlas FLEXIBLE TYPE object;"));
}
#[test]
fn default_value_is_quoted_for_string_types() {
let mut f = field("visibility", ir::Ty::Primitive(ir::Prim::String), true);
f.default = Some("private".to_string());
let mut g = field("count", ir::Ty::Primitive(ir::Prim::Int), true);
g.default = Some("0".to_string());
let schema = ir::Schema {
records: vec![ir::Record {
name: "Doc".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![f, g],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("DEFAULT 'private'"), "string default quoted");
assert!(out.contains("DEFAULT 0"), "numeric default unquoted");
}
#[test]
fn record_and_field_descriptions_become_comment_clauses() {
let mut content = field("content", ir::Ty::Primitive(ir::Prim::String), true);
content.description = Some("Memory content text".to_string());
let schema = ir::Schema {
records: vec![ir::Record {
name: "Memory".to_string(),
description: Some("User memory".to_string()),
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![content],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(
out.contains("DEFINE TABLE memory TYPE NORMAL SCHEMAFULL COMMENT 'User memory';"),
"table COMMENT; got: {out}"
);
assert!(
out.contains(
"DEFINE FIELD content ON memory TYPE string COMMENT 'Memory content text';"
),
"field COMMENT; got: {out}"
);
}
#[test]
fn numeric_constraints_become_assert_range() {
let mut f = field("confidence", ir::Ty::Primitive(ir::Prim::Float), true);
f.constraints = ir::Constraints {
min: Some(0),
max: Some(1),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(
out.contains(
"DEFINE FIELD confidence ON t TYPE float ASSERT $value >= 0 AND $value <= 1;"
),
"got: {out}"
);
}
#[test]
fn string_length_and_pattern_constraints_become_assert() {
let mut f = field("name", ir::Ty::Primitive(ir::Prim::String), true);
f.constraints = ir::Constraints {
min_length: Some(1),
max_length: Some(32),
pattern: Some("^[a-z]+$".to_string()),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(
out.contains(
"ASSERT string::len($value) >= 1 AND string::len($value) <= 32 AND string::matches($value, '^[a-z]+$')"
),
"string length + pattern ASSERT; got: {out}"
);
}
#[test]
fn array_length_constraint_uses_array_len() {
let mut f = field(
"tags",
ir::Ty::Array(Box::new(ir::Ty::Primitive(ir::Prim::String))),
true,
);
f.constraints = ir::Constraints {
min_length: Some(2),
..Default::default()
};
let schema = ir::Schema {
types: vec![ir::TypeDef::Struct {
name: "T".to_string(),
description: None,
fields: vec![f],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("ASSERT array::len($value) >= 2"), "got: {out}");
}
#[test]
fn enum_value_set_and_constraint_assert_are_anded() {
let mut f = field("role", ir::Ty::Named("Role".to_string()), true);
f.constraints = ir::Constraints {
pattern: Some("^[a-z]+$".to_string()),
..Default::default()
};
let schema = ir::Schema {
types: vec![
ir::TypeDef::Struct {
name: "User".to_string(),
description: None,
fields: vec![f],
},
ir::TypeDef::Enum {
name: "Role".to_string(),
description: None,
variants: vec!["admin".to_string(), "member".to_string()],
},
],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(
out.contains(
"ASSERT $value INSIDE ['admin', 'member'] AND string::matches($value, '^[a-z]+$')"
),
"value-set AND constraint; got: {out}"
);
}
#[test]
fn assert_uses_inside_not_in() {
let schema = ir::Schema {
records: vec![ir::Record {
name: "Doc".to_string(),
description: None,
id_strategy: ir::IdStrategy::Uuidv7,
fields: vec![field(
"visibility",
ir::Ty::Union(vec![
ir::Ty::Literal("public".to_string()),
ir::Ty::Literal("private".to_string()),
]),
true,
)],
}],
..Default::default()
};
let out = SurrealQlEmitter::new().emit(&schema);
assert!(out.contains("ASSERT $value INSIDE ['public', 'private']"));
assert!(!out.contains("$value IN ["), "no legacy IN operator");
}
}