use std::fmt::Write;
use std::sync::{Arc, Mutex};
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{enum_type_name, enum_variant_name, fn_name, row_struct_name, to_pascal_case};
use scythe_backend::types::resolve_type;
use scythe_core::analyzer::{AnalyzedQuery, CompositeInfo, EnumInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use scythe_core::parser::QueryCommand;
use crate::backend_trait::{CodegenBackend, GroupedQueryFn, ResolvedColumn, ResolvedParam};
const DEFAULT_MANIFEST_TOML: &str = include_str!("../../manifests/csharp-npgsql.toml");
const DEFAULT_MANIFEST_REDSHIFT: &str = include_str!("../../manifests/csharp-npgsql.redshift.toml");
pub struct CsharpNpgsqlBackend {
manifest: BackendManifest,
generated_enums: Arc<Mutex<Vec<EnumInfo>>>,
}
impl CsharpNpgsqlBackend {
pub fn new(engine: &str) -> Result<Self, ScytheError> {
let default_toml = match engine {
"postgresql" | "postgres" | "pg" => DEFAULT_MANIFEST_TOML,
"redshift" => DEFAULT_MANIFEST_REDSHIFT,
_ => {
return Err(ScytheError::new(
ErrorCode::InternalError,
format!(
"csharp-npgsql only supports PostgreSQL/Redshift, got engine '{}'",
engine
),
));
}
};
let manifest = super::parse_manifest(default_toml)?;
Ok(Self {
manifest,
generated_enums: Arc::new(Mutex::new(Vec::new())),
})
}
}
fn reader_method(neutral_type: &str) -> &'static str {
match neutral_type {
"bool" => "GetBoolean",
"int16" => "GetInt16",
"int32" => "GetInt32",
"int64" => "GetInt64",
"float32" => "GetFloat",
"float64" => "GetDouble",
"string" | "json" | "inet" | "interval" => "GetString",
"uuid" => "GetGuid",
"decimal" => "GetDecimal",
"date" => "GetFieldValue<DateOnly>",
"time" | "time_tz" => "GetFieldValue<TimeOnly>",
"datetime" => "GetDateTime",
"datetime_tz" => "GetFieldValue<DateTimeOffset>",
_ => "GetValue",
}
}
fn column_read_expr(col: &ResolvedColumn, ordinal: usize) -> String {
if col.neutral_type.starts_with("enum::") {
format!(
"(Enum.TryParse<{typ}>(reader.GetString({ord}), true, out var enumVal{ord}) ? enumVal{ord} : throw new InvalidOperationException($\"Invalid enum value '{{reader.GetString({ord})}}' for {typ}\"))",
typ = col.lang_type,
ord = ordinal
)
} else {
let method = reader_method(&col.neutral_type);
format!("reader.{}({})", method, ordinal)
}
}
impl CodegenBackend for CsharpNpgsqlBackend {
fn name(&self) -> &str {
"csharp-npgsql"
}
fn manifest(&self) -> &scythe_backend::manifest::BackendManifest {
&self.manifest
}
fn supported_engines(&self) -> &[&str] {
&["postgresql", "redshift"]
}
fn file_header(&self) -> String {
"// Auto-generated by scythe. Do not edit.\n#nullable enable\n\nusing Npgsql;\n\npublic static class Queries {"
.to_string()
}
fn file_footer(&self) -> String {
"}".to_string()
}
fn post_footer(&self) -> String {
if let Ok(enums) = self.generated_enums.lock() {
if enums.is_empty() {
return String::new();
}
let mut out = String::new();
for (i, enum_info) in enums.iter().enumerate() {
if i > 0 {
out.push_str("\n\n");
}
let type_name = enum_type_name(&enum_info.sql_name, &self.manifest.naming);
let qualified_type = format!("Queries.{}", type_name);
let _ = writeln!(out, "public static class {}Extensions {{", type_name);
let _ = writeln!(
out,
" public static string ToDbValue(this {} value) => value switch {{",
qualified_type
);
for value in &enum_info.values {
let variant = enum_variant_name(value, &self.manifest.naming);
let _ = writeln!(out, " {}.{} => \"{}\",", qualified_type, variant, value);
}
let _ = writeln!(
out,
" _ => throw new ArgumentOutOfRangeException(nameof(value), value, null),"
);
let _ = writeln!(out, " }};");
let _ = write!(out, "}}");
}
out
} else {
String::new()
}
}
fn generate_row_struct(&self, query_name: &str, columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
let struct_name = row_struct_name(query_name, &self.manifest.naming);
let mut out = String::new();
let _ = writeln!(out, "public record {}(", struct_name);
for (i, c) in columns.iter().enumerate() {
let field = to_pascal_case(&c.field_name);
let sep = if i + 1 < columns.len() { "," } else { "" };
let _ = writeln!(out, " {} {}{}", c.full_type, field, sep);
}
let _ = write!(out, ");");
Ok(out)
}
fn generate_model_struct(&self, table_name: &str, columns: &[ResolvedColumn]) -> Result<String, ScytheError> {
let name = to_pascal_case(table_name);
self.generate_row_struct(&name, columns)
}
fn generate_query_fn(
&self,
analyzed: &AnalyzedQuery,
struct_name: &str,
columns: &[ResolvedColumn],
params: &[ResolvedParam],
) -> Result<String, ScytheError> {
let func_name = fn_name(&analyzed.name, &self.manifest.naming);
let mut sql = super::rewrite_pg_placeholders(
&super::clean_sql_oneline_with_optional(&analyzed.sql, &analyzed.optional_params, &analyzed.params),
|n| format!("@p{n}"),
);
for (i, p) in params.iter().enumerate() {
if let Some(enum_name) = p.neutral_type.strip_prefix("enum::") {
let placeholder = format!("@p{}", i + 1);
let casted = format!("@p{}::{}", i + 1, enum_name);
sql = sql.replace(&placeholder, &casted);
}
}
let mut out = String::new();
let param_list = params
.iter()
.map(|p| format!("{} {}", p.full_type, p.field_name))
.collect::<Vec<_>>()
.join(", ");
let sep = if param_list.is_empty() { "" } else { ", " };
if matches!(analyzed.command, QueryCommand::Batch) {
let batch_fn_name = format!("{}Batch", func_name);
if params.len() > 1 {
let params_record_name = format!("{}BatchParams", to_pascal_case(&analyzed.name));
let _ = writeln!(out, "public record {}(", params_record_name);
for (i, p) in params.iter().enumerate() {
let field = to_pascal_case(&p.field_name);
let sep = if i + 1 < params.len() { "," } else { "" };
let _ = writeln!(out, " {} {}{}", p.full_type, field, sep);
}
let _ = writeln!(out, ");");
let _ = writeln!(out);
let _ = writeln!(
out,
"public static async Task {}(NpgsqlConnection conn, List<{}> items) {{",
batch_fn_name, params_record_name
);
} else if params.len() == 1 {
let _ = writeln!(
out,
"public static async Task {}(NpgsqlConnection conn, List<{}> items) {{",
batch_fn_name, params[0].full_type
);
} else {
let _ = writeln!(
out,
"public static async Task {}(NpgsqlConnection conn, int count) {{",
batch_fn_name
);
}
let _ = writeln!(out, " await using var tx = await conn.BeginTransactionAsync();");
let _ = writeln!(out, " try {{");
if params.is_empty() {
let _ = writeln!(out, " for (int i = 0; i < count; i++) {{");
} else {
let _ = writeln!(out, " foreach (var item in items) {{");
}
let _ = writeln!(
out,
" await using var cmd = new NpgsqlCommand(\"{}\", conn, tx);",
sql
);
for (i, p) in params.iter().enumerate() {
let value_expr = if params.len() > 1 {
let field = to_pascal_case(&p.field_name);
if p.neutral_type.starts_with("enum::") {
format!("item.{}.ToDbValue()", field)
} else {
format!("item.{}", field)
}
} else if p.neutral_type.starts_with("enum::") {
"item.ToDbValue()".to_string()
} else {
"item".to_string()
};
let _ = writeln!(
out,
" cmd.Parameters.AddWithValue(\"p{}\", {});",
i + 1,
value_expr
);
}
let _ = writeln!(out, " await cmd.ExecuteNonQueryAsync();");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " await tx.CommitAsync();");
let _ = writeln!(out, " }} catch {{");
let _ = writeln!(out, " await tx.RollbackAsync();");
let _ = writeln!(out, " throw;");
let _ = writeln!(out, " }}");
let _ = write!(out, "}}");
return Ok(out);
}
let return_type = match &analyzed.command {
QueryCommand::One | QueryCommand::Opt => format!("{}?", struct_name),
QueryCommand::Many => format!("List<{}>", struct_name),
QueryCommand::Exec => "void".to_string(),
QueryCommand::ExecResult | QueryCommand::ExecRows => "int".to_string(),
QueryCommand::Batch => unreachable!(),
QueryCommand::Grouped => {
unreachable!("Grouped command is routed through generate_grouped_query_fn, not generate_query_fn")
}
};
let is_async_void = return_type == "void";
let task_type = if is_async_void {
"Task".to_string()
} else {
format!("Task<{}>", return_type)
};
let _ = writeln!(
out,
"public static async {} {}(NpgsqlConnection conn{}{}) {{",
task_type, func_name, sep, param_list
);
let _ = writeln!(out, " await using var cmd = new NpgsqlCommand(\"{}\", conn);", sql);
for (i, p) in params.iter().enumerate() {
let value_expr = if p.neutral_type.starts_with("enum::") {
format!("{}.ToDbValue()", p.field_name)
} else {
p.field_name.clone()
};
let _ = writeln!(out, " cmd.Parameters.AddWithValue(\"p{}\", {});", i + 1, value_expr);
}
match &analyzed.command {
QueryCommand::One | QueryCommand::Opt => {
let _ = writeln!(out, " await using var reader = await cmd.ExecuteReaderAsync();");
let _ = writeln!(out, " if (!await reader.ReadAsync()) return null;");
let _ = writeln!(out, " return new {}(", struct_name);
for (i, col) in columns.iter().enumerate() {
let expr = column_read_expr(col, i);
let sep = if i + 1 < columns.len() { "," } else { "" };
if col.nullable {
let _ = writeln!(out, " reader.IsDBNull({i}) ? null : {expr}{sep}");
} else {
let _ = writeln!(out, " {expr}{sep}");
}
}
let _ = writeln!(out, " );");
}
QueryCommand::Many => {
let _ = writeln!(out, " await using var reader = await cmd.ExecuteReaderAsync();");
let _ = writeln!(out, " var results = new List<{}>();", struct_name);
let _ = writeln!(out, " while (await reader.ReadAsync()) {{");
let _ = writeln!(out, " results.Add(new {}(", struct_name);
for (i, col) in columns.iter().enumerate() {
let expr = column_read_expr(col, i);
let sep = if i + 1 < columns.len() { "," } else { "" };
if col.nullable {
let _ = writeln!(out, " reader.IsDBNull({i}) ? null : {expr}{sep}");
} else {
let _ = writeln!(out, " {expr}{sep}");
}
}
let _ = writeln!(out, " ));");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " return results;");
}
QueryCommand::Exec => {
let _ = writeln!(out, " await cmd.ExecuteNonQueryAsync();");
}
QueryCommand::ExecResult | QueryCommand::ExecRows => {
let _ = writeln!(out, " return await cmd.ExecuteNonQueryAsync();");
}
QueryCommand::Batch => unreachable!(),
QueryCommand::Grouped => {
unreachable!("Grouped command is routed through generate_grouped_query_fn, not generate_query_fn")
}
}
let _ = write!(out, "}}");
Ok(out)
}
fn generate_grouped_structs(
&self,
parent_struct_name: &str,
child_struct_name: &str,
parent_columns: &[ResolvedColumn],
child_columns: &[ResolvedColumn],
_key_column: &str,
) -> Result<String, ScytheError> {
let mut out = String::new();
let _ = writeln!(out, "public record {}(", child_struct_name);
for (i, c) in child_columns.iter().enumerate() {
let field = to_pascal_case(&c.field_name);
let sep = if i + 1 < child_columns.len() { "," } else { "" };
let _ = writeln!(out, " {} {}{}", c.full_type, field, sep);
}
let _ = writeln!(out, ");");
let _ = writeln!(out);
let _ = writeln!(out, "public record {}(", parent_struct_name);
for c in parent_columns {
let field = to_pascal_case(&c.field_name);
let _ = writeln!(out, " {} {},", c.full_type, field);
}
let _ = writeln!(out, " List<{}> Children", child_struct_name);
let _ = write!(out, ");");
Ok(out)
}
fn generate_grouped_query_fn(&self, request: &GroupedQueryFn<'_>) -> Result<String, ScytheError> {
let analyzed = request.analyzed;
let parent_struct_name = request.parent_struct_name;
let child_struct_name = request.child_struct_name;
let all_columns = request.all_columns;
let parent_columns = request.parent_columns;
let child_columns = request.child_columns;
let params = request.params;
let key_column = request.key_column;
let func_name = fn_name(&analyzed.name, &self.manifest.naming);
let mut sql = super::rewrite_pg_placeholders(
&super::clean_sql_oneline_with_optional(&analyzed.sql, &analyzed.optional_params, &analyzed.params),
|n| format!("@p{n}"),
);
for (i, p) in params.iter().enumerate() {
if let Some(enum_name) = p.neutral_type.strip_prefix("enum::") {
let placeholder = format!("@p{}", i + 1);
let casted = format!("@p{}::{}", i + 1, enum_name);
sql = sql.replace(&placeholder, &casted);
}
}
let mut out = String::new();
let param_list = params
.iter()
.map(|p| format!("{} {}", p.full_type, p.field_name))
.collect::<Vec<_>>()
.join(", ");
let sep = if param_list.is_empty() { "" } else { ", " };
let key_col = parent_columns
.iter()
.find(|c| c.name == key_column)
.unwrap_or(&parent_columns[0]);
let key_type = &key_col.full_type;
let key_ordinal = all_columns.iter().position(|c| c.name == key_column).unwrap_or(0);
let _ = writeln!(
out,
"public static async Task<List<{parent_struct_name}>> {func_name}(NpgsqlConnection conn{sep}{param_list}) {{"
);
let _ = writeln!(out, " await using var cmd = new NpgsqlCommand(\"{sql}\", conn);");
for (i, p) in params.iter().enumerate() {
let value_expr = if p.neutral_type.starts_with("enum::") {
format!("{}.ToDbValue()", p.field_name)
} else {
p.field_name.clone()
};
let _ = writeln!(out, " cmd.Parameters.AddWithValue(\"p{}\", {});", i + 1, value_expr);
}
let _ = writeln!(out, " await using var reader = await cmd.ExecuteReaderAsync();");
let _ = writeln!(
out,
" var lookup = new Dictionary<{key_type}, {parent_struct_name}>();"
);
let _ = writeln!(out, " var result = new List<{parent_struct_name}>();");
let _ = writeln!(out, " while (await reader.ReadAsync()) {{");
let key_expr = column_read_expr(key_col, key_ordinal);
let _ = writeln!(out, " var key = {key_expr};");
let _ = writeln!(out, " var child = new {child_struct_name}(");
for (ci, col) in child_columns.iter().enumerate() {
let ord = all_columns
.iter()
.position(|c| c.name == col.name)
.unwrap_or(parent_columns.len() + ci);
let expr = column_read_expr(col, ord);
let trailing = if ci + 1 < child_columns.len() { "," } else { "" };
if col.nullable {
let _ = writeln!(out, " reader.IsDBNull({ord}) ? null : {expr}{trailing}");
} else {
let _ = writeln!(out, " {expr}{trailing}");
}
}
let _ = writeln!(out, " );");
let _ = writeln!(out, " if (lookup.TryGetValue(key, out var parent)) {{");
let _ = writeln!(out, " parent.Children.Add(child);");
let _ = writeln!(out, " }} else {{");
let _ = writeln!(out, " var newParent = new {parent_struct_name}(");
for col in parent_columns {
let ord = all_columns.iter().position(|c| c.name == col.name).unwrap_or(0);
let expr = column_read_expr(col, ord);
if col.nullable {
let _ = writeln!(out, " reader.IsDBNull({ord}) ? null : {expr},");
} else {
let _ = writeln!(out, " {expr},");
}
}
let _ = writeln!(out, " new List<{child_struct_name}> {{ child }}");
let _ = writeln!(out, " );");
let _ = writeln!(out, " lookup[key] = newParent;");
let _ = writeln!(out, " result.Add(newParent);");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " }}");
let _ = writeln!(out, " return result;");
let _ = write!(out, "}}");
Ok(out)
}
fn generate_enum_def(&self, enum_info: &EnumInfo) -> Result<String, ScytheError> {
let type_name = enum_type_name(&enum_info.sql_name, &self.manifest.naming);
let mut out = String::new();
let _ = writeln!(out, "public enum {} {{", type_name);
for value in &enum_info.values {
let variant = enum_variant_name(value, &self.manifest.naming);
let _ = writeln!(out, " {},", variant);
}
let _ = write!(out, "}}");
if let Ok(mut enums) = self.generated_enums.lock()
&& !enums.iter().any(|e| e.sql_name == enum_info.sql_name)
{
enums.push(enum_info.clone());
}
Ok(out)
}
fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
let name = to_pascal_case(&composite.sql_name);
let mut out = String::new();
if composite.fields.is_empty() {
let _ = writeln!(out, "public record {}();", name);
} else {
let _ = writeln!(out, "public record {}(", name);
for (i, field) in composite.fields.iter().enumerate() {
let cs_type = resolve_type(&field.neutral_type, &self.manifest, false)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "object".to_string());
let field_name = to_pascal_case(&field.name);
let sep = if i + 1 < composite.fields.len() { "," } else { "" };
let _ = writeln!(out, " {} {}{}", cs_type, field_name, sep);
}
let _ = write!(out, ");");
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, GroupByConfig};
use scythe_core::parser::QueryCommand;
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 {
name: "GetUsersWithOrders".to_string(),
command: QueryCommand::Grouped,
sql: "SELECT u.id, u.name, u.email, o.id AS order_id, o.total, o.created_at AS order_date\nFROM users u\nJOIN orders o ON o.user_id = u.id".to_string(),
columns: all_cols,
params: vec![],
deprecated: None,
source_table: None,
composites: vec![],
enums: vec![],
optional_params: vec![],
group_by: Some(GroupByConfig {
table: "users".to_string(),
key_column: "id".to_string(),
parent_columns: parent_cols,
child_columns: child_cols,
}),
custom: vec![],
}
}
#[test]
fn test_grouped_csharp_npgsql_structs() {
let backend = crate::backends::get_backend("csharp-npgsql", "postgresql").unwrap();
let query = make_grouped_query();
let result = crate::generate_with_backend(&query, &*backend).unwrap();
let row_struct = result.row_struct.as_deref().unwrap();
assert!(
row_struct.contains("public record GetUsersWithOrdersChildRow"),
"missing child record; got:\n{row_struct}"
);
assert!(
row_struct.contains("public record GetUsersWithOrdersRow"),
"missing parent record; got:\n{row_struct}"
);
assert!(
row_struct.contains("List<GetUsersWithOrdersChildRow> Children"),
"parent missing Children field; got:\n{row_struct}"
);
let child_pos = row_struct.find("public record GetUsersWithOrdersChildRow").unwrap();
let parent_pos = row_struct.find("public record GetUsersWithOrdersRow(").unwrap();
assert!(child_pos < parent_pos, "child must precede parent; got:\n{row_struct}");
assert!(result.model_struct.is_none(), "grouped must not produce model_struct");
}
#[test]
fn test_grouped_csharp_npgsql_query_fn() {
let backend = crate::backends::get_backend("csharp-npgsql", "postgresql").unwrap();
let query = make_grouped_query();
let result = crate::generate_with_backend(&query, &*backend).unwrap();
let query_fn = result.query_fn.as_deref().unwrap();
assert!(
query_fn.contains("Task<List<GetUsersWithOrdersRow>>"),
"wrong return type; got:\n{query_fn}"
);
assert!(
query_fn.contains("GetUsersWithOrders"),
"missing fn name; got:\n{query_fn}"
);
assert!(
query_fn.contains("NpgsqlConnection conn"),
"missing connection param; got:\n{query_fn}"
);
assert!(
query_fn.contains("Dictionary<"),
"must use Dictionary for fold lookup; got:\n{query_fn}"
);
assert!(
query_fn.contains("lookup.TryGetValue"),
"must fold with TryGetValue; got:\n{query_fn}"
);
assert!(
query_fn.contains("Children.Add(child)"),
"must append child; got:\n{query_fn}"
);
assert!(
query_fn.contains("return result;"),
"must return result; got:\n{query_fn}"
);
}
#[test]
fn test_grouped_csharp_npgsql_example55_print() {
let backend = crate::backends::get_backend("csharp-npgsql", "postgresql").unwrap();
let query = make_grouped_query();
let result = crate::generate_with_backend(&query, &*backend).unwrap();
println!(
"=== row_struct ===\n{}",
result.row_struct.as_deref().unwrap_or("(none)")
);
println!("=== query_fn ===\n{}", result.query_fn.as_deref().unwrap_or("(none)"));
}
}