pub mod backend_options;
pub mod backend_trait;
pub mod backends;
pub mod overrides;
pub mod provenance;
pub mod resolve;
pub mod sql_literal;
pub mod validation;
pub use backend_trait::{
CodegenBackend, RbsEnumInfo, RbsGenerationContext, RbsQueryInfo, ResolvedColumn, ResolvedParam,
};
pub use backends::get_backend;
pub use overrides::TypeOverride;
use ahash::AHashSet;
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{NamingConfig, apply_case, row_struct_name, sanitize_for_identifier, to_pascal_case};
use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, CompositeInfo, EnumInfo, NestedStructInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use scythe_core::parser::QueryCommand;
const GROUPED_CHILDREN_FIELD: &str = "children";
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct GeneratedCode {
pub query_fn: Option<String>,
pub row_struct: Option<String>,
pub model_struct: Option<String>,
pub enum_def: Option<String>,
pub nested_struct_defs: Vec<NestedStructDef>,
pub degraded_nested_structs: Vec<NestedStructDegradation>,
}
impl GeneratedCode {
#[must_use]
pub fn build(init: impl FnOnce(&mut Self)) -> Self {
let mut code = Self::default();
init(&mut code);
code
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NestedStructDef {
pub name: String,
pub code: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NestedStructDegradation {
pub column: String,
pub struct_name: String,
pub fallback_type: &'static str,
pub backend: String,
}
pub fn singularize(name: &str) -> String {
if let Some(stem) = name.strip_suffix("ies") {
format!("{stem}y")
} else if name.ends_with("sses")
|| name.ends_with("shes")
|| name.ends_with("ches")
|| name.ends_with("xes")
|| name.ends_with("zes")
|| name.ends_with("ses")
{
name[..name.len() - 2].to_string()
} else if name.ends_with('s') && !name.ends_with("ss") {
name[..name.len() - 1].to_string()
} else {
name.to_string()
}
}
pub fn get_manifest_for_backend(backend_name: &str) -> Result<BackendManifest, ScytheError> {
let backend = get_backend(backend_name, "postgresql")?;
Ok(backend.manifest().clone())
}
pub fn model_struct_name(table_name: &str, naming: &NamingConfig) -> String {
let sanitized = sanitize_for_identifier(table_name);
apply_case(&singularize(&sanitized), &naming.struct_case).into_owned()
}
fn determine_struct_name(analyzed: &AnalyzedQuery, manifest: &BackendManifest) -> String {
if let Some(ref table_name) = analyzed.source_table {
model_struct_name(table_name, &manifest.naming)
} else {
row_struct_name(&analyzed.name, &manifest.naming)
}
}
pub fn generate_with_backend(
analyzed: &AnalyzedQuery,
backend: &dyn CodegenBackend,
) -> Result<GeneratedCode, ScytheError> {
generate_with_backend_and_overrides(analyzed, backend, &[])
}
pub fn generate_with_backend_and_overrides(
analyzed: &AnalyzedQuery,
backend: &dyn CodegenBackend,
overrides: &[TypeOverride],
) -> Result<GeneratedCode, ScytheError> {
generate_with_backend_and_overrides_and_nested_composites(analyzed, backend, overrides, &AHashSet::new())
}
pub fn generate_with_backend_and_overrides_and_nested_composites(
analyzed: &AnalyzedQuery,
backend: &dyn CodegenBackend,
overrides: &[TypeOverride],
nested_composite_names: &AHashSet<String>,
) -> Result<GeneratedCode, ScytheError> {
let manifest = backend.manifest();
let source_table = analyzed.source_table.as_deref().unwrap_or("");
let (degraded_columns, nested_struct_defs, nested_degradations) = if analyzed.nested_structs.is_empty() {
(None, Vec::new(), Vec::new())
} else {
let (cols, defs, degradations) =
degrade_unsupported_nested_structs(&analyzed.columns, &analyzed.nested_structs, backend)?;
(Some(cols), defs, degradations)
};
let nested_refs = NestedTypeRefs::collect(analyzed, &nested_struct_defs);
let columns = resolve::resolve_columns(
degraded_columns.as_deref().unwrap_or(&analyzed.columns),
manifest,
overrides,
)?;
let params = resolve::resolve_params(&analyzed.params, manifest, overrides, source_table)?;
let mut result = GeneratedCode::default();
let struct_name = determine_struct_name(analyzed, manifest);
let needs_row_struct = matches!(
analyzed.command,
QueryCommand::One | QueryCommand::Opt | QueryCommand::Many
);
let declared_struct_name = (needs_row_struct && !analyzed.columns.is_empty()).then_some(struct_name.as_str());
let enum_def = generate_enum_defs_via_backend(analyzed, backend, &nested_refs, declared_struct_name)?;
if !enum_def.is_empty() {
result.enum_def = Some(enum_def);
}
if needs_row_struct && !analyzed.columns.is_empty() {
if let Some(ref table_name) = analyzed.source_table {
result.model_struct = Some(backend.generate_model_struct(table_name, &columns)?);
} else {
result.row_struct = Some(backend.generate_row_struct(&analyzed.name, &columns)?);
}
}
let mut extra_defs = String::new();
let reachable_composites = reachable_composite_names(analyzed, &nested_refs);
for comp in composites_in_dependency_order(&analyzed.composites) {
if !reachable_composites.contains(comp.sql_name.as_str()) {
continue;
}
let from_nested = nested_refs.composites.contains(comp.sql_name.as_str())
|| nested_composite_names.contains(comp.sql_name.as_str());
if !extra_defs.is_empty() {
extra_defs.push_str("\n\n");
}
if from_nested {
extra_defs.push_str(&backend.generate_composite_def_for_nested(comp)?);
} else {
extra_defs.push_str(&backend.generate_composite_def(comp)?);
}
}
if !extra_defs.is_empty() {
if let Some(ref mut existing) = result.model_struct {
existing.push_str("\n\n");
existing.push_str(&extra_defs);
} else {
result.model_struct = Some(extra_defs);
}
}
result.nested_struct_defs = nested_struct_defs;
result.degraded_nested_structs = nested_degradations;
if analyzed.command == QueryCommand::Grouped {
let group_by = analyzed.group_by.as_ref().ok_or_else(|| {
ScytheError::new(
ErrorCode::InternalError,
format!(
"query '{}' is :grouped but is missing @group_by annotation",
analyzed.name
),
)
})?;
let parent_struct_name = scythe_backend::naming::row_struct_name(&analyzed.name, &manifest.naming);
let child_struct_name = {
let suffix = &manifest.naming.row_suffix;
let base = parent_struct_name.trim_end_matches(suffix.as_str());
format!("{}Child{}", base, suffix)
};
let (degraded_parent, degraded_child) = if analyzed.nested_structs.is_empty() {
(None, None)
} else {
let (dp, _, parent_degradations) =
degrade_unsupported_nested_structs(&group_by.parent_columns, &analyzed.nested_structs, backend)?;
let (dc, _, child_degradations) =
degrade_unsupported_nested_structs(&group_by.child_columns, &analyzed.nested_structs, backend)?;
result.degraded_nested_structs.extend(parent_degradations);
result.degraded_nested_structs.extend(child_degradations);
(Some(dp), Some(dc))
};
let parent_cols = resolve::resolve_columns(
degraded_parent.as_deref().unwrap_or(&group_by.parent_columns),
manifest,
overrides,
)?;
let child_cols = resolve::resolve_columns(
degraded_child.as_deref().unwrap_or(&group_by.child_columns),
manifest,
overrides,
)?;
let children_field_name = scythe_backend::naming::field_name(GROUPED_CHILDREN_FIELD, &manifest.naming);
resolve::check_field_name_collisions(
parent_cols
.iter()
.map(|c| (c.name.as_str(), c.field_name.as_str()))
.chain(std::iter::once((
"<synthesized :grouped children field>",
children_field_name.as_ref(),
))),
"parent columns",
&manifest.naming,
)?;
result.row_struct = Some(backend.generate_grouped_structs(
&parent_struct_name,
&child_struct_name,
&parent_cols,
&child_cols,
&group_by.key_column,
)?);
result.query_fn = Some(
backend.generate_grouped_query_fn(&crate::backend_trait::GroupedQueryFn {
analyzed,
parent_struct_name: &parent_struct_name,
child_struct_name: &child_struct_name,
all_columns: &columns,
parent_columns: &parent_cols,
child_columns: &child_cols,
params: ¶ms,
key_column: &group_by.key_column,
})?,
);
} else {
result.query_fn = Some(backend.generate_query_fn(analyzed, &struct_name, &columns, ¶ms)?);
}
Ok(result)
}
#[derive(Debug, Default)]
struct NestedTypeRefs {
enums: ahash::AHashSet<String>,
composites: ahash::AHashSet<String>,
}
impl NestedTypeRefs {
fn collect(analyzed: &AnalyzedQuery, supported: &[NestedStructDef]) -> Self {
let mut refs = Self::default();
for nested in &analyzed.nested_structs {
if !supported.iter().any(|def| def.name == nested.name) {
continue;
}
for field in &nested.fields {
let unwrapped = unwrap_containers(&field.neutral_type);
if let Some(name) = unwrapped.strip_prefix("enum::") {
refs.enums.insert(name.to_string());
} else if let Some(name) = unwrapped.strip_prefix("composite::") {
refs.composites.insert(name.to_string());
}
}
}
refs
}
}
fn composites_in_dependency_order(composites: &[CompositeInfo]) -> Vec<&CompositeInfo> {
let by_name: ahash::AHashMap<&str, &CompositeInfo> = composites.iter().map(|c| (c.sql_name.as_str(), c)).collect();
let mut ordered: Vec<&CompositeInfo> = Vec::with_capacity(composites.len());
let mut emitted: ahash::AHashSet<&str> = ahash::AHashSet::new();
let mut visiting: ahash::AHashSet<&str> = ahash::AHashSet::new();
fn visit<'a>(
comp: &'a CompositeInfo,
by_name: &ahash::AHashMap<&'a str, &'a CompositeInfo>,
emitted: &mut ahash::AHashSet<&'a str>,
visiting: &mut ahash::AHashSet<&'a str>,
ordered: &mut Vec<&'a CompositeInfo>,
) {
let name = comp.sql_name.as_str();
if emitted.contains(name) || !visiting.insert(name) {
return;
}
for field in &comp.fields {
if let Some(dep) = unwrap_containers(&field.neutral_type).strip_prefix("composite::")
&& let Some(dep) = by_name.get(dep)
{
visit(dep, by_name, emitted, visiting, ordered);
}
}
visiting.remove(name);
if emitted.insert(name) {
ordered.push(comp);
}
}
for comp in composites {
visit(comp, &by_name, &mut emitted, &mut visiting, &mut ordered);
}
ordered
}
pub(crate) fn unwrap_containers(neutral: &str) -> &str {
let mut current = neutral;
loop {
if let Some(inner) = current.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) {
current = inner.trim();
} else if let Some(inner) = current.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
current = inner.trim();
} else {
return current;
}
}
}
fn type_referenced_by_columns(analyzed: &AnalyzedQuery, prefix: &str, sql_name: &str) -> bool {
let neutral = format!("{prefix}{sql_name}");
analyzed
.columns
.iter()
.any(|col| unwrap_containers(&col.neutral_type) == neutral)
|| analyzed
.params
.iter()
.any(|param| unwrap_containers(¶m.neutral_type) == neutral)
}
fn reachable_composite_names<'a>(
analyzed: &'a AnalyzedQuery,
nested_refs: &NestedTypeRefs,
) -> ahash::AHashSet<&'a str> {
let mut reachable: ahash::AHashSet<&str> = ahash::AHashSet::new();
let mut worklist: Vec<&str> = analyzed
.composites
.iter()
.filter(|comp| {
nested_refs.composites.contains(comp.sql_name.as_str())
|| type_referenced_by_columns(analyzed, "composite::", &comp.sql_name)
})
.map(|comp| comp.sql_name.as_str())
.collect();
while let Some(name) = worklist.pop() {
if !reachable.insert(name) {
continue;
}
let Some(comp) = analyzed.composites.iter().find(|c| c.sql_name == name) else {
continue;
};
for field in &comp.fields {
if let Some(nested_name) = unwrap_containers(&field.neutral_type).strip_prefix("composite::") {
worklist.push(nested_name);
}
}
}
reachable
}
pub fn nested_type_is_emitted(
analyzed: &AnalyzedQuery,
supported: &[NestedStructDef],
prefix: &str,
sql_name: &str,
) -> bool {
let neutral = format!("{prefix}{sql_name}");
analyzed
.nested_structs
.iter()
.filter(|nested| supported.iter().any(|def| def.name == nested.name))
.any(|nested| {
nested
.fields
.iter()
.any(|field| unwrap_containers(&field.neutral_type) == neutral)
})
}
fn generate_enum_defs_via_backend(
analyzed: &AnalyzedQuery,
backend: &dyn CodegenBackend,
nested_refs: &NestedTypeRefs,
declared_struct_name: Option<&str>,
) -> Result<String, ScytheError> {
use ahash::AHashSet;
use std::fmt::Write;
let naming = &backend.manifest().naming;
let enum_sources: Vec<&str> = analyzed
.columns
.iter()
.filter_map(|col| unwrap_containers(&col.neutral_type).strip_prefix("enum::"))
.chain(
analyzed
.params
.iter()
.filter_map(|p| unwrap_containers(&p.neutral_type).strip_prefix("enum::")),
)
.chain(nested_refs.enums.iter().map(String::as_str))
.collect();
let mut seen_enums: AHashSet<&str> = AHashSet::new();
let unique_sources: Vec<&str> = enum_sources
.into_iter()
.filter(|name| seen_enums.insert(*name))
.collect();
let generated_names: Vec<(&str, String)> = unique_sources
.iter()
.map(|sql_name| (*sql_name, scythe_backend::naming::enum_type_name(sql_name, naming)))
.collect();
let query_type_entry = declared_struct_name.map(|name| ("<query row/model type>", name.to_string()));
resolve::check_type_name_collisions(
generated_names
.iter()
.chain(query_type_entry.iter())
.map(|(source, name)| (*source, name.as_str())),
"enum and query type names",
)?;
let mut out = String::new();
for sql_name in unique_sources {
if !out.is_empty() {
let _ = writeln!(out);
}
let stub_info;
let enum_info = match analyzed.enums.iter().find(|e| e.sql_name == sql_name) {
Some(info) => info,
None => {
stub_info = EnumInfo {
sql_name: sql_name.to_string(),
values: vec![],
};
&stub_info
}
};
let variant_names: Vec<(&str, String)> = enum_info
.values
.iter()
.map(|value| (value.as_str(), scythe_backend::naming::enum_variant_name(value, naming)))
.collect();
resolve::check_enum_variant_collisions(
variant_names.iter().map(|(source, name)| (*source, name.as_str())),
sql_name,
)?;
if nested_refs.enums.contains(sql_name) {
out.push_str(&backend.generate_enum_def_for_nested(enum_info)?);
} else {
out.push_str(&backend.generate_enum_def(enum_info)?);
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct NestedColumnShape<'a> {
pub(crate) is_array: bool,
pub(crate) element_nullable: bool,
pub(crate) name: &'a str,
}
pub(crate) fn nested_struct_shape(neutral_type: &str) -> Option<NestedColumnShape<'_>> {
let inner = neutral_type.strip_prefix("json_nested<")?.strip_suffix('>')?;
let Some(element) = inner.strip_prefix("array<").and_then(|r| r.strip_suffix('>')) else {
return Some(NestedColumnShape {
is_array: false,
element_nullable: false,
name: inner,
});
};
match element.strip_prefix("nullable<").and_then(|r| r.strip_suffix('>')) {
Some(name) => Some(NestedColumnShape {
is_array: true,
element_nullable: true,
name,
}),
None => Some(NestedColumnShape {
is_array: true,
element_nullable: false,
name: element,
}),
}
}
pub type DegradedNestedStructs = (Vec<AnalyzedColumn>, Vec<NestedStructDef>, Vec<NestedStructDegradation>);
pub fn degrade_unsupported_nested_structs(
columns: &[AnalyzedColumn],
nested_structs: &[NestedStructInfo],
backend: &dyn CodegenBackend,
) -> Result<DegradedNestedStructs, ScytheError> {
if nested_structs.is_empty() {
return Ok((columns.to_vec(), Vec::new(), Vec::new()));
}
use ahash::AHashSet;
let mut unsupported: AHashSet<String> = AHashSet::new();
let mut defs: Vec<NestedStructDef> = Vec::new();
for nested in nested_structs {
match backend.generate_nested_struct_def(nested)? {
Some(def) => defs.push(NestedStructDef {
name: nested.name.clone(),
code: def,
}),
None => {
unsupported.insert(to_pascal_case(&nested.name).into_owned());
}
}
}
if unsupported.is_empty() {
return Ok((columns.to_vec(), defs, Vec::new()));
}
let supports_json_array = backend.manifest().types.scalars.contains_key("json_array");
let backend_name = backend.name().to_string();
let mut degradations: Vec<NestedStructDegradation> = Vec::new();
let degraded = columns
.iter()
.cloned()
.map(|mut col| {
if let Some(shape) = nested_struct_shape(&col.neutral_type)
&& unsupported.contains(shape.name)
{
let fallback_type = if shape.is_array && supports_json_array {
"json_array"
} else {
"json"
};
degradations.push(NestedStructDegradation {
column: col.name.clone(),
struct_name: shape.name.to_string(),
fallback_type,
backend: backend_name.clone(),
});
col.neutral_type = fallback_type.to_string();
}
col
})
.collect();
Ok((degraded, defs, degradations))
}
pub fn generate(analyzed: &AnalyzedQuery) -> Result<GeneratedCode, ScytheError> {
let backend = get_backend("rust-sqlx", "postgresql")?;
generate_with_backend(analyzed, &*backend)
}
pub fn generate_single_enum_def_with_backend(
enum_info: &EnumInfo,
backend: &dyn CodegenBackend,
reachable_from_nested: bool,
) -> Result<String, ScytheError> {
if reachable_from_nested {
backend.generate_enum_def_for_nested(enum_info)
} else {
backend.generate_enum_def(enum_info)
}
}
pub fn generate_single_enum_def(enum_info: &EnumInfo, manifest: &BackendManifest) -> String {
use scythe_backend::naming::{enum_type_name, enum_variant_name};
use std::fmt::Write;
let mut out = String::with_capacity(256);
let type_name = enum_type_name(&enum_info.sql_name, &manifest.naming);
let _ = writeln!(out, "#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)]");
let _ = writeln!(
out,
"#[sqlx(type_name = \"{}\", rename_all = \"snake_case\")]",
enum_info.sql_name
);
let _ = writeln!(out, "pub enum {type_name} {{");
for value in &enum_info.values {
let variant = enum_variant_name(value, &manifest.naming);
let _ = writeln!(out, " {variant},");
}
let _ = write!(out, "}}");
out
}
pub fn load_or_default_manifest() -> Result<BackendManifest, ScytheError> {
let b = backends::sqlx::SqlxBackend::new("postgresql")?;
Ok(b.manifest().clone())
}
#[cfg(test)]
mod tests {
use super::*;
use scythe_core::analyzer::{AnalyzedColumn, AnalyzedParam, AnalyzedQuery, GroupByConfig, NestedFieldInfo};
use scythe_core::parser::QueryCommand;
fn make_query(
name: &str,
command: QueryCommand,
sql: &str,
columns: Vec<AnalyzedColumn>,
params: Vec<AnalyzedParam>,
) -> AnalyzedQuery {
AnalyzedQuery::build(|aq| {
aq.name = name.to_string();
aq.command = command;
aq.sql = sql.to_string();
aq.columns = columns;
aq.params = params;
aq.deprecated = None;
aq.source_table = None;
aq.composites = Vec::new();
aq.enums = Vec::new();
aq.optional_params = Vec::new();
aq.group_by = None;
aq.custom = Vec::new();
})
}
fn test_naming() -> NamingConfig {
NamingConfig {
struct_case: "PascalCase".to_string(),
fn_case: "snake_case".to_string(),
enum_variant_case: "PascalCase".to_string(),
row_suffix: "Row".to_string(),
field_case: "snake_case".to_string(),
reserved: Vec::new(),
reserved_bindings: Vec::new(),
sanitize_field_names: false,
}
}
#[test]
fn model_struct_name_sanitizes_a_schema_qualified_dot() {
assert_eq!(model_struct_name("app.widgets", &test_naming()), "AppWidget");
}
#[test]
fn test_generate_select_many() {
let query = make_query(
"ListUsers",
QueryCommand::Many,
"SELECT id, name, email FROM users",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "email".to_string(),
neutral_type: "string".to_string(),
nullable: true,
..Default::default()
},
],
vec![],
);
let result = generate(&query).unwrap();
let row_struct = result.row_struct.unwrap();
assert!(row_struct.contains("pub struct ListUsersRow"));
assert!(row_struct.contains("pub id: i32"));
assert!(row_struct.contains("pub name: String"));
assert!(row_struct.contains("pub email: Option<String>"));
let query_fn = result.query_fn.unwrap();
assert!(query_fn.contains("pub async fn list_users("));
assert!(query_fn.contains("-> Result<Vec<ListUsersRow>, sqlx::Error>"));
assert!(query_fn.contains(".fetch_all(pool)"));
}
#[test]
fn test_generate_select_one_with_param() {
let query = make_query(
"GetUser",
QueryCommand::One,
"SELECT id, name FROM users WHERE id = $1",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
],
vec![AnalyzedParam {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
position: 1,
source_relation: None,
}],
);
let result = generate(&query).unwrap();
let query_fn = result.query_fn.unwrap();
assert!(query_fn.contains("pub async fn get_user("));
assert!(query_fn.contains("id: i32"));
assert!(query_fn.contains("-> Result<GetUserRow, sqlx::Error>"));
assert!(query_fn.contains(".fetch_one(pool)"));
}
#[test]
fn test_generate_exec() {
let query = make_query(
"DeleteUser",
QueryCommand::Exec,
"DELETE FROM users WHERE id = $1",
vec![],
vec![AnalyzedParam {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
position: 1,
source_relation: None,
}],
);
let result = generate(&query).unwrap();
assert!(result.row_struct.is_none());
let query_fn = result.query_fn.unwrap();
assert!(query_fn.contains("pub async fn delete_user("));
assert!(query_fn.contains("-> Result<(), sqlx::Error>"));
assert!(query_fn.contains(".execute(pool)"));
}
#[test]
fn test_generate_with_enum_column() {
let query = make_query(
"GetUserStatus",
QueryCommand::One,
"SELECT id, status FROM users WHERE id = $1",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "status".to_string(),
neutral_type: "enum::user_status".to_string(),
nullable: false,
..Default::default()
},
],
vec![AnalyzedParam {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
position: 1,
source_relation: None,
}],
);
let result = generate(&query).unwrap();
assert!(result.enum_def.is_some());
let enum_def = result.enum_def.unwrap();
assert!(enum_def.contains("pub enum UserStatus"));
assert!(enum_def.contains("type_name = \"user_status\""));
let row_struct = result.row_struct.unwrap();
assert!(row_struct.contains("pub status: UserStatus"));
}
#[test]
fn test_singularize_basic() {
assert_eq!(singularize("users"), "user");
assert_eq!(singularize("orders"), "order");
assert_eq!(singularize("posts"), "post");
}
#[test]
fn test_singularize_ies() {
assert_eq!(singularize("categories"), "category");
assert_eq!(singularize("entries"), "entry");
}
#[test]
fn test_singularize_sses() {
assert_eq!(singularize("addresses"), "address");
assert_eq!(singularize("classes"), "class");
}
#[test]
fn test_singularize_no_change() {
assert_eq!(singularize("status"), "statu");
assert_eq!(singularize("boss"), "boss");
assert_eq!(singularize("address"), "address");
}
#[test]
fn test_singularize_shes_ches_xes() {
assert_eq!(singularize("batches"), "batch");
assert_eq!(singularize("boxes"), "box");
assert_eq!(singularize("wishes"), "wish");
}
fn make_grouped_query() -> AnalyzedQuery {
let parent_cols = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "email".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
];
let child_cols = vec![
AnalyzedColumn {
name: "order_id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "total".to_string(),
neutral_type: "decimal".to_string(),
nullable: true,
..Default::default()
},
AnalyzedColumn {
name: "order_date".to_string(),
neutral_type: "datetime".to_string(),
nullable: false,
..Default::default()
},
];
let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
AnalyzedQuery::build(|aq| {
aq.name = "GetUsersWithOrders".to_string();
aq.command = QueryCommand::Grouped;
aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\n\
FROM users u\n\
JOIN orders o ON o.user_id = u.id"
.to_string();
aq.columns = all_cols;
aq.params = vec![];
aq.deprecated = None;
aq.source_table = None;
aq.composites = vec![];
aq.enums = vec![];
aq.optional_params = vec![];
aq.group_by = Some(GroupByConfig {
table: "users".to_string(),
key_column: "id".to_string(),
parent_columns: parent_cols,
child_columns: child_cols,
});
aq.custom = vec![];
})
}
#[test]
fn test_grouped_sqlx_structs() {
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let query = make_grouped_query();
let result = generate_with_backend(&query, &*backend).unwrap();
let row_struct = result.row_struct.as_deref().unwrap();
assert!(
row_struct.contains("pub struct GetUsersWithOrdersChildRow"),
"missing child struct; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub order_id: i32"),
"child struct missing order_id field; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub struct GetUsersWithOrdersRow"),
"missing parent struct; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub id: i32"),
"parent struct missing id field; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub name: String"),
"parent struct missing name field; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub email: String"),
"parent struct missing email field; got:\n{row_struct}"
);
assert!(
row_struct.contains("pub children: Vec<GetUsersWithOrdersChildRow>"),
"parent struct missing children field; got:\n{row_struct}"
);
let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
let parent_pos = row_struct.find("pub struct GetUsersWithOrdersRow").unwrap();
assert!(
child_pos < parent_pos,
"child struct must be defined before parent struct"
);
assert!(
result.model_struct.is_none(),
"grouped should not produce a model_struct"
);
}
#[test]
fn test_grouped_sqlx_query_fn() {
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let query = make_grouped_query();
let result = generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
query_fn.contains("pub async fn get_users_with_orders("),
"missing fn; got:\n{query_fn}"
);
assert!(
query_fn.contains("-> Result<Vec<GetUsersWithOrdersRow>, sqlx::Error>"),
"wrong return type; got:\n{query_fn}"
);
assert!(
query_fn.contains("sqlx::query!("),
"grouped fn must use sqlx::query!; got:\n{query_fn}"
);
assert!(
query_fn.contains("GetUsersWithOrdersChildRow {"),
"fn must construct child struct; got:\n{query_fn}"
);
assert!(
query_fn.contains("children: vec![child]"),
"fn must initialize children vec; got:\n{query_fn}"
);
assert!(
query_fn.contains("parent.children.push(child)"),
"fn must fold child into existing parent; got:\n{query_fn}"
);
assert!(
query_fn.contains("Ok(result)"),
"fn must return result; got:\n{query_fn}"
);
}
#[test]
fn test_grouped_python_asyncpg_structs() {
let backend = get_backend("python-asyncpg", "postgresql").unwrap();
let query = make_grouped_query();
let result = generate_with_backend(&query, &*backend).unwrap();
let row_struct = result.row_struct.as_deref().unwrap();
assert!(
row_struct.contains("class GetUsersWithOrdersChildRow"),
"missing child class; got:\n{row_struct}"
);
assert!(
row_struct.contains("order_id: int"),
"child class missing order_id field; got:\n{row_struct}"
);
assert!(
row_struct.contains("class GetUsersWithOrdersRow"),
"missing parent class; got:\n{row_struct}"
);
assert!(
row_struct.contains("id: int"),
"parent class missing id field; got:\n{row_struct}"
);
assert!(
row_struct.contains("name: str"),
"parent class missing name field; got:\n{row_struct}"
);
assert!(
row_struct.contains("children: list[GetUsersWithOrdersChildRow]"),
"parent class missing children field; got:\n{row_struct}"
);
let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
let parent_pos = row_struct.find("class GetUsersWithOrdersRow").unwrap();
assert!(
child_pos < parent_pos,
"child class must be defined before parent class"
);
}
#[test]
fn test_grouped_python_asyncpg_query_fn() {
let backend = get_backend("python-asyncpg", "postgresql").unwrap();
let query = make_grouped_query();
let result = generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
query_fn.contains("async def get_users_with_orders(conn: Connection)"),
"missing fn; got:\n{query_fn}"
);
assert!(
query_fn.contains("-> list[GetUsersWithOrdersRow]:"),
"wrong return type; got:\n{query_fn}"
);
assert!(
query_fn.contains("conn.fetch("),
"fn must use conn.fetch; got:\n{query_fn}"
);
assert!(
query_fn.contains("GetUsersWithOrdersChildRow("),
"fn must construct child class; got:\n{query_fn}"
);
assert!(
query_fn.contains("GetUsersWithOrdersRow(**parent_kwargs, children=children)"),
"fn must construct parent with **kwargs; got:\n{query_fn}"
);
assert!(
query_fn.contains("_index"),
"fn must use index dict for O(1) lookup; got:\n{query_fn}"
);
}
struct StubBackend {
manifest: BackendManifest,
}
impl CodegenBackend for StubBackend {
fn name(&self) -> &str {
"stub-backend"
}
fn manifest(&self) -> &BackendManifest {
&self.manifest
}
fn manifest_mut(&mut self) -> &mut BackendManifest {
&mut self.manifest
}
fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_query_fn(
&self,
_analyzed: &AnalyzedQuery,
_struct_name: &str,
_columns: &[ResolvedColumn],
_params: &[ResolvedParam],
) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_composite_def(
&self,
_composite: &scythe_core::analyzer::CompositeInfo,
) -> Result<String, ScytheError> {
Ok(String::new())
}
}
#[test]
fn test_grouped_unsupported_backend_returns_clear_error() {
let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
let backend = StubBackend { manifest };
let query = make_grouped_query();
let result = generate_with_backend(&query, &backend);
assert!(
result.is_err(),
"stub backend should return an error for grouped queries"
);
let err = result.unwrap_err();
assert!(
err.message.contains("not yet supported"),
"error should contain 'not yet supported', got: {}",
err.message
);
assert!(
err.message.contains("stub-backend"),
"error should name the backend, got: {}",
err.message
);
}
#[test]
fn test_tokio_postgres_backend_basic() {
let backend = get_backend("tokio-postgres", "postgresql").unwrap();
let query = make_query(
"ListUsers",
QueryCommand::Many,
"SELECT id, name FROM users",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
],
vec![],
);
let result = generate_with_backend(&query, &*backend).unwrap();
let row_struct = result.row_struct.unwrap();
assert!(row_struct.contains("pub struct ListUsersRow"));
assert!(row_struct.contains("pub id: i32"));
assert!(row_struct.contains("pub name: String"));
assert!(row_struct.contains("from_row"));
assert!(row_struct.contains("tokio_postgres::Row"));
assert!(!row_struct.contains("sqlx"));
let query_fn = result.query_fn.unwrap();
assert!(query_fn.contains("pub async fn list_users("));
assert!(query_fn.contains("tokio_postgres::GenericClient"));
assert!(query_fn.contains("tokio_postgres::Error"));
assert!(!query_fn.contains("sqlx"));
}
#[test]
fn test_tokio_postgres_enum() {
let backend = get_backend("tokio-postgres", "postgresql").unwrap();
let enum_info = scythe_core::analyzer::EnumInfo {
sql_name: "user_status".to_string(),
values: vec!["active".to_string(), "inactive".to_string()],
};
let def = backend.generate_enum_def(&enum_info).unwrap();
assert!(def.contains("pub enum UserStatus"));
assert!(def.contains("Active"));
assert!(def.contains("Inactive"));
assert!(def.contains("impl std::fmt::Display"));
assert!(def.contains("impl std::str::FromStr"));
assert!(!def.contains("sqlx"));
}
#[test]
fn test_sql_with_least_coalesce_sum_preserved() {
let backend = get_backend("tokio-postgres", "postgresql").unwrap();
let query = make_query(
"GetBillingAggregates",
QueryCommand::One,
"SELECT LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000) as aggregated_free_pages FROM billing_aggregates ba WHERE ba.customer_id = $1",
vec![AnalyzedColumn {
name: "aggregated_free_pages".to_string(),
neutral_type: "int64".to_string(),
nullable: false,
..Default::default()
}],
vec![AnalyzedParam {
name: "customer_id".to_string(),
neutral_type: "uuid".to_string(),
nullable: false,
position: 1,
source_relation: None,
}],
);
let result = generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.unwrap();
assert!(
query_fn.contains("LEAST(COALESCE(SUM(ba.free_pages_remaining), 0), 10000)"),
"SQL should preserve original LEAST/COALESCE/SUM function names and casing"
);
assert!(
query_fn.contains("as aggregated_free_pages"),
"SQL should preserve alias keyword (as)"
);
}
#[test]
fn test_generated_rust_code_structure() {
let backend = get_backend("tokio-postgres", "postgresql").unwrap();
let query = make_query(
"GetUser",
QueryCommand::One,
"SELECT id, name FROM users WHERE id = $1",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
],
vec![AnalyzedParam {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
position: 1,
source_relation: None,
}],
);
let result = generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.unwrap();
assert!(query_fn.contains("pub async fn get_user("));
assert!(query_fn.contains("tokio_postgres::GenericClient"));
assert!(query_fn.contains("GetUserRow"));
assert!(query_fn.contains("SELECT id, name FROM users"));
}
fn a_nested_struct() -> NestedStructInfo {
NestedStructInfo {
name: "get_user_posts_row_posts".to_string(),
fields: vec![NestedFieldInfo {
name: "title".to_string(),
neutral_type: "string".to_string(),
nullable: false,
}],
}
}
#[test]
fn test_nested_struct_shape_unwraps_array() {
let shape = nested_struct_shape("json_nested<array<GetUserPostsRowPosts>>").unwrap();
assert!(shape.is_array);
assert!(!shape.element_nullable);
assert_eq!(shape.name, "GetUserPostsRowPosts");
}
#[test]
fn test_nested_struct_shape_unwraps_nullable_array_element() {
let shape = nested_struct_shape("json_nested<array<nullable<GetUserPostsRowPosts>>>").unwrap();
assert!(shape.is_array);
assert!(shape.element_nullable);
assert_eq!(shape.name, "GetUserPostsRowPosts");
}
#[test]
fn test_nested_struct_shape_bare() {
let shape = nested_struct_shape("json_nested<GetPostAsJsonRowPost>").unwrap();
assert!(!shape.is_array);
assert_eq!(shape.name, "GetPostAsJsonRowPost");
}
#[test]
fn test_nested_struct_shape_ignores_user_json_typed_mapping() {
assert!(nested_struct_shape("json_typed<EventData>").is_none());
}
#[test]
fn test_nested_struct_shape_none_for_ordinary_types() {
assert!(nested_struct_shape("int32").is_none());
assert!(nested_struct_shape("json").is_none());
assert!(nested_struct_shape("array<int32>").is_none());
}
#[test]
fn test_degrade_unsupported_nested_structs_defaults_to_plain_json() {
let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
let backend = StubBackend { manifest };
let nested = a_nested_struct();
let columns = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
nullable: true,
..Default::default()
},
AnalyzedColumn {
name: "profile".to_string(),
neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
nullable: true,
..Default::default()
},
];
let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
assert_eq!(
degraded[0].neutral_type, "int32",
"an unrelated column must be untouched"
);
assert_eq!(
degraded[1].neutral_type, "json",
"a backend without an explicit JSON-array mapping must keep the safe plain-json fallback"
);
assert_eq!(
degraded[2].neutral_type, "json",
"an unsupported row_to_json result must remain a single object"
);
assert!(
defs.is_empty(),
"an unsupported backend must not emit a struct definition"
);
assert_eq!(degradations.len(), 2, "only the two nested columns were rewritten");
assert_eq!(degradations[0].column, "posts");
assert_eq!(degradations[0].struct_name, "GetUserPostsRowPosts");
assert_eq!(degradations[0].fallback_type, "json");
assert_eq!(
degradations[0].backend, "stub-backend",
"the backend the user configured, from CodegenBackend::name -- not the manifest it borrows"
);
assert_eq!(degradations[1].column, "profile");
assert_eq!(degradations[1].struct_name, "GetUserPostsRowPosts");
assert_eq!(degradations[1].fallback_type, "json");
assert_eq!(degradations[1].backend, "stub-backend");
}
#[test]
fn test_degrade_unsupported_nested_structs_uses_explicit_json_array_marker() {
let backend = get_backend("python-asyncpg", "postgresql").unwrap();
let nested = a_nested_struct();
let columns = vec![
AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
nullable: true,
..Default::default()
},
AnalyzedColumn {
name: "profile".to_string(),
neutral_type: "json_nested<GetUserPostsRowPosts>".to_string(),
nullable: true,
..Default::default()
},
];
let (degraded, defs, degradations) =
degrade_unsupported_nested_structs(&columns, &[nested], &*backend).unwrap();
assert_eq!(degraded[0].neutral_type, "json_array");
assert_eq!(degraded[1].neutral_type, "json");
assert!(defs.is_empty());
assert_eq!(degradations.len(), 2);
assert_eq!(degradations[0].fallback_type, "json_array");
assert_eq!(degradations[0].backend, "python-asyncpg");
assert_eq!(degradations[1].fallback_type, "json");
}
struct NestedSupportingBackend {
manifest: BackendManifest,
}
impl CodegenBackend for NestedSupportingBackend {
fn name(&self) -> &str {
"nested-supporting-backend"
}
fn manifest(&self) -> &BackendManifest {
&self.manifest
}
fn manifest_mut(&mut self) -> &mut BackendManifest {
&mut self.manifest
}
fn generate_row_struct(&self, _query_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_model_struct(&self, _table_name: &str, _columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_query_fn(
&self,
_analyzed: &AnalyzedQuery,
_struct_name: &str,
_columns: &[ResolvedColumn],
_params: &[ResolvedParam],
) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_enum_def(&self, _enum_info: &scythe_core::analyzer::EnumInfo) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_composite_def(
&self,
_composite: &scythe_core::analyzer::CompositeInfo,
) -> Result<String, ScytheError> {
Ok(String::new())
}
fn generate_nested_struct_def(&self, nested: &NestedStructInfo) -> Result<Option<String>, ScytheError> {
Ok(Some(format!("struct {} {{}}", to_pascal_case(&nested.name))))
}
}
#[test]
fn test_degrade_unsupported_nested_structs_keeps_supported_column_and_collects_def() {
let manifest = get_backend("rust-sqlx", "postgresql").unwrap().manifest().clone();
let backend = NestedSupportingBackend { manifest };
let nested = a_nested_struct();
let columns = vec![AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json_nested<array<GetUserPostsRowPosts>>".to_string(),
nullable: true,
..Default::default()
}];
let (degraded, defs, degradations) = degrade_unsupported_nested_structs(&columns, &[nested], &backend).unwrap();
assert_eq!(
degraded[0].neutral_type, "json_nested<array<GetUserPostsRowPosts>>",
"a supported backend must leave the nested reference untouched"
);
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].name, "get_user_posts_row_posts");
assert_eq!(defs[0].code, "struct GetUserPostsRowPosts {}");
assert!(
degradations.is_empty(),
"a supported struct must not be reported as degraded"
);
}
#[test]
fn test_structural_backends_match_json_array_baseline() {
for backend_name in ["python-asyncpg", "elixir-postgrex", "ruby-pg"] {
let backend = get_backend(backend_name, "postgresql").unwrap();
let baseline = make_query(
"GetUserPosts",
QueryCommand::Many,
"SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
vec![AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json_array".to_string(),
nullable: true,
..Default::default()
}],
vec![],
);
let mut nested_query = baseline.clone();
nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
nested_query.nested_structs = vec![a_nested_struct()];
let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
assert_eq!(
baseline_result.row_struct, nested_result.row_struct,
"{backend_name} row struct output must match the JSON array baseline"
);
assert_eq!(
baseline_result.query_fn, nested_result.query_fn,
"{backend_name} query output must match the JSON array baseline"
);
assert_eq!(baseline_result.model_struct, nested_result.model_struct);
}
}
#[test]
fn test_php_backends_keep_native_nested_json_rows() {
for backend_name in ["php-pdo", "php-amphp"] {
let backend = get_backend(backend_name, "postgresql").unwrap();
let baseline = make_query(
"GetUserPosts",
QueryCommand::Many,
"SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
vec![AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json_array".to_string(),
nullable: true,
..Default::default()
}],
vec![],
);
let mut nested_query = baseline.clone();
nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
let mut nested = a_nested_struct();
nested.fields.push(NestedFieldInfo {
name: "high_precision_total".to_string(),
neutral_type: "decimal".to_string(),
nullable: false,
});
nested_query.nested_structs = vec![nested];
let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
assert_ne!(
baseline_result.row_struct, nested_result.row_struct,
"{backend_name} must retain its typed nested JSON row instead of degrading"
);
assert_eq!(nested_result.nested_struct_defs.len(), 1);
let nested_def = &nested_result.nested_struct_defs[0].code;
assert!(
nested_def.contains("public float $high_precision_total,")
&& nested_def.contains("high_precision_total: (float) $value['high_precision_total']")
&& !nested_def.contains("@var string"),
"{backend_name} must expose JSON numeric values as floats instead of pretending the decoded value is an exact string:\n{nested_def}"
);
assert!(
nested_result.degraded_nested_structs.is_empty(),
"{backend_name} must not report native nested JSON support as degraded"
);
}
}
#[test]
fn test_csharp_nested_aggregate_matches_plain_json_baseline() {
let backend = get_backend("csharp-npgsql", "postgresql").unwrap();
let baseline = make_query(
"GetUserPosts",
QueryCommand::Many,
"SELECT json_agg(p.*) AS posts FROM users u JOIN posts p ON u.id = p.user_id",
vec![AnalyzedColumn {
name: "posts".to_string(),
neutral_type: "json".to_string(),
nullable: true,
..Default::default()
}],
vec![],
);
let mut nested_query = baseline.clone();
nested_query.columns[0].neutral_type = "json_nested<array<GetUserPostsRowPosts>>".to_string();
nested_query.nested_structs = vec![a_nested_struct()];
let baseline_result = generate_with_backend(&baseline, &*backend).unwrap();
let nested_result = generate_with_backend(&nested_query, &*backend).unwrap();
assert_eq!(baseline_result.row_struct, nested_result.row_struct);
assert_eq!(baseline_result.query_fn, nested_result.query_fn);
assert_eq!(baseline_result.model_struct, nested_result.model_struct);
assert_eq!(baseline_result.enum_def, nested_result.enum_def);
assert_eq!(baseline_result.nested_struct_defs, nested_result.nested_struct_defs);
assert!(
nested_result
.row_struct
.as_deref()
.is_some_and(|row| row.contains("string? Posts"))
);
assert!(
nested_result
.query_fn
.as_deref()
.is_some_and(|query_fn| query_fn.contains("reader.GetString(0)"))
);
}
#[test]
fn test_enum_inside_array_is_emitted_not_just_referenced() {
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let mut query = make_query(
"GetMany",
QueryCommand::Many,
"SELECT id, many_status FROM t",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "many_status".to_string(),
neutral_type: "array<enum::user_status>".to_string(),
nullable: false,
..Default::default()
},
],
vec![],
);
query.enums = vec![EnumInfo {
sql_name: "user_status".to_string(),
values: vec!["active".to_string(), "banned".to_string()],
}];
let result = generate_with_backend(&query, &*backend).unwrap();
let enum_def = result
.enum_def
.expect("an enum reached only through array<enum::...> must still get a definition emitted");
assert!(
enum_def.contains("pub enum UserStatus"),
"expected a UserStatus enum definition; got:\n{enum_def}"
);
let row_struct = result.row_struct.unwrap();
assert!(
row_struct.contains("Vec<UserStatus>"),
"row struct must reference the array of the enum; got:\n{row_struct}"
);
}
#[test]
fn test_composite_inside_array_is_emitted_not_just_referenced() {
use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let mut query = make_query(
"GetManyAddrs",
QueryCommand::Many,
"SELECT id, addrs FROM t",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "addrs".to_string(),
neutral_type: "array<composite::address>".to_string(),
nullable: false,
..Default::default()
},
],
vec![],
);
query.composites = vec![CompositeInfo {
sql_name: "address".to_string(),
fields: vec![CompositeFieldInfo {
name: "street".to_string(),
neutral_type: "string".to_string(),
nullable: false,
}],
}];
let result = generate_with_backend(&query, &*backend).unwrap();
let model = result
.model_struct
.expect("a composite reached only through array<composite::...> must still get a definition emitted");
assert!(
model.contains("pub struct Address"),
"expected an Address composite definition; got:\n{model}"
);
}
#[test]
fn test_composite_reachable_only_through_another_composites_field_is_emitted() {
use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let mut query = make_query(
"GetShape",
QueryCommand::One,
"SELECT id, bounds FROM shapes",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "bounds".to_string(),
neutral_type: "composite::outer_shape".to_string(),
nullable: false,
..Default::default()
},
],
vec![],
);
query.composites = vec![
CompositeInfo {
sql_name: "outer_shape".to_string(),
fields: vec![CompositeFieldInfo {
name: "origin".to_string(),
neutral_type: "composite::inner_point".to_string(),
nullable: false,
}],
},
CompositeInfo {
sql_name: "inner_point".to_string(),
fields: vec![CompositeFieldInfo {
name: "x".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
}],
},
];
let result = generate_with_backend(&query, &*backend).unwrap();
let model = result
.model_struct
.expect("outer_shape, selected directly, must get a definition emitted");
assert!(
model.contains("pub struct OuterShape"),
"expected an OuterShape composite definition; got:\n{model}"
);
assert!(
model.contains("pub struct InnerPoint"),
"inner_point, reachable only through outer_shape's own field list, must also get a \
definition emitted; got:\n{model}"
);
}
#[test]
fn file_wide_nested_composite_promotion_uses_nested_capable_definition() {
use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let mut query = make_query(
"RoundTripAddress",
QueryCommand::One,
"SELECT $1::user_address AS address",
vec![AnalyzedColumn {
name: "address".to_string(),
neutral_type: "composite::user_address".to_string(),
nullable: false,
..Default::default()
}],
Vec::new(),
);
query.composites = vec![CompositeInfo {
sql_name: "user_address".to_string(),
fields: vec![CompositeFieldInfo {
name: "street".to_string(),
neutral_type: "string".to_string(),
nullable: false,
}],
}];
let nested_names = AHashSet::from_iter(["user_address".to_string()]);
let result =
generate_with_backend_and_overrides_and_nested_composites(&query, backend.as_ref(), &[], &nested_names)
.unwrap();
let definition = result.model_struct.unwrap();
assert!(definition.contains("serde::Serialize"), "{definition}");
assert!(definition.contains("serde::Deserialize"), "{definition}");
assert_eq!(definition.matches("pub struct UserAddress").count(), 1);
}
#[test]
fn composites_are_emitted_after_the_composites_their_fields_reference() {
use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
let composites = vec![
CompositeInfo {
sql_name: "outer_shape".to_string(),
fields: vec![CompositeFieldInfo {
name: "origin".to_string(),
neutral_type: "array<composite::inner_point>".to_string(),
nullable: false,
}],
},
CompositeInfo {
sql_name: "inner_point".to_string(),
fields: vec![CompositeFieldInfo {
name: "unit".to_string(),
neutral_type: "composite::scale".to_string(),
nullable: false,
}],
},
CompositeInfo {
sql_name: "scale".to_string(),
fields: vec![CompositeFieldInfo {
name: "factor".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
}],
},
];
let ordered: Vec<&str> = composites_in_dependency_order(&composites)
.iter()
.map(|c| c.sql_name.as_str())
.collect();
assert_eq!(ordered, vec!["scale", "inner_point", "outer_shape"]);
}
#[test]
fn composites_in_dependency_order_terminates_on_a_cycle() {
use scythe_core::analyzer::{CompositeFieldInfo, CompositeInfo};
let composites = vec![
CompositeInfo {
sql_name: "a".to_string(),
fields: vec![CompositeFieldInfo {
name: "b".to_string(),
neutral_type: "composite::b".to_string(),
nullable: false,
}],
},
CompositeInfo {
sql_name: "b".to_string(),
fields: vec![CompositeFieldInfo {
name: "a".to_string(),
neutral_type: "composite::a".to_string(),
nullable: false,
}],
},
];
let ordered: Vec<&str> = composites_in_dependency_order(&composites)
.iter()
.map(|c| c.sql_name.as_str())
.collect();
assert_eq!(ordered.len(), 2, "every composite must still be emitted exactly once");
assert!(ordered.contains(&"a") && ordered.contains(&"b"));
}
#[test]
fn test_unwrap_containers() {
assert_eq!(unwrap_containers("enum::user_status"), "enum::user_status");
assert_eq!(unwrap_containers("array<enum::user_status>"), "enum::user_status");
assert_eq!(unwrap_containers("array<composite::address>"), "composite::address");
assert_eq!(
unwrap_containers("array<nullable<enum::user_status>>"),
"enum::user_status"
);
assert_eq!(unwrap_containers("nullable<array<int32>>"), "int32");
assert_eq!(unwrap_containers("int32"), "int32");
}
#[test]
fn test_grouped_parent_column_named_children_collides_with_synthesized_field() {
let backend = get_backend("rust-sqlx", "postgresql").unwrap();
let parent_cols = vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "children".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
];
let child_cols = vec![AnalyzedColumn {
name: "order_id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
}];
let all_cols = [parent_cols.clone(), child_cols.clone()].concat();
let query = AnalyzedQuery::build(|aq| {
aq.name = "GetUsersWithOrders".to_string();
aq.command = QueryCommand::Grouped;
aq.sql = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\n\
SELECT u.id, u.children, o.id AS order_id\n\
FROM users u\n\
JOIN orders o ON o.user_id = u.id"
.to_string();
aq.columns = all_cols;
aq.params = vec![];
aq.deprecated = None;
aq.source_table = None;
aq.composites = vec![];
aq.enums = vec![];
aq.optional_params = vec![];
aq.group_by = Some(GroupByConfig {
table: "users".to_string(),
key_column: "id".to_string(),
parent_columns: parent_cols,
child_columns: child_cols,
});
aq.custom = vec![];
});
let result = generate_with_backend(&query, &*backend);
let err = result.expect_err(
"a parent column literally named 'children' must be rejected, not silently \
produce two `children` fields on the generated struct",
);
assert_eq!(err.code, ErrorCode::DuplicateAlias);
assert!(
err.message.contains("children"),
"error should name the colliding field, got: {}",
err.message
);
}
fn strip_comments(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut i = 0;
while i < source.len() {
let rest = &source[i..];
if rest.starts_with("//") || rest.starts_with('#') {
match rest.find('\n') {
Some(nl) => i += nl,
None => i = source.len(),
}
continue;
}
if rest.starts_with("/*") {
match rest.find("*/") {
Some(end) => i += end + 2,
None => i = source.len(),
}
continue;
}
if let Some(quote) = ["\"\"\"", "'''"].into_iter().find(|q| rest.starts_with(q)) {
match rest[quote.len()..].find(quote) {
Some(end) => i += quote.len() + end + quote.len(),
None => i = source.len(),
}
continue;
}
let ch = rest.chars().next().expect("i < source.len()");
out.push(ch);
i += ch.len_utf8();
}
out
}
fn contains_identifier(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
let mut start = 0;
while let Some(rel) = haystack[start..].find(needle) {
let idx = start + rel;
let before_ok = haystack[..idx].chars().next_back().is_none_or(|c| !is_ident_char(c));
let after_ok = haystack[idx + needle.len()..]
.chars()
.next()
.is_none_or(|c| !is_ident_char(c));
if before_ok && after_ok {
return true;
}
start = idx + 1;
if start >= haystack.len() {
break;
}
}
false
}
const ALL_BACKEND_NAMES: &[&str] = &[
"rust-sqlx",
"rust-tokio-postgres",
"python-psycopg3",
"python-asyncpg",
"python-aiomysql",
"python-aiosqlite",
"python-duckdb",
"typescript-postgres",
"javascript-postgres",
"typescript-pg",
"javascript-pg",
"typescript-mysql2",
"javascript-mysql2",
"typescript-better-sqlite3",
"javascript-better-sqlite3",
"typescript-duckdb",
"javascript-duckdb",
"typescript-node-sqlite",
"javascript-node-sqlite",
"typescript-wasm-sqlite",
"javascript-wasm-sqlite",
"typescript-kysely",
"go-database-sql",
"go-pgx",
"java-jdbc",
"java-r2dbc",
"kotlin-exposed",
"kotlin-jdbc",
"kotlin-r2dbc",
"csharp-npgsql",
"csharp-mysqlconnector",
"csharp-microsoft-sqlite",
"elixir-postgrex",
"elixir-ecto",
"elixir-myxql",
"elixir-exqlite",
"ruby-pg",
"ruby-mysql2",
"ruby-sqlite3",
"ruby-trilogy",
"php-pdo",
"php-amphp",
"rust-tiberius",
"python-pyodbc",
"typescript-mssql",
"javascript-mssql",
"csharp-sqlclient",
"ruby-tiny-tds",
"elixir-tds",
"rust-sibyl",
"python-oracledb",
"typescript-oracledb",
"javascript-oracledb",
"go-godror",
"csharp-oracle",
"ruby-oci8",
"elixir-jamdb",
"python-snowflake",
"typescript-snowflake",
"javascript-snowflake",
"go-gosnowflake",
"csharp-snowflake",
];
fn backend_for_select_star(name: &str) -> Box<dyn CodegenBackend> {
const ENGINES: &[&str] = &[
"postgresql",
"mysql",
"sqlite",
"mssql",
"mariadb",
"duckdb",
"oracle",
"snowflake",
"redshift",
];
for engine in ENGINES {
if let Ok(backend) = get_backend(name, engine) {
return backend;
}
}
panic!("no known engine works for backend '{name}'");
}
fn select_star_query() -> AnalyzedQuery {
let mut query = make_query(
"GetAllUsers",
QueryCommand::Many,
"SELECT * FROM users",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "name".to_string(),
neutral_type: "string".to_string(),
nullable: false,
..Default::default()
},
],
vec![],
);
query.source_table = Some("users".to_string());
query
}
struct BackendNote {
backend: &'static str,
reason: &'static str,
}
const KNOWN_DIVERGENT_BACKENDS: &[BackendNote] = &[];
const NOT_APPLICABLE_BACKENDS: &[BackendNote] = &[
BackendNote {
backend: "javascript-postgres",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-pg",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-mysql2",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-better-sqlite3",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-node-sqlite",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-wasm-sqlite",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-snowflake",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-duckdb",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-oracledb",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
BackendNote {
backend: "javascript-mssql",
reason: "js_mode types via JSDoc comments only; no runtime identifier for either side of \
the check to find once comments are stripped",
},
];
#[test]
fn test_select_star_declares_and_references_the_same_struct_name_across_all_backends() {
let query = select_star_query();
let mut regressions = Vec::new();
let mut stale_entries = Vec::new();
let mut seen_not_applicable: Vec<&str> = Vec::new();
for &name in ALL_BACKEND_NAMES {
if let Some(note) = NOT_APPLICABLE_BACKENDS.iter().find(|n| n.backend == name) {
seen_not_applicable.push(note.backend);
continue;
}
let backend = backend_for_select_star(name);
let struct_name = determine_struct_name(&query, backend.manifest());
let agrees = match generate_with_backend(&query, &*backend) {
Err(_) => false,
Ok(result) => match (result.model_struct, result.query_fn) {
(Some(model_struct), Some(query_fn)) => {
let declared = contains_identifier(&strip_comments(&model_struct), &struct_name);
let referenced = contains_identifier(&strip_comments(&query_fn), &struct_name);
declared && referenced
}
_ => false,
},
};
let listed = KNOWN_DIVERGENT_BACKENDS.iter().find(|n| n.backend == name);
match (agrees, listed) {
(true, Some(_)) => stale_entries.push(name),
(false, None) => regressions.push(name),
(false, Some(note)) => eprintln!("expected divergence, {name}: {}", note.reason),
(true, None) => {}
}
}
let unknown_not_applicable: Vec<&str> = NOT_APPLICABLE_BACKENDS
.iter()
.map(|n| n.backend)
.filter(|b| !seen_not_applicable.contains(b))
.collect();
let mut failures = Vec::new();
for name in ®ressions {
let reason = "diverges on SELECT * struct naming and is not in KNOWN_DIVERGENT_BACKENDS";
failures.push(format!(
"REGRESSION: {name}: {reason} -- investigate before adding a line"
));
}
for name in &stale_entries {
failures.push(format!(
"STALE ALLOWLIST: {name}: listed in KNOWN_DIVERGENT_BACKENDS but now agrees -- delete its entry"
));
}
for name in &unknown_not_applicable {
failures.push(format!(
"STALE ALLOWLIST: {name}: listed in NOT_APPLICABLE_BACKENDS but is not in ALL_BACKEND_NAMES"
));
}
assert!(
failures.is_empty(),
"{} of {} backends need attention:\n{}",
failures.len(),
ALL_BACKEND_NAMES.len(),
failures.join("\n")
);
}
}