use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
use super::dsl::FieldKind;
use super::emit::Plan;
use super::model::LOCK_VERSION_COLUMN;
use super::naming::{pascal, snake};
use super::schema_edit::{add_mod_declaration, singularize, update_main_rs};
use super::{GenerateError, ensure_project_root, read_or_empty};
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Column {
pub name: String,
pub kind: FieldKind,
pub nullable: bool,
pub is_pk: bool,
pub has_default: bool,
pub has_sequence_default: bool,
pub default_is_integer_literal: bool,
pub is_generated: bool,
pub is_identity: bool,
}
impl Column {
#[must_use]
pub const fn is_db_generated(&self) -> bool {
self.has_sequence_default || self.is_identity
}
#[must_use]
pub fn rust_type(&self) -> String {
let inner = self.kind.rust_type();
if self.nullable {
format!("Option<{inner}>")
} else {
inner.to_owned()
}
}
#[must_use]
pub fn schema_type(&self) -> String {
let inner = self.kind.schema_type();
if self.nullable {
format!("Nullable<{inner}>")
} else {
inner.to_owned()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableSchema {
pub table: String,
pub columns: Vec<Column>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PullOptions {
pub with_repository: bool,
pub force: bool,
pub explicit: bool,
}
pub fn plan_pull(
project_root: &Path,
tables: &[TableSchema],
options: PullOptions,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let mut plan = Plan::new(project_root);
let models_dir = project_root.join("src").join("models");
let repos_dir = project_root.join("src").join("repositories");
let mut models_mod = read_or_empty(&models_dir.join("mod.rs"));
let mut schema = read_or_empty(&project_root.join("src").join("schema.rs"));
let mut repos_mod = read_or_empty(&repos_dir.join("mod.rs"));
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut emitted_repository = false;
let mut route_entries: Vec<String> = Vec::new();
for table in tables {
let resource = singularize(&table.table);
let snake_name = snake(&resource);
let pascal_name = pascal(&resource);
if let Some(reason) = unsupported_reason(table, &resource) {
if options.explicit {
return Err(GenerateError::Config(format!(
"cannot pull table '{}': {reason}",
table.table
)));
}
eprintln!(" \u{2139} Skipping table '{}': {reason}", table.table);
continue;
}
if !seen.insert(snake_name.clone()) {
return Err(GenerateError::Config(format!(
"two pulled tables map to the same model module '{snake_name}' \
(last from table '{}'); pull them individually or rename one",
table.table
)));
}
if let Some(col) = table.columns.iter().find(|c| is_lock_version_column(c))
&& !annotate_lock_version(&table.columns, col)
{
plan.warn(format!(
"table '{}' has a `{}` column, but it is the only insertable column — pulling it \
as `#[lock_version]` would leave New{pascal_name} empty and the project would \
not compile, so it came back as an ordinary integer with no conflict checking. \
Add another column, or hand-write the attribute once the table has one.",
table.table, LOCK_VERSION_COLUMN,
));
}
plan.create(
models_dir.join(format!("{snake_name}.rs")),
render_model(&pascal_name, &table.table, &table.columns),
);
models_mod = add_mod_declaration(&models_mod, &snake_name);
schema = upsert_schema_block(&schema, &table.table, &table.columns, options.force)?;
if options.with_repository {
plan.create(
repos_dir.join(format!("{snake_name}.rs")),
super::scaffold::render_repository_for_pull(
&pascal_name,
&snake_name,
&table.table,
),
);
repos_mod = add_mod_declaration(&repos_mod, &snake_name);
route_entries.push(format!("repositories::{snake_name}::{snake_name}_api_list"));
route_entries.push(format!("repositories::{snake_name}::{snake_name}_api_get"));
emitted_repository = true;
}
}
if seen.is_empty() {
eprintln!(" \u{2139} No supported tables to pull \u{2014} nothing was generated.");
return Ok(Plan::new(project_root));
}
plan.modify(models_dir.join("mod.rs"), models_mod);
plan.modify(project_root.join("src").join("schema.rs"), schema);
let main_path = project_root.join("src").join("main.rs");
if let Ok(main_existing) = std::fs::read_to_string(&main_path) {
let mut mods = vec!["models", "schema"];
if emitted_repository {
mods.push("repositories");
}
let updated = update_main_rs(&main_existing, &mods, &route_entries);
plan.modify(main_path, updated);
}
if emitted_repository {
plan.modify(repos_dir.join("mod.rs"), repos_mod);
}
let cargo_path = project_root.join("Cargo.toml");
let existing = read_or_empty(&cargo_path);
let with_deps = super::model::ensure_cargo_dependencies(&existing, super::model::MODEL_DEPS);
let (with_db, db_enabled) =
super::schema_edit::ensure_autumn_web_feature_status(&with_deps, "db");
let (updated, _) = super::schema_edit::ensure_autumn_web_feature_status(&with_db, "postgres");
if !db_enabled {
eprintln!(
" \u{26a0} Could not enable autumn-web's `db` feature automatically. \
Add `features = [\"db\", \"postgres\"]` to the `autumn-web` dependency in Cargo.toml \
so the generated models compile."
);
}
if updated != existing {
plan.modify(cargo_path, updated);
}
Ok(plan)
}
#[must_use]
pub fn render_model(pascal_name: &str, table: &str, columns: &[Column]) -> String {
let mut out = String::with_capacity(columns.len() * 64 + 256);
out.push_str("//! Generated by `autumn generate`.\n");
out.push_str("//!\n");
out.push_str("//! Edit this file freely — once a generator has run, the\n");
out.push_str("//! framework treats this as ordinary user code.\n\n");
let _ = writeln!(out, "use crate::schema::{table};");
out.push('\n');
if inferred_table_name(pascal_name) == table {
out.push_str("#[autumn_web::model]\n");
} else {
let _ = writeln!(out, "#[autumn_web::model(table = \"{table}\")]");
}
let _ = writeln!(out, "pub struct {pascal_name} {{");
for col in columns {
if col.is_pk {
out.push_str(" #[id]\n");
} else if annotate_lock_version(columns, col) {
out.push_str(" #[lock_version]\n");
} else if is_write_excluded(col) {
out.push_str(" #[default]\n");
}
let _ = writeln!(out, " pub {}: {},", col.name, col.rust_type());
}
out.push_str("}\n");
out
}
fn inferred_table_name(pascal_name: &str) -> String {
format!("{}s", snake(pascal_name))
}
fn is_write_excluded(col: &Column) -> bool {
col.is_db_generated() || col.is_generated || (col.name == "created_at" && col.has_default)
}
fn annotate_lock_version(columns: &[Column], col: &Column) -> bool {
if !is_lock_version_column(col) {
return false;
}
columns
.iter()
.any(|other| !other.is_pk && !is_write_excluded(other) && !is_lock_version_column(other))
}
fn is_lock_version_column(col: &Column) -> bool {
col.name == super::model::LOCK_VERSION_COLUMN
&& !col.nullable
&& matches!(col.kind, FieldKind::I32 | FieldKind::I64)
&& col.default_is_integer_literal
}
#[must_use]
pub fn render_schema_block(table: &str, columns: &[Column]) -> String {
let pk: Vec<&str> = columns
.iter()
.filter(|c| c.is_pk)
.map(|c| c.name.as_str())
.collect();
let pk_clause = if pk.is_empty() {
"id".to_owned()
} else {
pk.join(", ")
};
let mut out = String::new();
out.push_str("diesel::table! {\n");
let _ = writeln!(out, " {table} ({pk_clause}) {{");
for col in columns {
let _ = writeln!(out, " {} -> {},", col.name, col.schema_type());
}
out.push_str(" }\n");
out.push_str("}\n");
out
}
fn upsert_schema_block(
existing: &str,
table: &str,
columns: &[Column],
force: bool,
) -> Result<String, GenerateError> {
let block = render_schema_block(table, columns);
match schema_block_range(existing, table) {
Some((start, end)) => {
if force {
let mut out = String::with_capacity(existing.len() + block.len());
out.push_str(&existing[..start]);
out.push_str(block.trim_end());
out.push_str(&existing[end..]);
Ok(out)
} else if existing[start..end].trim() == block.trim() {
Ok(existing.to_owned())
} else {
Err(GenerateError::Config(format!(
"src/schema.rs already has a `table!` block for '{table}' that differs from \
the live database; re-run with --force to replace it (the regenerated model \
would otherwise reference a stale schema and fail to compile)"
)))
}
}
None if existing.is_empty() => Ok(block),
None => {
let trimmed = existing.trim_end();
Ok(format!("{trimmed}\n\n{block}"))
}
}
}
pub fn scan_code(text: &str, from: usize, mut f: impl FnMut(usize, u8) -> bool) {
let b = text.as_bytes();
let find = |from: usize, pat: &[u8]| {
b.get(from..)
.and_then(|rest| rest.windows(pat.len()).position(|w| w == pat))
.map(|p| from + p)
};
let mut i = from;
while i < b.len() {
let c = b[i];
let next = b.get(i + 1).copied();
if c == b'/' && next == Some(b'/') {
i = find(i, b"\n").unwrap_or(b.len());
} else if c == b'/' && next == Some(b'*') {
let mut depth = 1;
i += 2;
while i < b.len() && depth > 0 {
if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
depth += 1;
i += 2;
} else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
} else if c == b'"' {
i += 1;
while i < b.len() && b[i] != b'"' {
i += if b[i] == b'\\' { 2 } else { 1 };
}
i += 1;
} else if let Some(end) = raw_string_end(b, i, &find) {
i = end;
} else if c == b'\'' && next == Some(b'\\') {
i = find(i + 2, b"'").map_or(b.len(), |p| p + 1);
} else if c == b'\'' && b.get(i + 2) == Some(&b'\'') {
i += 3;
} else {
if f(i, c) {
return;
}
i += 1;
}
}
}
fn raw_string_end(
b: &[u8],
i: usize,
find: &impl Fn(usize, &[u8]) -> Option<usize>,
) -> Option<usize> {
let ident = |c: u8| c.is_ascii_alphanumeric() || c == b'_';
let starts_word = i == 0
|| !ident(b[i - 1])
|| (matches!(b[i - 1], b'b' | b'c') && (i == 1 || !ident(b[i - 2])));
if b[i] != b'r' || !starts_word {
return None;
}
let hashes = b[i + 1..].iter().take_while(|&&c| c == b'#').count();
if b.get(i + 1 + hashes) != Some(&b'"') {
return None;
}
let mut close = vec![b'"'];
close.extend(std::iter::repeat_n(b'#', hashes));
Some(find(i + 2 + hashes, &close).map_or(b.len(), |p| p + close.len()))
}
#[must_use]
pub fn ends_in_word(text: &str) -> bool {
text.chars()
.next_back()
.is_some_and(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || c == '_')
}
#[must_use]
pub fn macro_path_start(text: &str, at: usize) -> usize {
let mut start = at;
while let Some(rest) = text[..start].trim_end().strip_suffix("::") {
let segment = rest.trim_end();
let word = segment
.trim_end_matches(|c: char| !c.is_ascii() || c.is_ascii_alphanumeric() || c == '_');
let word = word
.strip_suffix("r#")
.filter(|w| !ends_in_word(w))
.unwrap_or(word);
if word.len() == segment.len() {
return rest.len();
}
start = word.len();
}
start
}
#[must_use]
pub fn is_in_comment_or_string(text: &str, at: usize) -> bool {
let mut code = false;
scan_code(text, 0, |i, _| {
code = i == at;
i >= at
});
!code
}
fn block_table_name(text: &str, open: usize) -> Option<String> {
let mut name: Vec<u8> = Vec::new();
let mut brackets = 0usize;
let mut in_use = false;
scan_code(text, open + 1, |_, c| {
if in_use {
in_use = c != b';';
return false;
}
if brackets > 0 || name.is_empty() && matches!(c, b'#' | b'[') {
match c {
b'[' => brackets += 1,
b']' => brackets = brackets.saturating_sub(1),
_ => {}
}
return false;
}
if c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80 || (c == b'#' && name == b"r") {
name.push(c);
return false;
}
if name.is_empty() {
return !c.is_ascii_whitespace();
}
if name == b"use" {
name.clear();
in_use = c != b';';
return false;
}
true
});
String::from_utf8(name).ok().filter(|n| !n.is_empty())
}
pub fn schema_block_range(existing: &str, table: &str) -> Option<(usize, usize)> {
let unraw = |name: &str| name.strip_prefix("r#").unwrap_or(name).to_owned();
let bytes = existing.as_bytes();
let want = unraw(table);
let mut search_from = 0;
while let Some(macro_rel) = existing[search_from..].find("table!") {
let name_start = search_from + macro_rel;
search_from = name_start + "table!".len();
let in_word = ends_in_word(&existing[..name_start]);
if in_word || is_in_comment_or_string(existing, name_start) {
continue;
}
let macro_start = macro_path_start(existing, name_start);
let mut open = None;
let mut end = None;
let mut depth = 0usize;
scan_code(existing, search_from, |i, c| {
if open.is_none() && !matches!(c, b'{' | b'(' | b'[') {
return !c.is_ascii_whitespace();
}
match c {
b'{' | b'(' | b'[' => {
open.get_or_insert(i);
depth += 1;
}
b'}' | b')' | b']' => {
depth -= 1;
if depth == 0 {
end = Some(i + 1);
return true;
}
}
_ => {}
}
false
});
let (Some(open), Some(mut end)) = (open, end) else {
break;
};
let gap = bytes[end..]
.iter()
.take_while(|&&c| c == b' ' || c == b'\t')
.count();
if bytes[open] != b'{' && bytes.get(end + gap) == Some(&b';') {
end += gap + 1;
}
if block_table_name(existing, open).is_some_and(|name| unraw(&name) == want) {
return Some((macro_start, end));
}
search_from = end;
}
None
}
fn has_autumn_id_pk(columns: &[Column]) -> bool {
let pks: Vec<&Column> = columns.iter().filter(|c| c.is_pk).collect();
matches!(
pks.as_slice(),
[pk] if pk.name == "id" && pk.kind == FieldKind::I64 && pk.is_db_generated()
)
}
fn unsupported_reason(table: &TableSchema, resource: &str) -> Option<String> {
if super::model::validate_resource_name(resource).is_err() {
return Some(format!(
"the derived model name '{resource}' is not a valid Rust identifier"
));
}
if !super::dsl::is_valid_ident(&table.table) || super::dsl::is_rust_keyword(&table.table) {
return Some(format!(
"the table name '{}' is not a valid snake_case Rust identifier (or is a reserved keyword)",
table.table
));
}
for col in &table.columns {
if !super::dsl::is_valid_ident(&col.name) || super::dsl::is_rust_keyword(&col.name) {
return Some(format!(
"column '{}' is not a valid snake_case Rust identifier (or is a reserved keyword)",
col.name
));
}
}
if !has_autumn_id_pk(&table.columns) {
let pks: Vec<&Column> = table.columns.iter().filter(|c| c.is_pk).collect();
if matches!(pks.as_slice(), [pk] if pk.name == "id" && pk.kind == FieldKind::Uuid) {
return Some(
"it uses a UUID `id` primary key, which `db pull` does not yet support \
(introspection currently round-trips only database-generated `id` BIGINT \
(i64) primary keys); UUID-keyed models can still be created with \
`autumn generate model … --id uuid`"
.to_owned(),
);
}
return Some(
"it lacks the Autumn convention of a single database-generated `id` BIGINT (i64) \
primary key (BIGSERIAL/DEFAULT or GENERATED AS IDENTITY); the #[model] macro \
references the `id` column directly and excludes it from inserts"
.to_owned(),
);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use crate::generate::emit::Action;
use std::fs;
use tempfile::TempDir;
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nedition = \"2024\"\n\n[dependencies]\nautumn-web = \"0.5\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(
tmp.path().join("src/main.rs"),
"use autumn_web::prelude::*;\n\n#[autumn_web::main]\nasync fn main() {\n autumn_web::app()\n .routes(routes![])\n .run()\n .await;\n}\n",
)
.unwrap();
tmp
}
fn col(name: &str, kind: FieldKind, nullable: bool, is_pk: bool) -> Column {
Column {
name: name.to_owned(),
kind,
nullable,
is_pk,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
}
}
fn id_col() -> Column {
Column {
name: "id".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: true,
has_default: true,
has_sequence_default: true,
default_is_integer_literal: true,
is_generated: false,
is_identity: false,
}
}
fn created_at_col() -> Column {
Column {
name: "created_at".to_owned(),
kind: FieldKind::NaiveDateTime,
nullable: false,
is_pk: false,
has_default: true,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
}
}
fn post_table() -> TableSchema {
TableSchema {
table: "posts".to_owned(),
columns: vec![
id_col(),
col("title", FieldKind::String, false, false),
col("body", FieldKind::String, false, false),
col("published", FieldKind::Bool, false, false),
created_at_col(),
],
}
}
fn rel_paths(plan: &Plan) -> Vec<String> {
plan.actions
.iter()
.map(|a| {
a.path()
.strip_prefix(&plan.project_root)
.unwrap()
.display()
.to_string()
.replace('\\', "/")
})
.collect()
}
#[test]
fn plan_pull_outside_project_root_errors() {
let tmp = TempDir::new().unwrap();
let err = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap_err();
assert!(matches!(err, GenerateError::NotInProject));
}
#[test]
fn plan_pull_creates_expected_file_set_and_no_migration() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
let p = rel_paths(&plan);
assert!(p.contains(&"src/models/post.rs".into()));
assert!(p.contains(&"src/models/mod.rs".into()));
assert!(p.contains(&"src/schema.rs".into()));
assert!(
p.iter().all(|path| !path.contains("migrations")),
"db pull must not emit a migration: {p:?}"
);
}
#[test]
fn plan_pull_model_honors_conventions() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/post.rs")).unwrap();
assert!(model.contains("#[autumn_web::model]"));
assert!(model.contains("pub struct Post"));
assert!(model.contains("#[id]"));
assert!(model.contains("pub id: i64,"), "i64 PK must be preserved");
assert!(model.contains("pub title: String,"));
assert!(model.contains("pub published: bool,"));
assert!(model.contains("#[default]\n pub created_at: chrono::NaiveDateTime,"));
}
#[test]
fn plan_pull_nullable_columns_become_option() {
let tmp = project();
let table = TableSchema {
table: "notes".to_owned(),
columns: vec![id_col(), col("body", FieldKind::String, true, false)],
};
let plan = plan_pull(tmp.path(), &[table], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/note.rs")).unwrap();
assert!(
model.contains("pub body: Option<String>,"),
"nullable column must be Option<T>: {model}"
);
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(
schema.contains("body -> Nullable<Text>,"),
"nullable column schema must be Nullable<T>: {schema}"
);
}
#[test]
fn plan_pull_schema_block_and_mod_line() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert!(schema.contains("posts (id)"));
assert!(schema.contains("title -> Text,"));
assert!(schema.contains("id -> Int8,"));
let mod_rs = fs::read_to_string(tmp.path().join("src/models/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod post;"));
}
#[test]
fn plan_pull_wires_modules_into_main_rs() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(
main.contains("mod models;"),
"main.rs must declare models: {main}"
);
assert!(
main.contains("mod schema;"),
"main.rs must declare schema: {main}"
);
}
#[test]
fn plan_pull_multiple_tables_fold_aggregators_once() {
let tmp = project();
let comments = TableSchema {
table: "comments".to_owned(),
columns: vec![
id_col(),
col("body", FieldKind::String, false, false),
created_at_col(),
],
};
let plan = plan_pull(
tmp.path(),
&[post_table(), comments],
PullOptions::default(),
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let schema = fs::read_to_string(tmp.path().join("src/schema.rs")).unwrap();
assert_eq!(schema.matches("posts (id)").count(), 1);
assert_eq!(schema.matches("comments (id)").count(), 1);
let mod_rs = fs::read_to_string(tmp.path().join("src/models/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod post;"));
assert!(mod_rs.contains("pub mod comment;"));
let schema_mods = plan
.actions
.iter()
.filter(|a| matches!(a, Action::Modify { path, .. } if path.ends_with("schema.rs")))
.count();
assert_eq!(schema_mods, 1);
}
#[test]
fn plan_pull_with_repository_flag() {
let tmp = project();
let plan = plan_pull(
tmp.path(),
&[post_table()],
PullOptions {
with_repository: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/post.rs")).unwrap();
assert!(
repo.contains(
"#[autumn_web::repository(Post, table = \"posts\", api = \"/api/posts\")]"
)
);
assert!(repo.contains("use crate::schema::posts;"));
let repo_mod = fs::read_to_string(tmp.path().join("src/repositories/mod.rs")).unwrap();
assert!(repo_mod.contains("pub mod post;"));
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main.contains("mod repositories;"));
assert!(
main.contains("repositories::post::post_api_list"),
"API list route must be registered: {main}"
);
assert!(main.contains("repositories::post::post_api_get"));
assert!(
!main.contains("post_api_create"),
"mutating API routes must not be auto-registered: {main}"
);
}
#[test]
fn plan_pull_unscoped_all_unsupported_writes_nothing() {
let tmp = project();
let sessions = TableSchema {
table: "sessions".to_owned(),
columns: vec![col("token", FieldKind::Uuid, false, true)],
};
let plan = plan_pull(tmp.path(), &[sessions], PullOptions::default()).unwrap();
assert!(
plan.actions.is_empty(),
"an all-skipped unscoped pull must produce no actions"
);
plan.execute(Flags::default()).unwrap();
assert!(!tmp.path().join("src/schema.rs").exists());
assert!(!tmp.path().join("src/models/mod.rs").exists());
}
#[test]
fn plan_pull_without_repository_flag_emits_no_repository() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
assert!(!tmp.path().join("src/repositories/post.rs").exists());
}
#[test]
fn plan_pull_collision_without_force_errors() {
let tmp = project();
let models = tmp.path().join("src/models");
fs::create_dir_all(&models).unwrap();
fs::write(models.join("post.rs"), "// existing").unwrap();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
let err = plan.execute(Flags::default()).unwrap_err();
assert!(matches!(err, GenerateError::Collisions(_)));
assert_eq!(
fs::read_to_string(models.join("post.rs")).unwrap(),
"// existing"
);
}
#[test]
fn plan_pull_force_overwrites() {
let tmp = project();
let models = tmp.path().join("src/models");
fs::create_dir_all(&models).unwrap();
fs::write(models.join("post.rs"), "// existing").unwrap();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
assert!(
fs::read_to_string(models.join("post.rs"))
.unwrap()
.contains("pub struct Post")
);
}
#[test]
fn plan_pull_dry_run_writes_nothing() {
let tmp = project();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags {
dry_run: true,
force: false,
})
.unwrap();
assert!(!tmp.path().join("src/models/post.rs").exists());
assert!(!tmp.path().join("src/schema.rs").exists());
}
#[test]
fn plan_pull_errors_on_table_without_primary_key_when_explicit() {
let tmp = project();
let no_pk = TableSchema {
table: "audit_logs".to_owned(),
columns: vec![
col("message", FieldKind::String, false, false),
created_at_col(),
],
};
let err = plan_pull(
tmp.path(),
&[no_pk],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("audit_logs"),
"error must name the table: {msg}"
);
assert!(msg.contains("primary key"), "error must explain why: {msg}");
assert!(!tmp.path().join("src/models/audit_log.rs").exists());
}
#[test]
fn unsupported_reason_explains_uuid_id_pk() {
let uuid_posts = TableSchema {
table: "posts".to_owned(),
columns: vec![
Column {
name: "id".to_owned(),
kind: FieldKind::Uuid,
nullable: false,
is_pk: true,
has_default: true,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
},
col("title", FieldKind::String, false, false),
created_at_col(),
],
};
let reason =
unsupported_reason(&uuid_posts, "Post").expect("UUID id PK must be unsupported");
assert!(
reason.contains("UUID") && reason.contains("db pull"),
"reason must call out UUID + db pull: {reason}"
);
let tmp = project();
let err = plan_pull(
tmp.path(),
&[uuid_posts],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("UUID"),
"explicit pull error must mention UUID: {err}"
);
assert!(!tmp.path().join("src/models/post.rs").exists());
}
#[test]
fn plan_pull_skips_unsupported_table_on_unscoped_pull() {
let tmp = project();
let no_pk = TableSchema {
table: "audit_logs".to_owned(),
columns: vec![col("message", FieldKind::String, false, false)],
};
let plan = plan_pull(tmp.path(), &[no_pk, post_table()], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
assert!(!tmp.path().join("src/models/audit_log.rs").exists());
assert!(tmp.path().join("src/models/post.rs").exists());
}
#[test]
fn plan_pull_errors_on_invalid_column_identifier_when_explicit() {
let tmp = project();
let bad = TableSchema {
table: "items".to_owned(),
columns: vec![id_col(), col("type", FieldKind::String, false, false)],
};
let err = plan_pull(
tmp.path(),
&[bad],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("items"), "error must name the table: {msg}");
assert!(msg.contains("type"), "error must name the column: {msg}");
assert!(!tmp.path().join("src/models/item.rs").exists());
}
#[test]
fn plan_pull_errors_on_colliding_generated_module_names() {
let tmp = project();
let status = TableSchema {
table: "status".to_owned(),
columns: vec![id_col()],
};
let statuses = TableSchema {
table: "statuses".to_owned(),
columns: vec![id_col()],
};
let err = plan_pull(tmp.path(), &[status, statuses], PullOptions::default()).unwrap_err();
assert!(
err.to_string().contains("same model module"),
"error must explain the collision: {err}"
);
}
#[test]
fn plan_pull_default_annotation_rules() {
let tmp = project();
let table = TableSchema {
table: "widgets".to_owned(),
columns: vec![
id_col(),
Column {
name: "status".to_owned(),
kind: FieldKind::String,
nullable: false,
is_pk: false,
has_default: true, has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
},
Column {
name: "search".to_owned(),
kind: FieldKind::String,
nullable: false,
is_pk: false,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: true,
is_identity: false,
},
col("label", FieldKind::String, false, false),
created_at_col(),
],
};
let plan = plan_pull(tmp.path(), &[table], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/widget.rs")).unwrap();
assert!(model.contains("#[id]\n pub id: i64,"));
assert!(model.contains(" pub status: String,"));
assert!(!model.contains("#[default]\n pub status:"));
assert!(model.contains("#[default]\n pub search: String,"));
assert!(model.contains("#[default]\n pub created_at: chrono::NaiveDateTime,"));
assert!(!model.contains("#[id]\n #[default]"));
assert!(model.contains(" pub label: String,"));
}
#[test]
fn plan_pull_emits_table_override_for_irregular_plural() {
let tmp = project();
let people = TableSchema {
table: "people".to_owned(),
columns: vec![id_col(), created_at_col()],
};
let plan = plan_pull(tmp.path(), &[people], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/person.rs")).unwrap();
assert!(
model.contains("#[autumn_web::model(table = \"people\")]"),
"irregular plural needs an explicit table override: {model}"
);
assert!(model.contains("use crate::schema::people;"));
}
#[test]
fn plan_pull_skips_non_id_pk_table_entirely() {
let tmp = project();
let sessions = TableSchema {
table: "sessions".to_owned(),
columns: vec![
col("token", FieldKind::Uuid, false, true),
col("data", FieldKind::String, false, false),
],
};
let plan = plan_pull(
tmp.path(),
&[sessions],
PullOptions {
with_repository: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
assert!(!tmp.path().join("src/models/session.rs").exists());
assert!(!tmp.path().join("src/repositories/session.rs").exists());
let main = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(!main.contains("mod repositories;"));
}
#[test]
fn plan_pull_repository_uses_real_table_name_for_irregular_plural() {
let tmp = project();
let statuses = TableSchema {
table: "statuses".to_owned(),
columns: vec![id_col(), created_at_col()],
};
let plan = plan_pull(
tmp.path(),
&[statuses],
PullOptions {
with_repository: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags::default()).unwrap();
let repo = fs::read_to_string(tmp.path().join("src/repositories/status.rs")).unwrap();
assert!(
repo.contains("use crate::schema::statuses;"),
"repo must import the real table module: {repo}"
);
assert!(
repo.contains("table = \"statuses\""),
"repo macro must pin the real table name: {repo}"
);
assert!(
repo.contains("api = \"/api/statuses\""),
"repo REST mount must use the real table name: {repo}"
);
assert!(!repo.contains("statuss\""), "must not re-pluralize: {repo}");
}
#[test]
fn plan_pull_rejects_non_database_generated_id() {
let tmp = project();
let bare_id = TableSchema {
table: "things".to_owned(),
columns: vec![
Column {
name: "id".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: true,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
},
col("label", FieldKind::String, false, false),
],
};
let err = plan_pull(
tmp.path(),
&[bare_id],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("database-generated"),
"error must explain the id must be DB-generated: {err}"
);
}
#[test]
fn plan_pull_accepts_identity_id_and_excludes_identity_columns() {
let tmp = project();
let invoices = TableSchema {
table: "invoices".to_owned(),
columns: vec![
Column {
name: "id".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: true,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: true,
},
Column {
name: "invoice_no".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: false,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: true,
},
created_at_col(),
],
};
let plan = plan_pull(tmp.path(), &[invoices], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/invoice.rs")).unwrap();
assert!(model.contains("#[id]\n pub id: i64,"));
assert!(
model.contains("#[default]\n pub invoice_no: i64,"),
"identity column must be write-excluded: {model}"
);
}
#[test]
fn plan_pull_excludes_non_pk_sequence_column_from_writes() {
let tmp = project();
let table = TableSchema {
table: "invoices".to_owned(),
columns: vec![
id_col(),
Column {
name: "invoice_no".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: false,
has_default: true,
has_sequence_default: true,
default_is_integer_literal: true,
is_generated: false,
is_identity: false,
},
created_at_col(),
],
};
let plan = plan_pull(tmp.path(), &[table], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let model = fs::read_to_string(tmp.path().join("src/models/invoice.rs")).unwrap();
assert!(
model.contains("#[default]\n pub invoice_no: i64,"),
"non-PK sequence column must be write-excluded: {model}"
);
}
#[test]
fn plan_pull_rejects_keyword_table_name_when_explicit() {
let tmp = project();
let bad = TableSchema {
table: "as".to_owned(),
columns: vec![id_col()],
};
let err = plan_pull(
tmp.path(),
&[bad],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("table name 'as'"),
"error must name the offending table: {err}"
);
}
#[test]
fn plan_pull_rejects_bare_underscore_column_when_explicit() {
let tmp = project();
let bad = TableSchema {
table: "items".to_owned(),
columns: vec![id_col(), col("_", FieldKind::String, false, false)],
};
let err = plan_pull(
tmp.path(),
&[bad],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("column '_'"),
"error must name the offending column: {err}"
);
}
#[test]
fn plan_pull_enables_autumn_web_db_feature() {
let tmp = project();
let table = TableSchema {
table: "widgets".to_owned(),
columns: vec![id_col(), col("name", FieldKind::String, false, false)],
};
let plan = plan_pull(tmp.path(), &[table], PullOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(
cargo.contains("autumn-web = { version = \"0.5\", features = [\"db\", \"postgres\"] }"),
"pull must enable autumn-web's db feature and its postgres backend: {cargo}"
);
}
#[test]
fn plan_pull_force_replaces_stale_schema_block() {
let tmp = project();
let src = tmp.path().join("src");
fs::write(
src.join("schema.rs"),
"diesel::table! {\n posts (id) {\n id -> Int8,\n old_col -> Text,\n }\n}\n",
)
.unwrap();
let plan = plan_pull(
tmp.path(),
&[post_table()],
PullOptions {
force: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(src.join("schema.rs")).unwrap();
assert!(
!schema.contains("old_col"),
"stale column must be replaced under --force: {schema}"
);
assert!(schema.contains("title -> Text,"));
assert_eq!(schema.matches("posts (id)").count(), 1);
}
#[test]
fn plan_pull_force_replaces_block_with_brace_in_comment() {
let tmp = project();
let src = tmp.path().join("src");
fs::write(
src.join("schema.rs"),
"diesel::table! {\n posts (id) {\n /// trailing brace } in a comment\n old_col -> Text,\n }\n}\n",
)
.unwrap();
let plan = plan_pull(
tmp.path(),
&[post_table()],
PullOptions {
force: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(src.join("schema.rs")).unwrap();
assert!(
!schema.contains("old_col"),
"stale block must be replaced: {schema}"
);
assert!(
!schema.contains("trailing brace"),
"comment must be gone: {schema}"
);
assert!(schema.contains("title -> Text,"));
assert_eq!(schema.matches("posts (id)").count(), 1);
assert_eq!(
schema.matches('{').count(),
schema.matches('}').count(),
"braces must be balanced: {schema}"
);
}
#[test]
fn plan_pull_without_force_errors_on_stale_schema_block() {
let tmp = project();
let src = tmp.path().join("src");
fs::write(
src.join("schema.rs"),
"diesel::table! {\n posts (id) {\n id -> Int8,\n old_col -> Text,\n }\n}\n",
)
.unwrap();
let err = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap_err();
assert!(
err.to_string().contains("differs from the live database"),
"stale schema block must be reported: {err}"
);
}
#[test]
fn plan_pull_without_force_keeps_matching_schema_block() {
let tmp = project();
let src = tmp.path().join("src");
let block = render_schema_block("posts", &post_table().columns);
fs::write(src.join("schema.rs"), format!("{block}\n")).unwrap();
let plan = plan_pull(tmp.path(), &[post_table()], PullOptions::default()).unwrap();
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(src.join("schema.rs")).unwrap();
assert_eq!(
schema.matches("posts (id)").count(),
1,
"an identical block must not be duplicated: {schema}"
);
assert!(schema.contains("title -> Text,"));
}
#[test]
fn plan_pull_force_replaces_unqualified_table_block() {
let tmp = project();
let src = tmp.path().join("src");
fs::write(
src.join("schema.rs"),
"table! {\n posts (id) {\n id -> Int8,\n old_col -> Text,\n }\n}\n",
)
.unwrap();
let plan = plan_pull(
tmp.path(),
&[post_table()],
PullOptions {
force: true,
..PullOptions::default()
},
)
.unwrap();
plan.execute(Flags {
force: true,
dry_run: false,
})
.unwrap();
let schema = fs::read_to_string(src.join("schema.rs")).unwrap();
assert!(
!schema.contains("old_col"),
"stale column must be replaced even for an unqualified table! block: {schema}"
);
assert!(schema.contains("title -> Text,"));
assert!(
!schema.contains("diesel::diesel::"),
"the regenerated block must not be double-prefixed: {schema}"
);
assert_eq!(schema.matches("posts (id)").count(), 1);
}
#[test]
fn plan_pull_rejects_constant_default_id() {
let tmp = project();
let table = TableSchema {
table: "things".to_owned(),
columns: vec![
Column {
name: "id".to_owned(),
kind: FieldKind::I64,
nullable: false,
is_pk: true,
has_default: true,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
},
col("label", FieldKind::String, false, false),
],
};
let err = plan_pull(
tmp.path(),
&[table],
PullOptions {
explicit: true,
..PullOptions::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("database-generated"),
"a constant DEFAULT id must be rejected: {err}"
);
}
#[test]
fn round_trip_matches_greenfield_model_byte_for_byte() {
use crate::generate::dsl::{Field, FieldConstraints, sql_type_to_field_kind};
use crate::generate::model::render_model_file_for_test;
let fields = vec![
Field {
name: "title".into(),
kind: FieldKind::String,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
Field {
name: "body".into(),
kind: FieldKind::Text,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
Field {
name: "published".into(),
kind: FieldKind::Bool,
nullable: false,
variants: Vec::new(),
unique: false,
constraints: FieldConstraints::default(),
state_machine: None,
},
];
let greenfield = render_model_file_for_test("Post", "posts", &fields);
let udts = [
("id", "int8", false, true, false),
("title", "text", false, false, false),
("body", "text", false, false, false),
("published", "bool", false, false, false),
("created_at", "timestamp", false, false, true),
];
let columns: Vec<Column> = udts
.iter()
.map(|(name, udt, nullable, is_pk, has_default)| Column {
name: (*name).to_owned(),
kind: sql_type_to_field_kind(udt).unwrap(),
nullable: *nullable,
is_pk: *is_pk,
has_default: *has_default,
has_sequence_default: *is_pk,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
})
.collect();
let re_derived = render_model(&pascal(&singularize("posts")), "posts", &columns);
assert_eq!(
greenfield, re_derived,
"introspected model must be byte-identical to greenfield"
);
}
fn lock_version_column(has_default: bool) -> Column {
Column {
name: "lock_version".into(),
kind: FieldKind::I32,
nullable: false,
is_pk: false,
has_default,
has_sequence_default: false,
default_is_integer_literal: has_default,
is_generated: false,
is_identity: false,
}
}
fn id_column() -> Column {
Column {
name: "id".into(),
kind: FieldKind::I64,
nullable: false,
is_pk: true,
has_default: true,
has_sequence_default: true,
default_is_integer_literal: true,
is_generated: false,
is_identity: false,
}
}
fn title_column() -> Column {
Column {
name: "title".into(),
kind: FieldKind::String,
nullable: false,
is_pk: false,
has_default: false,
has_sequence_default: false,
default_is_integer_literal: false,
is_generated: false,
is_identity: false,
}
}
#[test]
fn pulling_a_generated_lock_version_column_reproduces_the_attribute() {
let model = render_model(
"Post",
"posts",
&[id_column(), title_column(), lock_version_column(true)],
);
assert!(
model.contains("#[lock_version]\n pub lock_version: i32,"),
"model:\n{model}"
);
}
#[test]
fn pulling_a_lock_only_table_declines_the_annotation_rather_than_emitting_an_empty_insert() {
let model = render_model("Post", "posts", &[id_column(), lock_version_column(true)]);
assert!(
!model.contains("#[lock_version]"),
"annotating would empty NewPost:\n{model}"
);
assert!(model.contains("pub lock_version: i32,"), "model:\n{model}");
let model = render_model(
"Post",
"posts",
&[id_column(), title_column(), lock_version_column(true)],
);
assert!(model.contains("#[lock_version]"), "model:\n{model}");
}
#[test]
fn pulling_a_lock_version_defaulted_to_a_null_valued_expression_leaves_it_ordinary() {
let mut col = lock_version_column(true);
col.default_is_integer_literal = false;
let model = render_model("Post", "posts", &[id_column(), title_column(), col]);
assert!(
!model.contains("#[lock_version]"),
"a default that does not initialize the column must not be annotated:\n{model}"
);
assert!(model.contains("pub lock_version: i32,"), "model:\n{model}");
}
#[test]
fn pulling_a_defaultless_lock_version_column_leaves_it_an_ordinary_integer() {
let model = render_model(
"Post",
"posts",
&[id_column(), title_column(), lock_version_column(false)],
);
assert!(
!model.contains("#[lock_version]"),
"a defaultless column must not be annotated:\n{model}"
);
assert!(model.contains("pub lock_version: i32,"), "model:\n{model}");
}
}