mod checks;
pub mod conventions;
mod db_mod;
mod emit;
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;
#[cfg(test)]
pub(crate) mod test_support;
pub mod types;
pub mod write;
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use crate::parser::Rejection;
use crate::queries::AnalyzedQuery;
use crate::schema::{BaseType, Column, EnumType, Table};
use crate::{Config, Conventions, Derive};
use self::checks::{check_imports, check_serde};
use self::emit::{CHAIN_WIDTH, FN_CALL_WIDTH, MAX_WIDTH};
pub use self::db_mod::{DbMod, render_db_mod, render_schema_snapshot};
pub use self::emit::{
StaticQuery, doc_name, push_and_chain, push_build_and_fetch, push_chain, push_doc,
push_fn_head, push_let_call, push_serde_derive, push_serde_snake_case, types_import, use_list,
use_paths,
};
const HEADER: &str = "// @generated by rok-sqlc. Do not edit by hand.\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>,
}
#[derive(Debug, Clone, Default)]
pub struct KeptResources {
pub tables: Vec<Table>,
pub enums: Vec<EnumType>,
}
impl<'a> QueriesModule<'a> {
fn is_declared(self) -> bool {
!matches!(self, QueriesModule::None)
}
fn analyzed(self) -> Vec<&'a AnalyzedQuery> {
match self {
QueriesModule::Generate(analyzed) => analyzed.iter().collect(),
QueriesModule::GenerateModules(modules) => {
modules.iter().flat_map(|module| &module.queries).collect()
}
QueriesModule::None | QueriesModule::Keep => Vec::new(),
}
}
}
#[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,
&KeptResources::default(),
)
.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);
keys::check_names(&prepared)?;
relationships::infer(&mut prepared, changes, scope)?;
Ok(prepared)
}
pub fn table_failures(table: &Table, config: &Config) -> Option<Rejection> {
let mut applied = [table.clone()];
conventions::apply(&mut applied, &config.conventions);
let [table] = &applied;
if let Some((column, reason)) = conventions::unsourced(table) {
return Some(Rejection {
reason,
unsourced_column: Some(column.sql_name.clone()),
});
}
search::name_clash(table).map(|reason| Rejection {
reason,
unsourced_column: None,
})
}
pub fn plan_files_with_queries(
config: &Config,
schema_sql: &str,
tables: &[Table],
enums: &[EnumType],
queries: QueriesModule<'_>,
kept: &KeptResources,
) -> Result<Vec<(PathBuf, String)>, String> {
let (conventions, output) = (&config.conventions, config.output.as_path());
let serde = config.derive.contains(&Derive::Serde);
if serde {
check_serde(tables, queries)?;
}
let mut files = Vec::new();
let all: Vec<Table> = tables.iter().chain(&kept.tables).cloned().collect();
let resolved = prepare(
&all,
conventions,
&config.relationships,
relationships::Scope::Run,
)?;
let tables = resolved.as_slice();
let enums: Vec<EnumType> = enums.iter().chain(&kept.enums).cloned().collect();
let is_kept = |table: &Table| {
kept.tables
.iter()
.any(|kept| kept.module_name == table.module_name)
};
for table in tables.iter().filter(|table| !is_kept(table)) {
let dir = output.join(&table.module_name);
let filter = search::render_filter(table, tables, serde);
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(serde)));
if serde {
files.push((output.join("nullable.rs"), models::render_nullable()));
}
if !enums.is_empty() {
files.push((output.join("types.rs"), types::render(&enums, serde)));
}
let session = session::render(conventions);
let db_mod = DbMod {
has_types: !enums.is_empty(),
has_session: session.is_some(),
queries,
serde,
};
if let Some(session) = session {
files.push((output.join("session.rs"), session));
}
files.push((output.join("mod.rs"), render_db_mod(tables, &db_mod)));
files.push((output.join("schema.rs"), render_schema_snapshot(schema_sql)));
for (path, text) in &files {
check_imports(output, path, text)?;
}
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 {
match column.base_type() {
BaseType::Text => match &column.typed_id {
Some(id) => format!("&{}", id.name),
None => "&str".to_owned(),
},
BaseType::Bytes => "&[u8]".to_owned(),
_ => column.value_type().to_owned(),
}
}
#[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 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 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 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);
}
}