use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::path::Path;
use super::dsl::FieldKind;
use super::emit::Plan;
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 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
)));
}
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 (updated, db_enabled) =
super::schema_edit::ensure_autumn_web_feature_status(&with_deps, "db");
if !db_enabled {
eprintln!(
" \u{26a0} Could not enable autumn-web's `db` feature automatically. \
Add `features = [\"db\"]` 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 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)
}
#[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}"))
}
}
}
fn schema_block_range(existing: &str, table: &str) -> Option<(usize, usize)> {
let needle = format!("{table} (");
let bytes = existing.as_bytes();
let mut search_from = 0;
while let Some(macro_rel) = existing[search_from..].find("table!") {
let name_start = search_from + macro_rel;
let mut macro_start = name_start;
while macro_start > 0 {
let c = bytes[macro_start - 1];
if c.is_ascii_alphanumeric() || c == b'_' || c == b':' {
macro_start -= 1;
} else {
break;
}
}
let Some(brace_rel) = existing[name_start..].find('{') else {
break;
};
let open = name_start + brace_rel;
let mut depth = 0usize;
let mut end = None;
let mut in_string = false;
let mut in_line_comment = false;
let region = &existing.as_bytes()[open..];
let mut i = 0;
while i < region.len() {
let b = region[i];
if in_line_comment {
if b == b'\n' {
in_line_comment = false;
}
} else if in_string {
if b == b'\\' {
i += 1; } else if b == b'"' {
in_string = false;
}
} else if b == b'/' && region.get(i + 1) == Some(&b'/') {
in_line_comment = true;
i += 1;
} else if b == b'"' {
in_string = true;
} else if b == b'{' {
depth += 1;
} else if b == b'}' {
depth -= 1;
if depth == 0 {
end = Some(open + i + 1);
break;
}
}
i += 1;
}
let Some(end) = end else { break };
if existing[open..end]
.lines()
.any(|l| l.trim().starts_with(&needle))
{
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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\"] }"),
"pull must enable autumn-web's db feature: {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,
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,
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"
);
}
}