use std::fmt::Write;
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{composite_type_name, enum_type_name, to_camel_case, to_pascal_case};
use scythe_core::analyzer::{CompositeInfo, NestedStructInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use scythe_core::parser::QueryCommand;
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::InvalidConfig,
format!("invalid row_type '{}': expected 'interface' or 'zod'", value),
)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TsFieldCase {
#[default]
Snake,
Camel,
}
impl TsFieldCase {
pub fn from_option(value: &str) -> Result<Self, ScytheError> {
match value {
"snake_case" => Ok(Self::Snake),
"camelCase" => Ok(Self::Camel),
_ => Err(ScytheError::new(
ErrorCode::InvalidConfig,
format!("invalid field_case '{}': expected 'snake_case' or 'camelCase'", value),
)),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TsRowShape {
#[default]
Flat,
Union,
}
impl TsRowShape {
pub fn from_outer_join_unions(outer_join_unions: bool) -> Self {
if outer_join_unions { Self::Union } else { Self::Flat }
}
pub fn cast_type(self, col: &ResolvedColumn) -> &str {
if self == Self::Union && col.is_join_discriminant() {
return &col.lang_type;
}
&col.full_type
}
}
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::InvalidConfig,
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 ts_property_key(name: &str) -> String {
if is_ts_identifier(name) {
name.to_string()
} else {
format!("\"{}\"", escape_ts_double_quoted_literal(name))
}
}
pub fn ts_member_access(object: &str, name: &str) -> String {
if is_ts_identifier(name) {
format!("{object}.{name}")
} else {
format!("{object}[\"{}\"]", escape_ts_double_quoted_literal(name))
}
}
pub fn ts_index_access(object: &str, name: &str) -> String {
format!("{object}['{}']", escape_ts_single_quoted_literal(name))
}
fn escape_ts_single_quoted_literal(value: &str) -> String {
value.replace('\\', "\\\\").replace('\'', "\\'")
}
fn is_ts_identifier(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
pub fn ts_type_to_zod(ts_type: &str) -> String {
let ts_type = ts_type.trim();
if let Some(base) = ts_type.strip_suffix(" | null") {
return format!("{}.nullable()", ts_type_to_zod(base));
}
if let Some(element) = ts_type.strip_prefix("Array<").and_then(|value| value.strip_suffix('>')) {
return format!("z.array({})", ts_type_to_zod(element));
}
if let Some(element) = ts_type.strip_suffix("[]") {
return format!("z.array({})", ts_type_to_zod(element));
}
match ts_type {
"boolean" => "z.boolean()".to_string(),
"number" => "z.number()".to_string(),
"bigint" => "z.bigint()".to_string(),
"string" => "z.string()".to_string(),
"Date" => "z.date()".to_string(),
"unknown" => "z.unknown()".to_string(),
"Buffer" | "Uint8Array" => format!("z.instanceof({ts_type})"),
"Record<string, unknown>" => "z.record(z.string(), z.unknown())".to_string(),
_ => format!("z.custom<{ts_type}>()"),
}
}
pub fn resolve_ts_json_runtime_type(neutral_type: &str, manifest: &BackendManifest) -> Result<String, ScytheError> {
let neutral_type = neutral_type.trim();
if let Some(inner) = container_inner(neutral_type, "nullable") {
return Ok(format!("{} | null", resolve_ts_json_runtime_type(inner, manifest)?));
}
if let Some(inner) = container_inner(neutral_type, "array") {
return Ok(format!("Array<{}>", resolve_ts_json_runtime_type(inner, manifest)?));
}
if let Some(inner) = container_inner(neutral_type, "json_nested") {
return resolve_ts_json_runtime_type(inner, manifest);
}
if let Some(inner) = container_inner(neutral_type, "json_typed") {
return Ok(inner.to_string());
}
if let Some(sql_name) = neutral_type.strip_prefix("enum::") {
return Ok(enum_type_name(sql_name, &manifest.naming));
}
if let Some(sql_name) = neutral_type.strip_prefix("composite::") {
return Ok(format!("{}Json", composite_type_name(sql_name, &manifest.naming)));
}
let runtime_type = match neutral_type {
"bool" => "boolean",
"int16" | "int32" | "int64" | "float32" | "float64" | "decimal" => "number",
"string" | "bytes" | "uuid" | "date" | "time" | "time_tz" | "datetime" | "datetime_tz" | "interval"
| "inet" => "string",
"json" => "unknown",
"json_array" => "Array<unknown>",
custom if custom.chars().next().is_some_and(char::is_uppercase) => custom,
_ => {
return Err(ScytheError::new(
ErrorCode::InternalError,
format!("unsupported TypeScript JSON runtime type: {neutral_type}"),
));
}
};
Ok(runtime_type.to_string())
}
pub fn generate_ts_nested_interface(
nested: &NestedStructInfo,
manifest: &BackendManifest,
) -> Result<String, ScytheError> {
generate_ts_nested_interface_with_field_case(nested, manifest, TsFieldCase::Snake)
}
pub fn generate_ts_nested_interface_with_field_case(
nested: &NestedStructInfo,
manifest: &BackendManifest,
field_case: TsFieldCase,
) -> Result<String, ScytheError> {
let name = to_pascal_case(&nested.name);
let mut out = String::new();
let _ = writeln!(out, "/** JSON object produced for {}. */", nested.name);
let _ = writeln!(out, "export interface {name} {{");
for field in &nested.fields {
let mut field_type = resolve_ts_json_runtime_type(&field.neutral_type, manifest)?;
if field.nullable {
field_type.push_str(" | null");
}
let field_name = match field_case {
TsFieldCase::Snake => field.name.clone(),
TsFieldCase::Camel => to_camel_case(&field.name).into_owned(),
};
let _ = writeln!(out, "\t{}: {field_type};", ts_property_key(&field_name));
}
let _ = write!(out, "}}");
Ok(out)
}
pub fn generate_ts_json_composite_interface(
composite: &CompositeInfo,
manifest: &BackendManifest,
) -> Result<String, ScytheError> {
generate_ts_json_composite_interface_with_field_case(composite, manifest, TsFieldCase::Snake)
}
pub fn generate_ts_json_composite_interface_with_field_case(
composite: &CompositeInfo,
manifest: &BackendManifest,
field_case: TsFieldCase,
) -> Result<String, ScytheError> {
let name = format!("{}Json", composite_type_name(&composite.sql_name, &manifest.naming));
let mut out = String::new();
let _ = writeln!(
out,
"/** JSON representation of composite type {}. */",
composite.sql_name
);
let _ = writeln!(out, "export interface {name} {{");
for field in &composite.fields {
let mut field_type = resolve_ts_json_runtime_type(&field.neutral_type, manifest)?;
if field.nullable {
field_type.push_str(" | null");
}
let field_name = match field_case {
TsFieldCase::Snake => field.name.clone(),
TsFieldCase::Camel => to_camel_case(&field.name).into_owned(),
};
let _ = writeln!(out, "\t{}: {field_type};", ts_property_key(&field_name));
}
let _ = write!(out, "}}");
Ok(out)
}
fn container_inner<'a>(neutral_type: &'a str, container: &str) -> Option<&'a str> {
neutral_type
.strip_prefix(container)?
.strip_prefix('<')?
.strip_suffix('>')
.map(str::trim)
}
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{}: {};", ts_property_key(&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.as_ref().is_none_or(|group| !groups.contains(group)))
{
let _ = writeln!(out, "\t{}: {};", ts_property_key(&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, "{}: {}", ts_property_key(&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", ts_property_key(&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{}: {},", ts_property_key(&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 {
let base = if col.neutral_type == "uuid" && col.lang_type == "string" {
"z.string().uuid()".to_string()
} else {
ts_type_to_zod(&col.lang_type)
};
if nullable { format!("{base}.nullable()") } else { base }
}
}
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.as_ref().is_none_or(|group| !groups.contains(group)))
{
let zod_type = column_to_zod(col);
let _ = writeln!(out, "\t{}: {},", ts_property_key(&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!(
"{}: {}",
ts_property_key(&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()", ts_property_key(&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{}: {};", ts_property_key(&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{}: {};", ts_property_key(&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{}: {},", ts_property_key(&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{}: {},", ts_property_key(&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_row_object_literal(
columns: &[ResolvedColumn],
indent: &str,
shape: TsRowShape,
row_access: impl Fn(&str, &str) -> String,
) -> String {
let mut out = String::new();
for col in columns {
let _ = writeln!(
out,
"{indent}{}: {},",
ts_property_key(&col.field_name),
row_access(&col.name, shape.cast_type(col))
);
}
out
}
pub fn ts_row_not_found_throw(query_name: &str) -> String {
format!(
"throw new Error(\"no row found for query: {}\");",
escape_ts_double_quoted_literal(query_name)
)
}
pub fn generate_ts_one_row_remap(
columns: &[ResolvedColumn],
shape: TsRowShape,
command: &QueryCommand,
query_name: &str,
row_access: impl Fn(&str, &str) -> String,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "\tif (!row) {{");
if matches!(command, QueryCommand::One) {
let _ = writeln!(out, "\t\t{}", ts_row_not_found_throw(query_name));
} else {
let _ = writeln!(out, "\t\treturn null;");
}
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn {{");
out.push_str(&generate_ts_row_object_literal(columns, "\t\t", shape, row_access));
let _ = writeln!(out, "\t}};");
out
}
pub fn generate_ts_many_row_remap(
columns: &[ResolvedColumn],
shape: TsRowShape,
row_access: impl Fn(&str, &str) -> String,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "\treturn rows.map((row) => ({{");
out.push_str(&generate_ts_row_object_literal(columns, "\t\t", shape, row_access));
let _ = writeln!(out, "\t}}));");
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,
js_mode: bool,
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();
if js_mode {
let _ = writeln!(out, "\tconst result = [];");
let _ = writeln!(out, "\tconst index = new Map();");
} else {
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 = {{");
out.push_str(&generate_ts_row_object_literal(
parent_columns,
"\t\t\t\t",
TsRowShape::Flat,
&row_access,
));
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({{");
out.push_str(&generate_ts_row_object_literal(
child_columns,
"\t\t\t",
TsRowShape::Flat,
&row_access,
));
let _ = writeln!(out, "\t\t}});");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn result;");
out
}
pub fn js_doc_name(name: &str) -> String {
if is_ts_identifier(name) {
return name.to_string();
}
let mut mangled = String::with_capacity(name.len());
for ch in name.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' {
mangled.push(ch);
} else {
mangled.push('_');
}
}
if mangled.starts_with(|c: char| c.is_ascii_digit()) {
mangled.insert(0, '_');
}
mangled
}
pub fn js_property_line(col: &ResolvedColumn) -> String {
format!(" * @property {{{}}} {}", col.full_type, js_doc_name(&col.field_name))
}
fn every_key_is_a_bare_jsdoc_name(columns: &[ResolvedColumn]) -> bool {
columns.iter().all(|col| is_ts_identifier(&col.field_name))
}
fn render_row_typedef_body(out: &mut String, struct_name: &str, columns: &[ResolvedColumn], children_of: Option<&str>) {
if every_key_is_a_bare_jsdoc_name(columns) {
let _ = writeln!(out, " * @typedef {{object}} {}", struct_name);
for col in columns {
let _ = writeln!(out, "{}", js_property_line(col));
}
if let Some(child_struct_name) = children_of {
let _ = writeln!(out, " * @property {{{}[]}} children", child_struct_name);
}
return;
}
let mut fields: Vec<String> = columns
.iter()
.map(|col| format!("{}: {}", ts_property_key(&col.field_name), col.full_type))
.collect();
if let Some(child_struct_name) = children_of {
fields.push(format!("children: {child_struct_name}[]"));
}
let _ = writeln!(out, " * @typedef {{{{ {} }}}} {}", fields.join(", "), struct_name);
}
pub fn generate_js_typedef_row_struct(struct_name: &str, query_name: &str, columns: &[ResolvedColumn]) -> String {
let mut out = String::new();
let _ = writeln!(out, "/**");
let _ = writeln!(out, " * Row type for {} queries.", query_name);
render_row_typedef_body(&mut out, struct_name, columns, None);
let _ = write!(out, " */");
out
}
pub fn generate_js_typedef(type_name: &str, description: &str, fields: &[(String, String)]) -> String {
let mut out = String::new();
let _ = writeln!(out, "/**");
let _ = writeln!(out, " * {}", description);
let _ = writeln!(out, " * @typedef {{object}} {}", type_name);
for (name, ty) in fields {
let _ = writeln!(out, " * @property {{{}}} {}", ty, js_doc_name(name));
}
let _ = write!(out, " */");
out
}
pub fn generate_js_grouped_typedef_structs(
child_struct_name: &str,
parent_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
) -> String {
let mut out = String::new();
let _ = writeln!(out, "/**");
let _ = writeln!(out, " * Child row type for grouped query.");
render_row_typedef_body(&mut out, child_struct_name, child_columns, None);
let _ = writeln!(out, " */");
let _ = writeln!(out);
let _ = writeln!(out, "/**");
let _ = writeln!(out, " * Parent row type for grouped query.");
render_row_typedef_body(&mut out, parent_struct_name, parent_columns, Some(child_struct_name));
let _ = write!(out, " */");
out
}
pub fn js_type_cast(ty: &str, expr: &str) -> String {
format!("/** @type {{{ty}}} */ ({expr})")
}
pub fn generate_jsdoc_fn_header(description: &str, sig_params: &[(String, String)], ret_type: &str) -> String {
let mut out = String::new();
let _ = writeln!(out, "/**");
let _ = writeln!(out, " * {}", description);
for (name, ty) in sig_params {
let _ = writeln!(out, " * @param {{{}}} {}", ty, js_doc_name(name));
}
let _ = writeln!(out, " * @returns {{{}}}", ret_type);
let _ = write!(out, " */");
out
}
pub fn js_fn_signature_line(is_async: bool, name: &str, sig_params: &[(String, String)]) -> String {
let params_inline = sig_params.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>().join(", ");
let keyword = if is_async {
"export async function"
} else {
"export function"
};
format!("{keyword} {name}({params_inline}) {{")
}
pub fn generate_zod_enum(type_name: &str, values: &[String], naming: &scythe_backend::naming::NamingConfig) -> String {
let schema_name = format!("{type_name}Schema");
let mut out = String::new();
let variants: Vec<String> = values
.iter()
.map(|v| format!("\"{}\"", escape_ts_double_quoted_literal(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::enum_variant_name(value, naming);
let _ = writeln!(
out,
"\t{}: \"{}\",",
ts_property_key(&key),
escape_ts_double_quoted_literal(value)
);
}
let _ = write!(out, "}} as const;");
out
}
pub fn generate_js_enum_def(type_name: &str, variants: &[(String, String)]) -> String {
let values_name = format!("{type_name}Values");
let mut out = String::new();
let _ = writeln!(out, "export const {} = /** @type {{const}} */ ({{", values_name);
for (variant, value) in variants {
let _ = writeln!(out, "\t{}: \"{}\",", variant, value);
}
let _ = writeln!(out, "}});");
let _ = writeln!(out);
let _ = write!(
out,
"/** @typedef {{typeof {}[keyof typeof {}]}} {} */",
values_name, values_name, type_name
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ts_property_key_quotes_non_identifier_names() {
assert_eq!(ts_property_key("with-dash"), "\"with-dash\"");
assert_eq!(ts_property_key("my col"), "\"my col\"");
assert_eq!(ts_property_key("1st"), "\"1st\"");
}
#[test]
fn ts_property_key_leaves_valid_identifiers_and_keywords_bare() {
assert_eq!(ts_property_key("user_id"), "user_id");
assert_eq!(ts_property_key("class"), "class");
assert_eq!(ts_property_key("_private"), "_private");
assert_eq!(ts_property_key("$special"), "$special");
}
#[test]
fn ts_property_key_escapes_a_quote_inside_the_name() {
assert_eq!(ts_property_key("weird\"name"), "\"weird\\\"name\"");
}
fn postgres_manifest() -> BackendManifest {
crate::backends::parse_manifest(include_str!("../../manifests/typescript-pg.toml"))
.expect("TypeScript PostgreSQL manifest must parse")
}
#[test]
fn json_runtime_types_follow_postgresql_json_encoding() {
let manifest = postgres_manifest();
assert_eq!(resolve_ts_json_runtime_type("decimal", &manifest).unwrap(), "number");
assert_eq!(
resolve_ts_json_runtime_type("datetime_tz", &manifest).unwrap(),
"string"
);
assert_eq!(resolve_ts_json_runtime_type("bytes", &manifest).unwrap(), "string");
assert_eq!(
resolve_ts_json_runtime_type("enum::order_status", &manifest).unwrap(),
"OrderStatus"
);
assert_eq!(
resolve_ts_json_runtime_type("array<nullable<int32>>", &manifest).unwrap(),
"Array<number | null>"
);
assert_eq!(
resolve_ts_json_runtime_type("composite::shipping_address", &manifest).unwrap(),
"ShippingAddressJson"
);
assert_eq!(
resolve_ts_json_runtime_type("json_typed<ApiPayload>", &manifest).unwrap(),
"ApiPayload"
);
assert!(resolve_ts_json_runtime_type("unknown", &manifest).is_err());
assert!(resolve_ts_json_runtime_type("future_geography", &manifest).is_err());
}
#[test]
fn nested_interface_preserves_raw_json_keys_and_required_nullability() {
let nested = NestedStructInfo {
name: "get_orders_row_orders".to_string(),
fields: vec![
scythe_core::analyzer::NestedFieldInfo {
name: "createdAt".to_string(),
neutral_type: "datetime_tz".to_string(),
nullable: false,
},
scythe_core::analyzer::NestedFieldInfo {
name: "delivery notes".to_string(),
neutral_type: "string".to_string(),
nullable: true,
},
],
};
assert_eq!(
generate_ts_nested_interface(&nested, &postgres_manifest()).unwrap(),
"/** JSON object produced for get_orders_row_orders. */\n\
export interface GetOrdersRowOrders {\n\
\tcreatedAt: string;\n\
\t\"delivery notes\": string | null;\n\
}"
);
}
#[test]
fn json_composite_interface_is_distinct_from_driver_native_type() {
let composite = CompositeInfo {
sql_name: "shipping_address".to_string(),
fields: vec![scythe_core::analyzer::CompositeFieldInfo {
name: "zip_code".to_string(),
neutral_type: "int32".to_string(),
nullable: true,
}],
};
assert_eq!(
generate_ts_json_composite_interface(&composite, &postgres_manifest()).unwrap(),
"/** JSON representation of composite type shipping_address. */\n\
export interface ShippingAddressJson {\n\
\tzip_code: number | null;\n\
}"
);
}
#[test]
fn zod_array_uses_custom_nested_element_type_without_recursive_validation() {
assert_eq!(
ts_type_to_zod("Array<GetOrdersRowOrders | null>"),
"z.array(z.custom<GetOrdersRowOrders>().nullable())"
);
}
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 keeps_columns_from_join_groups_that_carry_no_discriminant() {
let columns = vec![
column("id", "number", false, None, false),
column("bio", "string", true, Some("p"), true),
column("website", "string", true, Some("p"), true),
column("label", "string", true, Some("b"), false),
];
let out = generate_ts_union_row_struct("R", "Q", &columns, None);
assert!(out.contains("bio: string | null;"), "{out}");
assert!(out.contains("website: string | null;"), "{out}");
assert!(out.contains("| { label: string }"), "{out}");
assert!(out.contains("| { label: null }"), "{out}");
assert_eq!(
out.matches(" & (").count(),
1,
"only the discriminated group becomes a union: {out}"
);
}
#[test]
fn zod_keeps_columns_from_join_groups_that_carry_no_discriminant() {
let columns = vec![
column("id", "number", false, None, false),
column("bio", "string", true, Some("p"), true),
column("website", "string", true, Some("p"), true),
column("label", "string", true, Some("b"), false),
];
let out = generate_zod_union_row_struct("R", "Q", &columns);
assert!(out.contains("bio:"), "{out}");
assert!(out.contains("website:"), "{out}");
assert!(out.contains("label:"), "{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}"
);
}
#[test]
fn test_ts_field_case_from_option_accepts_known_values() {
assert_eq!(TsFieldCase::from_option("snake_case").unwrap(), TsFieldCase::Snake);
assert_eq!(TsFieldCase::from_option("camelCase").unwrap(), TsFieldCase::Camel);
}
#[test]
fn test_ts_field_case_from_option_rejects_unknown_values() {
let err = TsFieldCase::from_option("PascalCase").expect_err("PascalCase is not a valid field_case");
assert!(err.to_string().contains("field_case"), "{err}");
}
#[test]
fn test_ts_field_case_default_is_snake() {
assert_eq!(TsFieldCase::default(), TsFieldCase::Snake);
}
#[test]
fn test_generate_ts_row_object_literal_matches_grouped_fold_field_lines() {
let columns = vec![
column("id", "number", false, None, false),
column("name", "string", false, None, false),
];
let out = generate_ts_row_object_literal(&columns, "\t\t\t\t", TsRowShape::Flat, |name, ty| {
format!("row.{name} as {ty}")
});
assert_eq!(
out,
"\t\t\t\tid: row.id as number,\n\t\t\t\tname: row.name as string,\n"
);
}
#[test]
fn test_generate_ts_row_object_literal_empty_columns_is_empty() {
let out = generate_ts_row_object_literal(&[], "\t", TsRowShape::Flat, |name, ty| format!("row.{name} as {ty}"));
assert_eq!(out, "");
}
#[test]
fn test_generate_ts_row_object_literal_union_shape_casts_discriminant_to_matched_type() {
let columns = user_orders_columns();
let out = generate_ts_row_object_literal(&columns, "\t", TsRowShape::Union, |name, ty| {
format!("row['{name}'] as {ty}")
});
assert_eq!(
out,
"\tid: row['id'] as number,\n\
\tname: row['name'] as string,\n\
\ttotal: row['total'] as string,\n\
\tnotes: row['notes'] as string | null,\n"
);
}
#[test]
fn test_generate_ts_row_object_literal_flat_shape_keeps_full_type() {
let columns = user_orders_columns();
let out = generate_ts_row_object_literal(&columns, "\t", TsRowShape::Flat, |name, ty| {
format!("row['{name}'] as {ty}")
});
assert!(
out.contains("total: row['total'] as string | null,"),
"flat rows declare the discriminant nullable; got:\n{out}"
);
}
#[test]
fn test_ts_row_shape_from_outer_join_unions() {
assert_eq!(TsRowShape::from_outer_join_unions(false), TsRowShape::Flat);
assert_eq!(TsRowShape::from_outer_join_unions(true), TsRowShape::Union);
assert_eq!(TsRowShape::default(), TsRowShape::Flat);
}
#[test]
fn test_ts_row_shape_union_keeps_full_type_for_non_discriminants() {
let columns = user_orders_columns();
let notes = columns.iter().find(|c| c.name == "notes").unwrap();
assert_eq!(TsRowShape::Union.cast_type(notes), "string | null");
let name = columns.iter().find(|c| c.name == "name").unwrap();
assert_eq!(TsRowShape::Union.cast_type(name), "string");
}
#[test]
fn test_generate_ts_one_row_remap_opt_null_checks_then_builds_fields() {
let columns = vec![
column("id", "number", false, None, false),
column("name", "string", false, None, false),
];
let out = generate_ts_one_row_remap(&columns, TsRowShape::Flat, &QueryCommand::Opt, "GetUser", |name, ty| {
format!("row['{name}'] as {ty}")
});
assert_eq!(
out,
"\tif (!row) {\n\
\t\treturn null;\n\
\t}\n\
\treturn {\n\
\t\tid: row['id'] as number,\n\
\t\tname: row['name'] as string,\n\
\t};\n"
);
}
#[test]
fn test_generate_ts_one_row_remap_one_throws_instead_of_returning_null() {
let columns = vec![column("id", "number", false, None, false)];
let out = generate_ts_one_row_remap(&columns, TsRowShape::Flat, &QueryCommand::One, "GetUser", |name, ty| {
format!("row['{name}'] as {ty}")
});
assert_eq!(
out,
"\tif (!row) {\n\
\t\tthrow new Error(\"no row found for query: GetUser\");\n\
\t}\n\
\treturn {\n\
\t\tid: row['id'] as number,\n\
\t};\n"
);
assert!(!out.contains("return null"), "{out}");
}
#[test]
fn test_ts_row_not_found_throw_escapes_the_query_name() {
assert_eq!(
ts_row_not_found_throw("weird\"name"),
"throw new Error(\"no row found for query: weird\\\"name\");"
);
}
#[test]
fn test_generate_ts_many_row_remap_maps_each_row() {
let columns = vec![column("id", "number", false, None, false)];
let out = generate_ts_many_row_remap(&columns, TsRowShape::Flat, |name, ty| format!("row['{name}'] as {ty}"));
assert_eq!(
out,
"\treturn rows.map((row) => ({\n\
\t\tid: row['id'] as number,\n\
\t}));\n"
);
}
#[test]
fn test_js_property_line_source_cannot_emit_optional_syntax_into_the_name_position() {
let source = include_str!("typescript_common.rs");
let start = source
.find("// jsdoc-property-line-start")
.expect("marker comment must exist in this file");
let end = source
.find("// jsdoc-property-line-end")
.expect("marker comment must exist in this file");
let body = &source[start..end];
assert!(
!body.contains('['),
"js_property_line's source must contain no '[' -- a bracket-optional code path \
would need one: {body}"
);
assert!(
!body.contains(']'),
"js_property_line's source must contain no ']' -- a bracket-optional code path \
would need one: {body}"
);
assert!(
!body.contains('?'),
"js_property_line's source must contain no '?' -- a question-mark-optional code \
path would need one: {body}"
);
}
#[test]
fn test_js_property_line_never_emits_bracket_or_question_mark_syntax() {
let nullable = column("bio", "string", true, None, false);
let non_nullable = column("id", "number", false, None, false);
let nullable_line = js_property_line(&nullable);
let non_nullable_line = js_property_line(&non_nullable);
assert_eq!(nullable_line, " * @property {string | null} bio");
assert_eq!(non_nullable_line, " * @property {number} id");
assert!(!nullable_line.contains('['), "{nullable_line}");
assert!(!nullable_line.contains(']'), "{nullable_line}");
assert!(!nullable_line.contains('?'), "{nullable_line}");
}
#[test]
fn test_generate_js_typedef_row_struct_matches_property_lines() {
let columns = vec![
column("id", "number", false, None, false),
column("bio", "string", true, None, false),
];
let out = generate_js_typedef_row_struct("GetUserRow", "GetUser", &columns);
assert!(out.starts_with("/**\n"), "{out}");
assert!(out.contains(" * Row type for GetUser queries.\n"), "{out}");
assert!(out.contains(" * @typedef {object} GetUserRow\n"), "{out}");
assert!(out.contains(" * @property {number} id\n"), "{out}");
assert!(out.contains(" * @property {string | null} bio\n"), "{out}");
assert!(out.ends_with(" */"), "{out}");
}
#[test]
fn test_js_type_cast_wraps_expr_in_a_jsdoc_type_comment() {
assert_eq!(
js_type_cast("GetUserRow | undefined", "stmt.get()"),
"/** @type {GetUserRow | undefined} */ (stmt.get())"
);
}
#[test]
fn test_generate_jsdoc_fn_header_renders_params_and_return() {
let out = generate_jsdoc_fn_header(
"Fetch a single row.",
&[
("client".to_string(), "import(\"pg\").PoolClient".to_string()),
("id".to_string(), "number".to_string()),
],
"Promise<Row | null>",
);
assert_eq!(
out,
"/**\n * Fetch a single row.\n * @param {import(\"pg\").PoolClient} client\n * @param {number} id\n \
* @returns {Promise<Row | null>}\n */"
);
}
#[test]
fn test_js_fn_signature_line_has_no_type_annotations() {
let sig_params = vec![
("client".to_string(), "import(\"pg\").PoolClient".to_string()),
("id".to_string(), "number".to_string()),
];
assert_eq!(
js_fn_signature_line(true, "getUserById", &sig_params),
"export async function getUserById(client, id) {"
);
assert_eq!(
js_fn_signature_line(false, "getUserById", &sig_params),
"export function getUserById(client, id) {"
);
}
#[test]
fn should_classify_bad_option_values_as_invalid_config() {
assert_eq!(
TsRowType::from_option("zodd").expect_err("unknown row_type").code,
ErrorCode::InvalidConfig
);
assert_eq!(
TsFieldCase::from_option("CamelCase")
.expect_err("unknown field_case")
.code,
ErrorCode::InvalidConfig
);
assert_eq!(
parse_bool_option("structs_only", "maybe")
.expect_err("non-boolean bool option")
.code,
ErrorCode::InvalidConfig
);
}
}