use std::fmt::Write;
use scythe_backend::manifest::BackendManifest;
use scythe_backend::naming::{
NamingConfig, composite_type_name, enum_type_name, enum_variant_name, fn_name, to_pascal_case,
};
use scythe_backend::types::resolve_type;
use scythe_core::analyzer::{AnalyzedQuery, CompositeFieldInfo, CompositeInfo, EnumInfo};
use scythe_core::errors::{ErrorCode, ScytheError};
use scythe_core::parser::QueryCommand;
use crate::GeneratedCode;
use crate::backend_trait::{CodegenBackend, ResolvedColumn, ResolvedParam};
use crate::backends::go_common::go_file_header;
const DEFAULT_MANIFEST_TOML: &str = include_str!("../../manifests/go-pgx.toml");
const DEFAULT_MANIFEST_REDSHIFT: &str = include_str!("../../manifests/go-pgx.redshift.toml");
const PGX_HEADER_PREAMBLE: &str =
"// Code generated by scythe. DO NOT EDIT.\n// Run `goimports -w .` to fix imports.\npackage queries";
const GO_PARSE_COMPOSITE_FIELDS_FUNC: &str = r#"func parseCompositeFields(text string) []*string {
inner := text[1 : len(text)-1]
var fields []*string
i := 0
n := len(inner)
for {
var field []byte
isNull := false
if i < n && inner[i] == '"' {
i++
for i < n {
c := inner[i]
if c == '\\' && i + 1 < n {
field = append(field, inner[i+1])
i += 2
} else if c == '"' && i + 1 < n && inner[i+1] == '"' {
field = append(field, '"')
i += 2
} else if c == '"' {
i++
break
} else {
field = append(field, c)
i++
}
}
} else {
start := i
for i < n && inner[i] != ',' {
i++
}
field = append(field, inner[start:i]...)
isNull = len(field) == 0
}
if isNull {
fields = append(fields, nil)
} else {
s := string(field)
fields = append(fields, &s)
}
if i < n && inner[i] == ',' {
i++
continue
}
break
}
return fields
}"#;
const GO_ENCODE_COMPOSITE_FIELD_FUNC: &str = r#"type pgCompositeTextEncoder interface {
ToPgText() string
}
func encodeCompositeField(value any) string {
if value == nil {
return ""
}
var raw string
if nested, ok := value.(pgCompositeTextEncoder); ok {
raw = nested.ToPgText()
} else {
raw = fmt.Sprint(value)
}
if raw != "" && !strings.ContainsAny(raw, ",()\"\\") && raw == strings.TrimSpace(raw) {
return raw
}
escaped := strings.ReplaceAll(strings.ReplaceAll(raw, "\\", "\\\\"), "\"", "\"\"")
return "\"" + escaped + "\""
}"#;
fn go_pgx_param_expr(param: &ResolvedParam, raw: &str) -> String {
if !param.neutral_type.starts_with("composite::") {
return raw.to_string();
}
if param.nullable {
format!("func() *string {{ if {raw} == nil {{ return nil }}; text := {raw}.ToPgText(); return &text }}()")
} else {
format!("{raw}.ToPgText()")
}
}
fn composite_field_go_pgx_supported(neutral_type: &str) -> bool {
if neutral_type.starts_with("enum::") || neutral_type.starts_with("composite::") {
return true;
}
matches!(
neutral_type,
"bool"
| "int16"
| "int32"
| "int64"
| "float32"
| "float64"
| "string"
| "bytes"
| "uuid"
| "decimal"
| "date"
| "time"
| "time_tz"
| "datetime"
| "datetime_tz"
| "json"
| "inet"
)
}
fn composite_field_conversion(
field: &CompositeFieldInfo,
field_go_type: &str,
composite_display: &str,
index: usize,
naming: &NamingConfig,
) -> Vec<String> {
let assign = format!("v.{}", to_pascal_case(&field.name));
if let Some(sql_name) = field.neutral_type.strip_prefix("composite::") {
let expr = format!("{}FromText(raw)", composite_type_name(sql_name, naming));
return go_pgx_fallible_conversion(&assign, &expr, composite_display, &field.name, index, None);
}
if field.neutral_type.starts_with("enum::") {
return vec![format!("{assign} = {field_go_type}(raw)")];
}
match field.neutral_type.as_str() {
"bool" => vec![format!("{assign} = raw == \"t\"")],
"string" => vec![format!("{assign} = raw")],
"json" => vec![format!("{assign} = json.RawMessage(raw)")],
"int16" => go_pgx_fallible_conversion(
&assign,
"strconv.ParseInt(raw, 10, 16)",
composite_display,
&field.name,
index,
Some("int16"),
),
"int32" => go_pgx_fallible_conversion(
&assign,
"strconv.ParseInt(raw, 10, 32)",
composite_display,
&field.name,
index,
Some("int32"),
),
"int64" => go_pgx_fallible_conversion(
&assign,
"strconv.ParseInt(raw, 10, 64)",
composite_display,
&field.name,
index,
None,
),
"float32" => go_pgx_fallible_conversion(
&assign,
"strconv.ParseFloat(raw, 32)",
composite_display,
&field.name,
index,
Some("float32"),
),
"float64" => go_pgx_fallible_conversion(
&assign,
"strconv.ParseFloat(raw, 64)",
composite_display,
&field.name,
index,
None,
),
"uuid" => go_pgx_fallible_conversion(&assign, "uuid.Parse(raw)", composite_display, &field.name, index, None),
"decimal" => go_pgx_fallible_conversion(
&assign,
"decimal.NewFromString(raw)",
composite_display,
&field.name,
index,
None,
),
"date" => go_pgx_fallible_conversion(
&assign,
"time.Parse(\"2006-01-02\", raw)",
composite_display,
&field.name,
index,
None,
),
"time" => go_pgx_fallible_conversion(
&assign,
"time.Parse(\"15:04:05.999999\", raw)",
composite_display,
&field.name,
index,
None,
),
"datetime" => go_pgx_fallible_conversion(
&assign,
"time.Parse(\"2006-01-02 15:04:05.999999\", raw)",
composite_display,
&field.name,
index,
None,
),
"time_tz" => {
go_pgx_offset_time_conversion(&assign, "15:04:05.999999Z07:00", composite_display, &field.name, index)
}
"datetime_tz" => go_pgx_offset_time_conversion(
&assign,
"2006-01-02T15:04:05.999999Z07:00",
composite_display,
&field.name,
index,
),
"bytes" => go_pgx_bytes_conversion(&assign, composite_display, &field.name, index),
"inet" => go_pgx_inet_conversion(&assign, composite_display, &field.name, index),
_ => vec![format!("{assign} = raw")],
}
}
fn go_pgx_fallible_conversion(
assign: &str,
expr: &str,
composite_display: &str,
field_name: &str,
index: usize,
cast: Option<&str>,
) -> Vec<String> {
let value = cast.map_or_else(|| "parsed".to_string(), |cast| format!("{cast}(parsed)"));
vec![
format!("parsed, err := {expr}"),
"if err != nil {".to_string(),
format!("\treturn zero, fmt.Errorf(\"{composite_display}: field {index} ({field_name}): %w\", err)"),
"}".to_string(),
format!("{assign} = {value}"),
]
}
fn go_pgx_offset_time_conversion(
assign: &str,
layout: &str,
composite_display: &str,
field_name: &str,
index: usize,
) -> Vec<String> {
let mut out = vec!["raw = strings.Replace(raw, \" \", \"T\", 1)".to_string()];
out.push("if n := len(raw); n >= 3 && (raw[n-3] == '+' || raw[n-3] == '-') {".to_string());
out.push("\traw += \":00\"".to_string());
out.push("}".to_string());
out.extend(go_pgx_fallible_conversion(
assign,
&format!("time.Parse(\"{layout}\", raw)"),
composite_display,
field_name,
index,
None,
));
out
}
fn go_pgx_bytes_conversion(assign: &str, composite_display: &str, field_name: &str, index: usize) -> Vec<String> {
let mut out = vec![
"if len(raw) < 2 || raw[0] != '\\\\' || raw[1] != 'x' {".to_string(),
format!(
"\treturn zero, fmt.Errorf(\"{composite_display}: field {index} ({field_name}): expected \
hex-encoded bytea\")"
),
"}".to_string(),
];
out.extend(go_pgx_fallible_conversion(
assign,
"hex.DecodeString(raw[2:])",
composite_display,
field_name,
index,
None,
));
out
}
fn go_pgx_inet_conversion(assign: &str, composite_display: &str, field_name: &str, index: usize) -> Vec<String> {
vec![
"var parsed netip.Addr".to_string(),
"var err error".to_string(),
"if strings.Contains(raw, \"/\") {".to_string(),
"\tvar prefix netip.Prefix".to_string(),
"\tprefix, err = netip.ParsePrefix(raw)".to_string(),
"\tparsed = prefix.Addr()".to_string(),
"} else {".to_string(),
"\tparsed, err = netip.ParseAddr(raw)".to_string(),
"}".to_string(),
"if err != nil {".to_string(),
format!("\treturn zero, fmt.Errorf(\"{composite_display}: field {index} ({field_name}): %w\", err)"),
"}".to_string(),
format!("{assign} = parsed"),
]
}
struct GoPgxColumnScan {
raw_decl: Option<String>,
scan_target: String,
conversion: Vec<String>,
}
fn go_pgx_plan_column_scan(
col: &ResolvedColumn,
assign_target: &str,
err_return: &str,
naming: &NamingConfig,
) -> GoPgxColumnScan {
let Some(sql_name) = col.neutral_type.strip_prefix("composite::") else {
return GoPgxColumnScan {
raw_decl: None,
scan_target: format!("&{assign_target}"),
conversion: Vec::new(),
};
};
let raw_var = format!("{}Raw", col.field_name);
let from_text_fn = format!("{}FromText", composite_type_name(sql_name, naming));
if col.nullable {
GoPgxColumnScan {
raw_decl: Some(format!("var {raw_var} *string")),
scan_target: format!("&{raw_var}"),
conversion: vec![
format!("if {raw_var} != nil {{"),
format!("\tparsed, err := {from_text_fn}(*{raw_var})"),
"\tif err != nil {".to_string(),
format!("\t\treturn {err_return}"),
"\t}".to_string(),
format!("\t{assign_target} = &parsed"),
"}".to_string(),
],
}
} else {
GoPgxColumnScan {
raw_decl: Some(format!("var {raw_var} string")),
scan_target: format!("&{raw_var}"),
conversion: vec![
"{".to_string(),
format!("\tparsed, err := {from_text_fn}({raw_var})"),
"\tif err != nil {".to_string(),
format!("\t\treturn {err_return}"),
"\t}".to_string(),
format!("\t{assign_target} = parsed"),
"}".to_string(),
],
}
}
}
struct GoPgxScanPlan {
raw_decls: Vec<String>,
scan_targets: Vec<String>,
conversions: Vec<String>,
}
fn go_pgx_plan_scan(
columns: &[ResolvedColumn],
assign_target: impl Fn(&ResolvedColumn) -> String,
err_return: &str,
indent: &str,
naming: &NamingConfig,
) -> GoPgxScanPlan {
let mut plan = GoPgxScanPlan {
raw_decls: Vec::new(),
scan_targets: Vec::new(),
conversions: Vec::new(),
};
for col in columns {
let target = assign_target(col);
let column_scan = go_pgx_plan_column_scan(col, &target, err_return, naming);
if let Some(decl) = column_scan.raw_decl {
plan.raw_decls.push(format!("{indent}{decl}"));
}
plan.scan_targets.push(column_scan.scan_target);
for line in column_scan.conversion {
plan.conversions.push(format!("{indent}{line}"));
}
}
plan
}
pub struct GoPgxBackend {
manifest: BackendManifest,
nested_aggregates: bool,
}
impl GoPgxBackend {
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::InvalidConfig,
format!("go-pgx only supports PostgreSQL/Redshift, got engine '{}'", engine),
));
}
};
let manifest = super::parse_manifest(default_toml)?;
Ok(Self {
manifest,
nested_aggregates: super::engine_supports_nested_aggregates(engine),
})
}
fn generate_composite_from_text(&self, composite: &CompositeInfo, name: &str, field_go_types: &[String]) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"// ~keep pgx registers no CompositeCodec for {name} without a runtime type-map"
);
let _ = writeln!(
out,
"// registration this generated code has no connection to perform (see board #221)."
);
let _ = writeln!(out, "// Parse the driver's composite text form instead.");
let _ = writeln!(out, "func {name}FromText(text string) ({name}, error) {{");
let _ = writeln!(out, "\tvar zero {name}");
if let Some(unsupported) = composite
.fields
.iter()
.find(|f| !composite_field_go_pgx_supported(&f.neutral_type))
{
let _ = writeln!(
out,
"\treturn zero, fmt.Errorf(\"{name}: field %q has type %q, which generated FromText does not \
support\", \"{}\", \"{}\")",
unsupported.name, unsupported.neutral_type
);
let _ = write!(out, "}}");
return out;
}
let _ = writeln!(out, "\tf := parseCompositeFields(text)");
let _ = writeln!(out, "\tif len(f) < {} {{", composite.fields.len());
let _ = writeln!(
out,
"\t\treturn zero, fmt.Errorf(\"{name}: expected {} fields, got %d\", len(f))",
composite.fields.len()
);
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\tvar v {name}");
for (index, (field, go_type)) in composite.fields.iter().zip(field_go_types).enumerate() {
let _ = writeln!(out, "\tif f[{index}] == nil {{");
let _ = writeln!(
out,
"\t\treturn zero, fmt.Errorf(\"{name}: field {index} ({}) is NULL\")",
field.name
);
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\t{{");
let _ = writeln!(out, "\t\traw := *f[{index}]");
for line in composite_field_conversion(field, go_type, name, index, &self.manifest.naming) {
let _ = writeln!(out, "\t\t{line}");
}
let _ = writeln!(out, "\t}}");
}
let _ = writeln!(out, "\treturn v, nil");
let _ = write!(out, "}}");
out
}
}
impl CodegenBackend for GoPgxBackend {
fn name(&self) -> &str {
"go-pgx"
}
fn manifest(&self) -> &scythe_backend::manifest::BackendManifest {
&self.manifest
}
fn manifest_mut(&mut self) -> &mut scythe_backend::manifest::BackendManifest {
&mut self.manifest
}
fn supported_engines(&self) -> &[&str] {
&["postgresql", "redshift"]
}
fn file_header(&self) -> String {
go_file_header(
PGX_HEADER_PREAMBLE,
&["context"],
&["github.com/jackc/pgx/v5/pgxpool"],
&self.manifest,
&[],
)
}
fn file_header_for_results(&self, generated: &[GeneratedCode]) -> String {
let mut third_party_imports = vec!["github.com/jackc/pgx/v5/pgxpool"];
if super::go_common::generated_code_uses_prefix(generated, "pgx.ErrNoRows") {
third_party_imports.push("github.com/jackc/pgx/v5");
}
let mut header = go_file_header(
PGX_HEADER_PREAMBLE,
&["context"],
&third_party_imports,
&self.manifest,
generated,
);
if super::go_common::generated_code_uses_prefix(generated, "parseCompositeFields(") {
header.push('\n');
header.push_str(GO_PARSE_COMPOSITE_FIELDS_FUNC);
}
if super::go_common::generated_code_uses_prefix(generated, "encodeCompositeField(") {
header.push('\n');
header.push_str(GO_ENCODE_COMPOSITE_FIELD_FUNC);
}
header
}
fn generate_struct_decl(
&self,
struct_name: &str,
_query_name: &str,
columns: &[ResolvedColumn],
) -> Result<String, ScytheError> {
let mut out = String::new();
let _ = writeln!(out, "type {} struct {{", struct_name);
for col in columns {
let field = to_pascal_case(&col.field_name);
let json_tag = &col.field_name;
let _ = writeln!(out, "\t{} {} `json:\"{}\"`", field, col.full_type, json_tag);
}
let _ = write!(out, "}}");
Ok(out)
}
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 cleaned_sql =
super::clean_sql_oneline_with_optional(&analyzed.sql, &analyzed.optional_params, &analyzed.params);
let (rewritten_sql, _) =
super::rewrite_placeholders_indexed(&cleaned_sql, scythe_core::SqlDialect::PostgreSQL, |position| {
let placeholder = format!("${position}");
let param = super::resolved_param_for_position(&analyzed.params, params, position);
param
.neutral_type
.strip_prefix("composite::")
.map_or(placeholder.clone(), |sql_type| {
format!("{placeholder}::text::{sql_type}")
})
});
let sql = crate::sql_literal::escape_go_interpreted_string(&rewritten_sql);
let param_list = params
.iter()
.map(|p| {
let field = to_pascal_case(&p.field_name);
format!("{} {}", field, p.full_type)
})
.collect::<Vec<_>>()
.join(", ");
let sep = if param_list.is_empty() { "" } else { ", " };
let args = params
.iter()
.map(|p| go_pgx_param_expr(p, &to_pascal_case(&p.field_name)))
.collect::<Vec<_>>();
let mut out = String::new();
match &analyzed.command {
QueryCommand::Exec => {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) error {{",
func_name, sep, param_list
);
let args_str = if args.is_empty() {
String::new()
} else {
format!(", {}", args.join(", "))
};
let _ = writeln!(out, "\t_, err := db.Exec(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\treturn err");
let _ = write!(out, "}}");
}
QueryCommand::ExecResult | QueryCommand::ExecRows => {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) (int64, error) {{",
func_name, sep, param_list
);
let args_str = if args.is_empty() {
String::new()
} else {
format!(", {}", args.join(", "))
};
let _ = writeln!(out, "\tresult, err := db.Exec(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\tif err != nil {{");
let _ = writeln!(out, "\t\treturn 0, err");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn result.RowsAffected(), nil");
let _ = write!(out, "}}");
}
QueryCommand::One => {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) ({}, error) {{",
func_name, sep, param_list, struct_name
);
let args_str = if args.is_empty() {
String::new()
} else {
format!(", {}", args.join(", "))
};
let _ = writeln!(out, "\trow := db.QueryRow(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\tvar r {}", struct_name);
let plan = go_pgx_plan_scan(
columns,
|c| format!("r.{}", to_pascal_case(&c.field_name)),
"r, err",
"\t",
&self.manifest.naming,
);
for decl in &plan.raw_decls {
let _ = writeln!(out, "{decl}");
}
let _ = writeln!(out, "\terr := row.Scan({})", plan.scan_targets.join(", "));
if plan.conversions.is_empty() {
let _ = writeln!(out, "\treturn r, err");
} else {
let _ = writeln!(out, "\tif err != nil {{");
let _ = writeln!(out, "\t\treturn r, err");
let _ = writeln!(out, "\t}}");
for line in &plan.conversions {
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out, "\treturn r, nil");
}
let _ = write!(out, "}}");
}
QueryCommand::Opt => {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) (*{}, error) {{",
func_name, sep, param_list, struct_name
);
let args_str = if args.is_empty() {
String::new()
} else {
format!(", {}", args.join(", "))
};
let _ = writeln!(out, "\trow := db.QueryRow(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\tvar r {}", struct_name);
let plan = go_pgx_plan_scan(
columns,
|c| format!("r.{}", to_pascal_case(&c.field_name)),
"nil, err",
"\t",
&self.manifest.naming,
);
for decl in &plan.raw_decls {
let _ = writeln!(out, "{decl}");
}
let _ = writeln!(
out,
"\tif err := row.Scan({}); err != nil {{",
plan.scan_targets.join(", ")
);
let _ = writeln!(out, "\t\tif err == pgx.ErrNoRows {{");
let _ = writeln!(out, "\t\t\treturn nil, nil");
let _ = writeln!(out, "\t\t}}");
let _ = writeln!(out, "\t\treturn nil, err");
let _ = writeln!(out, "\t}}");
for line in &plan.conversions {
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out, "\treturn &r, nil");
let _ = write!(out, "}}");
}
QueryCommand::Many => {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) ([]{}, error) {{",
func_name, sep, param_list, struct_name
);
let args_str = if args.is_empty() {
String::new()
} else {
format!(", {}", args.join(", "))
};
let _ = writeln!(out, "\trows, err := db.Query(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\tif err != nil {{");
let _ = writeln!(out, "\t\treturn nil, err");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\tdefer rows.Close()");
let _ = writeln!(out, "\tvar result []{}", struct_name);
let _ = writeln!(out, "\tfor rows.Next() {{");
let _ = writeln!(out, "\t\tvar r {}", struct_name);
let plan = go_pgx_plan_scan(
columns,
|c| format!("r.{}", to_pascal_case(&c.field_name)),
"nil, err",
"\t\t",
&self.manifest.naming,
);
for decl in &plan.raw_decls {
let _ = writeln!(out, "{decl}");
}
let _ = writeln!(
out,
"\t\tif err := rows.Scan({}); err != nil {{",
plan.scan_targets.join(", ")
);
let _ = writeln!(out, "\t\t\treturn nil, err");
let _ = writeln!(out, "\t\t}}");
for line in &plan.conversions {
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out, "\t\tresult = append(result, r)");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn result, rows.Err()");
let _ = write!(out, "}}");
}
QueryCommand::Batch => {
let batch_fn_name = format!("{}Batch", func_name);
if params.len() > 1 {
let params_struct_name = format!("{}BatchParams", func_name);
let _ = writeln!(out, "type {} struct {{", params_struct_name);
for p in params {
let field = to_pascal_case(&p.field_name);
let _ = writeln!(out, "\t{} {}", field, p.full_type);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool, items []{}) error {{",
batch_fn_name, params_struct_name
);
} else if params.len() == 1 {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool, items []{}) error {{",
batch_fn_name, params[0].full_type
);
} else {
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool, count int) error {{",
batch_fn_name
);
}
let _ = writeln!(out, "\ttx, err := db.Begin(ctx)");
let _ = writeln!(out, "\tif err != nil {{");
let _ = writeln!(out, "\t\treturn err");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\tdefer tx.Rollback(ctx)");
if params.is_empty() {
let _ = writeln!(out, "\tfor i := 0; i < count; i++ {{");
let _ = writeln!(out, "\t\t_, err := tx.Exec(ctx, \"{}\")", sql);
} else {
let _ = writeln!(out, "\tfor _, item := range items {{");
if params.len() > 1 {
let item_args: Vec<String> = params
.iter()
.map(|p| go_pgx_param_expr(p, &format!("item.{}", to_pascal_case(&p.field_name))))
.collect();
let _ = writeln!(out, "\t\t_, err := tx.Exec(ctx, \"{}\", {})", sql, item_args.join(", "));
} else {
let item = go_pgx_param_expr(¶ms[0], "item");
let _ = writeln!(out, "\t\t_, err := tx.Exec(ctx, \"{}\", {})", sql, item);
}
}
let _ = writeln!(out, "\t\tif err != nil {{");
let _ = writeln!(out, "\t\t\treturn err");
let _ = writeln!(out, "\t\t}}");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn tx.Commit(ctx)");
let _ = write!(out, "}}");
}
QueryCommand::Grouped => {
unreachable!("Grouped is handled by generate_grouped_query_fn, not generate_query_fn")
}
}
Ok(out)
}
fn generate_grouped_structs(
&self,
parent_struct_name: &str,
child_struct_name: &str,
parent_columns: &[crate::backend_trait::ResolvedColumn],
child_columns: &[crate::backend_trait::ResolvedColumn],
_key_column: &str,
) -> Result<String, ScytheError> {
let mut out = String::new();
let _ = writeln!(out, "type {} struct {{", child_struct_name);
for col in child_columns {
let field = to_pascal_case(&col.field_name);
let _ = writeln!(out, "\t{} {} `json:\"{}\"`", field, col.full_type, col.field_name);
}
let _ = writeln!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out, "type {} struct {{", parent_struct_name);
for col in parent_columns {
let field = to_pascal_case(&col.field_name);
let _ = writeln!(out, "\t{} {} `json:\"{}\"`", field, col.full_type, col.field_name);
}
let _ = writeln!(out, "\tChildren []{} `json:\"children\"`", child_struct_name);
let _ = write!(out, "}}");
Ok(out)
}
fn generate_grouped_query_fn(
&self,
request: &crate::backend_trait::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 cleaned_sql =
super::clean_sql_oneline_with_optional(&analyzed.sql, &analyzed.optional_params, &analyzed.params);
let (rewritten_sql, _) =
super::rewrite_placeholders_indexed(&cleaned_sql, scythe_core::SqlDialect::PostgreSQL, |position| {
let placeholder = format!("${position}");
let param = super::resolved_param_for_position(&analyzed.params, params, position);
param
.neutral_type
.strip_prefix("composite::")
.map_or(placeholder.clone(), |sql_type| {
format!("{placeholder}::text::{sql_type}")
})
});
let sql = crate::sql_literal::escape_go_interpreted_string(&rewritten_sql);
let param_list = params
.iter()
.map(|p| format!("{} {}", to_pascal_case(&p.field_name), p.full_type))
.collect::<Vec<_>>()
.join(", ");
let sep = if param_list.is_empty() { "" } else { ", " };
let args_str = if params.is_empty() {
String::new()
} else {
let args: Vec<String> = params
.iter()
.map(|p| go_pgx_param_expr(p, &to_pascal_case(&p.field_name)))
.collect();
format!(", {}", args.join(", "))
};
let key_go_type = parent_columns
.iter()
.find(|c| c.name == key_column || c.field_name == key_column)
.map(|c| c.full_type.as_str())
.unwrap_or("int64");
let mut out = String::new();
let _ = writeln!(
out,
"func {}(ctx context.Context, db *pgxpool.Pool{}{}) ([]{}, error) {{",
func_name, sep, param_list, parent_struct_name
);
let _ = writeln!(out, "\trows, err := db.Query(ctx, \"{}\"{})", sql, args_str);
let _ = writeln!(out, "\tif err != nil {{");
let _ = writeln!(out, "\t\treturn nil, err");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\tdefer rows.Close()");
let _ = writeln!(out, "\tvar result []{}", parent_struct_name);
let _ = writeln!(out, "\tindex := make(map[{}]*{})", key_go_type, parent_struct_name);
let _ = writeln!(out, "\tfor rows.Next() {{");
for col in all_columns {
let _ = writeln!(out, "\t\tvar {} {}", col.field_name, col.full_type);
}
let plan = go_pgx_plan_scan(
all_columns,
|c| c.field_name.clone(),
"nil, err",
"\t\t",
&self.manifest.naming,
);
for decl in &plan.raw_decls {
let _ = writeln!(out, "{decl}");
}
let _ = writeln!(
out,
"\t\tif err := rows.Scan({}); err != nil {{",
plan.scan_targets.join(", ")
);
let _ = writeln!(out, "\t\t\treturn nil, err");
let _ = writeln!(out, "\t\t}}");
for line in &plan.conversions {
let _ = writeln!(out, "{line}");
}
let _ = writeln!(out, "\t\tchild := {} {{", child_struct_name);
for col in child_columns {
let _ = writeln!(out, "\t\t\t{}: {},", to_pascal_case(&col.field_name), col.field_name);
}
let _ = writeln!(out, "\t\t}}");
let _ = writeln!(out, "\t\tif parent, ok := index[{}]; ok {{", key_column);
let _ = writeln!(out, "\t\t\tparent.Children = append(parent.Children, child)");
let _ = writeln!(out, "\t\t}} else {{");
let _ = writeln!(out, "\t\t\tresult = append(result, {} {{", parent_struct_name);
for col in parent_columns {
let _ = writeln!(out, "\t\t\t\t{}: {},", to_pascal_case(&col.field_name), col.field_name);
}
let _ = writeln!(out, "\t\t\t\tChildren: []{}{{child}},", child_struct_name);
let _ = writeln!(out, "\t\t\t}})");
let _ = writeln!(out, "\t\t\tindex[{}] = &result[len(result)-1]", key_column);
let _ = writeln!(out, "\t\t}}");
let _ = writeln!(out, "\t}}");
let _ = writeln!(out, "\treturn result, rows.Err()");
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, "type {} string", type_name);
let _ = writeln!(out);
let _ = writeln!(out, "const (");
for value in &enum_info.values {
let variant = enum_variant_name(value, &self.manifest.naming);
let _ = writeln!(out, "\t{}{} {} = \"{}\"", type_name, variant, type_name, value);
}
let _ = write!(out, ")");
Ok(out)
}
fn generate_composite_def(&self, composite: &CompositeInfo) -> Result<String, ScytheError> {
let name = composite_type_name(&composite.sql_name, &self.manifest.naming);
let mut out = String::new();
let _ = writeln!(out, "type {} struct {{", name);
let field_go_types: Vec<String> = composite
.fields
.iter()
.map(|field| {
resolve_type(&field.neutral_type, &self.manifest, false)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "any".to_string())
})
.collect();
for (field, go_type) in composite.fields.iter().zip(&field_go_types) {
let field_name = to_pascal_case(&field.name);
let _ = writeln!(out, "\t{} {} `json:\"{}\"`", field_name, go_type, field.name);
}
let _ = write!(out, "}}");
if !composite.fields.is_empty() {
let encoded_fields = composite
.fields
.iter()
.map(|field| format!("encodeCompositeField(v.{})", to_pascal_case(&field.name)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out);
let _ = writeln!(out);
let _ = writeln!(out, "func (v {}) ToPgText() string {{", name);
let _ = writeln!(
out,
"\treturn \"(\" + strings.Join([]string{{{encoded_fields}}}, \",\") + \")\""
);
let _ = write!(out, "}}");
let _ = writeln!(out);
let _ = writeln!(out);
out.push_str(&self.generate_composite_from_text(composite, &name, &field_go_types));
}
Ok(out)
}
fn generate_nested_struct_def(
&self,
nested: &scythe_core::analyzer::NestedStructInfo,
) -> Result<Option<String>, ScytheError> {
if !self.nested_aggregates {
return Ok(None);
}
let name = to_pascal_case(&nested.name);
let mut out = String::new();
let _ = writeln!(out, "type {} struct {{", name);
for field in &nested.fields {
let field_name = to_pascal_case(&field.name);
let go_type = resolve_type(&field.neutral_type, &self.manifest, field.nullable)
.map(|t| t.into_owned())
.unwrap_or_else(|_| "any".to_string());
let json_tag = &field.name;
let _ = writeln!(out, "\t{} {} `json:\"{}\"`", field_name, go_type, json_tag);
}
let _ = write!(out, "}}");
Ok(Some(out))
}
}
#[cfg(test)]
mod tests {
use scythe_core::analyzer::{AnalyzedColumn, AnalyzedQuery, GroupByConfig};
use scythe_core::parser::QueryCommand;
use crate::backends::get_backend;
use crate::generate_with_backend;
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_go_pgx_structs() {
let backend = get_backend("go-pgx", "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("type GetUsersWithOrdersChildRow struct"),
"missing child struct; got:\n{row_struct}"
);
assert!(
row_struct.contains("OrderId"),
"child struct missing OrderId field; got:\n{row_struct}"
);
assert!(
row_struct.contains("type GetUsersWithOrdersRow struct"),
"missing parent struct; got:\n{row_struct}"
);
assert!(
row_struct.contains("Id"),
"parent struct missing Id field; got:\n{row_struct}"
);
assert!(
row_struct.contains("Name"),
"parent struct missing Name field; got:\n{row_struct}"
);
assert!(
row_struct.contains("Children []GetUsersWithOrdersChildRow"),
"parent struct missing Children field; got:\n{row_struct}"
);
let child_pos = row_struct.find("GetUsersWithOrdersChildRow").unwrap();
let parent_pos = row_struct.find("type GetUsersWithOrdersRow struct").unwrap();
assert!(
child_pos < parent_pos,
"child struct must be defined before parent struct"
);
assert!(result.model_struct.is_none(), "grouped must not produce a model_struct");
}
#[test]
fn test_grouped_go_pgx_query_fn() {
let backend = get_backend("go-pgx", "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("func GetUsersWithOrders("),
"missing function; got:\n{query_fn}"
);
assert!(
query_fn.contains("[]GetUsersWithOrdersRow, error)"),
"wrong return type; got:\n{query_fn}"
);
assert!(
query_fn.contains("db.Query(ctx,"),
"must use db.Query; got:\n{query_fn}"
);
assert!(
query_fn.contains("index := make(map["),
"must declare index map; got:\n{query_fn}"
);
assert!(
query_fn.contains("parent.Children = append(parent.Children, child)"),
"must fold child into existing parent; got:\n{query_fn}"
);
assert!(
query_fn.contains("Children: []GetUsersWithOrdersChildRow{child}"),
"must initialize Children slice; got:\n{query_fn}"
);
assert!(
query_fn.contains("rows.Err()"),
"must return rows.Err(); got:\n{query_fn}"
);
}
fn make_simple_query(name: &str, sql: &str, columns: Vec<AnalyzedColumn>) -> AnalyzedQuery {
AnalyzedQuery::build(|aq| {
aq.name = name.to_string();
aq.command = QueryCommand::Many;
aq.sql = sql.to_string();
aq.columns = columns;
aq.params = vec![];
})
}
#[test]
fn test_go_pgx_file_header_omits_time_and_decimal_for_int_and_string_only_schema() {
let backend = get_backend("go-pgx", "postgresql").unwrap();
let query = make_simple_query(
"ListWidgets",
"-- @name ListWidgets\n-- @returns :many\nSELECT id, name FROM widgets",
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()
},
],
);
let code = generate_with_backend(&query, &*backend).unwrap();
let header = backend.file_header_for_results(&[code]);
assert!(
!header.contains("\"time\""),
"int/string-only schema must not import \"time\"; got:\n{header}"
);
assert!(
!header.contains("shopspring/decimal"),
"int/string-only schema must not import shopspring/decimal; got:\n{header}"
);
assert!(header.contains("\"context\""), "got:\n{header}");
assert!(header.contains("pgxpool"), "got:\n{header}");
}
#[test]
fn test_go_pgx_file_header_includes_time_and_decimal_when_columns_need_them() {
let backend = get_backend("go-pgx", "postgresql").unwrap();
let query = make_simple_query(
"ListOrders",
"-- @name ListOrders\n-- @returns :many\nSELECT id, total, created_at FROM orders",
vec![
AnalyzedColumn {
name: "id".to_string(),
neutral_type: "int32".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "total".to_string(),
neutral_type: "decimal".to_string(),
nullable: false,
..Default::default()
},
AnalyzedColumn {
name: "created_at".to_string(),
neutral_type: "datetime".to_string(),
nullable: false,
..Default::default()
},
],
);
let code = generate_with_backend(&query, &*backend).unwrap();
let header = backend.file_header_for_results(&[code]);
assert!(header.contains("\"time\""), "got:\n{header}");
assert!(header.contains("shopspring/decimal"), "got:\n{header}");
}
}