use std::fmt::Write;
use scythe_core::errors::{ErrorCode, ScytheError};
use crate::backend_trait::ResolvedColumn;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TsRowType {
#[default]
Interface,
Zod,
}
impl TsRowType {
pub fn from_option(value: &str) -> Result<Self, ScytheError> {
match value {
"interface" => Ok(Self::Interface),
"zod" => Ok(Self::Zod),
_ => Err(ScytheError::new(
ErrorCode::InternalError,
format!("invalid row_type '{}': expected 'interface' or 'zod'", value),
)),
}
}
}
pub fn parse_bool_option(option_name: &str, value: &str) -> Result<bool, ScytheError> {
match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Ok(true),
"false" | "0" | "no" => Ok(false),
_ => Err(ScytheError::new(
ErrorCode::InternalError,
format!("invalid {option_name} '{value}': expected 'true'/'false', '1'/'0', or 'yes'/'no'"),
)),
}
}
pub fn escape_ts_template_literal(sql: &str) -> String {
sql.replace('\\', "\\\\").replace('`', "\\`").replace("${", "\\${")
}
pub fn escape_ts_double_quoted_literal(sql: &str) -> String {
sql.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn neutral_to_zod(neutral_type: &str, nullable: bool) -> String {
let base = match neutral_type {
"int16" | "int32" | "int64" => "z.number()",
"float32" | "float64" => "z.number()",
"string" | "text" | "inet" | "interval" | "time" | "time_tz" => "z.string()",
"bool" => "z.boolean()",
"datetime" | "datetime_tz" => "z.date()",
"date" => "z.string()",
"uuid" => "z.string().uuid()",
"json" => "z.unknown()",
"decimal" => "z.string()",
"bytes" => "z.instanceof(Buffer)",
t if t.starts_with("enum::") => "z.string()",
_ => "z.unknown()",
};
if nullable {
format!("{base}.nullable()")
} else {
base.to_string()
}
}
pub fn generate_ts_interface_row_struct(struct_name: &str, query_name: &str, columns: &[ResolvedColumn]) -> String {
generate_ts_interface_row_struct_with_base(struct_name, query_name, columns, None)
}
pub fn generate_ts_interface_row_struct_with_base(
struct_name: &str,
query_name: &str,
columns: &[ResolvedColumn],
base: Option<&str>,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "/** Row type for {} queries. */", query_name);
match base {
Some(base) => {
let _ = writeln!(out, "export interface {} extends {} {{", struct_name, base);
}
None => {
let _ = writeln!(out, "export interface {} {{", struct_name);
}
}
for col in columns {
let _ = writeln!(out, "\t{}: {};", col.field_name, col.full_type);
}
let _ = write!(out, "}}");
out
}
fn discriminated_join_groups(columns: &[ResolvedColumn]) -> Vec<String> {
let mut groups: Vec<String> = Vec::new();
for col in columns {
if let Some(group) = &col.join_group
&& !groups.contains(group)
&& columns
.iter()
.any(|c| c.join_group.as_ref() == Some(group) && c.is_join_discriminant())
{
groups.push(group.clone());
}
}
groups
}
pub fn generate_ts_union_row_struct(
struct_name: &str,
query_name: &str,
columns: &[ResolvedColumn],
base: Option<&str>,
) -> String {
let groups = discriminated_join_groups(columns);
if groups.is_empty() {
return generate_ts_interface_row_struct_with_base(struct_name, query_name, columns, base);
}
let mut out = String::new();
let _ = writeln!(out, "/** Row type for {} queries. */", query_name);
match base {
Some(base) => {
let _ = writeln!(out, "export type {} = {} & {{", struct_name, base);
}
None => {
let _ = writeln!(out, "export type {} = {{", struct_name);
}
}
for col in columns.iter().filter(|c| c.join_group.is_none()) {
let _ = writeln!(out, "\t{}: {};", col.field_name, col.full_type);
}
let _ = write!(out, "}}");
for group in &groups {
let members: Vec<&ResolvedColumn> = columns
.iter()
.filter(|c| c.join_group.as_ref() == Some(group))
.collect();
let _ = writeln!(out, " & (");
let _ = write!(out, "\t| {{ ");
for (index, col) in members.iter().enumerate() {
if index > 0 {
let _ = write!(out, "; ");
}
let matched_type = if col.nullable_before_join {
col.full_type.as_str()
} else {
col.lang_type.as_str()
};
let _ = write!(out, "{}: {}", col.field_name, matched_type);
}
let _ = writeln!(out, " }}");
let _ = write!(out, "\t| {{ ");
for (index, col) in members.iter().enumerate() {
if index > 0 {
let _ = write!(out, "; ");
}
let _ = write!(out, "{}: null", col.field_name);
}
let _ = writeln!(out, " }}");
let _ = write!(out, ")");
}
let _ = write!(out, ";");
out
}
pub fn generate_zod_row_struct(struct_name: &str, query_name: &str, columns: &[ResolvedColumn]) -> String {
let schema_name = format!("{struct_name}Schema");
let mut out = String::new();
let _ = writeln!(out, "/** Row type for {} queries. */", query_name);
let _ = writeln!(out, "export const {} = z.object({{", schema_name);
for col in columns {
let zod_type = column_to_zod(col);
let _ = writeln!(out, "\t{}: {},", col.field_name, zod_type);
}
let _ = writeln!(out, "}});");
let _ = writeln!(out);
let _ = write!(out, "export type {} = z.infer<typeof {}>;", struct_name, schema_name);
out
}
fn column_to_zod(col: &ResolvedColumn) -> String {
column_to_zod_with_nullable(col, col.nullable)
}
fn column_to_zod_with_nullable(col: &ResolvedColumn, nullable: bool) -> String {
if col.neutral_type.starts_with("enum::") {
let base = if col.lang_type.starts_with("enum::") {
col.lang_type
.strip_prefix("enum::")
.unwrap_or(&col.lang_type)
.to_string()
} else {
col.lang_type.clone()
};
let schema_name = format!("{}Schema", base);
if nullable {
format!("{schema_name}.nullable()")
} else {
schema_name
}
} else if col.neutral_type == "bytes" {
let base = format!("z.instanceof({})", col.lang_type);
if nullable { format!("{base}.nullable()") } else { base }
} else {
neutral_to_zod(&col.neutral_type, nullable)
}
}
pub fn generate_zod_union_row_struct(struct_name: &str, query_name: &str, columns: &[ResolvedColumn]) -> String {
let groups = discriminated_join_groups(columns);
if groups.is_empty() {
return generate_zod_row_struct(struct_name, query_name, columns);
}
let schema_name = format!("{struct_name}Schema");
let mut out = String::new();
let _ = writeln!(out, "/** Row type for {} queries. */", query_name);
let _ = writeln!(out, "export const {} = z.object({{", schema_name);
for col in columns.iter().filter(|c| c.join_group.is_none()) {
let zod_type = column_to_zod(col);
let _ = writeln!(out, "\t{}: {},", col.field_name, zod_type);
}
let _ = write!(out, "}})");
for group in &groups {
let members: Vec<&ResolvedColumn> = columns
.iter()
.filter(|c| c.join_group.as_ref() == Some(group))
.collect();
let matched_fields: Vec<String> = members
.iter()
.map(|col| {
format!(
"{}: {}",
col.field_name,
column_to_zod_with_nullable(col, col.nullable_before_join)
)
})
.collect();
let unmatched_fields: Vec<String> = members
.iter()
.map(|col| format!("{}: z.null()", col.field_name))
.collect();
let _ = write!(
out,
".and(z.union([z.object({{ {} }}), z.object({{ {} }})]))",
matched_fields.join(", "),
unmatched_fields.join(", "),
);
}
let _ = writeln!(out, ";");
let _ = writeln!(out);
let _ = write!(out, "export type {} = z.infer<typeof {}>;", struct_name, schema_name);
out
}
pub fn generate_grouped_interface_structs(
child_struct_name: &str,
parent_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
) -> String {
let mut out = String::new();
let _ = writeln!(out, "/** Child row type for grouped query. */");
let _ = writeln!(out, "export interface {child_struct_name} {{");
for col in child_columns {
let _ = writeln!(out, "\t{}: {};", col.field_name, col.full_type);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out, "/** Parent row type for grouped query. */");
let _ = writeln!(out, "export interface {parent_struct_name} {{");
for col in parent_columns {
let _ = writeln!(out, "\t{}: {};", col.field_name, col.full_type);
}
let _ = writeln!(out, "\tchildren: {child_struct_name}[];");
let _ = write!(out, "}}");
out
}
pub fn generate_zod_grouped_structs(
child_struct_name: &str,
parent_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
) -> String {
let child_schema = format!("{child_struct_name}Schema");
let parent_schema = format!("{parent_struct_name}Schema");
let mut out = String::new();
let _ = writeln!(out, "/** Child row type for grouped query. */");
let _ = writeln!(out, "export const {child_schema} = z.object({{");
for col in child_columns {
let zod = column_to_zod(col);
let _ = writeln!(out, "\t{}: {},", col.field_name, zod);
}
let _ = writeln!(out, "}});");
let _ = writeln!(out);
let _ = writeln!(out, "export type {child_struct_name} = z.infer<typeof {child_schema}>;");
let _ = writeln!(out);
let _ = writeln!(out, "/** Parent row type for grouped query. */");
let _ = writeln!(out, "export const {parent_schema} = z.object({{");
for col in parent_columns {
let zod = column_to_zod(col);
let _ = writeln!(out, "\t{}: {},", col.field_name, zod);
}
let _ = writeln!(out, "\tchildren: z.array({child_schema}),");
let _ = writeln!(out, "}});");
let _ = writeln!(out);
let _ = write!(
out,
"export type {parent_struct_name} = z.infer<typeof {parent_schema}>;"
);
out
}
pub fn generate_ts_grouped_fold_body(
parent_struct_name: &str,
_child_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
key_col_name: &str,
row_access: impl Fn(&str, &str) -> String,
) -> String {
let key_type = parent_columns
.iter()
.find(|c| c.name == key_col_name)
.map_or("unknown", |c| c.full_type.as_str());
let mut out = String::new();
let _ = writeln!(out, "\tconst result: {parent_struct_name}[] = [];");
let _ = writeln!(out, "\tconst index = new Map<unknown, {parent_struct_name}>();");
let _ = writeln!(out, "\tfor (const row of flatRows) {{");
let _ = writeln!(out, "\t\tconst key = {};", row_access(key_col_name, key_type));
let _ = writeln!(out, "\t\tlet parent = index.get(key);");
let _ = writeln!(out, "\t\tif (!parent) {{");
let _ = writeln!(out, "\t\t\tparent = {{");
for col in parent_columns {
let _ = writeln!(
out,
"\t\t\t\t{}: {},",
col.field_name,
row_access(&col.name, &col.full_type)
);
}
let _ = writeln!(out, "\t\t\t\tchildren: [],");
let _ = writeln!(out, "\t\t\t}};");
let _ = writeln!(out, "\t\t\tindex.set(key, parent);");
let _ = writeln!(out, "\t\t\tresult.push(parent);");
let _ = writeln!(out, "\t\t}}");
let _ = writeln!(out, "\t\tparent.children.push({{");
for col in child_columns {
let _ = writeln!(
out,
"\t\t\t{}: {},",
col.field_name,
row_access(&col.name, &col.full_type)
);
}
let _ = writeln!(out, "\t\t}});");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn result;");
out
}
pub fn generate_zod_enum(type_name: &str, values: &[String]) -> String {
let schema_name = format!("{type_name}Schema");
let mut out = String::new();
let variants: Vec<String> = values.iter().map(|v| format!("\"{}\"", v)).collect();
let _ = writeln!(out, "export const {} = z.enum([{}]);", schema_name, variants.join(", "));
let _ = writeln!(out);
let _ = write!(out, "export type {} = z.infer<typeof {}>;", type_name, schema_name);
let _ = writeln!(out);
let _ = writeln!(out);
let _ = writeln!(out, "export const {} = {{", type_name);
for value in values {
let key = scythe_backend::naming::to_pascal_case(value);
let _ = writeln!(out, "\t{}: \"{}\",", key, value);
}
let _ = write!(out, "}} as const;");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn column(
name: &str,
lang_type: &str,
nullable: bool,
group: Option<&str>,
nullable_before_join: bool,
) -> ResolvedColumn {
ResolvedColumn {
name: name.to_string(),
field_name: name.to_string(),
lang_type: lang_type.to_string(),
full_type: if nullable {
format!("{lang_type} | null")
} else {
lang_type.to_string()
},
neutral_type: "string".to_string(),
sql_type: "text".to_string(),
nullable,
join_group: group.map(str::to_string),
nullable_before_join,
}
}
fn user_orders_columns() -> Vec<ResolvedColumn> {
vec![
column("id", "number", false, None, false),
column("name", "string", false, None, false),
column("total", "string", true, Some("o"), false),
column("notes", "string", true, Some("o"), true),
]
}
#[test]
fn emits_a_union_that_ties_the_outer_join_columns_together() {
let out = generate_ts_union_row_struct("GetUserOrdersRow", "GetUserOrders", &user_orders_columns(), None);
assert!(out.contains("export type GetUserOrdersRow = {"), "{out}");
assert!(out.contains("\tid: number;"), "{out}");
assert!(out.contains("\tname: string;"), "{out}");
assert!(out.contains("| { total: string; notes: string | null }"), "{out}");
assert!(out.contains("| { total: null; notes: null }"), "{out}");
assert!(out.ends_with(");"), "type alias must be terminated: {out}");
}
#[test]
fn falls_back_to_the_flat_interface_when_no_column_discriminates() {
let columns = vec![
column("id", "number", false, None, false),
column("notes", "string", true, Some("o"), true),
];
let out = generate_ts_union_row_struct("R", "Q", &columns, None);
assert!(out.contains("export interface R {"), "{out}");
assert!(!out.contains(" & ("), "no union without a discriminant: {out}");
}
#[test]
fn falls_back_to_the_flat_interface_when_there_is_no_outer_join() {
let columns = vec![
column("id", "number", false, None, false),
column("name", "string", false, None, false),
];
let out = generate_ts_union_row_struct("R", "Q", &columns, None);
assert_eq!(out, generate_ts_interface_row_struct("R", "Q", &columns));
}
#[test]
fn intersects_the_base_type_when_the_target_requires_one() {
let out = generate_ts_union_row_struct("R", "Q", &user_orders_columns(), Some("RowDataPacket"));
assert!(out.contains("export type R = RowDataPacket & {"), "{out}");
}
#[test]
fn emits_one_alternative_per_join_group() {
let columns = vec![
column("id", "number", false, None, false),
column("total", "string", true, Some("o"), false),
column("addr", "string", true, Some("a"), false),
];
let out = generate_ts_union_row_struct("R", "Q", &columns, None);
assert!(out.contains("| { total: string }"), "{out}");
assert!(out.contains("| { total: null }"), "{out}");
assert!(out.contains("| { addr: string }"), "{out}");
assert!(out.contains("| { addr: null }"), "{out}");
assert_eq!(out.matches(" & (").count(), 2, "one group per relation: {out}");
}
#[test]
fn escape_ts_template_literal_escapes_backtick_dollar_brace_and_backslash() {
let out = escape_ts_template_literal(r"a`b${c}d\e");
assert_eq!(out, r"a\`b\${c}d\\e");
}
#[test]
fn escape_ts_template_literal_does_not_double_escape_inserted_backslashes() {
let out = escape_ts_template_literal("`${x}");
assert_eq!(out, r"\`\${x}");
}
#[test]
fn escape_ts_template_literal_leaves_plain_sql_untouched() {
let out = escape_ts_template_literal("SELECT id, name FROM users WHERE id = $1");
assert_eq!(out, "SELECT id, name FROM users WHERE id = $1");
}
#[test]
fn escape_ts_double_quoted_literal_escapes_quote_and_backslash() {
let out = escape_ts_double_quoted_literal(r#"a"b\c"#);
assert_eq!(out, r#"a\"b\\c"#);
}
#[test]
fn parse_bool_option_accepts_true_spellings_case_insensitively() {
for value in ["true", "True", "TRUE", "1", "yes", "Yes", "YES"] {
assert!(
parse_bool_option("outer_join_unions", value).unwrap(),
"expected {value} to parse as true"
);
}
}
#[test]
fn parse_bool_option_accepts_false_spellings_case_insensitively() {
for value in ["false", "False", "FALSE", "0", "no", "No", "NO"] {
assert!(
!parse_bool_option("outer_join_unions", value).unwrap(),
"expected {value} to parse as false"
);
}
}
#[test]
fn parse_bool_option_rejects_unrecognized_values_with_an_error_naming_the_option() {
for value in ["on", "maybe", ""] {
let err =
parse_bool_option("outer_join_unions", value).expect_err(&format!("expected '{value}' to be rejected"));
let message = err.to_string();
assert!(
message.contains("outer_join_unions"),
"error should name the option: {message}"
);
assert!(message.contains(value), "error should include the bad value: {message}");
}
}
fn bytes_column(name: &str, lang_type: &str, nullable: bool) -> ResolvedColumn {
ResolvedColumn {
name: name.to_string(),
field_name: name.to_string(),
lang_type: lang_type.to_string(),
full_type: if nullable {
format!("{lang_type} | null")
} else {
lang_type.to_string()
},
neutral_type: "bytes".to_string(),
sql_type: "bytea".to_string(),
nullable,
join_group: None,
nullable_before_join: false,
}
}
#[test]
fn test_zod_bytes_schema_follows_the_backend_runtime_type() {
let buffer_schema =
generate_zod_row_struct("GetBlobRow", "GetBlob", &[bytes_column("payload", "Buffer", false)]);
assert!(
buffer_schema.contains("z.instanceof(Buffer)"),
"Buffer backends keep Buffer; got:\n{buffer_schema}"
);
let uint8_schema =
generate_zod_row_struct("GetBlobRow", "GetBlob", &[bytes_column("payload", "Uint8Array", false)]);
assert!(
uint8_schema.contains("z.instanceof(Uint8Array)"),
"Uint8Array backends must not emit Buffer; got:\n{uint8_schema}"
);
assert!(
!uint8_schema.contains("Buffer"),
"Uint8Array backends must not reference Buffer at all; got:\n{uint8_schema}"
);
}
#[test]
fn test_zod_bytes_schema_preserves_nullability() {
let schema = generate_zod_row_struct("GetBlobRow", "GetBlob", &[bytes_column("payload", "Uint8Array", true)]);
assert!(
schema.contains("z.instanceof(Uint8Array).nullable()"),
"nullable bytes must stay nullable; got:\n{schema}"
);
}
#[test]
fn test_grouped_zod_structs_use_column_aware_schemas() {
let mut enum_col = column("status", "UserStatus", false, None, false);
enum_col.neutral_type = "enum::user_status".to_string();
let grouped = generate_zod_grouped_structs(
"GetUsersWithOrdersChildRow",
"GetUsersWithOrdersRow",
&[bytes_column("avatar", "Uint8Array", false)],
&[enum_col],
);
assert!(
grouped.contains("UserStatusSchema"),
"grouped enum must use its enum schema, not z.string(); got:\n{grouped}"
);
assert!(
grouped.contains("z.instanceof(Uint8Array)"),
"grouped bytes must follow the backend type; got:\n{grouped}"
);
}
}