use std::borrow::Cow;
use thiserror::Error;
fn presentation_startup_error_summary(
errors: &[crate::presentation::PresentationStartupError],
) -> String {
if errors.is_empty() {
return "no individual codec startup errors were collected".to_owned();
}
errors
.iter()
.enumerate()
.map(|(idx, error)| {
let msg = error.to_string();
let msg = msg
.strip_prefix("presentation codec startup: ")
.unwrap_or(msg.as_str());
format!("{}. {msg}", idx + 1)
})
.collect::<Vec<_>>()
.join("; ")
}
#[derive(Debug)]
pub struct DbError(DbErrorKind);
#[derive(Debug)]
enum DbErrorKind {
Pg(tokio_postgres::Error),
Message(Box<str>),
#[cfg(test)]
SyntheticDb {
code: tokio_postgres::error::SqlState,
message: Box<str>,
},
}
impl DbError {
pub fn other(message: impl Into<String>) -> Self {
Self(DbErrorKind::Message(message.into().into_boxed_str()))
}
pub fn code(&self) -> Option<&tokio_postgres::error::SqlState> {
match &self.0 {
DbErrorKind::Pg(error) => error.code(),
DbErrorKind::Message(_) => None,
#[cfg(test)]
DbErrorKind::SyntheticDb { code, .. } => Some(code),
}
}
pub fn message(&self) -> Cow<'_, str> {
match &self.0 {
DbErrorKind::Pg(error) => error
.as_db_error()
.map(|db| Cow::Borrowed(db.message()))
.unwrap_or_else(|| Cow::Owned(error.to_string())),
DbErrorKind::Message(message) => Cow::Borrowed(message),
#[cfg(test)]
DbErrorKind::SyntheticDb { message, .. } => Cow::Borrowed(message),
}
}
#[cfg(test)]
fn synthetic_sqlstate(code: &str, message: &str) -> Self {
Self(DbErrorKind::SyntheticDb {
code: tokio_postgres::error::SqlState::from_code(code),
message: message.to_owned().into_boxed_str(),
})
}
}
impl std::fmt::Display for DbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
DbErrorKind::Pg(error) => write!(f, "{error}"),
DbErrorKind::Message(message) => write!(f, "{message}"),
#[cfg(test)]
DbErrorKind::SyntheticDb { message, .. } => write!(f, "{message}"),
}
}
}
impl std::error::Error for DbError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.0 {
DbErrorKind::Pg(error) => Some(error),
DbErrorKind::Message(_) => None,
#[cfg(test)]
DbErrorKind::SyntheticDb { .. } => None,
}
}
}
impl From<tokio_postgres::Error> for DbError {
fn from(error: tokio_postgres::Error) -> Self {
Self(DbErrorKind::Pg(error))
}
}
#[derive(Debug)]
pub struct IdGenerationError(pub Box<dyn std::error::Error + Send + Sync>);
impl std::fmt::Display for IdGenerationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for IdGenerationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(self.0.as_ref())
}
}
impl IdGenerationError {
pub fn new<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
IdGenerationError(Box::new(e))
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DjogiError {
#[error("auth error: {0}")]
Auth(#[from] crate::auth::AuthError),
#[cfg(feature = "spatial")]
#[error("geo error: {0}")]
Geo(#[from] crate::geo::GeoError),
#[error("database error: {0}")]
Db(#[source] DbError),
#[error("row not found in `{table}`")]
#[non_exhaustive]
NotFound { table: &'static str },
#[error("multiple objects returned from `{table}` (saw {count_seen}, expected exactly 1)")]
#[non_exhaustive]
MultipleObjects {
table: &'static str,
count_seen: usize,
},
#[error("id generation failed: {0}")]
IdGeneration(IdGenerationError),
#[error(
"relation field `{field}` on `{model}` was not loaded — \
use .prefetch() or .select_related() before .expect_resolved()"
)]
#[non_exhaustive]
RelationUnloaded {
model: &'static str,
field: &'static str,
},
#[error("JSON serialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("visage error: {0}")]
Visage(#[from] crate::visage::VisageError),
#[error("aggregate '{model}' id={id} is gone: {reason}")]
#[non_exhaustive]
GoneAggregate {
model: &'static str,
id: String,
reason: &'static str,
},
#[error("row decode error: {0}")]
Decode(String),
#[error(
"model '{model}' has no #[model(idempotency_key = \"...\")] declared; \
set one or use bulk_upsert with an explicit conflict-key slice"
)]
#[non_exhaustive]
MissingIdempotencyKey { model: &'static str },
#[error("validation error: {0}")]
Validation(String),
#[error("lock conflict: {0}")]
LockConflict(#[source] DbError),
#[error("QuerySet::stream requires an active transaction — wrap the call in atomic()")]
StreamOutsideTransaction,
#[error(
"transaction is poisoned ({reason}): a nested atomic future was dropped before \
savepoint cleanup could run; the transaction is unsafe to commit, roll it back \
and retry the outer unit of work"
)]
#[non_exhaustive]
TransactionPoisoned {
reason: &'static str,
},
#[error(
"raw SQL statement {statement} is not allowed inside an atomic() transaction; \
use a transaction-local form (`SET LOCAL`, `SET CONSTRAINTS`, `SET TRANSACTION`) \
or run the session-scoped statement on a pool-backed context"
)]
#[non_exhaustive]
SessionStatementDisallowedInTransaction {
statement: &'static str,
},
#[error(
"raw transaction-control statement {statement} is not allowed on a \
transaction-backed DjogiContext; use djogi's transaction API so COMMIT \
drains on_commit callbacks, ROLLBACK clears framework state, and savepoint \
depth stays synchronized"
)]
#[non_exhaustive]
RawTransactionControlDisallowedInTransaction {
statement: &'static str,
},
#[error("unsupported aggregate: {op} — {reason}")]
#[non_exhaustive]
UnsupportedAggregate {
op: &'static str,
reason: &'static str,
},
#[error("alias collision in SELECT list: {alias}")]
AliasCollision {
alias: String,
},
#[error("pool timeout ({phase})")]
#[non_exhaustive]
PoolTimeout {
phase: &'static str,
},
#[error(
"set_role can only be called inside an atomic() transaction; \
pool-backed contexts have no transaction scope to bind SET LOCAL ROLE to"
)]
SetRoleOutsideTransaction,
#[error(
"invalid Postgres role name {0:?}: must match Postgres identifier grammar \
(ASCII letter or underscore followed by ASCII alphanumerics or underscores, \
up to 63 bytes; no embedded quotes or control characters)"
)]
InvalidRoleName(String),
#[error("predicate cannot be lowered to SQL: {0}")]
Predicate(#[from] crate::query::PortablePredicateError),
#[error(
"set-op arm `{side}` on `{table}` is incompatible with set-operation subquery: {reason}"
)]
#[non_exhaustive]
SetOpArmInvalid {
table: &'static str,
side: &'static str,
reason: &'static str,
},
#[error("set-op outer ORDER BY on `{table}` is incompatible with set-operation: {reason}")]
#[non_exhaustive]
SetOpOuterOrderingInvalid {
table: &'static str,
reason: &'static str,
},
#[error(
"atomic_with(level={requested}) called inside an open atomic() scope; \
Postgres pins the isolation level at the outer BEGIN, savepoints \
cannot change it — use atomic() for nested scopes, or move the \
atomic_with call outside the enclosing transaction"
)]
IsolationLevelOnNestedScope {
requested: crate::transaction::IsolationLevel,
},
#[error(
"defer_constraints / set_constraints_immediate can only be called \
inside an atomic() transaction; pool-backed contexts have no \
transaction scope for SET CONSTRAINTS to bind to"
)]
ConstraintModeOutsideTransaction,
#[error(
"unknown constraint name {0:?} — no `#[derive(Model)]`-declared FK \
registers under that name. Expected names follow the convention \
`<table>_<column>_fkey` (truncated to 63 bytes for long names)"
)]
UnknownConstraintName(String),
#[error(
"constraint {0:?} is not declared deferrable; SET CONSTRAINTS only \
applies to constraints declared `DEFERRABLE`. Declare the FK with \
`#[field(deferrable = true)]` (and optionally `initially_deferred = \
true`) at the model declaration"
)]
ConstraintNotDeferrable(String),
#[error(
"DeferScope::Named requires at least one constraint name; \
empty slices produce malformed `SET CONSTRAINTS` SQL. Use \
`DeferScope::All` to target every deferrable constraint, or \
skip the call when the list is empty"
)]
EmptyDeferConstraintsScope,
#[error(
"conflicting DeferrabilitySpec inventory entries for \
{model_type_name}.{field_name}: \
first = {first:?}, second = {second:?}. Two `#[derive(Model)]` \
emissions disagree on `(deferrable, initially_deferred)`; \
resolve the duplicate model definition at the source"
)]
ConflictingDeferrabilitySpec {
model_type_name: String,
field_name: String,
first: (bool, bool),
second: (bool, bool),
},
#[error(
"orphan DeferrabilitySpec for {model_type_name}.{field_name}: \
no matching ModelDescriptor is registered in the inventory. \
`#[derive(Model)]` emits both side by side; the orphan \
indicates a hand-written `inventory::submit!` outside the \
macro or a partial-emit bug"
)]
OrphanDeferrabilitySpec {
model_type_name: String,
field_name: String,
},
#[error(
"FK constraint name {constraint_name:?} collides across two \
distinct fields: ({first_model}.{first_field}) and \
({second_model}.{second_field}). Postgres' 63-byte identifier \
limit truncates long `<table>_<column>_fkey` strings; shorten \
the offending table or column name to disambiguate"
)]
DuplicateConstraintName {
constraint_name: String,
first_model: String,
first_field: String,
second_model: String,
second_field: String,
},
#[error(
"clone_for_concurrent_reads requires a pool-backed DjogiContext; \
transaction-backed contexts own a single connection that cannot \
be aliased across concurrent reads. Move the concurrent-reads \
block outside the surrounding atomic() scope, or fetch \
sequentially"
)]
ConcurrentReadsRequirePoolContext,
#[error("merge source queryset on `{table}` is invalid: {reason}")]
#[non_exhaustive]
MergeSourceInvalid {
table: &'static str,
reason: &'static str,
},
#[error("merge branch on `{table}` is invalid: {reason}")]
#[non_exhaustive]
MergeBranchInvalid { table: &'static str, reason: String },
#[error("merge statement on `{table}` is invalid: {reason}")]
#[non_exhaustive]
MergeNoBranches { table: &'static str, reason: String },
#[error(
"presentation codec startup validation failed ({} error(s)): {}",
.0.len(),
crate::error::presentation_startup_error_summary(&.0)
)]
PresentationStartup(Vec<crate::presentation::PresentationStartupError>),
#[error(
"PostgreSQL {detected_major}.{detected_minor} is below the minimum supported \
version {minimum_major}. Upgrade to PostgreSQL {minimum_major} or later"
)]
#[non_exhaustive]
UnsupportedPostgresVersion {
detected_major: u32,
detected_minor: u32,
minimum_major: u32,
},
}
impl From<tokio_postgres::Error> for DjogiError {
fn from(e: tokio_postgres::Error) -> Self {
map_pg_err(e)
}
}
impl From<std::convert::Infallible> for DjogiError {
fn from(never: std::convert::Infallible) -> Self {
match never {}
}
}
impl DjogiError {
pub fn not_found(table: &'static str) -> Self {
DjogiError::NotFound { table }
}
pub fn multiple_objects(table: &'static str, count_seen: usize) -> Self {
DjogiError::MultipleObjects { table, count_seen }
}
pub fn relation_unloaded(model: &'static str, field: &'static str) -> Self {
DjogiError::RelationUnloaded { model, field }
}
pub fn missing_idempotency_key(model: &'static str) -> Self {
DjogiError::MissingIdempotencyKey { model }
}
pub fn gone_aggregate(model: &'static str, id: String, reason: &'static str) -> Self {
DjogiError::GoneAggregate { model, id, reason }
}
pub fn unsupported_postgres_version(
detected_major: u32,
detected_minor: u32,
minimum_major: u32,
) -> Self {
DjogiError::UnsupportedPostgresVersion {
detected_major,
detected_minor,
minimum_major,
}
}
pub fn is_transient(&self) -> bool {
match self {
DjogiError::LockConflict(_) => true,
DjogiError::Db(e) => is_lock_error(e),
DjogiError::PoolTimeout { .. } => true,
_ => false,
}
}
pub fn is_terminal(&self) -> bool {
!self.is_transient()
}
}
pub(crate) fn is_lock_error(e: &DbError) -> bool {
use tokio_postgres::error::SqlState;
e.code()
.map(|code| {
code == &SqlState::T_R_SERIALIZATION_FAILURE
|| code == &SqlState::T_R_DEADLOCK_DETECTED
|| code == &SqlState::LOCK_NOT_AVAILABLE
})
.unwrap_or(false)
}
pub(crate) fn map_pg_err(e: tokio_postgres::Error) -> DjogiError {
let error = DbError::from(e);
if is_lock_error(&error) {
DjogiError::LockConflict(error)
} else {
DjogiError::Db(error)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn not_found_displays_table_name() {
let err = DjogiError::not_found("posts");
let msg = format!("{err}");
assert!(
msg.contains("posts"),
"expected table name in error message, got: {msg}"
);
assert!(msg.to_lowercase().contains("not found"));
}
#[test]
fn multiple_objects_displays_table_and_count() {
let err = DjogiError::multiple_objects("posts", 2);
let msg = format!("{err}");
assert!(msg.contains("posts"));
assert!(msg.contains("2"));
assert!(msg.to_lowercase().contains("multiple"));
}
#[test]
fn relation_unloaded_displays_model_and_field() {
let err = DjogiError::relation_unloaded("Vehicle", "owner_id");
let msg = format!("{err}");
assert!(msg.contains("Vehicle"), "expected model name, got: {msg}");
assert!(msg.contains("owner_id"), "expected field name, got: {msg}");
assert!(
msg.contains("prefetch") || msg.contains("select_related"),
"expected remediation hint, got: {msg}"
);
}
fn db_err_with_code(code: &str) -> DbError {
DbError::synthetic_sqlstate(code, "synthetic database error")
}
#[test]
fn db_variant_constructs_from_tokio_postgres_error() {
let driver_error = tokio_postgres::Error::__private_api_timeout();
let mapped = DjogiError::from(driver_error);
assert!(matches!(mapped, DjogiError::Db(_)));
}
#[test]
fn is_lock_error_matches_retryable_sqlstates() {
assert!(is_lock_error(&db_err_with_code("40001")));
assert!(is_lock_error(&db_err_with_code("40P01")));
assert!(is_lock_error(&db_err_with_code("55P03")));
}
#[test]
fn is_lock_error_rejects_unrelated_sqlstate() {
assert!(!is_lock_error(&db_err_with_code("23505")));
}
#[test]
fn is_lock_error_rejects_message_only_error() {
assert!(!is_lock_error(&DbError::other("no sqlstate here")));
}
#[test]
fn db_error_message_accessor_preserves_framework_generated_message() {
let err = DbError::other("DjogiContext::commit called on a pool-backed context");
assert_eq!(
err.message(),
"DjogiContext::commit called on a pool-backed context"
);
}
#[test]
fn is_transient_covers_lock_conflict_and_retryable_db() {
let lc = DjogiError::LockConflict(db_err_with_code("55P03"));
assert!(lc.is_transient(), "LockConflict must be transient");
assert!(!lc.is_terminal(), "LockConflict must not be terminal");
for code in ["40001", "40P01", "55P03"] {
let err = DjogiError::Db(db_err_with_code(code));
assert!(
err.is_transient(),
"Db with SQLSTATE {code} must be transient"
);
}
let unique = DjogiError::Db(db_err_with_code("23505"));
assert!(unique.is_terminal(), "unique_violation must be terminal");
assert!(
!unique.is_transient(),
"unique_violation must not be transient"
);
}
#[test]
fn is_terminal_covers_every_known_variant() {
assert!(DjogiError::not_found("t").is_terminal());
assert!(DjogiError::multiple_objects("t", 2).is_terminal());
assert!(DjogiError::relation_unloaded("M", "f").is_terminal());
assert!(DjogiError::missing_idempotency_key("M").is_terminal());
assert!(DjogiError::Validation("bad".into()).is_terminal());
assert!(
DjogiError::Decode("column `id`: type mismatch".into()).is_terminal(),
"Decode must be terminal — a type mismatch cannot be resolved by retrying"
);
assert!(
DjogiError::gone_aggregate("M", "42".into(), "deleted").is_terminal(),
"GoneAggregate must be terminal — retry cannot resurrect a deleted aggregate"
);
assert!(
DjogiError::Visage(crate::visage::VisageError::UnresolvedRelation {
model: "M",
field: "f",
scope: "public"
})
.is_terminal(),
"Visage conversion failures must be terminal unless explicitly reclassified"
);
assert!(
DjogiError::PresentationStartup(vec![]).is_terminal(),
"PresentationStartup must be terminal — missing startup prerequisites need operator action"
);
let presentation_startup_msg = DjogiError::PresentationStartup(vec![
crate::presentation::PresentationStartupError::MissingEnvVar {
name: "DJOGI_PRESENTATION_HMAC_KEY",
},
crate::presentation::PresentationStartupError::Usage {
model: "User",
field: "email",
scope: "public",
codec_path: "djogi::presentation::builtins::HmacSha256HexString",
source: Box::new(
crate::presentation::PresentationStartupError::MissingEnvVar {
name: "DJOGI_PRESENTATION_HMAC_KEY",
},
),
},
])
.to_string();
assert!(
presentation_startup_msg.contains("2 error(s)"),
"PresentationStartup display must include the error count: {presentation_startup_msg}"
);
assert!(
presentation_startup_msg.contains("missing env var `DJOGI_PRESENTATION_HMAC_KEY`"),
"PresentationStartup display must include the actionable inner error: {presentation_startup_msg}"
);
assert!(
presentation_startup_msg.contains("User.email scope `public` codec `djogi::presentation::builtins::HmacSha256HexString` failed"),
"PresentationStartup display must include the usage context: {presentation_startup_msg}"
);
assert!(
DjogiError::PoolTimeout { phase: "wait" }.is_transient(),
"PoolTimeout must be transient — saturation is a retry-with-backoff condition"
);
assert!(
!DjogiError::PoolTimeout { phase: "wait" }.is_terminal(),
"PoolTimeout must NOT be terminal — generic retry helpers must not dead-letter it"
);
assert!(
DjogiError::TransactionPoisoned {
reason: "nested atomic future dropped before savepoint cleanup"
}
.is_terminal(),
"TransactionPoisoned must be terminal — retry cannot clean the same context"
);
assert!(
DjogiError::SetRoleOutsideTransaction.is_terminal(),
"SetRoleOutsideTransaction must be terminal — retry cannot promote a pool-backed context"
);
assert!(
DjogiError::InvalidRoleName("readonly".into()).is_terminal(),
"InvalidRoleName must be terminal — a malformed role name is a programming error"
);
assert!(
DjogiError::SetOpOuterOrderingInvalid {
table: "t",
reason: "spatial ST_Distance(...) is an expression, not an output column"
}
.is_terminal(),
"SetOpOuterOrderingInvalid must be terminal — retry cannot reshape the ordering"
);
assert!(
DjogiError::MergeSourceInvalid {
table: "t",
reason: "prefetch is not supported"
}
.is_terminal(),
"MergeSourceInvalid must be terminal"
);
assert!(
DjogiError::MergeBranchInvalid {
table: "t",
reason: "duplicate column".into()
}
.is_terminal(),
"MergeBranchInvalid must be terminal"
);
assert!(
DjogiError::MergeNoBranches {
table: "t",
reason: "at least one branch required".into()
}
.is_terminal(),
"MergeNoBranches must be terminal"
);
assert!(
DjogiError::unsupported_postgres_version(17, 4, 18).is_terminal(),
"UnsupportedPostgresVersion must be terminal — version mismatch cannot be resolved by retrying"
);
assert!(
DjogiError::RawTransactionControlDisallowedInTransaction {
statement: "COMMIT"
}
.is_terminal(),
"RawTransactionControlDisallowedInTransaction must be terminal"
);
}
#[test]
fn set_role_outside_transaction_is_terminal() {
let err = DjogiError::SetRoleOutsideTransaction;
assert!(
err.is_terminal(),
"SetRoleOutsideTransaction must be terminal"
);
assert!(
!err.is_transient(),
"SetRoleOutsideTransaction must not be transient"
);
}
#[test]
fn invalid_role_name_is_terminal() {
let err = DjogiError::InvalidRoleName("x".to_string());
assert!(err.is_terminal(), "InvalidRoleName must be terminal");
assert!(!err.is_transient(), "InvalidRoleName must not be transient");
}
#[test]
fn invalid_role_name_display_includes_offending_value() {
let err = DjogiError::InvalidRoleName("readonly\"; DROP TABLE".into());
let msg = format!("{err}");
assert!(
msg.contains("readonly\\\"; DROP TABLE"),
"expected debug-quoted role name in error message, got: {msg}"
);
}
#[test]
fn isolation_level_on_nested_scope_is_terminal() {
let err = DjogiError::IsolationLevelOnNestedScope {
requested: crate::transaction::IsolationLevel::Serializable,
};
assert!(
err.is_terminal(),
"IsolationLevelOnNestedScope must be terminal"
);
assert!(
!err.is_transient(),
"IsolationLevelOnNestedScope must not be transient"
);
}
#[test]
fn isolation_level_on_nested_scope_display_includes_level() {
let err = DjogiError::IsolationLevelOnNestedScope {
requested: crate::transaction::IsolationLevel::RepeatableRead,
};
let msg = format!("{err}");
assert!(
msg.contains("REPEATABLE READ"),
"expected requested isolation level in message, got: {msg}"
);
}
#[test]
fn transaction_poisoned_is_terminal() {
let err = DjogiError::TransactionPoisoned {
reason: "nested atomic future dropped before savepoint cleanup",
};
assert!(err.is_terminal(), "TransactionPoisoned must be terminal");
assert!(
!err.is_transient(),
"TransactionPoisoned must not be transient"
);
}
#[test]
fn session_statement_disallowed_in_transaction_is_terminal() {
let err = DjogiError::SessionStatementDisallowedInTransaction { statement: "SET" };
assert!(
err.is_terminal(),
"SessionStatementDisallowedInTransaction must be terminal"
);
assert!(
!err.is_transient(),
"SessionStatementDisallowedInTransaction must not be transient"
);
}
#[test]
fn raw_transaction_control_disallowed_in_transaction_is_terminal() {
let err = DjogiError::RawTransactionControlDisallowedInTransaction {
statement: "COMMIT",
};
assert!(
err.is_terminal(),
"RawTransactionControlDisallowedInTransaction must be terminal"
);
assert!(
!err.is_transient(),
"RawTransactionControlDisallowedInTransaction must not be transient"
);
let msg = err.to_string();
assert!(
msg.contains("COMMIT"),
"display must name the refused statement, got: {msg}"
);
assert!(
msg.contains("transaction-backed"),
"display must explain this is about transaction-backed contexts, got: {msg}"
);
}
#[test]
fn constraint_mode_outside_transaction_is_terminal() {
let err = DjogiError::ConstraintModeOutsideTransaction;
assert!(
err.is_terminal(),
"ConstraintModeOutsideTransaction must be terminal"
);
assert!(
!err.is_transient(),
"ConstraintModeOutsideTransaction must not be transient"
);
}
#[test]
fn unknown_constraint_name_is_terminal() {
let err = DjogiError::UnknownConstraintName("typo_fkey".into());
assert!(err.is_terminal());
assert!(!err.is_transient());
let msg = format!("{err}");
assert!(
msg.contains("typo_fkey"),
"expected offending name in message, got: {msg}"
);
}
#[test]
fn constraint_not_deferrable_is_terminal() {
let err = DjogiError::ConstraintNotDeferrable("posts_author_id_fkey".into());
assert!(err.is_terminal());
assert!(!err.is_transient());
let msg = format!("{err}");
assert!(
msg.contains("posts_author_id_fkey"),
"expected offending name in message, got: {msg}"
);
assert!(
msg.contains("deferrable"),
"expected remediation hint mentioning `deferrable`, got: {msg}"
);
}
#[test]
fn concurrent_reads_require_pool_context_is_terminal() {
let err = DjogiError::ConcurrentReadsRequirePoolContext;
assert!(err.is_terminal());
assert!(!err.is_transient());
}
#[test]
fn empty_defer_constraints_scope_is_terminal_and_names_alternative() {
let err = DjogiError::EmptyDeferConstraintsScope;
assert!(
err.is_terminal(),
"EmptyDeferConstraintsScope must be terminal"
);
assert!(
!err.is_transient(),
"EmptyDeferConstraintsScope must not be transient"
);
let msg = format!("{err}");
assert!(
msg.contains("DeferScope::All"),
"expected `DeferScope::All` remediation hint in message, got: {msg}"
);
}
#[test]
fn conflicting_deferrability_spec_is_terminal_and_carries_payload() {
let err = DjogiError::ConflictingDeferrabilitySpec {
model_type_name: "Post".into(),
field_name: "author_id".into(),
first: (true, false),
second: (true, true),
};
assert!(
err.is_terminal(),
"ConflictingDeferrabilitySpec must be terminal"
);
assert!(!err.is_transient());
let msg = format!("{err}");
assert!(
msg.contains("Post.author_id"),
"expected `model.field` in message, got: {msg}"
);
assert!(
msg.contains("(true, false)") && msg.contains("(true, true)"),
"expected both conflicting (deferrable, initially_deferred) tuples in message, got: {msg}"
);
}
#[test]
fn orphan_deferrability_spec_is_terminal_and_names_field() {
let err = DjogiError::OrphanDeferrabilitySpec {
model_type_name: "Ghost".into(),
field_name: "haunts".into(),
};
assert!(err.is_terminal());
assert!(!err.is_transient());
let msg = format!("{err}");
assert!(
msg.contains("Ghost.haunts"),
"expected `model.field` in message, got: {msg}"
);
}
#[test]
fn duplicate_constraint_name_is_terminal_and_carries_both_fields() {
let err = DjogiError::DuplicateConstraintName {
constraint_name: "long_table_long_column_fkey".into(),
first_model: "Alpha".into(),
first_field: "ref".into(),
second_model: "Beta".into(),
second_field: "ref".into(),
};
assert!(err.is_terminal());
assert!(!err.is_transient());
let msg = format!("{err}");
assert!(
msg.contains("Alpha") && msg.contains("Beta"),
"expected both colliding models in message, got: {msg}"
);
assert!(
msg.contains("long_table_long_column_fkey"),
"expected colliding constraint name in message, got: {msg}"
);
}
#[test]
fn unsupported_postgres_version_is_terminal() {
let err = DjogiError::unsupported_postgres_version(16, 4, 18);
assert!(
err.is_terminal(),
"UnsupportedPostgresVersion must be terminal"
);
assert!(
!err.is_transient(),
"UnsupportedPostgresVersion must not be transient"
);
}
#[test]
fn unsupported_postgres_version_displays_versions_and_upgrade() {
let err = DjogiError::unsupported_postgres_version(16, 4, 18);
let msg = format!("{err}");
assert!(
msg.contains("16.4"),
"Display must name detected version, got: {msg}"
);
assert!(
msg.contains("18"),
"Display must name minimum version, got: {msg}"
);
assert!(
msg.contains("upgrade") || msg.contains("Upgrade"),
"Display must suggest upgrade, got: {msg}"
);
}
#[test]
fn unsupported_postgres_version_error_is_clear() {
let err = DjogiError::unsupported_postgres_version(17, 0, 18);
let msg = format!("{err}");
assert!(
msg.contains("PostgreSQL"),
"Display must mention PostgreSQL, got: {msg}"
);
assert!(
msg.contains("minimum") || msg.contains("below"),
"Display must indicate minimum requirement, got: {msg}"
);
}
}