#![cfg(feature = "db")]
#![allow(clippy::must_use_candidate, clippy::missing_const_for_fn)]
use std::sync::Arc;
use autumn_web::Patch;
use autumn_web::repository::AutumnCounterCaches as _;
use autumn_web::tenancy::CURRENT_TENANT;
use diesel::sql_types::BigInt;
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::deadpool::Pool;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
diesel::table! {
cc_posts (id) {
id -> Int8,
title -> Text,
cc_comment_count -> Int8,
}
}
#[autumn_web::model(table = "cc_posts")]
pub struct CcPost {
#[id]
pub id: i64,
pub title: String,
#[default]
pub cc_comment_count: i64,
}
#[autumn_web::repository(CcPost, table = "cc_posts")]
pub trait CcPostRepository {}
diesel::table! {
cc_comments (id) {
id -> Int8,
body -> Text,
post_id -> Int8,
}
}
#[autumn_web::model(table = "cc_comments")]
#[belongs_to(CcPost, fk = post_id, counter_cache)]
pub struct CcComment {
#[id]
pub id: i64,
pub body: String,
pub post_id: i64,
}
#[autumn_web::repository(CcComment, table = "cc_comments")]
pub trait CcCommentRepository {}
diesel::table! {
cc_teams (id) {
id -> Int8,
name -> Text,
member_count -> Int8,
}
}
#[autumn_web::model(table = "cc_teams")]
pub struct CcTeam {
#[id]
pub id: i64,
pub name: String,
#[default]
pub member_count: i64,
}
#[autumn_web::repository(CcTeam, table = "cc_teams")]
pub trait CcTeamRepository {}
diesel::table! {
cc_members (id) {
id -> Int8,
nick -> Text,
team_id -> Int8,
}
}
#[autumn_web::model(table = "cc_members")]
#[belongs_to(CcTeam, fk = team_id, counter_cache = "member_count")]
pub struct CcMember {
#[id]
pub id: i64,
pub nick: String,
pub team_id: i64,
}
#[autumn_web::repository(CcMember, table = "cc_members")]
pub trait CcMemberRepository {}
diesel::table! {
cc_pages (id) {
id -> Int8,
title -> Text,
cc_revision_count -> Int8,
}
}
#[autumn_web::model(table = "cc_pages")]
pub struct CcPage {
#[id]
pub id: i64,
pub title: String,
#[default]
pub cc_revision_count: i64,
}
#[autumn_web::repository(CcPage, table = "cc_pages")]
pub trait CcPageRepository {}
diesel::table! {
cc_revisions (id) {
id -> Int8,
page_id -> Int8,
deleted_at -> Nullable<Timestamp>,
}
}
#[autumn_web::model(table = "cc_revisions")]
#[belongs_to(CcPage, fk = page_id, counter_cache)]
pub struct CcRevision {
#[id]
pub id: i64,
pub page_id: i64,
#[default]
pub deleted_at: Option<chrono::NaiveDateTime>,
}
#[autumn_web::repository(CcRevision, table = "cc_revisions", soft_delete)]
pub trait CcRevisionRepository {}
diesel::table! {
cc_users (id) {
id -> Int8,
name -> Text,
sent_count -> Int8,
}
}
#[autumn_web::model(table = "cc_users")]
pub struct CcUser {
#[id]
pub id: i64,
pub name: String,
#[default]
pub sent_count: i64,
}
#[autumn_web::repository(CcUser, table = "cc_users")]
pub trait CcUserRepository {}
diesel::table! {
cc_rooms (id) {
id -> Int8,
topic -> Text,
cc_message_count -> Int8,
}
}
#[autumn_web::model(table = "cc_rooms")]
pub struct CcRoom {
#[id]
pub id: i64,
pub topic: String,
#[default]
pub cc_message_count: i64,
}
#[autumn_web::repository(CcRoom, table = "cc_rooms")]
pub trait CcRoomRepository {}
#[autumn_web::model(table = "cc_messages")]
#[belongs_to(CcUser, fk = sender_id, name = sender, counter_cache = "sent_count")]
#[belongs_to(CcRoom, fk = room_id, counter_cache)]
pub struct CcMessage {
#[id]
pub id: i64,
pub body: String,
pub sender_id: i64,
pub room_id: i64,
}
diesel::table! {
cc_messages (id) {
id -> Int8,
body -> Text,
sender_id -> Int8,
room_id -> Int8,
}
}
#[autumn_web::repository(CcMessage, table = "cc_messages")]
pub trait CcMessageRepository {}
diesel::table! {
cc_tenant_posts (id) {
id -> Int8,
title -> Text,
tenant_id -> Text,
cc_tenant_comment_count -> Int8,
}
}
#[autumn_web::model(table = "cc_tenant_posts")]
pub struct CcTenantPost {
#[id]
pub id: i64,
pub title: String,
pub tenant_id: String,
#[default]
pub cc_tenant_comment_count: i64,
}
#[autumn_web::repository(CcTenantPost, table = "cc_tenant_posts", tenant_scoped)]
pub trait CcTenantPostRepository {}
diesel::table! {
cc_tenant_comments (id) {
id -> Int8,
body -> Text,
post_id -> Int8,
tenant_id -> Text,
}
}
#[autumn_web::model(table = "cc_tenant_comments")]
#[belongs_to(
CcTenantPost,
fk = post_id,
counter_cache = "cc_tenant_comment_count",
counter_cache_tenant = "tenant_id"
)]
pub struct CcTenantComment {
#[id]
pub id: i64,
pub body: String,
pub post_id: i64,
pub tenant_id: String,
}
#[autumn_web::repository(CcTenantComment, table = "cc_tenant_comments", tenant_scoped)]
pub trait CcTenantCommentRepository {}
diesel::table! {
cc_capped_parents (id) {
id -> Int8,
label -> Text,
cc_capped_count -> Int8,
}
}
#[autumn_web::model(table = "cc_capped_parents")]
pub struct CcCappedParent {
#[id]
pub id: i64,
pub label: String,
#[default]
pub cc_capped_count: i64,
}
#[autumn_web::repository(CcCappedParent, table = "cc_capped_parents")]
pub trait CcCappedParentRepository {}
diesel::table! {
cc_cappeds (id) {
id -> Int8,
parent_id -> Int8,
}
}
#[autumn_web::model(table = "cc_cappeds")]
#[belongs_to(CcCappedParent, fk = parent_id, counter_cache)]
pub struct CcCapped {
#[id]
pub id: i64,
pub parent_id: i64,
}
#[autumn_web::repository(CcCapped, table = "cc_cappeds")]
pub trait CcCappedRepository {}
const DDL: &[&str] = &[
"CREATE TABLE IF NOT EXISTS cc_posts \
(id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, \
cc_comment_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_comments \
(id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, \
post_id BIGINT NOT NULL REFERENCES cc_posts(id))",
"CREATE TABLE IF NOT EXISTS cc_teams \
(id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, \
member_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_members \
(id BIGSERIAL PRIMARY KEY, nick TEXT NOT NULL, \
team_id BIGINT NOT NULL REFERENCES cc_teams(id))",
"CREATE TABLE IF NOT EXISTS cc_pages \
(id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, \
cc_revision_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_revisions \
(id BIGSERIAL PRIMARY KEY, page_id BIGINT NOT NULL REFERENCES cc_pages(id), \
deleted_at TIMESTAMP NULL)",
"CREATE TABLE IF NOT EXISTS cc_users \
(id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, \
sent_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_rooms \
(id BIGSERIAL PRIMARY KEY, topic TEXT NOT NULL, \
cc_message_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_messages \
(id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, \
sender_id BIGINT NOT NULL REFERENCES cc_users(id), \
room_id BIGINT NOT NULL REFERENCES cc_rooms(id))",
"CREATE TABLE IF NOT EXISTS cc_tenant_posts \
(id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, tenant_id TEXT NOT NULL, \
cc_tenant_comment_count BIGINT NOT NULL DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS cc_tenant_comments \
(id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, post_id BIGINT NOT NULL, \
tenant_id TEXT NOT NULL)",
"CREATE TABLE IF NOT EXISTS cc_capped_parents \
(id BIGSERIAL PRIMARY KEY, label TEXT NOT NULL, \
cc_capped_count BIGINT NOT NULL DEFAULT 0 CHECK (cc_capped_count <= 2))",
"CREATE TABLE IF NOT EXISTS cc_cappeds \
(id BIGSERIAL PRIMARY KEY, \
parent_id BIGINT NOT NULL REFERENCES cc_capped_parents(id))",
];
async fn setup_pool() -> (
Pool<AsyncPgConnection>,
testcontainers::ContainerAsync<Postgres>,
) {
let container = Postgres::default()
.start()
.await
.expect("failed to start postgres container");
let host = container.get_host().await.expect("host");
let port = container.get_host_port_ipv4(5432).await.expect("port");
let url = format!("postgres://postgres:postgres@{host}:{port}/postgres");
let manager = AsyncDieselConnectionManager::<AsyncPgConnection>::new(&url);
let pool = Pool::builder(manager).max_size(60).build().expect("pool");
let mut conn = pool.get().await.expect("conn");
for stmt in DDL {
diesel::sql_query(*stmt)
.execute(&mut conn)
.await
.unwrap_or_else(|e| panic!("DDL failed ({stmt}): {e}"));
}
drop(conn);
(pool, container)
}
#[derive(diesel::QueryableByName)]
struct IdRow {
#[diesel(sql_type = BigInt)]
id: i64,
}
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = BigInt)]
count: i64,
}
#[derive(diesel::QueryableByName)]
struct SnapshotRow {
#[diesel(sql_type = BigInt)]
persisted: i64,
#[diesel(sql_type = BigInt)]
ground_truth: i64,
}
async fn seed_post(conn: &mut AsyncPgConnection, title: &str) -> i64 {
diesel::sql_query("INSERT INTO cc_posts (title) VALUES ($1) RETURNING id")
.bind::<diesel::sql_types::Text, _>(title)
.get_result::<IdRow>(conn)
.await
.expect("seed post")
.id
}
async fn seed_one_col(conn: &mut AsyncPgConnection, table: &str, column: &str, value: &str) -> i64 {
diesel::sql_query(format!(
"INSERT INTO {table} ({column}) VALUES ($1) RETURNING id"
))
.bind::<diesel::sql_types::Text, _>(value)
.get_result::<IdRow>(conn)
.await
.expect("seed row")
.id
}
async fn seed_tenant_post(conn: &mut AsyncPgConnection, tenant: &str, title: &str) -> i64 {
diesel::sql_query("INSERT INTO cc_tenant_posts (title, tenant_id) VALUES ($1, $2) RETURNING id")
.bind::<diesel::sql_types::Text, _>(title)
.bind::<diesel::sql_types::Text, _>(tenant)
.get_result::<IdRow>(conn)
.await
.expect("seed tenant post")
.id
}
async fn seed_capped_parent(conn: &mut AsyncPgConnection) -> i64 {
seed_one_col(conn, "cc_capped_parents", "label", "capped").await
}
async fn counter(conn: &mut AsyncPgConnection, table: &str, column: &str, id: i64) -> i64 {
diesel::sql_query(format!(
"SELECT {column} AS count FROM {table} WHERE id = $1"
))
.bind::<BigInt, _>(id)
.get_result::<CountRow>(conn)
.await
.expect("read counter")
.count
}
async fn row_count(conn: &mut AsyncPgConnection, table: &str, predicate: &str) -> i64 {
diesel::sql_query(format!(
"SELECT COUNT(*)::BIGINT AS count FROM {table} WHERE {predicate}"
))
.get_result::<CountRow>(conn)
.await
.expect("count rows")
.count
}
async fn post_snapshot(conn: &mut AsyncPgConnection, post_id: i64) -> SnapshotRow {
diesel::sql_query(
"SELECT p.cc_comment_count AS persisted, \
(SELECT COUNT(*) FROM cc_comments c WHERE c.post_id = p.id)::BIGINT AS ground_truth \
FROM cc_posts p WHERE p.id = $1",
)
.bind::<BigInt, _>(post_id)
.get_result::<SnapshotRow>(conn)
.await
.expect("read post snapshot")
}
async fn total_post_drift(conn: &mut AsyncPgConnection) -> i64 {
diesel::sql_query(
"SELECT COALESCE(SUM(ABS(p.cc_comment_count - \
(SELECT COUNT(*) FROM cc_comments c WHERE c.post_id = p.id))), 0)::BIGINT AS count \
FROM cc_posts p",
)
.get_result::<CountRow>(conn)
.await
.expect("read total drift")
.count
}
#[test]
fn counter_cache_specs_are_generated_from_the_conventions() {
let specs = CcComment::counter_caches();
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].counter_column, "cc_comment_count");
assert_eq!(specs[0].parent_table, "cc_posts");
assert_eq!(specs[0].parent_pk, "id");
assert_eq!(specs[0].fk_column, "post_id");
assert_eq!(specs[0].child_table, "cc_comments");
assert_eq!(specs[0].child_pk, "id");
assert!(
!specs[0].child_soft_delete,
"a child with no deleted_at column must not emit a live-row predicate"
);
const { assert!(CcComment::HAS_COUNTER_CACHES) };
let specs = CcMember::counter_caches();
assert_eq!(specs[0].counter_column, "member_count");
assert_eq!(specs[0].parent_table, "cc_teams");
assert_eq!(
(specs[0].fk_of)(&CcMember {
id: 1,
nick: "x".into(),
team_id: 7
}),
Some(7)
);
assert!(CcRevision::counter_caches()[0].child_soft_delete);
let specs = CcMessage::counter_caches();
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].counter_column, "sent_count");
assert_eq!(specs[0].parent_table, "cc_users");
assert_eq!(specs[1].counter_column, "cc_message_count");
assert_eq!(specs[1].parent_table, "cc_rooms");
assert_eq!(
CcTenantComment::counter_caches()[0].tenant_column,
Some("tenant_id")
);
assert_eq!(CcComment::counter_caches()[0].tenant_column, None);
assert!(CcPost::counter_caches().is_empty());
const { assert!(!CcPost::HAS_COUNTER_CACHES) };
}
#[test]
fn recompute_methods_are_generated() {
fn assert_is_fn<F>(_f: F) {}
assert_is_fn(PgCcCommentRepository::recompute_counter_caches);
assert_is_fn(PgCcCommentRepository::recompute_counter_caches_for);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn save_increments_the_parent_counter() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "hello").await;
for i in 0..3 {
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: post,
})
.await
.expect("save comment");
}
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 3);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_failing_counter_update_rolls_the_child_insert_back() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCappedRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let parent = seed_capped_parent(&mut conn).await;
repo.save(&NewCcCapped { parent_id: parent })
.await
.expect("first");
repo.save(&NewCcCapped { parent_id: parent })
.await
.expect("second");
let err = repo.save(&NewCcCapped { parent_id: parent }).await;
assert!(err.is_err(), "the CHECK on the counter column must fail");
assert_eq!(
row_count(&mut conn, "cc_cappeds", &format!("parent_id = {parent}")).await,
2,
"the child insert must roll back with the failed counter update — \
a third row here means the two statements were not in one transaction"
);
assert_eq!(
counter(&mut conn, "cc_capped_parents", "cc_capped_count", parent).await,
2
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_null_foreign_key_moves_no_counter() {
let (pool, _container) = setup_pool().await;
let repo = PgCcMemberRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let team = seed_one_col(&mut conn, "cc_teams", "name", "core").await;
diesel::sql_query("ALTER TABLE cc_members ALTER COLUMN team_id DROP NOT NULL")
.execute(&mut conn)
.await
.expect("relax the constraint for this test");
diesel::sql_query("INSERT INTO cc_members (nick, team_id) VALUES ('drifter', NULL)")
.execute(&mut conn)
.await
.expect("seed an unparented member");
repo.save(&NewCcMember {
nick: "joiner".into(),
team_id: team,
})
.await
.expect("save assigned member");
assert_eq!(
counter(&mut conn, "cc_teams", "member_count", team).await,
1,
"only the assigned child counts"
);
repo.recompute_counter_caches().await.expect("recompute");
assert_eq!(
counter(&mut conn, "cc_teams", "member_count", team).await,
1
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn both_counter_cached_legs_move_on_one_insert() {
let (pool, _container) = setup_pool().await;
let repo = PgCcMessageRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let user = seed_one_col(&mut conn, "cc_users", "name", "ada").await;
let room = seed_one_col(&mut conn, "cc_rooms", "topic", "general").await;
repo.save(&NewCcMessage {
body: "hi".into(),
sender_id: user,
room_id: room,
})
.await
.expect("save message");
assert_eq!(counter(&mut conn, "cc_users", "sent_count", user).await, 1);
assert_eq!(
counter(&mut conn, "cc_rooms", "cc_message_count", room).await,
1
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn save_many_increments_once_per_child() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let a = seed_post(&mut conn, "a").await;
let b = seed_post(&mut conn, "b").await;
repo.save_many(&[
NewCcComment {
body: "1".into(),
post_id: a,
},
NewCcComment {
body: "2".into(),
post_id: a,
},
NewCcComment {
body: "3".into(),
post_id: b,
},
])
.await
.expect("save_many");
assert_eq!(post_snapshot(&mut conn, a).await.persisted, 2);
assert_eq!(post_snapshot(&mut conn, b).await.persisted, 1);
assert_eq!(total_post_drift(&mut conn).await, 0);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn delete_decrements_the_parent_counter() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "hello").await;
let first = repo
.save(&NewCcComment {
body: "one".into(),
post_id: post,
})
.await
.expect("save");
repo.save(&NewCcComment {
body: "two".into(),
post_id: post,
})
.await
.expect("save");
repo.delete_by_id(first.id).await.expect("delete");
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 1);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn delete_many_decrements_once_per_child() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "hello").await;
let mut ids = Vec::new();
for i in 0..4 {
ids.push(
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: post,
})
.await
.expect("save")
.id,
);
}
repo.delete_many(&ids[..3]).await.expect("delete_many");
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 1);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn soft_delete_decrements_and_counts_only_live_rows() {
let (pool, _container) = setup_pool().await;
let repo = PgCcRevisionRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let page = seed_one_col(&mut conn, "cc_pages", "title", "home").await;
let first = repo
.save(&NewCcRevision { page_id: page })
.await
.expect("save");
repo.save(&NewCcRevision { page_id: page })
.await
.expect("save");
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
2
);
repo.delete_by_id(first.id).await.expect("soft delete");
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
1,
"a soft delete decrements"
);
assert_eq!(
row_count(&mut conn, "cc_revisions", &format!("page_id = {page}")).await,
2,
"the row itself survives a soft delete"
);
let _ = repo.delete_by_id(first.id).await;
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
1,
"re-deleting a soft-deleted child must not double-decrement"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn restore_increments_the_parent_counter_again() {
let (pool, _container) = setup_pool().await;
let repo = PgCcRevisionRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let page = seed_one_col(&mut conn, "cc_pages", "title", "home").await;
let rev = repo
.save(&NewCcRevision { page_id: page })
.await
.expect("save");
repo.delete_by_id(rev.id).await.expect("soft delete");
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
0
);
repo.restore(rev.id).await.expect("restore");
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
1,
"restore puts the count back"
);
let _ = repo.restore(rev.id).await;
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
1,
"restoring an already-live child must not double-increment"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn reassigning_the_parent_moves_the_count() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let old = seed_post(&mut conn, "old").await;
let new = seed_post(&mut conn, "new").await;
let comment = repo
.save(&NewCcComment {
body: "moving".into(),
post_id: old,
})
.await
.expect("save");
assert_eq!(post_snapshot(&mut conn, old).await.persisted, 1);
repo.update(
comment.id,
&UpdateCcComment {
post_id: Patch::Set(new),
..Default::default()
},
)
.await
.expect("reassign");
assert_eq!(post_snapshot(&mut conn, old).await.persisted, 0);
assert_eq!(post_snapshot(&mut conn, new).await.persisted, 1);
assert_eq!(total_post_drift(&mut conn).await, 0);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn an_update_that_keeps_the_parent_moves_nothing() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "stable").await;
let comment = repo
.save(&NewCcComment {
body: "before".into(),
post_id: post,
})
.await
.expect("save");
repo.update(
comment.id,
&UpdateCcComment {
body: Patch::Set("after".into()),
..Default::default()
},
)
.await
.expect("edit body");
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 1);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn only_the_changed_leg_moves() {
let (pool, _container) = setup_pool().await;
let repo = PgCcMessageRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let user = seed_one_col(&mut conn, "cc_users", "name", "ada").await;
let old_room = seed_one_col(&mut conn, "cc_rooms", "topic", "old").await;
let new_room = seed_one_col(&mut conn, "cc_rooms", "topic", "new").await;
let msg = repo
.save(&NewCcMessage {
body: "hi".into(),
sender_id: user,
room_id: old_room,
})
.await
.expect("save");
repo.update(
msg.id,
&UpdateCcMessage {
room_id: Patch::Set(new_room),
..Default::default()
},
)
.await
.expect("move room");
assert_eq!(
counter(&mut conn, "cc_users", "sent_count", user).await,
1,
"the sender leg did not change, so its counter must not move"
);
assert_eq!(
counter(&mut conn, "cc_rooms", "cc_message_count", old_room).await,
0
);
assert_eq!(
counter(&mut conn, "cc_rooms", "cc_message_count", new_room).await,
1
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn update_many_moves_every_reassigned_child() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let old = seed_post(&mut conn, "old").await;
let new = seed_post(&mut conn, "new").await;
let mut ids = Vec::new();
for i in 0..3 {
ids.push(
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: old,
})
.await
.expect("save")
.id,
);
}
repo.update_many(
&ids,
&UpdateCcComment {
post_id: Patch::Set(new),
..Default::default()
},
)
.await
.expect("update_many");
assert_eq!(post_snapshot(&mut conn, old).await.persisted, 0);
assert_eq!(post_snapshot(&mut conn, new).await.persisted, 3);
assert_eq!(total_post_drift(&mut conn).await, 0);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn concurrent_inserts_yield_exactly_n() {
const N: usize = 50;
let (pool, _container) = setup_pool().await;
let repo = Arc::new(PgCcCommentRepository::with_pool_untracked(pool.clone()));
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "storm").await;
let mut handles = Vec::with_capacity(N);
for i in 0..N {
let repo = Arc::clone(&repo);
handles.push(tokio::spawn(async move {
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: post,
})
.await
.expect("concurrent save");
}));
}
for h in handles {
h.await.expect("join");
}
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(
snap.persisted,
i64::try_from(N).expect("N fits in i64"),
"concurrent inserts must not lose counter updates"
);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn concurrent_deletes_drain_to_exactly_zero() {
const N: usize = 30;
let (pool, _container) = setup_pool().await;
let repo = Arc::new(PgCcCommentRepository::with_pool_untracked(pool.clone()));
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "drain").await;
let mut ids = Vec::with_capacity(N);
for i in 0..N {
ids.push(
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: post,
})
.await
.expect("save")
.id,
);
}
let mut handles = Vec::with_capacity(N);
for id in ids {
let repo = Arc::clone(&repo);
handles.push(tokio::spawn(async move {
repo.delete_by_id(id).await.expect("concurrent delete");
}));
}
for h in handles {
h.await.expect("join");
}
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 0);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn recompute_repairs_drift_and_is_idempotent() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let a = seed_post(&mut conn, "a").await;
let b = seed_post(&mut conn, "b").await;
for i in 0..3 {
repo.save(&NewCcComment {
body: format!("a{i}"),
post_id: a,
})
.await
.expect("save");
}
diesel::sql_query("INSERT INTO cc_comments (body, post_id) VALUES ('legacy', $1)")
.bind::<BigInt, _>(b)
.execute(&mut conn)
.await
.expect("legacy insert");
diesel::sql_query("UPDATE cc_posts SET cc_comment_count = 99 WHERE id = $1")
.bind::<BigInt, _>(a)
.execute(&mut conn)
.await
.expect("inflate");
assert_ne!(total_post_drift(&mut conn).await, 0, "drift is present");
let touched = repo
.recompute_counter_caches()
.await
.expect("recompute all parents");
assert_eq!(touched, 2, "both drifted parents are repaired");
assert_eq!(post_snapshot(&mut conn, a).await.persisted, 3);
assert_eq!(post_snapshot(&mut conn, b).await.persisted, 1);
assert_eq!(
total_post_drift(&mut conn).await,
0,
"observed counter drift across all parents is 0 after recompute"
);
assert_eq!(
repo.recompute_counter_caches()
.await
.expect("recompute again"),
0,
"a second sweep must repair nothing"
);
assert_eq!(post_snapshot(&mut conn, a).await.persisted, 3);
assert_eq!(post_snapshot(&mut conn, b).await.persisted, 1);
assert_eq!(total_post_drift(&mut conn).await, 0);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn recompute_for_repairs_only_the_named_parent() {
let (pool, _container) = setup_pool().await;
let repo = PgCcCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let a = seed_post(&mut conn, "a").await;
let b = seed_post(&mut conn, "b").await;
diesel::sql_query("UPDATE cc_posts SET cc_comment_count = 7")
.execute(&mut conn)
.await
.expect("inflate both");
repo.recompute_counter_caches_for(a)
.await
.expect("recompute a");
assert_eq!(post_snapshot(&mut conn, a).await.persisted, 0);
assert_eq!(
counter(&mut conn, "cc_posts", "cc_comment_count", b).await,
7,
"the untargeted parent is left alone"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn recompute_ignores_soft_deleted_children() {
let (pool, _container) = setup_pool().await;
let repo = PgCcRevisionRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let page = seed_one_col(&mut conn, "cc_pages", "title", "home").await;
let first = repo
.save(&NewCcRevision { page_id: page })
.await
.expect("save");
repo.save(&NewCcRevision { page_id: page })
.await
.expect("save");
repo.delete_by_id(first.id).await.expect("soft delete");
diesel::sql_query("UPDATE cc_pages SET cc_revision_count = 42")
.execute(&mut conn)
.await
.expect("inflate");
repo.recompute_counter_caches().await.expect("recompute");
assert_eq!(
counter(&mut conn, "cc_pages", "cc_revision_count", page).await,
1,
"the soft-deleted revision must not be counted"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_repair_sweep_cannot_overwrite_a_concurrent_increment() {
const N: usize = 40;
const SWEEPS: usize = 25;
let (pool, _container) = setup_pool().await;
let repo = Arc::new(PgCcCommentRepository::with_pool_untracked(pool.clone()));
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "repaired under load").await;
let mut handles = Vec::with_capacity(N + 1);
for i in 0..N {
let repo = Arc::clone(&repo);
handles.push(tokio::spawn(async move {
repo.save(&NewCcComment {
body: format!("c{i}"),
post_id: post,
})
.await
.expect("concurrent save");
}));
}
let sweeper = Arc::clone(&repo);
handles.push(tokio::spawn(async move {
for _ in 0..SWEEPS {
sweeper
.recompute_counter_caches()
.await
.expect("recompute under load");
}
}));
for h in handles {
h.await.expect("join");
}
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(
snap.persisted,
i64::try_from(N).expect("N fits in i64"),
"a repair sweep must never overwrite a committed increment"
);
assert_eq!(snap.persisted, snap.ground_truth);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_parent_model_carries_the_maintained_count() {
let (pool, _container) = setup_pool().await;
let comments = PgCcCommentRepository::with_pool_untracked(pool.clone());
let posts = PgCcPostRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let post_id = seed_post(&mut conn, "hello").await;
for i in 0..2 {
comments
.save(&NewCcComment {
body: format!("c{i}"),
post_id,
})
.await
.expect("save");
}
let post = posts
.find_by_id(post_id)
.await
.expect("reload")
.expect("post exists");
assert_eq!(post.cc_comment_count, 2);
let all = posts.find_all().await.expect("list");
assert_eq!(all.iter().map(|p| p.cc_comment_count).sum::<i64>(), 2);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn a_cross_tenant_foreign_key_moves_no_counter() {
let (pool, _container) = setup_pool().await;
let repo = PgCcTenantCommentRepository::with_pool_untracked(pool.clone());
let mut conn = pool.get().await.expect("conn");
let mine = seed_tenant_post(&mut conn, "acme", "mine").await;
let theirs = seed_tenant_post(&mut conn, "globex", "theirs").await;
CURRENT_TENANT
.scope(Some("acme".to_owned()), async {
repo.save(&NewCcTenantComment {
body: "trespass".into(),
post_id: theirs,
tenant_id: "acme".into(),
})
.await
.expect("the insert itself is not what we are testing");
repo.save(&NewCcTenantComment {
body: "legitimate".into(),
post_id: mine,
tenant_id: "acme".into(),
})
.await
.expect("save");
})
.await;
assert_eq!(
counter(
&mut conn,
"cc_tenant_posts",
"cc_tenant_comment_count",
theirs
)
.await,
0,
"a cross-tenant foreign key must not move the other tenant's counter"
);
assert_eq!(
counter(
&mut conn,
"cc_tenant_posts",
"cc_tenant_comment_count",
mine
)
.await,
1,
"the caller's own tenant still works"
);
}
#[tokio::test]
#[ignore = "requires Docker (testcontainers)"]
async fn the_manual_escape_hatch_maintains_the_counter() {
use diesel_async::AsyncConnection as _;
let (pool, _container) = setup_pool().await;
let mut conn = pool.get().await.expect("conn");
let post = seed_post(&mut conn, "manual").await;
conn.transaction::<(), autumn_web::AutumnError, _>(async move |conn| {
let id = diesel::sql_query(
"INSERT INTO cc_comments (body, post_id) VALUES ('manual', $1) RETURNING id",
)
.bind::<BigInt, _>(post)
.get_result::<IdRow>(&mut *conn)
.await?
.id;
autumn_web::repository::counter_cache_after_insert_by_id(
conn,
CcComment::counter_caches(),
id,
)
.await?;
Ok(())
})
.await
.expect("manual insert");
let snap = post_snapshot(&mut conn, post).await;
assert_eq!(snap.persisted, 1);
assert_eq!(snap.persisted, snap.ground_truth);
}