use backbone_orm::generic_repository::{
EntityRepoMeta, GenericCrudRepository, HardDelete, SoftDelete,
};
use backbone_orm::org_scope::{with_org_request_scope, OrgScope};
use backbone_orm::{current_write_tx, in_write_tx};
use serde::{Deserialize, Serialize};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
fn admin_dsn() -> Option<String> {
std::env::var("BACKBONE_ORM_RLS_DSN").ok()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)]
struct Thing {
id: Uuid,
org_unit_id: Uuid,
name: String,
metadata: serde_json::Value,
}
impl EntityRepoMeta for Thing {
fn column_types() -> HashMap<String, String> {
HashMap::from([("id".to_string(), "uuid".to_string())])
}
fn search_fields() -> &'static [&'static str] {
&["name"]
}
}
fn thing(unit: Uuid, name: &str) -> Thing {
Thing {
id: Uuid::new_v4(),
org_unit_id: unit,
name: name.into(),
metadata: serde_json::json!({}),
}
}
struct Fixture {
admin: PgPool,
app: PgPool,
schema: String,
role: String,
unit: Uuid,
other: Uuid,
}
impl Fixture {
async fn open() -> Option<Self> {
let dsn = admin_dsn()?;
let admin = PgPoolOptions::new()
.max_connections(3)
.connect(&dsn)
.await
.expect("admin connect");
let tag = Uuid::new_v4().simple().to_string()[..8].to_string();
let (schema, role) = (format!("orm_fence_{tag}"), format!("orm_fence_app_{tag}"));
let predicate =
"org_unit_id = ANY(string_to_array(current_setting('app.scope_unit_ids', true), ',')::uuid[])";
sqlx::raw_sql(&format!(
"CREATE SCHEMA {schema};
CREATE TABLE {schema}.things (
id uuid PRIMARY KEY,
org_unit_id uuid NOT NULL,
name text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{{}}'::jsonb
);
CREATE TABLE {schema}.things_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_unit_id uuid NOT NULL,
what text NOT NULL
);
ALTER TABLE {schema}.things ENABLE ROW LEVEL SECURITY;
ALTER TABLE {schema}.things FORCE ROW LEVEL SECURITY;
CREATE POLICY things_org ON {schema}.things FOR ALL USING ({predicate}) WITH CHECK ({predicate});
ALTER TABLE {schema}.things_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE {schema}.things_log FORCE ROW LEVEL SECURITY;
CREATE POLICY things_log_org ON {schema}.things_log FOR ALL USING ({predicate}) WITH CHECK ({predicate});
CREATE ROLE {role} LOGIN PASSWORD 'fencepw';
GRANT USAGE ON SCHEMA {schema} TO {role};
GRANT SELECT, INSERT, UPDATE, DELETE ON {schema}.things, {schema}.things_log TO {role};"
))
.execute(&admin)
.await
.expect("set up the fenced schema");
let after_at = dsn.rsplit('@').next().expect("dsn has an @host part");
let app = PgPoolOptions::new()
.max_connections(4)
.connect(&format!("postgresql://{role}:fencepw@{after_at}"))
.await
.expect("connect as the app role");
Some(Self {
admin,
app,
schema,
role,
unit: Uuid::new_v4(),
other: Uuid::new_v4(),
})
}
fn table(&self) -> String {
format!("{}.things", self.schema)
}
fn soft(&self) -> GenericCrudRepository<Thing, SoftDelete> {
GenericCrudRepository::new(self.app.clone(), &self.table())
}
fn hard(&self) -> GenericCrudRepository<Thing, HardDelete> {
GenericCrudRepository::new(self.app.clone(), &self.table())
}
async fn seed(&self, unit: Uuid, names: &[&str]) -> Vec<Thing> {
let rows: Vec<Thing> = names.iter().map(|n| thing(unit, n)).collect();
for row in &rows {
sqlx::query(&format!(
"INSERT INTO {} (id, org_unit_id, name) VALUES ($1, $2, $3)",
self.table()
))
.bind(row.id)
.bind(row.org_unit_id)
.bind(&row.name)
.execute(&self.admin)
.await
.unwrap();
}
rows
}
fn scope(&self) -> OrgScope {
OrgScope::for_company_unit(self.unit)
}
async fn deleted(&self, id: Uuid) -> Option<bool> {
sqlx::query_scalar::<_, bool>(&format!(
"SELECT (metadata->>'deleted_at') IS NOT NULL FROM {} WHERE id = $1",
self.table()
))
.bind(id)
.fetch_optional(&self.admin)
.await
.unwrap()
}
async fn logged(&self) -> i64 {
sqlx::query_scalar(&format!("SELECT count(*) FROM {}.things_log", self.schema))
.fetch_one(&self.admin)
.await
.unwrap()
}
async fn log_in_write_tx(&self, what: &str) -> Result<(), sqlx::Error> {
let tx = current_write_tx().expect("inside a write transaction");
let mut conn = tx.conn().await;
sqlx::query(&format!(
"INSERT INTO {}.things_log (org_unit_id, what) VALUES ($1, $2)",
self.schema
))
.bind(self.unit)
.bind(what)
.execute(&mut *conn)
.await?;
Ok(())
}
async fn drop(self) {
self.app.close().await;
sqlx::raw_sql(&format!(
"DROP SCHEMA {} CASCADE; DROP ROLE {};",
self.schema, self.role
))
.execute(&self.admin)
.await
.unwrap();
}
}
#[tokio::test]
async fn the_id_list_batches_work_under_the_org_fence_inside_a_request_scope() {
let Some(f) = Fixture::open().await else {
return;
};
let rows = f.seed(f.unit, &["a", "b", "c"]).await;
let foreign = f.seed(f.other, &["x"]).await;
let ids: Vec<String> = rows.iter().map(|r| r.id.to_string()).collect();
let repo = f.soft();
let affected = with_org_request_scope(&f.app, f.scope(), repo.bulk_soft_delete(&ids))
.await
.expect("scope")
.expect("a soft delete under the org fence");
assert_eq!(affected, 3);
assert!(f.deleted(rows[0].id).await.unwrap());
let restored = with_org_request_scope(&f.app, f.scope(), repo.bulk_restore(&ids[..2]))
.await
.expect("scope")
.expect("a restore under the org fence");
assert_eq!(restored.len(), 2);
assert!(!f.deleted(rows[0].id).await.unwrap());
assert!(
f.deleted(rows[2].id).await.unwrap(),
"the third id stayed in the trash"
);
let purged = with_org_request_scope(&f.app, f.scope(), repo.bulk_permanent_delete(&ids[2..]))
.await
.expect("scope")
.expect("a permanent delete under the org fence");
assert_eq!(purged, 1);
assert!(f.deleted(rows[2].id).await.is_none());
let edited: Vec<Thing> = restored
.iter()
.map(|r| Thing {
name: format!("{}!", r.name),
..r.clone()
})
.collect();
let updated = with_org_request_scope(&f.app, f.scope(), repo.bulk_update(&edited))
.await
.expect("scope")
.expect("a full-row bulk update under the org fence");
assert_eq!(
updated.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
vec!["a!", "b!"]
);
let hard = f.hard();
let doomed = f.seed(f.unit, &["h1", "h2"]).await;
let doomed_ids: Vec<String> = doomed.iter().map(|r| r.id.to_string()).collect();
let gone = with_org_request_scope(&f.app, f.scope(), hard.bulk_delete(&doomed_ids))
.await
.expect("scope")
.expect("a hard bulk delete under the org fence");
assert_eq!(gone, 2);
let mixed = vec![ids[0].clone(), foreign[0].id.to_string()];
let refused = with_org_request_scope(&f.app, f.scope(), repo.bulk_soft_delete(&mixed))
.await
.expect("scope");
assert!(refused.is_err(), "an id the fence hides is not found");
assert!(
!f.deleted(rows[0].id).await.unwrap(),
"the refused batch changed nothing"
);
assert!(!f.deleted(foreign[0].id).await.unwrap());
f.drop().await;
}
#[tokio::test]
async fn a_batch_inside_a_write_transaction_commits_and_rolls_back_with_it() {
let Some(f) = Fixture::open().await else {
return;
};
let rows = f.seed(f.unit, &["a", "b"]).await;
let ids: Vec<String> = rows.iter().map(|r| r.id.to_string()).collect();
let repo = f.soft();
let failed: Result<(), sqlx::Error> = with_org_request_scope(&f.app, f.scope(), async {
in_write_tx::<_, (), sqlx::Error>(&f.app, async {
repo.bulk_soft_delete(&ids)
.await
.map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
f.log_in_write_tx("deleted").await?;
Err(sqlx::Error::Protocol("a hook refused the write".into()))
})
.await
})
.await
.expect("scope");
assert!(failed.is_err());
assert!(
!f.deleted(rows[0].id).await.unwrap() && !f.deleted(rows[1].id).await.unwrap(),
"the batch was rolled back with the write"
);
assert_eq!(f.logged().await, 0, "and so was the event staged beside it");
with_org_request_scope(&f.app, f.scope(), async {
in_write_tx::<_, (), sqlx::Error>(&f.app, async {
repo.bulk_soft_delete(&ids)
.await
.map_err(|e| sqlx::Error::Protocol(e.to_string()))?;
f.log_in_write_tx("deleted").await?;
Ok(())
})
.await
})
.await
.expect("scope")
.expect("the write commits");
assert!(f.deleted(rows[0].id).await.unwrap() && f.deleted(rows[1].id).await.unwrap());
assert_eq!(f.logged().await, 1);
let survived: Result<(), sqlx::Error> = with_org_request_scope(&f.app, f.scope(), async {
in_write_tx::<_, (), sqlx::Error>(&f.app, async {
f.log_in_write_tx("before the refused batch").await?;
let bad = vec![ids[0].clone(), Uuid::new_v4().to_string()];
assert!(
repo.bulk_restore(&bad).await.is_err(),
"an id that is not in the trash refuses the batch"
);
f.log_in_write_tx("after the refused batch").await?;
Ok(())
})
.await
})
.await
.expect("scope");
survived.expect("the write carries on after the refused batch");
assert_eq!(f.logged().await, 3, "the refused batch undid only itself");
assert!(
f.deleted(rows[0].id).await.unwrap(),
"the refused restore restored nothing"
);
f.drop().await;
}
#[tokio::test]
async fn without_a_write_transaction_or_a_request_scope_a_batch_is_all_or_nothing_as_before() {
let Some(f) = Fixture::open().await else {
return;
};
let rows = f.seed(f.unit, &["a", "b"]).await;
let ids: Vec<String> = rows.iter().map(|r| r.id.to_string()).collect();
let repo: GenericCrudRepository<Thing, SoftDelete> =
GenericCrudRepository::new(f.admin.clone(), &f.table());
let mixed = vec![ids[0].clone(), Uuid::new_v4().to_string()];
assert!(
repo.bulk_soft_delete(&mixed).await.is_err(),
"a missing id refuses the whole batch"
);
assert!(
!f.deleted(rows[0].id).await.unwrap(),
"and nothing was changed"
);
assert_eq!(repo.bulk_soft_delete(&ids).await.unwrap(), 2);
assert_eq!(repo.bulk_restore(&ids).await.unwrap().len(), 2);
assert_eq!(repo.bulk_soft_delete(&ids).await.unwrap(), 2);
assert_eq!(repo.bulk_permanent_delete(&ids).await.unwrap(), 2);
f.drop().await;
}