use scythe_backend::naming::{fn_name, row_struct_name};
use scythe_codegen::resolve::{resolve_columns, resolve_params};
use scythe_codegen::{
CodegenBackend, GeneratedCode, RbsGenerationContext, RbsQueryInfo, generate_with_backend, get_backend,
};
use scythe_core::analyzer::{AnalyzedQuery, analyze};
use scythe_core::catalog::Catalog;
use scythe_core::dialect::SqlDialect;
use scythe_core::parser::{QueryCommand, parse_query_with_dialect};
const GROUPED_SCHEMA: [&str; 2] = [
"CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);",
"CREATE TABLE orders (\
id SERIAL PRIMARY KEY, \
user_id INT NOT NULL REFERENCES users (id), \
total DECIMAL(10, 2) NOT NULL, \
order_date TIMESTAMP NOT NULL\
);",
];
const GROUPED_QUERY: &str = "-- @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.order_date \
FROM users u JOIN orders o ON o.user_id = u.id;";
const DECIMAL_SCHEMA: &str = "CREATE TABLE orders (id SERIAL PRIMARY KEY, total DECIMAL(10, 2) NOT NULL);";
const DECIMAL_QUERY: &str = "-- @name GetOrder\n-- @returns :one\nSELECT id, total FROM orders WHERE id = $1;";
const NO_DECIMAL_QUERY: &str = "-- @name GetOrderId\n-- @returns :one\nSELECT id FROM orders WHERE id = $1;";
fn backend_for(name: &str, engine: &str) -> Box<dyn CodegenBackend> {
get_backend(name, engine).unwrap_or_else(|e| panic!("{name}/{engine} must construct: {e}"))
}
fn analyze_query(schema: &[&str], query: &str) -> AnalyzedQuery {
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");
analyze(&catalog, &parsed).expect("query must analyze")
}
fn grouped_rbs_context(analyzed: &AnalyzedQuery, backend: &dyn CodegenBackend) -> RbsGenerationContext {
let manifest = backend.manifest();
let group_by = analyzed.group_by.as_ref().expect("query must have @group_by");
let parent_cols = resolve_columns(&group_by.parent_columns, manifest, &[]).expect("parent columns resolve");
let child_cols = resolve_columns(&group_by.child_columns, manifest, &[]).expect("child columns resolve");
let params = resolve_params(&analyzed.params, manifest, &[], "").expect("params resolve");
RbsGenerationContext {
queries: vec![RbsQueryInfo {
func_name: fn_name(&analyzed.name, &manifest.naming),
struct_name: Some(row_struct_name(&analyzed.name, &manifest.naming)),
columns: parent_cols,
child_columns: child_cols,
params,
command: QueryCommand::Grouped,
}],
enums: vec![],
}
}
fn flat_rbs_context(analyzed: &AnalyzedQuery, backend: &dyn CodegenBackend) -> RbsGenerationContext {
let manifest = backend.manifest();
let columns = resolve_columns(&analyzed.columns, manifest, &[]).expect("columns resolve");
let params = resolve_params(&analyzed.params, manifest, &[], "").expect("params resolve");
RbsGenerationContext {
queries: vec![RbsQueryInfo {
func_name: fn_name(&analyzed.name, &manifest.naming),
struct_name: Some(row_struct_name(&analyzed.name, &manifest.naming)),
columns,
child_columns: Vec::new(),
params,
command: analyzed.command.clone(),
}],
enums: vec![],
}
}
fn full_rb_file(backend: &dyn CodegenBackend, code: &GeneratedCode) -> String {
let all = std::slice::from_ref(code);
let mut out = backend.file_header_for_results(all);
out.push('\n');
for text in [&code.enum_def, &code.model_struct, &code.row_struct, &code.query_fn]
.into_iter()
.flatten()
{
out.push_str(text);
out.push('\n');
}
out.push_str(&backend.file_footer());
out
}
#[test]
fn grouped_query_rbs_declares_child_before_parent_with_children_reader() {
let analyzed = analyze_query(&GROUPED_SCHEMA, GROUPED_QUERY);
for (backend_name, engine) in [("ruby-pg", "postgresql"), ("ruby-sqlite3", "sqlite")] {
let backend = backend_for(backend_name, engine);
let context = grouped_rbs_context(&analyzed, &*backend);
let rbs = backend
.generate_rbs_file(&context)
.unwrap_or_else(|| panic!("{backend_name} must emit RBS"));
assert!(
rbs.contains("class GetUsersWithOrdersChildRow"),
"{backend_name}: missing child class; got:\n{rbs}"
);
assert!(
rbs.contains("class GetUsersWithOrdersRow"),
"{backend_name}: missing parent class; got:\n{rbs}"
);
assert!(
rbs.contains("attr_reader children: Array[GetUsersWithOrdersChildRow]"),
"{backend_name}: parent class must declare a `children` reader typed to the child \
class; got:\n{rbs}"
);
assert!(
rbs.contains("def self.get_users_with_orders: ") && rbs.contains("-> Array[GetUsersWithOrdersRow]"),
"{backend_name}: query method must return `Array[<parent>]`; got:\n{rbs}"
);
let child_pos = rbs
.find("class GetUsersWithOrdersChildRow")
.expect("child class position");
let parent_pos = rbs.find("class GetUsersWithOrdersRow").expect("parent class position");
assert!(
child_pos < parent_pos,
"{backend_name}: child class must be defined before the parent class (avoids a \
forward reference); got:\n{rbs}"
);
}
}
#[test]
fn grouped_query_child_column_types_are_not_lost_in_the_split() {
let analyzed = analyze_query(&GROUPED_SCHEMA, GROUPED_QUERY);
let backend = backend_for("ruby-pg", "postgresql");
let context = grouped_rbs_context(&analyzed, &*backend);
let rbs = backend.generate_rbs_file(&context).expect("ruby-pg must emit RBS");
assert!(rbs.contains("attr_reader id: Integer"), "got:\n{rbs}");
assert!(rbs.contains("attr_reader name: String"), "got:\n{rbs}");
assert!(rbs.contains("attr_reader order_id: Integer"), "got:\n{rbs}");
assert!(rbs.contains("attr_reader total: BigDecimal"), "got:\n{rbs}");
assert!(rbs.contains("attr_reader order_date:"), "got:\n{rbs}");
}
#[test]
fn pg_mysql2_and_trilogy_decimal_columns_coerce_to_bigdecimal_matching_the_rbs() {
let analyzed = analyze_query(&[DECIMAL_SCHEMA], DECIMAL_QUERY);
for (backend_name, engine) in [
("ruby-pg", "postgresql"),
("ruby-mysql2", "mysql"),
("ruby-trilogy", "mysql"),
] {
let backend = backend_for(backend_name, engine);
let code = generate_with_backend(&analyzed, &*backend).expect("codegen must succeed");
let query_fn = code.query_fn.as_deref().expect("query fn");
assert!(
query_fn.contains(".to_d"),
"{backend_name}: `total` (decimal -> BigDecimal) must be coerced with `.to_d`; got:\n{query_fn}"
);
let full_rb = full_rb_file(&*backend, &code);
assert!(
full_rb.contains("require \"bigdecimal/util\""),
"{backend_name}: a file whose generated code calls `.to_d` must require \
`bigdecimal/util`; got:\n{full_rb}"
);
let rbs_context = flat_rbs_context(&analyzed, &*backend);
let rbs = backend.generate_rbs_file(&rbs_context).expect("must emit RBS");
assert!(
rbs.contains("attr_reader total: BigDecimal"),
"{backend_name}: `.rbs` must declare `total` as `BigDecimal`, matching the `.rb` \
coercion above; got:\n{rbs}"
);
assert!(
!rbs.contains("library "),
"{backend_name}: an `.rbs` must not carry a `library` directive -- it is not RBS \
declaration syntax and makes the file unparseable; got:\n{rbs}"
);
}
}
#[test]
fn sqlite3_decimal_column_coerces_to_float_never_bigdecimal() {
let analyzed = analyze_query(&[DECIMAL_SCHEMA], DECIMAL_QUERY);
let backend = backend_for("ruby-sqlite3", "sqlite");
let code = generate_with_backend(&analyzed, &*backend).expect("codegen must succeed");
let query_fn = code.query_fn.as_deref().expect("query fn");
assert!(
query_fn.contains(".to_f"),
"sqlite3: `total` (decimal -> Float) must be coerced with `.to_f`; got:\n{query_fn}"
);
assert!(
!query_fn.contains(".to_d"),
"sqlite3 never declares BigDecimal, so it must never coerce with `.to_d`; got:\n{query_fn}"
);
let rbs_context = flat_rbs_context(&analyzed, &*backend);
let rbs = backend.generate_rbs_file(&rbs_context).expect("must emit RBS");
assert!(
rbs.contains("attr_reader total: Float"),
"sqlite3: `.rbs` must declare `total` as `Float`, matching the `.rb` coercion above; got:\n{rbs}"
);
assert!(
!rbs.contains("BigDecimal"),
"sqlite3 never declares BigDecimal; got:\n{rbs}"
);
}
#[test]
fn bigdecimal_directives_are_not_emitted_for_a_query_with_no_decimal_column() {
let analyzed = analyze_query(&[DECIMAL_SCHEMA], NO_DECIMAL_QUERY);
let backend = backend_for("ruby-pg", "postgresql");
let code = generate_with_backend(&analyzed, &*backend).expect("codegen must succeed");
let full_rb = full_rb_file(&*backend, &code);
assert!(
!full_rb.contains("bigdecimal"),
"a query selecting no decimal column must not require bigdecimal/util; got:\n{full_rb}"
);
let rbs_context = flat_rbs_context(&analyzed, &*backend);
let rbs = backend.generate_rbs_file(&rbs_context).expect("must emit RBS");
assert!(
!rbs.contains("BigDecimal"),
"a query selecting no decimal column must not reference BigDecimal in its `.rbs`; got:\n{rbs}"
);
}