use super::{DeriveOptions, HEADER, QueriesModule, queries, sql, write};
use crate::schema::{BaseType, Table, rust_type};
use crate::{Extractor, IdGeneration};
#[derive(Debug, Clone, Copy)]
pub struct DbMod<'a> {
pub has_types: bool,
pub has_session: bool,
pub queries: QueriesModule<'a>,
pub derives: DeriveOptions,
pub extractors: &'a [Extractor],
}
pub fn render_db_mod(tables: &[Table], db_mod: &DbMod<'_>) -> String {
let DbMod {
has_types,
has_session,
queries,
derives,
extractors,
} = *db_mod;
let mut types: Vec<String> = tables
.iter()
.flat_map(|table| &table.columns)
.map(|column| column.rust_type.clone())
.collect();
types.extend(queries::mentioned_types(queries.analyzed()));
let mut output =
format!("{HEADER}\n//! Typed database access for the schema, generated by rok-sqlc.\n\n");
output.push_str(
"// The generated code requires these dependencies in the consuming crate\n\
// (SQLx 0.9 needs Rust 1.94 or newer):\n",
);
let ids: Vec<(IdGeneration, BaseType)> = tables
.iter()
.flat_map(|table| &table.columns)
.filter_map(|column| Some((column.id_generation?, column.base_type())))
.collect();
output.push_str(&dependency_hints(&types, &ids, derives, extractors));
output.push('\n');
let mut modules: Vec<&str> = tables
.iter()
.map(|table| table.module_name.as_str())
.collect();
modules.push("schema");
modules.push("search");
if derives.serde {
modules.push("nullable");
}
if has_types {
modules.push("types");
}
if !extractors.is_empty() {
modules.push("http");
}
if has_session {
modules.push("session");
}
if queries.is_declared() {
modules.push("queries");
}
modules.sort_unstable();
for module in modules {
output.push_str(&format!("pub mod {module};\n"));
}
if tables.iter().any(write::has_versioned_update) {
output.push_str(UPDATE_OUTCOME);
}
output
}
const UPDATE_OUTCOME: &str = "
/// What an `update` on a table with a version column did.
#[must_use]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateOutcome<T> {
/// The row was at the expected version and is now updated, as stored.
Updated(T),
/// No row has the given primary key.
NotFound,
/// The row is at another version, so nothing changed.
VersionConflict,
}
";
fn dependency_hints(
types: &[String],
ids: &[(IdGeneration, BaseType)],
derives: DeriveOptions,
extractors: &[Extractor],
) -> String {
let serde = derives.serde;
let json_bodies = !extractors.is_empty();
let uses = |needle: &str| types.iter().any(|rust_type| rust_type.contains(needle));
let uses_uuid = uses("uuid::");
let uses_chrono = uses("chrono::") || uses(rust_type::TIME_TZ);
let uses_serde_json = uses("serde_json::");
let uses_rust_decimal = uses("rust_decimal::");
let uses_ipnetwork = uses("ipnetwork::");
let uses_mac_address = uses("mac_address::");
let mut features = String::from("\"runtime-tokio\", \"postgres\", \"derive\"");
if uses_uuid {
features.push_str(", \"uuid\"");
}
if uses_chrono {
features.push_str(", \"chrono\"");
}
if uses_serde_json {
features.push_str(", \"json\"");
}
if uses_rust_decimal {
features.push_str(", \"rust_decimal\"");
}
if uses_ipnetwork {
features.push_str(", \"ipnetwork\"");
}
if uses_mac_address {
features.push_str(", \"mac_address\"");
}
let uses_ids = |wanted: IdGeneration| ids.iter().any(|(ids, _)| *ids == wanted);
let uuid_features: Vec<&str> = [(IdGeneration::UuidV4, "v4"), (IdGeneration::UuidV7, "v7")]
.into_iter()
.filter(|(ids, _)| uses_ids(*ids))
.map(|(_, feature)| feature)
.collect();
let crate_hint = |name: &str, version: &str| crate_hint_with(name, version, serde, &[]);
let mut hints = format!("// sqlx = {{ version = \"0.9\", features = [{features}] }}\n");
if serde {
hints.push_str("// serde = { version = \"1\", features = [\"derive\"] }\n");
}
if uses_uuid {
hints.push_str(&crate_hint_with("uuid", "1", serde, &uuid_features));
}
if uses_chrono {
hints.push_str(&crate_hint("chrono", "0.4"));
}
if uses_serde_json || json_bodies {
hints.push_str("// serde_json = \"1\"\n");
}
if uses_rust_decimal {
hints.push_str(&crate_hint("rust_decimal", "1"));
}
if uses_ipnetwork {
hints.push_str(&crate_hint("ipnetwork", "0.21"));
}
if uses_mac_address {
hints.push_str(&crate_hint("mac_address", "1"));
}
if uses_ids(IdGeneration::Ulid) {
let on_uuid = ids
.iter()
.any(|(ids, base)| *ids == IdGeneration::Ulid && *base == BaseType::Uuid);
let features: &[&str] = if on_uuid { &["uuid"] } else { &[] };
hints.push_str(&crate_hint_with("ulid", "1", false, features));
}
if derives.validate {
hints.push_str("// validator = { version = \"0.21\", features = [\"derive\"] }\n");
}
if extractors.contains(&Extractor::Axum) {
hints.push_str("// axum = \"0.8\"\n// serde_path_to_error = \"0.1\"\n");
}
if extractors.contains(&Extractor::ActixWeb) {
hints.push_str("// actix-web = \"4\"\n");
}
hints
}
fn crate_hint_with(name: &str, version: &str, serde: bool, features: &[&str]) -> String {
let features: Vec<String> = features
.iter()
.copied()
.chain(serde.then_some("serde"))
.map(|feature| format!("\"{feature}\""))
.collect();
if features.is_empty() {
format!("// {name} = \"{version}\"\n")
} else {
format!(
"// {name} = {{ version = \"{version}\", features = [{}] }}\n",
features.join(", ")
)
}
}
pub fn render_schema_snapshot(schema_sql: &str) -> String {
let hashes = sql::raw_hashes(schema_sql);
format!(
"{HEADER}\n//! The schema the code was generated from.\n\n/// Verbatim snapshot of the input schema; never executed.\npub const SCHEMA_SQL: &str = r{hashes}\"{schema_sql}\"{hashes};\n"
)
}
#[cfg(test)]
mod tests {
use super::super::test_support::{column, table};
use super::*;
fn db_mod(tables: &[Table], queries: QueriesModule<'_>, serde: bool) -> String {
render_db_mod(
tables,
&DbMod {
has_types: false,
has_session: false,
queries,
derives: DeriveOptions {
serde,
validate: false,
},
extractors: &[],
},
)
}
#[test]
fn db_mod_lists_modules_and_conditional_dependencies() {
let tables = vec![table(vec![column("id", "uuid::Uuid")], &["id"])];
let rendered = db_mod(&tables, QueriesModule::None, false);
assert!(rendered.contains("pub mod users;"));
assert!(rendered.contains("pub mod schema;"));
assert!(rendered.contains("\"uuid\""));
assert!(!rendered.contains("\"chrono\""));
assert!(rendered.contains("uuid = \"1\""));
assert!(rendered.contains(
"// sqlx = { version = \"0.9\", features = [\"runtime-tokio\", \"postgres\", \"derive\", \"uuid\"] }"
));
assert!(rendered.contains("(SQLx 0.9 needs Rust 1.94 or newer)"));
}
#[test]
fn db_mod_declares_queries_when_generated_or_kept() {
let tables = vec![table(vec![column("id", "i64")], &["id"])];
assert!(!db_mod(&tables, QueriesModule::None, false).contains("pub mod queries;"));
assert!(
db_mod(&tables, QueriesModule::Keep, false)
.contains("pub mod queries;\npub mod schema;\npub mod search;\npub mod users;")
);
assert!(db_mod(&tables, QueriesModule::Generate(&[]), false).contains("pub mod queries;"));
}
#[test]
fn db_mod_declares_the_nullable_helper_with_serde() {
let tables = vec![table(vec![column("id", "i64")], &["id"])];
assert!(!db_mod(&tables, QueriesModule::None, false).contains("pub mod nullable;"));
assert!(
db_mod(&tables, QueriesModule::None, true)
.contains("pub mod nullable;\npub mod schema;\npub mod search;\npub mod users;")
);
}
#[test]
fn db_mod_hints_validator_with_validate() {
let tables = vec![table(vec![column("id", "i64")], &["id"])];
let hint = "// serde = { version = \"1\", features = [\"derive\"] }\n// validator = { version = \"0.21\", features = [\"derive\"] }\n";
let render = |validate| {
render_db_mod(
&tables,
&DbMod {
has_types: false,
has_session: false,
queries: QueriesModule::None,
derives: DeriveOptions {
serde: true,
validate,
},
extractors: &[],
},
)
};
assert!(render(true).contains(hint), "{}", render(true));
assert!(!render(false).contains("validator"));
}
#[test]
fn db_mod_hints_rust_decimal_when_used() {
let tables = vec![table(vec![column("id", "rust_decimal::Decimal")], &["id"])];
let rendered = db_mod(&tables, QueriesModule::None, false);
assert!(rendered.contains("\"rust_decimal\""));
assert!(rendered.contains("rust_decimal = \"1\""));
assert!(!rendered.contains("\"uuid\""));
}
#[test]
fn snapshot_escapes_raw_string_delimiters() {
let schema = "CREATE TABLE tricky (x TEXT, marker TEXT); -- contains \"# inside\n";
let rendered = render_schema_snapshot(schema);
assert!(rendered.contains("r##\""));
assert!(rendered.ends_with("\"##;\n"));
assert!(rendered.contains(schema));
}
#[test]
fn snapshot_uses_single_hash_when_safe() {
let rendered = render_schema_snapshot("CREATE TABLE plain (x TEXT);\n");
assert!(rendered.contains("r#\""));
assert!(rendered.ends_with("\"#;\n"));
}
}