use std::path::Path;
use crate::generate::emit::Plan;
#[must_use]
pub fn counter_column_for(child_snake: &str) -> String {
format!("{child_snake}_count")
}
#[must_use]
pub fn up_sql(parent_plural: &str, column: &str) -> String {
format!("ALTER TABLE {parent_plural} ADD COLUMN {column} BIGINT NOT NULL DEFAULT 0;\n")
}
#[must_use]
pub fn down_sql(parent_plural: &str, column: &str) -> String {
format!("ALTER TABLE {parent_plural} DROP COLUMN {column};\n")
}
#[must_use]
pub fn migration_dir_name(timestamp: &str, column: &str, parent_plural: &str) -> String {
format!(
"{}_add_{column}_to_{parent_plural}",
next_migration_version(timestamp)
)
}
fn next_migration_version(timestamp: &str) -> String {
format!("{timestamp}1")
}
pub fn push_counter_cache_migration(
plan: &mut Plan,
project_root: &Path,
timestamp: &str,
child_snake: &str,
parent_plural: &str,
) {
let column = counter_column_for(child_snake);
let dir =
project_root
.join("migrations")
.join(migration_dir_name(timestamp, &column, parent_plural));
plan.create(dir.join("up.sql"), up_sql(parent_plural, &column));
plan.create(dir.join("down.sql"), down_sql(parent_plural, &column));
}
pub fn push_counter_cache_warning(
plan: &mut Plan,
child_snake: &str,
parent_pascal: &str,
parent_plural: &str,
) {
let column = counter_column_for(child_snake);
plan.warn(format!(
"--counter-cache: `{parent_plural}.{column}` is added by the generated \
migration, but the parent's Rust side is a file this scaffold does not \
own, so it is left to you (two lines, both required before the app \
compiles):\n \
src/schema.rs, inside `diesel::table! {{ {parent_plural} (id) {{ … }} }}`:\n \
{column} -> Int8,\n \
the `{parent_pascal}` model struct:\n \
#[default]\n pub {column}: i64,\n \
Then run `{child_snake}s.recompute_counter_caches()` once if \
`{parent_plural}` already has rows."
));
}
#[must_use]
pub fn add_counter_cache_to_model_source(
source: &str,
pascal_name: &str,
parent_pascal: &str,
) -> String {
let existing = format!("#[belongs_to({parent_pascal}");
if let Some(start) = source.find(&existing) {
let Some(end_rel) = source[start..].find(")]") else {
return source.to_owned();
};
let end = start + end_rel;
if source[start..end].contains("counter_cache") {
return source.to_owned();
}
let mut out = String::with_capacity(source.len() + 16);
out.push_str(&source[..end]);
out.push_str(", counter_cache");
out.push_str(&source[end..]);
return out;
}
let needle = format!("pub struct {pascal_name} ");
let Some(struct_at) = source.find(&needle) else {
return source.to_owned();
};
let insert_at = source[..struct_at]
.rfind("\n#[")
.map_or(struct_at, |i| i + 1);
let mut out = String::with_capacity(source.len() + 48);
out.push_str(&source[..insert_at]);
out.push_str("#[belongs_to(");
out.push_str(parent_pascal);
out.push_str(", counter_cache)]\n");
out.push_str(&source[insert_at..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_migration_is_not_null_with_a_zero_default() {
let up = up_sql("posts", "comment_count");
assert!(up.contains("ALTER TABLE posts ADD COLUMN comment_count BIGINT"));
assert!(up.contains("NOT NULL"), "up.sql: {up}");
assert!(up.contains("DEFAULT 0"), "up.sql: {up}");
assert_eq!(
down_sql("posts", "comment_count"),
"ALTER TABLE posts DROP COLUMN comment_count;\n"
);
}
#[test]
fn the_column_name_follows_the_singular_convention() {
assert_eq!(counter_column_for("comment"), "comment_count");
assert_eq!(counter_column_for("blog_post"), "blog_post_count");
}
#[test]
fn the_migration_dir_names_both_sides() {
assert_eq!(
migration_dir_name("20260427000000", "comment_count", "posts"),
"202604270000001_add_comment_count_to_posts"
);
}
#[test]
fn the_counter_migration_gets_its_own_diesel_version() {
let scaffold_version = "20260427000000";
let dir = migration_dir_name(scaffold_version, "comment_count", "posts");
let version = dir.split('_').next().expect("a version prefix");
assert_ne!(version, scaffold_version);
assert!(
version > scaffold_version,
"the parent column is added after the child table exists"
);
}
#[test]
fn the_counter_version_does_not_claim_a_later_scaffolds_timestamp() {
let this_second = "20260427000000";
let next_second = "20260427000001";
let ours = next_migration_version(this_second);
assert_ne!(ours, next_second);
assert!(ours.as_str() > this_second, "sorts after its own scaffold");
assert!(
ours.as_str() < next_second,
"and before the next second's scaffold"
);
assert!(next_migration_version("20260427005959").as_str() < "20260427010000");
}
#[test]
fn a_non_numeric_timestamp_still_gets_a_distinct_version() {
assert_eq!(next_migration_version("not-a-number"), "not-a-number1");
}
#[test]
fn the_attribute_is_added_above_the_struct_when_absent() {
let src = "#[autumn_web::model]\npub struct Comment {\n pub id: i64,\n}\n";
let out = add_counter_cache_to_model_source(src, "Comment", "Post");
assert!(
out.contains(
"#[autumn_web::model]\n#[belongs_to(Post, counter_cache)]\npub struct Comment"
),
"{out}"
);
}
#[test]
fn an_existing_attribute_gains_the_key_and_is_idempotent() {
let src = "#[autumn_web::model]\n#[belongs_to(Post)]\npub struct Comment {\n}\n";
let once = add_counter_cache_to_model_source(src, "Comment", "Post");
assert!(
once.contains("#[belongs_to(Post, counter_cache)]"),
"{once}"
);
let twice = add_counter_cache_to_model_source(&once, "Comment", "Post");
assert_eq!(once, twice, "re-running the scaffold must not stack keys");
}
}