pub mod conventions;
mod joins;
pub mod keys;
pub mod models;
pub mod queries;
pub mod read;
pub mod relationships;
pub mod search;
pub mod session;
pub mod sql;
pub mod types;
pub mod write;
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use crate::queries::AnalyzedQuery;
use crate::schema::{BaseType, Column, EnumType, Table, mentions_generated_type};
use crate::{Config, Conventions, Derive};
const MAX_WIDTH: usize = 100;
const HEADER: &str = "// @generated by rok-sqlc. Do not edit by hand.\n";
pub fn push_serde_derive(output: &mut String, serde: bool) {
if !serde {
return;
}
let without_newline = output.strip_suffix('\n').unwrap_or(output);
let start = without_newline.rfind('\n').map_or(0, |index| index + 1);
let derives = output[start..]
.trim_end()
.strip_prefix("#[derive(")
.and_then(|rest| rest.strip_suffix(")]"))
.expect("serde derives follow a `#[derive(...)]` line")
.to_owned();
output.truncate(start);
let derives = format!("{derives}, serde::Serialize, serde::Deserialize");
let one_line = format!("#[derive({derives})]");
let packed = format!(" {derives},");
if one_line.len() < MAX_WIDTH - 1 {
output.push_str(&one_line);
output.push('\n');
} else if packed.len() < MAX_WIDTH {
output.push_str(&format!("#[derive(\n{packed}\n)]\n"));
} else {
output.push_str("#[derive(\n");
for derive in derives.split(", ") {
output.push_str(&format!(" {derive},\n"));
}
output.push_str(")]\n");
}
}
#[derive(Debug, Clone, Copy)]
pub enum QueriesModule<'a> {
None,
Generate(&'a [AnalyzedQuery]),
GenerateModules(&'a [QueryModule]),
Keep,
}
#[derive(Debug, Clone)]
pub struct QueryModule {
pub name: String,
pub queries: Vec<AnalyzedQuery>,
}
impl QueriesModule<'_> {
fn is_declared(self) -> bool {
!matches!(self, QueriesModule::None)
}
}
#[cfg(test)]
fn plan_files(schema_sql: &str, tables: &[Table], output: &Path) -> Vec<(PathBuf, String)> {
let config = Config {
output: output.to_path_buf(),
..Config::default()
};
plan_files_with_queries(&config, schema_sql, tables, &[], QueriesModule::None)
.expect("the tables generate")
}
pub fn prepare(
tables: &[Table],
conventions: &Conventions,
changes: &relationships::Changes,
scope: relationships::Scope,
) -> Result<Vec<Table>, String> {
let mut prepared = keys::resolve(tables);
conventions::apply(&mut prepared, conventions);
relationships::infer(&mut prepared, changes, scope)?;
Ok(prepared)
}
pub fn plan_files_with_queries(
config: &Config,
schema_sql: &str,
tables: &[Table],
enums: &[EnumType],
queries: QueriesModule<'_>,
) -> Result<Vec<(PathBuf, String)>, String> {
let (conventions, output) = (&config.conventions, config.output.as_path());
let serde = config.derive.contains(&Derive::Serde);
let mut files = Vec::new();
let resolved = prepare(
tables,
conventions,
&config.relationships,
relationships::Scope::Run,
)?;
let tables = resolved.as_slice();
for table in tables {
let dir = output.join(&table.module_name);
let filter = search::render_filter(table, tables);
let loaders = relationships::render(table, tables, serde);
files.push((
dir.join("mod.rs"),
render_resource_mod(table, filter.is_some(), loaders.is_some()),
));
files.push((dir.join("models.rs"), models::render(table, serde)));
if let Some(filter) = filter {
files.push((dir.join("filter.rs"), filter));
}
files.push((dir.join("read.rs"), read::render(table, tables)));
if let Some(loaders) = loaders {
files.push((dir.join("relationships.rs"), loaders));
}
files.push((dir.join("write.rs"), write::render(table)));
}
match queries {
QueriesModule::Generate(analyzed) => {
files.push((
output.join("queries.rs"),
queries::render(analyzed, tables, serde),
));
}
QueriesModule::GenerateModules(modules) => {
let dir = output.join("queries");
for module in modules {
files.push((
dir.join(format!("{}.rs", module.name)),
queries::render_nested(&module.queries, tables, serde),
));
}
files.push((dir.join("mod.rs"), queries::render_mod(modules)));
}
QueriesModule::None | QueriesModule::Keep => {}
}
files.push((output.join("search.rs"), search::render_shared()));
if !enums.is_empty() {
files.push((output.join("types.rs"), types::render(enums, serde)));
}
let session = session::render(conventions);
let has_session = session.is_some();
if let Some(session) = session {
files.push((output.join("session.rs"), session));
}
files.push((
output.join("mod.rs"),
render_db_mod(tables, !enums.is_empty(), has_session, queries, serde),
));
files.push((output.join("schema.rs"), render_schema_snapshot(schema_sql)));
Ok(files)
}
fn render_resource_mod(table: &Table, has_filter: bool, has_relationships: bool) -> String {
let mut output = format!("{HEADER}\n//! The `{}` table.\n\n", table.display_name());
if has_filter {
output.push_str("/// The `search` filter and sort keys.\npub mod filter;\n");
}
output.push_str(
"/// The row type, its key, and the inputs `create` and `update` take.\n\
pub mod models;\n\
/// Lookups, listing, counting, and search.\n\
pub mod read;\n",
);
if has_relationships {
output.push_str(
"/// Loaders fetching each relationship for many rows in one query.\n\
pub mod relationships;\n",
);
}
output.push_str("/// `create`, `update`, and `delete`.\npub mod write;\n");
output
}
pub fn insert_field_type(column: &Column) -> String {
if column.is_omittable_on_insert() {
format!("Option<{}>", column.field_type())
} else {
column.field_type()
}
}
pub fn update_field_type(column: &Column) -> String {
format!("Option<{}>", column.field_type())
}
const RESERVED_LOCALS: &[&str] = &[
"executor",
"input",
"query",
"columns",
"values",
"assignments",
"value",
"result",
"limit",
"offset",
"expected_version",
"rows",
"keys",
"loaded",
"related",
];
pub fn param_name(column: &Column) -> String {
if RESERVED_LOCALS.contains(&column.rust_name.as_str()) {
format!("{}_key", column.rust_name)
} else {
column.rust_name.clone()
}
}
pub fn param_type(column: &Column) -> String {
if column.base_type() == BaseType::Text {
match &column.typed_id {
Some(id) => format!("&{}", id.name),
None => "&str".to_owned(),
}
} else {
column.value_type().to_owned()
}
}
pub fn use_list(prefix: &str, names: &[String]) -> String {
if let [name] = names {
return format!("use {prefix}::{name};\n");
}
let one_line = format!("use {prefix}::{{{}}};", names.join(", "));
if one_line.len() < MAX_WIDTH - 1 {
return format!("{one_line}\n");
}
let mut output = format!("use {prefix}::{{\n");
let mut line = String::new();
for name in names {
if !line.is_empty() && line.len() + 1 + name.len() + 1 > MAX_WIDTH - 1 {
output.push_str(&line);
output.push('\n');
line.clear();
}
if line.is_empty() {
line.push_str(&format!(" {name},"));
} else {
line.push_str(&format!(" {name},"));
}
}
output.push_str(&line);
output.push_str("\n};\n");
output
}
pub fn use_paths(paths: &[String]) -> String {
let mut sorted: Vec<&String> = paths.iter().collect();
sorted.sort_by_key(|path| {
path.split("::")
.map(|segment| (segment != "super", segment.to_owned()))
.collect::<Vec<_>>()
});
sorted.dedup();
sorted.iter().map(|path| format!("use {path};\n")).collect()
}
pub fn types_import<'a>(db_path: &str, rust_types: impl IntoIterator<Item = &'a str>) -> String {
if rust_types.into_iter().any(mentions_generated_type) {
format!("use {db_path}::types;\n")
} else {
String::new()
}
}
pub fn doc_name(sql_name: &str) -> String {
format!("`{}`", sql_name.replace(['\r', '\n'], " "))
}
pub fn push_doc(output: &mut String, indent: usize, lines: &[&str]) {
let pad = " ".repeat(indent);
for line in lines {
if line.is_empty() {
output.push_str(&format!("{pad}///\n"));
} else {
output.push_str(&format!("{pad}/// {line}\n"));
}
}
}
pub fn push_fn_head(output: &mut String, name: &str, params: &[String], returns: &str) {
let mut all_params = vec!["executor: E".to_owned()];
all_params.extend(params.iter().cloned());
let open = format!("pub async fn {name}<'e, E>(");
let close = format!(") -> {returns}");
let one_line = format!("{open}{}{close}", all_params.join(", "));
if one_line.len() <= MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
} else {
output.push_str(&open);
output.push('\n');
for param in &all_params {
output.push_str(&format!(" {param},\n"));
}
output.push_str(&close);
output.push('\n');
}
output.push_str("where\n E: sqlx::PgExecutor<'e>,\n{\n");
}
pub fn push_and_chain(
output: &mut String,
indent: usize,
lead: &str,
terms: &[String],
trail: &str,
) {
let pad = " ".repeat(indent);
let one_line = format!("{pad}{lead}{}{trail}", terms.join(" && "));
if one_line.len() <= MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
return;
}
output.push_str(&format!("{pad}{lead}{}\n", terms[0]));
for term in &terms[1..] {
output.push_str(&format!("{pad} && {term}\n"));
}
let trail = trail.trim_start();
if !trail.is_empty() {
output.push_str(&format!("{pad}{trail}\n"));
}
}
pub fn push_let_call(
output: &mut String,
indent: usize,
binding: &str,
call: &str,
argument: &str,
) {
let pad = " ".repeat(indent);
let one_line = format!("{pad}{binding} = {call}({argument});");
let below = format!("{pad} {call}({argument});");
if one_line.len() <= MAX_WIDTH {
output.push_str(&one_line);
output.push('\n');
} else if below.len() <= MAX_WIDTH {
output.push_str(&format!("{pad}{binding} =\n{below}\n"));
} else {
output.push_str(&format!(
"{pad}{binding} = {call}(\n{pad} {argument},\n{pad});\n"
));
}
}
pub struct StaticQuery<'a> {
pub call: &'a str,
pub sql: Vec<String>,
pub binds: Vec<String>,
pub execute: &'a str,
}
impl StaticQuery<'_> {
pub fn push(&self, output: &mut String, indent: usize, lead: &str, trail: &str) {
let pad = " ".repeat(indent);
output.push_str(&format!("{pad}{lead}{}(\n", self.call));
output.push_str(&sql::raw_block(&self.sql, indent + 4));
output.push_str(",\n");
output.push_str(&format!("{pad})\n"));
for bind in &self.binds {
output.push_str(&format!("{pad}.bind({bind})\n"));
}
output.push_str(&format!("{pad}.{}\n", self.execute));
output.push_str(&format!("{pad}.await{trail}\n"));
}
}
pub fn push_build_and_fetch(output: &mut String, table: &Table, fetch: &str) {
let calls = [
format!(".build_query_as::<{}>()", table.struct_name),
format!(".{fetch}(executor)"),
".await".to_owned(),
];
let one_line = format!("query{}", calls.concat());
if one_line.len() <= 60 {
output.push_str(&format!(" {one_line}\n"));
} else {
output.push_str(" query\n");
for call in calls {
output.push_str(&format!(" {call}\n"));
}
}
}
pub fn render_db_mod(
tables: &[Table],
has_types: bool,
has_session: bool,
queries: QueriesModule<'_>,
serde: bool,
) -> String {
let mut types: Vec<String> = tables
.iter()
.flat_map(|table| &table.columns)
.map(|column| column.rust_type.clone())
.collect();
match queries {
QueriesModule::Generate(analyzed) => types.extend(queries::mentioned_types(analyzed)),
QueriesModule::GenerateModules(modules) => {
for module in modules {
types.extend(queries::mentioned_types(&module.queries));
}
}
QueriesModule::None | QueriesModule::Keep => {}
}
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",
);
output.push_str(&dependency_hints(&types, serde));
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 has_types {
modules.push("types");
}
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], serde: bool) -> String {
let uses = |needle: &str| types.iter().any(|rust_type| rust_type.contains(needle));
let uses_uuid = uses("uuid::");
let uses_chrono = uses("chrono::");
let uses_serde_json = uses("serde_json::");
let uses_rust_decimal = uses("rust_decimal::");
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\"");
}
let crate_hint = |name: &str, version: &str| {
if serde {
format!("// {name} = {{ version = \"{version}\", features = [\"serde\"] }}\n")
} else {
format!("// {name} = \"{version}\"\n")
}
};
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("uuid", "1"));
}
if uses_chrono {
hints.push_str(&crate_hint("chrono", "0.4"));
}
if uses_serde_json {
hints.push_str("// serde_json = \"1\"\n");
}
if uses_rust_decimal {
hints.push_str(&crate_hint("rust_decimal", "1"));
}
hints
}
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)]
pub(crate) mod test_support {
use crate::schema::{Column, Table};
pub fn column(sql_name: &str, rust_type: &str) -> Column {
Column {
sql_name: sql_name.to_owned(),
rust_name: sql_name.to_owned(),
rust_type: rust_type.to_owned(),
sqlx_type: rust_type.to_owned(),
nullable: false,
is_primary_key: false,
has_default: false,
is_generated: false,
value_list: None,
typed_id: None,
is_managed: false,
}
}
pub fn nullable(sql_name: &str, base_type: &str) -> Column {
Column {
rust_type: format!("Option<{base_type}>"),
sqlx_type: base_type.to_owned(),
nullable: true,
..column(sql_name, base_type)
}
}
pub fn defaulted(mut column: Column) -> Column {
column.has_default = true;
column
}
pub fn generated(mut column: Column) -> Column {
column.has_default = true;
column.is_generated = true;
column
}
pub fn table(mut columns: Vec<Column>, primary_key: &[&str]) -> Table {
for column in &mut columns {
column.is_primary_key = primary_key.contains(&column.sql_name.as_str());
}
Table {
schema: None,
sql_name: "users".to_owned(),
module_name: "users".to_owned(),
struct_name: "User".to_owned(),
columns,
primary_key: primary_key.iter().map(|key| (*key).to_owned()).collect(),
foreign_keys: Vec::new(),
unique_keys: Vec::new(),
key_constraints: Vec::new(),
version_column: None,
soft_delete_column: None,
tenant_column: None,
relationships: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{column, table};
use super::*;
#[test]
fn plans_resource_and_root_files() {
let tables = vec![table(vec![column("id", "i64")], &["id"])];
let files = plan_files("CREATE TABLE users (id BIGINT);", &tables, Path::new("out"));
let paths: Vec<_> = files.iter().map(|(p, _)| p.clone()).collect();
assert!(paths.contains(&Path::new("out").join("users").join("mod.rs")));
assert!(paths.contains(&Path::new("out").join("users").join("models.rs")));
assert!(paths.contains(&Path::new("out").join("users").join("read.rs")));
assert!(paths.contains(&Path::new("out").join("users").join("write.rs")));
assert!(paths.contains(&Path::new("out").join("mod.rs")));
assert!(paths.contains(&Path::new("out").join("schema.rs")));
}
#[test]
fn db_mod_lists_modules_and_conditional_dependencies() {
let tables = vec![table(vec![column("id", "uuid::Uuid")], &["id"])];
let rendered = render_db_mod(&tables, false, false, 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!(
!render_db_mod(&tables, false, false, QueriesModule::None, false)
.contains("pub mod queries;")
);
assert!(
render_db_mod(&tables, false, false, QueriesModule::Keep, false).contains(
"pub mod queries;
pub mod schema;
pub mod search;
pub mod users;"
)
);
assert!(
render_db_mod(&tables, false, false, QueriesModule::Generate(&[]), false)
.contains("pub mod queries;")
);
}
#[test]
fn db_mod_hints_rust_decimal_when_used() {
let tables = vec![table(vec![column("id", "rust_decimal::Decimal")], &["id"])];
let rendered = render_db_mod(&tables, false, false, QueriesModule::None, false);
assert!(rendered.contains("\"rust_decimal\""));
assert!(rendered.contains("rust_decimal = \"1\""));
assert!(!rendered.contains("\"uuid\""));
}
#[test]
fn module_files_are_documented() {
let users = table(vec![column("id", "i64")], &["id"]);
let files = plan_files(
"CREATE TABLE users (id BIGINT);",
&[users],
Path::new("out"),
);
let file = |path: &[&str]| {
let wanted = path
.iter()
.fold(Path::new("out").to_path_buf(), |p, s| p.join(s));
files
.iter()
.find(|(p, _)| *p == wanted)
.map(|(_, text)| text.clone())
.unwrap()
};
assert!(file(&["mod.rs"]).contains("\n//! "));
assert!(file(&["schema.rs"]).contains("\n//! "));
assert!(file(&["schema.rs"]).contains("\n/// "));
let resource = file(&["users", "mod.rs"]);
assert!(resource.contains("//! The `users` table."), "{resource}");
for module in ["filter", "models", "read", "write"] {
let before = resource
.split(&format!("pub mod {module};"))
.next()
.unwrap();
let doc = before.trim_end().lines().last().unwrap();
assert!(
doc.starts_with("/// "),
"`{module}` is undocumented:\n{resource}"
);
}
}
#[test]
fn serde_derives_join_the_derive_list_as_rustfmt_lays_it_out() {
let mut short = "/// Doc.\n#[derive(Debug, Clone)]\n".to_owned();
push_serde_derive(&mut short, true);
assert_eq!(
short,
"/// Doc.\n#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]\n"
);
let mut long =
"#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, sqlx::Type)]\n"
.to_owned();
push_serde_derive(&mut long, true);
assert_eq!(
long,
"#[derive(\n Debug,\n Clone,\n Copy,\n PartialEq,\n Eq,\n PartialOrd,\n Ord,\n Hash,\n sqlx::Type,\n serde::Serialize,\n serde::Deserialize,\n)]\n"
);
let mut packed =
"#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]\n".to_owned();
push_serde_derive(&mut packed, true);
assert_eq!(
packed,
"#[derive(\n Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,\n)]\n"
);
let mut off = "#[derive(Debug)]\n".to_owned();
push_serde_derive(&mut off, false);
assert_eq!(off, "#[derive(Debug)]\n");
}
#[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"));
}
#[test]
fn generated_query_functions_use_generic_executor_not_concrete_pool() {
let table = table(
vec![column("id", "i64"), column("email", "String")],
&["id"],
);
for rendered in [
read::render(&table, std::slice::from_ref(&table)),
write::render(&table),
] {
assert!(rendered.contains("E: sqlx::PgExecutor<'e>,"));
assert!(!rendered.contains("PgPool"));
assert!(!rendered.contains("PgConnection"));
}
}
#[test]
fn fn_head_stays_on_one_line_when_it_fits() {
let mut output = String::new();
push_fn_head(&mut output, "list", &[], "Result<(), sqlx::Error>");
assert_eq!(
output,
"pub async fn list<'e, E>(executor: E) -> Result<(), sqlx::Error>\nwhere\n E: sqlx::PgExecutor<'e>,\n{\n"
);
}
#[test]
fn fn_head_goes_vertical_when_too_wide() {
let mut output = String::new();
let params = vec![
"order_id: uuid::Uuid".to_owned(),
"product_id: uuid::Uuid".to_owned(),
];
push_fn_head(
&mut output,
"get_by_id",
¶ms,
"Result<Option<LineItem>, sqlx::Error>",
);
assert!(output.starts_with(
"pub async fn get_by_id<'e, E>(\n executor: E,\n order_id: uuid::Uuid,\n product_id: uuid::Uuid,\n) -> Result<Option<LineItem>, sqlx::Error>\nwhere\n"
));
}
#[test]
fn key_params_avoid_generated_local_names() {
assert_eq!(param_name(&column("query", "i64")), "query_key");
assert_eq!(param_name(&column("id", "i64")), "id");
}
#[test]
fn output_is_deterministic() {
let tables = vec![table(vec![column("id", "i64")], &["id"])];
let first = plan_files("CREATE TABLE users (id BIGINT);", &tables, Path::new("out"));
let second = plan_files("CREATE TABLE users (id BIGINT);", &tables, Path::new("out"));
assert_eq!(first, second);
}
}