use scythe_codegen::validation::{ToolValidation, strict_mode_enabled, validate_with_tools};
use scythe_codegen::{CodegenBackend, GeneratedCode, generate_with_backend, get_backend};
use scythe_core::analyzer::analyze;
use scythe_core::catalog::Catalog;
use scythe_core::dialect::SqlDialect;
use scythe_core::parser::parse_query_with_dialect;
const SCHEMA: &str = "CREATE TYPE inner_probe AS (x INTEGER, y INTEGER); \
CREATE TYPE outer_probe AS (label TEXT, pt inner_probe); \
CREATE TABLE things (id SERIAL PRIMARY KEY, val outer_probe NOT NULL);";
const QUERY: &str = "-- @name GetThing\n-- @returns :one\nSELECT id, val FROM things WHERE id = $1;";
fn backend_for(name: &str) -> Box<dyn CodegenBackend> {
get_backend(name, "postgresql").expect("backend must support postgresql")
}
fn generate(backend: &dyn CodegenBackend) -> GeneratedCode {
let catalog = Catalog::from_ddl_with_dialect(&[SCHEMA], &SqlDialect::PostgreSQL).expect("schema must parse");
let parsed = parse_query_with_dialect(QUERY, &SqlDialect::PostgreSQL).expect("query must parse");
let analyzed = analyze(&catalog, &parsed).expect("query must analyze");
generate_with_backend(&analyzed, backend).expect("codegen must succeed")
}
fn generated_model_struct(backend_name: &str) -> String {
generate(&*backend_for(backend_name))
.model_struct
.expect("composite columns must produce a model_struct")
}
fn generated_query_fn(backend_name: &str) -> String {
generate(&*backend_for(backend_name))
.query_fn
.expect("query must produce a query_fn")
}
fn generated_file(backend_name: &str) -> String {
let backend = backend_for(backend_name);
let code = generate(&*backend);
let all = std::slice::from_ref(&code);
let mut body = backend.file_header_for_results(all);
body.push('\n');
for text in [&code.enum_def, &code.model_struct, &code.row_struct]
.into_iter()
.flatten()
{
body.push_str(text);
body.push('\n');
}
let class_header = backend.query_class_header();
if !class_header.is_empty() {
body.push_str(&class_header);
body.push('\n');
}
if let Some(query_fn) = &code.query_fn {
body.push_str(query_fn);
body.push('\n');
}
let footer = backend.file_footer();
if !footer.is_empty() {
body.push_str(&footer);
body.push('\n');
}
scythe_codegen::provenance::assemble_file(
&backend.file_preamble(),
&scythe_codegen::provenance::header_line(
&*backend,
env!("CARGO_PKG_VERSION"),
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
),
&body,
)
}
fn assert_file_compiles(backend_name: &str, file: String) {
let validation = validate_with_tools(&file, backend_name);
assert_ne!(
validation,
ToolValidation::Unsupported,
"{backend_name} lost its tool validator; the compile half of this test is now vacuous"
);
for tool in validation.tools_run() {
eprintln!(" {backend_name}: `{tool}` ran against the generated file");
}
for tool in validation.missing_tools() {
eprintln!(" {backend_name}: `{tool}` is not on PATH -- the compile half went unchecked");
}
if strict_mode_enabled() {
assert!(
validation.fully_checked(),
"{backend_name}: tool validation reports nothing actually checked the code"
);
}
if let Err(errors) = validation.into_result() {
panic!("{backend_name} tool validation: {errors:?}\n\nGenerated file:\n{file}");
}
}
#[test]
fn both_elixir_backends_emit_from_tuple_and_route_composite_columns_through_it() {
for backend in ["elixir-postgrex", "elixir-ecto"] {
let model_struct = generated_model_struct(backend);
assert!(
model_struct.contains("defmodule InnerProbe do"),
"{backend}: missing InnerProbe; got:\n{model_struct}"
);
assert!(
model_struct.contains("defmodule OuterProbe do"),
"{backend}: missing OuterProbe; got:\n{model_struct}"
);
assert!(
model_struct.contains("def from_tuple(nil), do: nil"),
"{backend}: from_tuple must be nil-safe; got:\n{model_struct}"
);
assert!(
model_struct.contains("pt: InnerProbe.from_tuple(pt)"),
"{backend}: OuterProbe.from_tuple must recurse into the nested InnerProbe \
composite rather than assign its still-a-tuple field raw; got:\n{model_struct}"
);
let query_fn = generated_query_fn(backend);
assert!(
query_fn.contains("val: OuterProbe.from_tuple(val)"),
"{backend}: the `val` column must be routed through OuterProbe.from_tuple, not \
assigned as the bare destructured tuple; got:\n{query_fn}"
);
assert!(
!query_fn.contains("val: val"),
"{backend}: must not assign the raw Postgrex tuple straight into the struct field; \
got:\n{query_fn}"
);
}
}
#[test]
fn the_emitted_elixir_from_tuple_recovers_nested_composite_values() {
for backend in ["elixir-postgrex", "elixir-ecto"] {
let model_struct = generated_model_struct(backend);
let script = format!(
"{model_struct}\n\n\
populated = OuterProbe.from_tuple({{\"hi\", {{1, 2}}}})\n\
nested_nil = OuterProbe.from_tuple({{\"hi2\", nil}})\n\
top_nil = OuterProbe.from_tuple(nil)\n\
\n\
true = populated.label == \"hi\"\n\
true = populated.pt.__struct__ == InnerProbe\n\
true = populated.pt.x == 1\n\
true = populated.pt.y == 2\n\
true = nested_nil.label == \"hi2\"\n\
true = nested_nil.pt == nil\n\
true = top_nil == nil\n\
\n\
IO.puts(\"OK\")\n"
);
let dir = std::env::temp_dir().join("scythe_composite_from_tuple_probe");
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join(format!("{}.exs", backend.replace('-', "_")));
std::fs::write(&path, &script).expect("write probe");
let output = match std::process::Command::new("elixir").arg(&path).output() {
Ok(output) => output,
Err(e) => {
eprintln!(
"SKIP: elixir unavailable ({e}); {backend}'s from_tuple was checked by \
string match only"
);
continue;
}
};
assert!(
output.status.success(),
"{backend}: the emitted from_tuple must run and recover the expected values: {}\n\
script:\n{script}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"OK",
"{backend}: from_tuple output mismatch"
);
}
}
#[test]
fn elixir_postgrex_composite_from_tuple_file_compiles() {
assert_file_compiles("elixir-postgrex", generated_file("elixir-postgrex"));
}
#[test]
fn elixir_ecto_composite_from_tuple_file_compiles() {
assert_file_compiles("elixir-ecto", generated_file("elixir-ecto"));
}