use crate::auth::AuthContext;
use crate::pg::connection::PgConnection;
use crate::pg::pool::DjogiPool;
use crate::{DbError, DjogiError};
use futures::FutureExt;
use postgres_types::ToSql;
use std::panic::AssertUnwindSafe;
use std::pin::Pin;
use std::sync::Arc;
use tokio_postgres::Row;
mod pinned_ctx;
#[allow(unused_imports)] pub(crate) use pinned_ctx::OwnedPinnedCtx;
pub(crate) use pinned_ctx::PinnedCtx;
type OnCommitCallback = Box<
dyn FnOnce() -> Pin<Box<dyn std::future::Future<Output = Result<(), DjogiError>> + Send>>
+ Send,
>;
pub(crate) const NESTED_ATOMIC_CANCELLED_POISON_REASON: &str =
"nested atomic future dropped before savepoint cleanup";
#[derive(Clone, Copy, Debug)]
struct TransactionPoison {
reason: &'static str,
}
impl TransactionPoison {
fn into_error(self) -> DjogiError {
DjogiError::TransactionPoisoned {
reason: self.reason,
}
}
}
pub struct DjogiContext {
inner: ContextInner,
savepoint_depth: u32,
on_commit: Vec<OnCommitCallback>,
transaction_poisoned: Option<TransactionPoison>,
pub tenant_set: bool,
pub(crate) auth: Option<AuthContext>,
pub(crate) tenant_scope_suppressed: bool,
pub(crate) applied_tenant_id: Option<String>,
pub(crate) sassi: Arc<sassi::Sassi>,
}
#[derive(Clone)]
#[doc(hidden)]
pub struct AuthStateSnapshot {
pub(crate) auth: Option<AuthContext>,
pub(crate) applied_tenant_id: Option<String>,
pub(crate) tenant_set: bool,
pub(crate) tenant_scope_suppressed: bool,
}
#[allow(clippy::large_enum_variant)]
#[doc(hidden)]
pub enum ContextInner {
Pool(DjogiPool),
Transaction(PgConnection),
}
#[doc(hidden)]
pub type __ContextInnerForMacros = ContextInner;
impl DjogiContext {
pub fn from_pool(pool: DjogiPool) -> Self {
Self::new(ContextInner::Pool(pool), Self::build_sassi())
}
fn new(inner: ContextInner, sassi: Arc<sassi::Sassi>) -> Self {
DjogiContext {
inner,
savepoint_depth: 0,
on_commit: Vec::new(),
transaction_poisoned: None,
tenant_set: false,
auth: None,
tenant_scope_suppressed: false,
applied_tenant_id: None,
sassi,
}
}
pub fn from_connection(conn: PgConnection) -> Self {
Self::new(ContextInner::Transaction(conn), Self::build_sassi())
}
pub(crate) fn from_connection_with_sassi(conn: PgConnection, sassi: Arc<sassi::Sassi>) -> Self {
Self::new(ContextInner::Transaction(conn), sassi)
}
pub fn savepoint_depth(&self) -> u32 {
self.savepoint_depth
}
#[allow(dead_code)]
pub(crate) fn increment_savepoint_depth(&mut self) {
self.savepoint_depth = self.savepoint_depth.saturating_add(1);
}
#[allow(dead_code)]
pub(crate) fn decrement_savepoint_depth(&mut self) {
self.savepoint_depth = self.savepoint_depth.saturating_sub(1);
}
pub(crate) fn pool(&self) -> Option<&DjogiPool> {
match &self.inner {
ContextInner::Pool(pool) => Some(pool),
ContextInner::Transaction(_) => None,
}
}
pub fn share_pool(&self) -> Option<DjogiPool> {
self.pool().cloned()
}
pub(crate) fn conn(&mut self) -> Option<&mut PgConnection> {
match &mut self.inner {
ContextInner::Pool(_) => None,
ContextInner::Transaction(conn) => Some(conn),
}
}
pub fn is_pool_backed(&self) -> bool {
matches!(&self.inner, ContextInner::Pool(_))
}
#[allow(dead_code)] pub(crate) async fn pin_for_migration(&mut self) -> Result<PinnedCtx<'_>, DjogiError> {
match &self.inner {
ContextInner::Pool(pool) => {
let conn = pool.get().await?;
Ok(PinnedCtx::Owned(pinned_ctx::OwnedPinnedCtx::new(
DjogiContext::from_connection(conn),
)))
}
ContextInner::Transaction(_) => Ok(PinnedCtx::Borrowed(self)),
}
}
pub(crate) fn inner_mut(&mut self) -> &mut ContextInner {
&mut self.inner
}
pub(crate) fn poison_transaction(&mut self, reason: &'static str) {
debug_assert!(
matches!(&self.inner, ContextInner::Transaction(_)),
"transaction poison only applies to transaction-backed contexts",
);
self.transaction_poisoned = Some(TransactionPoison { reason });
}
pub(crate) fn transaction_poison_error(&self) -> Option<DjogiError> {
self.transaction_poisoned.map(TransactionPoison::into_error)
}
pub(crate) fn is_transaction_poisoned(&self) -> bool {
self.transaction_poisoned.is_some()
}
#[allow(dead_code)] pub(crate) fn is_transaction_backed(&self) -> bool {
matches!(&self.inner, ContextInner::Transaction(_))
}
fn clear_transaction_poison(&mut self) {
self.transaction_poisoned = None;
}
fn reject_if_transaction_poisoned(&self) -> Result<(), DjogiError> {
match self.transaction_poison_error() {
Some(err) => Err(err),
None => Ok(()),
}
}
#[doc(hidden)]
pub fn __inner_mut_for_macros(&mut self) -> &mut __ContextInnerForMacros {
&mut self.inner
}
#[doc(hidden)]
pub fn __djogi_is_transaction_backed_for_macros(&self) -> bool {
matches!(&self.inner, ContextInner::Transaction(_))
}
#[doc(hidden)]
pub async fn __query_all_for_macros(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<Row>, DjogiError> {
self.query_all(sql, params).await
}
#[doc(hidden)]
pub async fn __query_opt_for_macros(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Option<Row>, DjogiError> {
self.query_opt(sql, params).await
}
#[doc(hidden)]
pub async fn __query_one_for_macros(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Row, DjogiError> {
self.query_one(sql, params).await
}
#[doc(hidden)]
pub async fn __execute_for_macros(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<u64, DjogiError> {
self.execute(sql, params).await
}
#[doc(hidden)]
pub fn __tenant_scope_suppressed_for_macros(&self) -> bool {
self.tenant_scope_suppressed
}
#[doc(hidden)]
pub async fn __ensure_tenant_set_for_macros(
&mut self,
tenant_id: &str,
) -> Result<(), crate::DjogiError> {
self.ensure_tenant_set(tenant_id).await
}
pub(crate) fn snapshot_auth_state(&self) -> AuthStateSnapshot {
AuthStateSnapshot {
auth: self.auth.clone(),
applied_tenant_id: self.applied_tenant_id.clone(),
tenant_set: self.tenant_set,
tenant_scope_suppressed: self.tenant_scope_suppressed,
}
}
#[doc(hidden)]
pub fn __snapshot_auth_state_for_macros(&self) -> AuthStateSnapshot {
self.snapshot_auth_state()
}
pub(crate) fn restore_auth_state(&mut self, snapshot: AuthStateSnapshot) {
self.auth = snapshot.auth;
self.applied_tenant_id = snapshot.applied_tenant_id;
self.tenant_set = snapshot.tenant_set;
self.tenant_scope_suppressed = snapshot.tenant_scope_suppressed;
}
#[doc(hidden)]
pub async fn __restore_auth_state_for_macros(
&mut self,
snapshot: AuthStateSnapshot,
) -> Result<(), DjogiError> {
match snapshot.applied_tenant_id.as_deref() {
Some(tenant_id) => {
self.set_tenant(tenant_id).await?;
}
None => {
if self.applied_tenant_id.is_some() {
self.clear_tenant().await?;
}
}
}
self.restore_auth_state(snapshot);
Ok(())
}
pub(crate) fn build_sassi() -> Arc<sassi::Sassi> {
let mut s = sassi::Sassi::new();
for hook in inventory::iter::<crate::cache::SassiBootHook>() {
hook.run(&mut s);
}
Arc::new(s)
}
pub fn punnu<T>(&self) -> Option<Arc<sassi::Punnu<T>>>
where
T: crate::types::Cacheable + 'static,
{
self.sassi.pool::<T>()
}
pub fn use_punnu<T>(&self, p: &Arc<sassi::Punnu<T>>) -> Arc<sassi::Punnu<T>>
where
T: crate::types::Cacheable + 'static,
{
let registered = self.sassi.pool::<T>();
let same_context = registered
.as_ref()
.map(|ours| Arc::ptr_eq(p, ours))
.unwrap_or(false);
if !same_context {
if cfg!(debug_assertions) {
panic!(
"cross-context Punnu access: this Punnu<{}> was not registered \
on this DjogiContext's Sassi. Each DjogiContext has its own \
cache registry per cluster 8δ T7.4 (Path X tenant boundary). \
Acquire the Punnu via ctx.punnu::<T>() on this same context.",
std::any::type_name::<T>(),
);
} else {
tracing::error!(
target: "djogi::cache",
model = std::any::type_name::<T>(),
"cross-context Punnu access — returning empty Punnu fallback. \
The passed Punnu<T> is not bound to this DjogiContext: \
either it was acquired from a different context, or T \
was never registered on this context's boot inventory \
(e.g. pk = None or no #[derive(Model)]). Reads return \
None; writes go into the fallback's own L1 only. This \
is a release-build fail-closed; the caller should use \
ctx.punnu::<T>() on this context.",
);
return Arc::new(sassi::Punnu::<T>::builder().build());
}
}
Arc::clone(p)
}
pub(crate) async fn query_all(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Vec<Row>, DjogiError> {
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().query(sql, params).await;
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => conn.query(sql, params).await,
}
}
pub(crate) async fn query_opt(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Option<Row>, DjogiError> {
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().query_opt(sql, params).await;
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => conn.query_opt(sql, params).await,
}
}
pub(crate) async fn query_one(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<Row, DjogiError> {
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().query_one(sql, params).await;
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => conn.query_one(sql, params).await,
}
}
pub(crate) async fn execute(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<u64, DjogiError> {
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().execute(sql, params).await;
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => conn.execute(sql, params).await,
}
}
#[allow(dead_code)] pub(crate) async fn batch_execute(&mut self, sql: &str) -> Result<(), DjogiError> {
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().batch_execute(sql).await;
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => conn.batch_execute(sql).await,
}
}
pub(crate) async fn query_all_with<T, F>(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
finalize: F,
) -> Result<T, DjogiError>
where
F: FnOnce(Vec<Row>) -> Result<T, DjogiError>,
{
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().query(sql, params).await.and_then(finalize);
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => {
let rows = conn.query(sql, params).await?;
finalize(rows)
}
}
}
pub(crate) async fn query_opt_with<T, F>(
&mut self,
sql: &str,
params: &[&(dyn ToSql + Sync)],
finalize: F,
) -> Result<T, DjogiError>
where
F: FnOnce(Option<Row>) -> Result<T, DjogiError>,
{
self.reject_if_transaction_poisoned()?;
match &mut self.inner {
ContextInner::Pool(pool) => {
let mut guard = PoolConnGuard::checkout(pool).await?;
let result = guard.conn().query_opt(sql, params).await.and_then(finalize);
guard.finish(&result);
result
}
ContextInner::Transaction(conn) => {
let row = conn.query_opt(sql, params).await?;
finalize(row)
}
}
}
pub async fn commit(mut self) -> Result<(), DjogiError> {
if let Some(poison_err) = self.transaction_poison_error() {
if let Err(rb_err) = self.rollback_in_place().await {
tracing::error!(
error = ?rb_err,
"DjogiContext::commit: rollback of poisoned transaction failed; detaching connection",
);
self.detach_transaction_connection();
}
return Err(poison_err);
}
self.commit_in_place().await
}
pub(crate) async fn commit_in_place(&mut self) -> Result<(), DjogiError> {
if let Some(poison_err) = self.transaction_poison_error() {
if let Err(rb_err) = self.rollback_in_place().await {
tracing::error!(
error = ?rb_err,
"DjogiContext::commit_in_place: rollback of poisoned transaction failed",
);
}
return Err(poison_err);
}
match &mut self.inner {
ContextInner::Pool(_) => Err(DjogiError::Db(DbError::other(
"DjogiContext::commit called on a pool-backed context",
))),
ContextInner::Transaction(conn) => {
conn.batch_execute("COMMIT").await?;
let on_commit = std::mem::take(&mut self.on_commit);
drain_on_commit(on_commit).await;
Ok(())
}
}
}
pub async fn rollback(mut self) -> Result<(), DjogiError> {
match self.rollback_in_place().await {
Ok(()) => Ok(()),
Err(err) => {
self.detach_transaction_connection();
Err(err)
}
}
}
pub(crate) async fn rollback_in_place(&mut self) -> Result<(), DjogiError> {
self.on_commit.clear();
let result = match &mut self.inner {
ContextInner::Pool(_) => Err(DjogiError::Db(DbError::other(
"DjogiContext::rollback called on a pool-backed context",
))),
ContextInner::Transaction(conn) => {
conn.batch_execute("ROLLBACK").await?;
Ok(())
}
};
if result.is_ok() {
self.clear_transaction_poison();
}
result
}
pub(crate) fn detach_transaction_connection(self) {
let DjogiContext { inner, .. } = self;
if let ContextInner::Transaction(conn) = inner {
conn.detach();
}
}
pub(crate) fn detach_transaction_connection_mut(&mut self) {
if let Some(conn) = self.conn() {
conn.detach_mut();
}
}
pub async fn begin(&self) -> Result<DjogiContext, DjogiError> {
match &self.inner {
ContextInner::Pool(pool) => {
let mut conn = pool.get().await?;
conn.batch_execute("BEGIN").await?;
Ok(DjogiContext::from_connection_with_sassi(
conn,
Arc::clone(&self.sassi),
))
}
ContextInner::Transaction(_) => Err(DjogiError::Db(DbError::other(
"DjogiContext::begin called on a transaction-backed context; \
nested transactions require atomic() (Phase 4 Task 1)",
))),
}
}
#[track_caller]
pub fn on_commit<F, Fut>(&mut self, callback: F)
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<(), DjogiError>> + Send + 'static,
{
match &self.inner {
ContextInner::Pool(_) => {
tracing::warn!(
caller = %std::panic::Location::caller(),
"djogi::on_commit::pool_backed_drop: on_commit callback dropped on a \
pool-backed DjogiContext (no transaction commit will run to drain it); \
wrap the call in djogi::transaction::atomic(..) so the on_commit drain fires",
);
drop(callback);
}
ContextInner::Transaction(_) => {
let boxed: OnCommitCallback = Box::new(move || Box::pin(callback()));
self.on_commit.push(boxed);
}
}
}
pub(crate) fn on_commit_len(&self) -> usize {
self.on_commit.len()
}
pub(crate) fn on_commit_truncate(&mut self, new_len: usize) {
self.on_commit.truncate(new_len);
}
}
struct PoolConnGuard {
conn: Option<PgConnection>,
committed: bool,
}
impl PoolConnGuard {
async fn checkout(pool: &DjogiPool) -> Result<Self, DjogiError> {
let conn = pool.get().await?;
Ok(PoolConnGuard {
conn: Some(conn),
committed: false,
})
}
fn conn(&mut self) -> &mut PgConnection {
self.conn
.as_mut()
.expect("PoolConnGuard.conn is Some until Drop")
}
fn finish<T>(&mut self, result: &Result<T, DjogiError>) {
if result.is_ok() {
self.committed = true;
}
}
}
impl Drop for PoolConnGuard {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
if self.committed {
drop(conn);
} else {
conn.detach();
}
}
}
}
impl DjogiContext {
pub async fn ensure_enum_type(
&mut self,
name: &str,
variants: &[&str],
) -> Result<(), DjogiError> {
validate_runtime_plain_ident(name, "enum type name")?;
if variants.is_empty() {
return Err(DjogiError::Db(crate::error::DbError::other(
"ensure_enum_type requires at least one variant; Postgres rejects \
`CREATE TYPE … AS ENUM ()` with no labels",
)));
}
for v in variants {
if v.len() > 63 {
return Err(DjogiError::Db(crate::error::DbError::other(format!(
"enum variant {v:?} is {len} bytes; Postgres enum labels are \
limited to 63 bytes (NAMEDATALEN - 1)",
len = v.len(),
))));
}
if v.as_bytes().contains(&b'$') {
return Err(DjogiError::Db(crate::error::DbError::other(format!(
"enum variant {v:?} contains `$`; ensure_enum_type rejects \
`$` in variants because the wrapping DO block uses \
dollar-quoted strings whose terminator could be smuggled \
by hostile input. If you genuinely need `$` in a label, \
write the DDL with `ctx.raw_ddl(...)` directly.",
))));
}
}
let mut sql =
String::with_capacity(96 + variants.iter().map(|v| v.len() + 4).sum::<usize>());
const TAG: &str = "$djogi_ensure_enum$";
sql.push_str("DO ");
sql.push_str(TAG);
sql.push_str(" BEGIN CREATE TYPE ");
sql.push_str(name);
sql.push_str(" AS ENUM (");
for (i, v) in variants.iter().enumerate() {
if i > 0 {
sql.push_str(", ");
}
sql.push('\'');
for c in v.chars() {
if c == '\'' {
sql.push('\'');
}
sql.push(c);
}
sql.push('\'');
}
sql.push_str("); EXCEPTION WHEN duplicate_object THEN NULL; END ");
sql.push_str(TAG);
self.batch_execute(&sql).await
}
pub async fn set_tenant(&mut self, tenant_id: &str) -> Result<(), DjogiError> {
self.execute(
"SELECT set_config('app.tenant_id', $1, true)",
&[&tenant_id],
)
.await?;
self.tenant_set = true;
self.applied_tenant_id = Some(tenant_id.to_owned());
Ok(())
}
pub async fn clear_tenant(&mut self) -> Result<(), DjogiError> {
self.execute("SELECT set_config('app.tenant_id', $1, true)", &[&""])
.await?;
self.tenant_set = false;
self.applied_tenant_id = None;
Ok(())
}
pub fn applied_tenant_id(&self) -> Option<&str> {
self.applied_tenant_id.as_deref()
}
pub async fn set_role(&mut self, role: &str) -> Result<(), DjogiError> {
match &self.inner {
ContextInner::Pool(_) => return Err(DjogiError::SetRoleOutsideTransaction),
ContextInner::Transaction(_) => {}
}
validate_role_name(role)?;
let sql = format!("SET LOCAL ROLE \"{role}\"");
self.execute(&sql, &[]).await?;
Ok(())
}
pub async fn defer_constraints(
&mut self,
scope: crate::transaction::DeferScope,
) -> Result<(), DjogiError> {
self.set_constraint_mode(scope, crate::transaction::ConstraintMode::Deferred)
.await
}
pub async fn set_constraints_immediate(
&mut self,
scope: crate::transaction::DeferScope,
) -> Result<(), DjogiError> {
self.set_constraint_mode(scope, crate::transaction::ConstraintMode::Immediate)
.await
}
async fn set_constraint_mode(
&mut self,
scope: crate::transaction::DeferScope,
mode: crate::transaction::ConstraintMode,
) -> Result<(), DjogiError> {
match &self.inner {
ContextInner::Pool(_) => return Err(DjogiError::ConstraintModeOutsideTransaction),
ContextInner::Transaction(_) => {}
}
let sql = match scope {
crate::transaction::DeferScope::All => {
format!("SET CONSTRAINTS ALL {}", mode.as_sql_keyword())
}
crate::transaction::DeferScope::Named(names) => {
if names.is_empty() {
return Err(DjogiError::EmptyDeferConstraintsScope);
}
validate_constraint_names_against_descriptors(names)?;
build_set_constraints_named_sql(names, mode)
}
};
self.execute(&sql, &[]).await?;
Ok(())
}
}
fn validate_constraint_names_against_descriptors(names: &[&str]) -> Result<(), DjogiError> {
use crate::descriptor::{DeferrabilitySpec, ModelDescriptor};
validate_constraint_names_against_inventory(
names,
inventory::iter::<ModelDescriptor>(),
inventory::iter::<DeferrabilitySpec>(),
)
}
fn validate_constraint_names_against_inventory<'a, M, D>(
names: &[&str],
models: M,
specs: D,
) -> Result<(), DjogiError>
where
M: IntoIterator<Item = &'a crate::descriptor::ModelDescriptor>,
D: IntoIterator<Item = &'a crate::descriptor::DeferrabilitySpec>,
{
use std::collections::{BTreeMap, HashMap};
let mut table_by_type: HashMap<&str, &str> = HashMap::new();
for m in models {
table_by_type.insert(m.type_name, m.table_name);
}
let mut deferrability_by_field: BTreeMap<(&str, &str), (bool, bool)> = BTreeMap::new();
for spec in specs {
let key = (spec.model_type_name, spec.field_name);
let value = (spec.deferrable, spec.initially_deferred);
if let Some(prev) = deferrability_by_field.get(&key)
&& *prev != value
{
return Err(DjogiError::ConflictingDeferrabilitySpec {
model_type_name: spec.model_type_name.to_string(),
field_name: spec.field_name.to_string(),
first: *prev,
second: value,
});
}
deferrability_by_field.insert(key, value);
}
let mut deferrable_by_constraint_name: BTreeMap<String, (&str, &str, bool)> = BTreeMap::new();
for ((model_type_name, field_name), (deferrable, _initially_deferred)) in
&deferrability_by_field
{
let table = match table_by_type.get(model_type_name) {
Some(t) => *t,
None => {
return Err(DjogiError::OrphanDeferrabilitySpec {
model_type_name: (*model_type_name).to_string(),
field_name: (*field_name).to_string(),
});
}
};
let constraint_name = crate::migrate::sql::fk_constraint_name(table, field_name);
if let Some((first_model, first_field, _)) =
deferrable_by_constraint_name.get(&constraint_name)
{
return Err(DjogiError::DuplicateConstraintName {
constraint_name,
first_model: (*first_model).to_string(),
first_field: (*first_field).to_string(),
second_model: (*model_type_name).to_string(),
second_field: (*field_name).to_string(),
});
}
deferrable_by_constraint_name
.insert(constraint_name, (model_type_name, field_name, *deferrable));
}
for name in names {
match deferrable_by_constraint_name.get(*name) {
Some((_, _, true)) => continue, Some((_, _, false)) => {
return Err(DjogiError::ConstraintNotDeferrable((*name).to_string()));
}
None => return Err(DjogiError::UnknownConstraintName((*name).to_string())),
}
}
Ok(())
}
fn build_set_constraints_named_sql(
names: &[&str],
mode: crate::transaction::ConstraintMode,
) -> String {
let mut sql = String::with_capacity(
"SET CONSTRAINTS ".len()
+ mode.as_sql_keyword().len()
+ names.iter().map(|n| n.len() + 4).sum::<usize>(),
);
sql.push_str("SET CONSTRAINTS ");
for (i, name) in names.iter().enumerate() {
if i > 0 {
sql.push_str(", ");
}
sql.push('"');
sql.push_str(name);
sql.push('"');
}
sql.push(' ');
sql.push_str(mode.as_sql_keyword());
sql
}
impl DjogiContext {
pub fn clone_for_concurrent_reads(&self) -> Result<DjogiContext, DjogiError> {
let pool = match &self.inner {
ContextInner::Pool(p) => p.clone(),
ContextInner::Transaction(_) => {
return Err(DjogiError::ConcurrentReadsRequirePoolContext);
}
};
Ok(DjogiContext {
inner: ContextInner::Pool(pool),
savepoint_depth: 0,
on_commit: Vec::new(),
transaction_poisoned: None,
tenant_set: false,
auth: self.auth.clone(),
tenant_scope_suppressed: self.tenant_scope_suppressed,
applied_tenant_id: None,
sassi: std::sync::Arc::clone(&self.sassi),
})
}
}
fn validate_role_name(role: &str) -> Result<(), DjogiError> {
if crate::ident::check_plain_ident(role, false).is_err() {
return Err(DjogiError::InvalidRoleName(role.to_string()));
}
Ok(())
}
fn validate_runtime_plain_ident(value: &str, role: &str) -> Result<(), DjogiError> {
use crate::ident::IdentError;
crate::ident::check_user_supplied_ident(value, false).map_err(|e| {
let msg = match e {
IdentError::Empty => format!("{role} cannot be empty"),
IdentError::TooLong { len } => format!(
"{role} {value:?} is {len} bytes; Postgres limits identifiers to 63 bytes (NAMEDATALEN - 1)"
),
IdentError::BadFirst { .. } => {
format!("{role} {value:?} must start with an ASCII letter or underscore")
}
IdentError::BadByte { .. } => format!(
"{role} {value:?} contains a character that is not a valid \
unquoted Postgres identifier byte; only ASCII alphanumerics \
and underscores are permitted after the first character"
),
IdentError::Reserved => unreachable!("check_user_supplied_ident(reserved=false) cannot return Reserved"),
IdentError::ReservedDjogiPrefix => format!(
"{role} {value:?} starts with the framework-reserved `__djogi_` prefix; \
this namespace is used for djogi-internal identifiers — choose a \
different name"
),
};
DjogiError::Db(crate::error::DbError::other(msg))
})
}
pub(crate) async fn drain_on_commit(callbacks: Vec<OnCommitCallback>) {
for cb in callbacks {
let result = AssertUnwindSafe(cb()).catch_unwind().await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::error!(
error = ?e,
"on_commit callback returned Err; continuing",
),
Err(panic_payload) => {
let msg = panic_payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| panic_payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("<non-string panic payload>");
tracing::error!(
panic = %msg,
"on_commit callback panicked; continuing",
);
}
}
}
}
#[cfg(test)]
mod tests {
#[allow(unused_imports)]
use super::*;
#[test]
fn savepoint_depth_starts_at_zero_and_increments() {
}
fn err_msg(role: &str, value: &str) -> String {
match validate_runtime_plain_ident(value, role) {
Err(e) => e.to_string(),
Ok(()) => panic!("expected validate_runtime_plain_ident({role:?}, {value:?}) to error"),
}
}
#[test]
fn validate_runtime_plain_ident_accepts_letter_first() {
assert!(validate_runtime_plain_ident("color", "enum type name").is_ok());
assert!(validate_runtime_plain_ident("_priv", "enum type name").is_ok());
assert!(validate_runtime_plain_ident("a1_b2_c3", "enum type name").is_ok());
}
#[test]
fn validate_runtime_plain_ident_rejects_empty() {
let m = err_msg("enum type name", "");
assert!(m.contains("cannot be empty"), "got: {m}");
}
#[test]
fn validate_runtime_plain_ident_rejects_leading_digit() {
let m = err_msg("enum type name", "9color");
assert!(m.contains("ASCII letter or underscore"), "got: {m}");
}
#[test]
fn validate_runtime_plain_ident_rejects_non_ascii() {
let m = err_msg("enum type name", "color-hyphen");
assert!(
m.contains("not a valid unquoted Postgres identifier byte"),
"got: {m}",
);
let m = err_msg("enum type name", "café");
assert!(
m.contains("not a valid unquoted Postgres identifier byte"),
"got: {m}",
);
}
#[test]
fn validate_runtime_plain_ident_rejects_oversized() {
let big = "a".repeat(64);
let m = err_msg("enum type name", &big);
assert!(m.contains("63 bytes (NAMEDATALEN - 1)"), "got: {m}",);
}
#[test]
fn validate_runtime_plain_ident_accepts_exact_max_length() {
let exact = "a".repeat(63);
assert!(validate_runtime_plain_ident(&exact, "enum type name").is_ok());
}
#[test]
fn validate_runtime_plain_ident_rejects_djogi_reserved_prefix() {
let err = validate_runtime_plain_ident("__djogi_status", "enum type name")
.expect_err("must reject __djogi_ prefix");
let msg = err.to_string();
assert!(msg.contains("`__djogi_` prefix"), "got: {msg}");
assert!(validate_runtime_plain_ident("__djogi_", "enum type name").is_err());
assert!(validate_runtime_plain_ident("djogi_status", "enum type name").is_ok());
assert!(validate_runtime_plain_ident("_djogi_status", "enum type name").is_ok());
}
#[test]
fn validate_role_name_keeps_djogi_prefix_legal() {
assert!(super::validate_role_name("__djogi_admin").is_ok());
assert!(super::validate_role_name("__djogi_").is_ok());
}
#[test]
fn set_role_rejects_quote_injection() {
let attack = "readonly\"; DROP TABLE users--";
match super::validate_role_name(attack) {
Err(DjogiError::InvalidRoleName(s)) => assert_eq!(s, attack),
other => panic!("expected InvalidRoleName({attack:?}), got {other:?}"),
}
}
#[test]
fn set_role_rejects_empty_string() {
match super::validate_role_name("") {
Err(DjogiError::InvalidRoleName(s)) => assert_eq!(s, ""),
other => panic!("expected InvalidRoleName(\"\"), got {other:?}"),
}
}
#[test]
fn set_role_rejects_overlong_name() {
let overlong = "a".repeat(64);
assert_eq!(overlong.len(), 64);
match super::validate_role_name(&overlong) {
Err(DjogiError::InvalidRoleName(s)) => assert_eq!(s, overlong),
other => panic!("expected InvalidRoleName(<64-byte>), got {other:?}"),
}
}
#[test]
fn set_role_accepts_exactly_63_byte_name() {
let exact = "a".repeat(63);
assert_eq!(exact.len(), 63);
assert!(super::validate_role_name(&exact).is_ok());
}
#[test]
fn set_role_accepts_valid_identifier() {
assert!(super::validate_role_name("app_readonly_role").is_ok());
assert!(super::validate_role_name("_internal").is_ok());
assert!(super::validate_role_name("role1").is_ok());
}
#[test]
fn build_set_constraints_named_sql_emits_quoted_names_and_deferred_keyword() {
let sql = super::build_set_constraints_named_sql(
&["posts_author_id_fkey"],
crate::transaction::ConstraintMode::Deferred,
);
assert_eq!(sql, r#"SET CONSTRAINTS "posts_author_id_fkey" DEFERRED"#);
}
#[test]
fn build_set_constraints_named_sql_emits_quoted_names_and_immediate_keyword() {
let sql = super::build_set_constraints_named_sql(
&["posts_author_id_fkey"],
crate::transaction::ConstraintMode::Immediate,
);
assert_eq!(sql, r#"SET CONSTRAINTS "posts_author_id_fkey" IMMEDIATE"#);
}
#[test]
fn build_set_constraints_named_sql_joins_multiple_names_with_commas() {
let sql = super::build_set_constraints_named_sql(
&["a_fkey", "b_fkey", "c_fkey"],
crate::transaction::ConstraintMode::Deferred,
);
assert_eq!(
sql,
r#"SET CONSTRAINTS "a_fkey", "b_fkey", "c_fkey" DEFERRED"#
);
}
fn synth_model(
type_name: &'static str,
table_name: &'static str,
) -> crate::descriptor::ModelDescriptor {
crate::descriptor::model_descriptor(
type_name,
table_name,
crate::descriptor::PkType::HeerIdDesc,
&[],
)
}
fn synth_spec(
model_type_name: &'static str,
field_name: &'static str,
deferrable: bool,
initially_deferred: bool,
) -> crate::descriptor::DeferrabilitySpec {
crate::descriptor::DeferrabilitySpec {
model_type_name,
field_name,
deferrable,
initially_deferred,
}
}
#[test]
fn validator_accepts_deferrable_fk_named_by_convention() {
let models = [synth_model("DeferNode", "djogi_defer_nodes")];
let specs = [synth_spec("DeferNode", "peer_id", true, true)];
super::validate_constraint_names_against_inventory(
&["djogi_defer_nodes_peer_id_fkey"],
models.iter(),
specs.iter(),
)
.expect("conventional FK name on deferrable spec must validate");
}
#[test]
fn validator_rejects_unknown_name_with_typed_error() {
let models = [synth_model("DeferNode", "djogi_defer_nodes")];
let specs = [synth_spec("DeferNode", "peer_id", true, true)];
let err = super::validate_constraint_names_against_inventory(
&["typo_fkey"],
models.iter(),
specs.iter(),
)
.expect_err("unknown name must be rejected");
assert!(
matches!(err, DjogiError::UnknownConstraintName(ref n) if n == "typo_fkey"),
"got: {err:?}"
);
}
#[test]
fn validator_rejects_non_deferrable_named_constraint() {
let models = [synth_model("Post", "posts")];
let specs = [synth_spec("Post", "author_id", false, false)];
let err = super::validate_constraint_names_against_inventory(
&["posts_author_id_fkey"],
models.iter(),
specs.iter(),
)
.expect_err("non-deferrable name must be rejected");
assert!(
matches!(
err,
DjogiError::ConstraintNotDeferrable(ref n) if n == "posts_author_id_fkey"
),
"got: {err:?}"
);
}
#[test]
fn validator_accepts_idempotent_duplicate_deferrability_spec() {
let models = [synth_model("Post", "posts")];
let specs = [
synth_spec("Post", "author_id", true, false),
synth_spec("Post", "author_id", true, false),
];
super::validate_constraint_names_against_inventory(
&["posts_author_id_fkey"],
models.iter(),
specs.iter(),
)
.expect("idempotent duplicate must validate");
}
#[test]
fn validator_rejects_conflicting_deferrability_spec_with_typed_error() {
let models = [synth_model("Post", "posts")];
let specs = [
synth_spec("Post", "author_id", true, false),
synth_spec("Post", "author_id", true, true),
];
let err = super::validate_constraint_names_against_inventory(
&["posts_author_id_fkey"],
models.iter(),
specs.iter(),
)
.expect_err("conflicting spec must be rejected");
let DjogiError::ConflictingDeferrabilitySpec {
model_type_name,
field_name,
first,
second,
} = err
else {
panic!("expected ConflictingDeferrabilitySpec, got: {err:?}");
};
assert_eq!(model_type_name, "Post");
assert_eq!(field_name, "author_id");
assert_eq!(first, (true, false));
assert_eq!(second, (true, true));
}
#[test]
fn validator_rejects_orphan_deferrability_spec_with_typed_error() {
let models: [crate::descriptor::ModelDescriptor; 0] = [];
let specs = [synth_spec("Ghost", "haunts", true, false)];
let err =
super::validate_constraint_names_against_inventory(&[], models.iter(), specs.iter())
.expect_err("orphan spec must be rejected");
let DjogiError::OrphanDeferrabilitySpec {
model_type_name,
field_name,
} = err
else {
panic!("expected OrphanDeferrabilitySpec, got: {err:?}");
};
assert_eq!(model_type_name, "Ghost");
assert_eq!(field_name, "haunts");
}
#[test]
fn validator_rejects_duplicate_constraint_name_with_typed_error() {
let models = [
synth_model("Alpha", "shared_table"),
synth_model("Beta", "shared_table"),
];
let specs = [
synth_spec("Alpha", "shared_col", true, false),
synth_spec("Beta", "shared_col", true, false),
];
let err =
super::validate_constraint_names_against_inventory(&[], models.iter(), specs.iter())
.expect_err("duplicate constraint name must be rejected");
let DjogiError::DuplicateConstraintName {
constraint_name,
first_model,
first_field,
second_model,
second_field,
} = err
else {
panic!("expected DuplicateConstraintName, got: {err:?}");
};
assert_eq!(constraint_name, "shared_table_shared_col_fkey");
assert_eq!(first_model, "Alpha");
assert_eq!(first_field, "shared_col");
assert_eq!(second_model, "Beta");
assert_eq!(second_field, "shared_col");
}
}