use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use super::types::{FieldDef, FieldType, ObjectField, PrimitiveType, SchemaTypes, TableDef};
pub fn to_json(doc: &SchemaTypes, pretty: bool) -> Result<String> {
Ok(if pretty {
serde_json::to_string_pretty(doc)?
} else {
serde_json::to_string(doc)?
})
}
pub fn to_typescript(doc: &SchemaTypes) -> Result<String> {
let mut imports: BTreeSet<&'static str> = BTreeSet::new();
let mut body = String::new();
let tables = doc.tables.iter().filter(|t| !t.name.starts_with("__"));
for (idx, table) in tables.enumerate() {
if idx > 0 {
body.push('\n');
}
render_table(table, &mut body, &mut imports);
}
let mut out = String::new();
out.push_str("// Generated by SurrealKit — do not edit.\n");
out.push_str("// Run `surrealkit typegen` to regenerate.\n\n");
if !imports.is_empty() {
let list = imports.iter().copied().collect::<Vec<_>>().join(", ");
out.push_str(&format!("import type {{ {list} }} from 'surrealdb';\n\n"));
}
out.push_str(&body);
Ok(out)
}
fn render_table(table: &TableDef, out: &mut String, imports: &mut BTreeSet<&'static str>) {
imports.insert("RecordId");
out.push_str(&format!("export interface {} {{\n", pascal_case(&table.name)));
out.push_str(&format!(" id: RecordId<'{}'>;\n", table.name));
let tree = build_field_tree(&table.fields);
for (name, node) in &tree.children {
render_node(name, node, 1, out, imports);
}
out.push_str("}\n");
}
#[derive(Default)]
struct Node {
own_type: Option<FieldType>,
own_optional: bool,
elem_type: Option<FieldType>,
children: BTreeMap<String, Node>,
child_array: bool,
}
fn build_field_tree(fields: &[FieldDef]) -> Node {
let mut root = Node::default();
for field in fields {
let segments: Vec<&str> =
field.name.split('.').map(str::trim).filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
continue;
}
if segments.len() == 1 && segments[0] == "id" {
continue;
}
let mut node = &mut root;
let last = segments.len() - 1;
for (i, raw) in segments.iter().enumerate() {
let is_last = i == last;
let (name, is_array) = strip_array_marker(raw);
if name == "*" || (name.is_empty() && is_array) {
node.child_array = true;
if is_last {
node.elem_type = Some(field.r#type.clone());
}
continue;
}
node = node.children.entry(name.clone()).or_default();
if is_array {
node.child_array = true;
}
if is_last {
node.own_type = Some(field.r#type.clone());
node.own_optional = field.optional;
}
}
}
root
}
fn render_node(
name: &str,
node: &Node,
depth: usize,
out: &mut String,
imports: &mut BTreeSet<&'static str>,
) {
let indent = " ".repeat(depth);
let optional = if node.own_optional {
"?"
} else {
""
};
let key = format_key(name);
let ty = render_node_type(node, depth, imports);
out.push_str(&format!("{indent}{key}{optional}: {ty};\n"));
}
fn render_node_type(node: &Node, depth: usize, imports: &mut BTreeSet<&'static str>) -> String {
if !node.children.is_empty() {
let inner_indent = " ".repeat(depth + 1);
let close_indent = " ".repeat(depth);
let mut obj = String::from("{\n");
for (name, child) in &node.children {
let optional = if child.own_optional {
"?"
} else {
""
};
let key = format_key(name);
let ty = render_node_type(child, depth + 1, imports);
obj.push_str(&format!("{inner_indent}{key}{optional}: {ty};\n"));
}
obj.push_str(&format!("{close_indent}}}"));
let is_array = node.child_array
|| matches!(node.own_type, Some(FieldType::Array { .. } | FieldType::Set { .. }));
if is_array {
format!("({obj})[]")
} else {
obj
}
} else if let Some(ty) = &node.own_type {
field_type_to_ts(ty, imports)
} else if let Some(elem) = &node.elem_type {
let inner = field_type_to_ts(elem, imports);
wrap_array(&inner)
} else {
"unknown".to_string()
}
}
fn field_type_to_ts(ty: &FieldType, imports: &mut BTreeSet<&'static str>) -> String {
match ty {
FieldType::Primitive {
name,
} => primitive_to_ts(*name, imports),
FieldType::Option {
inner,
} => {
format!("{} | undefined", field_type_to_ts(inner, imports))
}
FieldType::Array {
inner,
..
}
| FieldType::Set {
inner,
..
} => wrap_array(&field_type_to_ts(inner, imports)),
FieldType::Record {
tables,
} => {
imports.insert("RecordId");
if tables.is_empty() {
"RecordId".to_string()
} else {
tables.iter().map(|t| format!("RecordId<'{t}'>")).collect::<Vec<_>>().join(" | ")
}
}
FieldType::Geometry {
kinds,
} => geometry_to_ts(kinds, imports),
FieldType::Literal {
value,
} => literal_to_ts(value),
FieldType::Union {
variants,
} => variants.iter().map(|v| field_type_to_ts(v, imports)).collect::<Vec<_>>().join(" | "),
FieldType::Object {
fields,
} => object_to_ts(fields, imports),
FieldType::Unknown {
..
} => "unknown".to_string(),
}
}
fn primitive_to_ts(name: PrimitiveType, imports: &mut BTreeSet<&'static str>) -> String {
match name {
PrimitiveType::String => "string",
PrimitiveType::Int | PrimitiveType::Float | PrimitiveType::Number => "number",
PrimitiveType::Decimal => {
imports.insert("Decimal");
"Decimal"
}
PrimitiveType::Bool => "boolean",
PrimitiveType::Datetime => "Date",
PrimitiveType::Duration => {
imports.insert("Duration");
"Duration"
}
PrimitiveType::Uuid => {
imports.insert("Uuid");
"Uuid"
}
PrimitiveType::Bytes => "Uint8Array",
PrimitiveType::Any => "any",
PrimitiveType::Null => "null",
PrimitiveType::None => "undefined",
PrimitiveType::Object => "{ [key: string]: unknown }",
PrimitiveType::Function => "unknown",
}
.to_string()
}
fn geometry_to_ts(kinds: &[String], imports: &mut BTreeSet<&'static str>) -> String {
if kinds.is_empty() {
imports.insert("Geometry");
return "Geometry".to_string();
}
kinds
.iter()
.map(|kind| {
let symbol = match kind.to_ascii_lowercase().as_str() {
"point" => "GeometryPoint",
"line" | "linestring" => "GeometryLine",
"polygon" => "GeometryPolygon",
"multipoint" => "GeometryMultiPoint",
"multiline" | "multilinestring" => "GeometryMultiLine",
"multipolygon" => "GeometryMultiPolygon",
"collection" => "GeometryCollection",
_ => "Geometry",
};
imports.insert(symbol);
symbol.to_string()
})
.collect::<Vec<_>>()
.join(" | ")
}
fn object_to_ts(fields: &[ObjectField], imports: &mut BTreeSet<&'static str>) -> String {
if fields.is_empty() {
return "{ [key: string]: unknown }".to_string();
}
let inner = fields
.iter()
.map(|f| format!("{}: {}", format_key(&f.name), field_type_to_ts(&f.r#type, imports)))
.collect::<Vec<_>>()
.join("; ");
format!("{{ {inner} }}")
}
fn literal_to_ts(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("{s:?}"),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => "null".to_string(),
other => format!("{other}"),
}
}
fn wrap_array(inner: &str) -> String {
if inner.contains(" | ") {
format!("({inner})[]")
} else {
format!("{inner}[]")
}
}
fn strip_array_marker(segment: &str) -> (String, bool) {
let mut name = segment;
let mut is_array = false;
while let Some(stripped) = name.strip_suffix("[*]") {
is_array = true;
name = stripped;
}
(name.to_string(), is_array)
}
fn format_key(name: &str) -> String {
if is_valid_identifier(name) {
name.to_string()
} else {
format!("{name:?}")
}
}
fn is_valid_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn pascal_case(name: &str) -> String {
let mut out = String::new();
for part in name.split(|c: char| !c.is_ascii_alphanumeric()) {
if part.is_empty() {
continue;
}
let mut chars = part.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
if out.is_empty() {
name.to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::typegen::types::{FieldType, PrimitiveType};
fn prim(name: PrimitiveType) -> FieldType {
FieldType::Primitive {
name,
}
}
fn field(name: &str, ty: FieldType, optional: bool) -> FieldDef {
FieldDef {
name: name.to_string(),
define: String::new(),
r#type: ty,
optional,
flexible: false,
readonly: false,
has_default: false,
raw_type: None,
}
}
fn table(name: &str, fields: Vec<FieldDef>) -> TableDef {
TableDef {
name: name.to_string(),
define: String::new(),
schemafull: Some(true),
kind: None,
fields,
events: vec![],
indexes: vec![],
}
}
fn doc(tables: Vec<TableDef>) -> SchemaTypes {
SchemaTypes {
version: 1,
generated_at: String::new(),
namespace: None,
database: None,
tables,
functions: vec![],
params: vec![],
analyzers: vec![],
accesses: vec![],
apis: vec![],
buckets: vec![],
sequences: vec![],
configs: vec![],
models: vec![],
users: vec![],
}
}
#[test]
fn pascal_case_variants() {
assert_eq!(pascal_case("user"), "User");
assert_eq!(pascal_case("user_profile"), "UserProfile");
assert_eq!(pascal_case("blog-post"), "BlogPost");
assert_eq!(pascal_case("User"), "User");
}
#[test]
fn table_becomes_interface_with_typed_id() {
let d = doc(vec![table("user", vec![field("name", prim(PrimitiveType::String), false)])]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("export interface User {"), "got:\n{ts}");
assert!(ts.contains("id: RecordId<'user'>;"), "got:\n{ts}");
assert!(ts.contains("name: string;"), "got:\n{ts}");
assert!(ts.contains("import type { RecordId } from 'surrealdb';"), "got:\n{ts}");
}
#[test]
fn optional_field_gets_question_mark() {
let d =
doc(vec![table("user", vec![field("nickname", prim(PrimitiveType::String), true)])]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("nickname?: string;"), "got:\n{ts}");
}
#[test]
fn record_link_maps_to_record_id() {
let d = doc(vec![table(
"comment",
vec![field(
"author",
FieldType::Record {
tables: vec!["user".into()],
},
false,
)],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("author: RecordId<'user'>;"), "got:\n{ts}");
}
#[test]
fn array_of_string_maps_to_array() {
let d = doc(vec![table(
"post",
vec![field(
"tags",
FieldType::Array {
inner: Box::new(prim(PrimitiveType::String)),
max: None,
},
false,
)],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("tags: string[];"), "got:\n{ts}");
}
#[test]
fn record_union_array_is_parenthesised() {
let d = doc(vec![table(
"post",
vec![field(
"refs",
FieldType::Array {
inner: Box::new(FieldType::Record {
tables: vec!["user".into(), "org".into()],
}),
max: None,
},
false,
)],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("refs: (RecordId<'user'> | RecordId<'org'>)[];"), "got:\n{ts}");
}
#[test]
fn literal_union_renders() {
let d = doc(vec![table(
"task",
vec![field(
"status",
FieldType::Union {
variants: vec![
FieldType::Literal {
value: serde_json::json!("open"),
},
FieldType::Literal {
value: serde_json::json!("done"),
},
],
},
false,
)],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains(r#"status: "open" | "done";"#), "got:\n{ts}");
}
#[test]
fn dotted_paths_become_nested_object() {
let d = doc(vec![table(
"user",
vec![
field("address.city", prim(PrimitiveType::String), false),
field("address.zip", prim(PrimitiveType::String), true),
],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("address: {"), "got:\n{ts}");
assert!(ts.contains("city: string;"), "got:\n{ts}");
assert!(ts.contains("zip?: string;"), "got:\n{ts}");
}
#[test]
fn array_element_object_from_star_paths() {
let d = doc(vec![table(
"user",
vec![
field(
"addresses",
FieldType::Array {
inner: Box::new(prim(PrimitiveType::Object)),
max: None,
},
false,
),
field("addresses[*].street", prim(PrimitiveType::String), false),
],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("addresses: ({"), "got:\n{ts}");
assert!(ts.contains("street: string;"), "got:\n{ts}");
assert!(ts.contains("})[];"), "got:\n{ts}");
}
#[test]
fn sdk_wrapper_types_import() {
let d = doc(vec![table(
"event",
vec![
field("at", prim(PrimitiveType::Datetime), false),
field("dur", prim(PrimitiveType::Duration), false),
field("amount", prim(PrimitiveType::Decimal), false),
],
)]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("at: Date;"), "got:\n{ts}");
assert!(ts.contains("dur: Duration;"), "got:\n{ts}");
assert!(ts.contains("amount: Decimal;"), "got:\n{ts}");
assert!(
ts.contains("import type { Decimal, Duration, RecordId } from 'surrealdb';"),
"got:\n{ts}"
);
}
#[test]
fn internal_tables_are_skipped() {
let d = doc(vec![
table("__entity", vec![field("key", prim(PrimitiveType::String), false)]),
table("user", vec![field("name", prim(PrimitiveType::String), false)]),
]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("export interface User {"), "got:\n{ts}");
assert!(!ts.contains("__entity"), "internal table leaked, got:\n{ts}");
assert!(!ts.contains("interface Entity"), "internal table leaked, got:\n{ts}");
}
#[test]
fn introspected_id_field_is_skipped() {
let d = doc(vec![table("user", vec![field("id", prim(PrimitiveType::String), false)])]);
let ts = to_typescript(&d).unwrap();
assert!(ts.contains("id: RecordId<'user'>;"), "got:\n{ts}");
assert!(!ts.contains("id: string;"), "synthesized id must win, got:\n{ts}");
}
}