use crate::error::AedbError;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryErrorCode {
TableNotFound,
ColumnNotFound,
TypeMismatch,
ScanBoundExceeded,
InvalidQuery,
PermissionDenied,
SeqNotYetVisible,
SeqGarbageCollected,
CursorExpired,
InvalidCursor,
SnapshotExpired,
SnapshotLimitReached,
InternalError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryErrorClass {
Retryable,
Permission,
Validation,
Unavailable,
Integrity,
}
impl QueryErrorCode {
pub fn as_str(self) -> &'static str {
match self {
QueryErrorCode::TableNotFound => "table_not_found",
QueryErrorCode::ColumnNotFound => "column_not_found",
QueryErrorCode::TypeMismatch => "type_mismatch",
QueryErrorCode::ScanBoundExceeded => "scan_bound_exceeded",
QueryErrorCode::InvalidQuery => "invalid_query",
QueryErrorCode::PermissionDenied => "permission_denied",
QueryErrorCode::SeqNotYetVisible => "seq_not_yet_visible",
QueryErrorCode::SeqGarbageCollected => "seq_garbage_collected",
QueryErrorCode::CursorExpired => "cursor_expired",
QueryErrorCode::InvalidCursor => "invalid_cursor",
QueryErrorCode::SnapshotExpired => "snapshot_expired",
QueryErrorCode::SnapshotLimitReached => "snapshot_limit_reached",
QueryErrorCode::InternalError => "internal_error",
}
}
pub fn class(self) -> QueryErrorClass {
match self {
QueryErrorCode::SeqNotYetVisible
| QueryErrorCode::CursorExpired
| QueryErrorCode::SnapshotExpired
| QueryErrorCode::SnapshotLimitReached => QueryErrorClass::Retryable,
QueryErrorCode::PermissionDenied => QueryErrorClass::Permission,
QueryErrorCode::ScanBoundExceeded => QueryErrorClass::Unavailable,
QueryErrorCode::InternalError => QueryErrorClass::Integrity,
QueryErrorCode::TableNotFound
| QueryErrorCode::ColumnNotFound
| QueryErrorCode::TypeMismatch
| QueryErrorCode::InvalidQuery
| QueryErrorCode::SeqGarbageCollected
| QueryErrorCode::InvalidCursor => QueryErrorClass::Validation,
}
}
pub fn is_retryable(self) -> bool {
matches!(self.class(), QueryErrorClass::Retryable)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryError {
TableNotFound {
project_id: String,
table: String,
},
ColumnNotFound {
table: String,
column: String,
},
TypeMismatch {
column: String,
expected: String,
got: String,
},
ScanBoundExceeded {
estimated_rows: u64,
max_scan_rows: u64,
},
InvalidQuery {
reason: String,
},
PermissionDenied {
permission: String,
scope: String,
},
SeqNotYetVisible {
requested: u64,
current: u64,
},
SeqGarbageCollected {
requested: u64,
oldest_available: u64,
},
CursorExpired {
original_seq: u64,
},
InvalidCursor,
SnapshotExpired,
SnapshotLimitReached,
InternalError(String),
}
fn parse_table_resource_id(resource_id: &str) -> (String, String) {
let mut parts = resource_id.split('.');
let project_id = parts.next().unwrap_or_default().to_string();
let table = resource_id
.rsplit('.')
.next()
.unwrap_or(resource_id)
.to_string();
(project_id, table)
}
impl fmt::Display for QueryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
QueryError::TableNotFound { project_id, table } => {
write!(f, "table '{table}' not found in project '{project_id}'")
}
QueryError::ColumnNotFound { table, column } => {
write!(f, "column '{column}' not found in table '{table}'")
}
QueryError::TypeMismatch {
column,
expected,
got,
} => {
write!(
f,
"type mismatch in column '{column}': expected {expected}, got {got}"
)
}
QueryError::ScanBoundExceeded {
estimated_rows,
max_scan_rows,
} => write!(
f,
"scan bound exceeded: estimated_rows={estimated_rows}, max_scan_rows={max_scan_rows}"
),
QueryError::InvalidQuery { reason } => write!(f, "invalid query: {reason}"),
QueryError::PermissionDenied { .. } => {
write!(f, "permission denied: permission denied")
}
QueryError::SeqNotYetVisible { requested, current } => write!(
f,
"requested seq {requested} is not yet visible (current={current})"
),
QueryError::SeqGarbageCollected {
requested,
oldest_available,
} => write!(
f,
"requested seq {requested} was garbage collected (oldest_available={oldest_available})"
),
QueryError::CursorExpired { original_seq } => {
write!(f, "cursor expired (original_seq={original_seq})")
}
QueryError::InvalidCursor => write!(f, "invalid cursor (tampered or malformed)"),
QueryError::SnapshotExpired => write!(f, "snapshot expired"),
QueryError::SnapshotLimitReached => write!(f, "snapshot limit reached"),
QueryError::InternalError(msg) => write!(f, "internal query error: {msg}"),
}
}
}
impl std::error::Error for QueryError {}
impl QueryError {
pub fn is_table_not_found(&self) -> bool {
matches!(self, QueryError::TableNotFound { .. })
}
pub fn code(&self) -> QueryErrorCode {
match self {
QueryError::TableNotFound { .. } => QueryErrorCode::TableNotFound,
QueryError::ColumnNotFound { .. } => QueryErrorCode::ColumnNotFound,
QueryError::TypeMismatch { .. } => QueryErrorCode::TypeMismatch,
QueryError::ScanBoundExceeded { .. } => QueryErrorCode::ScanBoundExceeded,
QueryError::InvalidQuery { .. } => QueryErrorCode::InvalidQuery,
QueryError::PermissionDenied { .. } => QueryErrorCode::PermissionDenied,
QueryError::SeqNotYetVisible { .. } => QueryErrorCode::SeqNotYetVisible,
QueryError::SeqGarbageCollected { .. } => QueryErrorCode::SeqGarbageCollected,
QueryError::CursorExpired { .. } => QueryErrorCode::CursorExpired,
QueryError::InvalidCursor => QueryErrorCode::InvalidCursor,
QueryError::SnapshotExpired => QueryErrorCode::SnapshotExpired,
QueryError::SnapshotLimitReached => QueryErrorCode::SnapshotLimitReached,
QueryError::InternalError(_) => QueryErrorCode::InternalError,
}
}
pub fn code_str(&self) -> &'static str {
self.code().as_str()
}
pub fn class(&self) -> QueryErrorClass {
self.code().class()
}
pub fn is_retryable(&self) -> bool {
self.code().is_retryable()
}
}
impl From<AedbError> for QueryError {
fn from(value: AedbError) -> Self {
match value {
AedbError::PermissionDenied(msg) => QueryError::PermissionDenied {
permission: msg,
scope: "query".to_string(),
},
AedbError::SnapshotExpired => QueryError::SnapshotExpired,
AedbError::Validation(reason) => QueryError::InvalidQuery { reason },
AedbError::InvalidConfig { message } => QueryError::InvalidQuery { reason: message },
AedbError::IntegrityError { message } => QueryError::InternalError(message),
AedbError::RecoveryIntegrity { diagnostic } => {
QueryError::InternalError(diagnostic.to_string())
}
AedbError::Unavailable { message } => QueryError::InternalError(message),
AedbError::CheckpointInProgress => {
QueryError::InternalError("checkpoint in progress".into())
}
AedbError::Decode(reason) => QueryError::InvalidQuery { reason },
AedbError::AlreadyExists {
resource_type,
resource_id,
} => QueryError::InvalidQuery {
reason: format!("{resource_type} '{resource_id}' already exists"),
},
AedbError::NotFound {
resource_type,
resource_id,
} => match resource_type {
crate::error::ResourceType::Table => {
let (project_id, table) = parse_table_resource_id(&resource_id);
QueryError::TableNotFound { project_id, table }
}
_ => QueryError::InvalidQuery {
reason: format!("{resource_type} '{resource_id}' not found"),
},
},
AedbError::DuplicatePK { table, key } => QueryError::InvalidQuery {
reason: format!("duplicate primary key in table '{table}': {key}"),
},
AedbError::UniqueViolation { table, index, .. } => QueryError::InvalidQuery {
reason: format!(
"unique constraint violation on index '{index}' in table '{table}'"
),
},
AedbError::ForeignKeyViolation {
fk_name,
ref_table,
ref_key,
..
} => QueryError::InvalidQuery {
reason: format!(
"foreign key violation: {fk_name} references {ref_table}({ref_key})"
),
},
AedbError::CheckConstraintFailed { table, constraint } => QueryError::InvalidQuery {
reason: format!("check constraint '{constraint}' failed on table '{table}'"),
},
AedbError::NotNullViolation { table, column } => QueryError::InvalidQuery {
reason: format!("NOT NULL violation: column '{column}' in table '{table}'"),
},
AedbError::TypeMismatch {
table,
column,
expected,
actual,
} => QueryError::InvalidQuery {
reason: format!(
"type mismatch in table '{table}' column '{column}': expected {expected}, got {actual}"
),
},
AedbError::UnknownColumn { table, column } => QueryError::InvalidQuery {
reason: format!("unknown column '{column}' in table '{table}'"),
},
AedbError::Encode(reason) => QueryError::InternalError(reason),
AedbError::Io(e) => QueryError::InternalError(e.to_string()),
AedbError::Conflict(reason) => QueryError::InternalError(reason),
AedbError::Underflow => QueryError::InternalError("underflow".into()),
AedbError::Overflow => QueryError::InternalError("overflow".into()),
AedbError::QueueFull => QueryError::SnapshotLimitReached,
AedbError::Timeout => QueryError::InternalError("timeout".into()),
AedbError::PartitionLockTimeout => {
QueryError::InternalError("partition lock timeout".into())
}
AedbError::EpochApplyTimeout => {
QueryError::InternalError("epoch apply timeout exceeded".into())
}
AedbError::ParallelApplyCancelled => {
QueryError::InternalError("parallel apply cancelled".into())
}
AedbError::ParallelApplyWorkerPanicked => {
QueryError::InternalError("parallel apply worker panicked".into())
}
AedbError::AssertionFailed { .. } => {
QueryError::InternalError("assertion failed".into())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::error::{AedbError, ResourceType};
use super::{QueryError, QueryErrorClass, QueryErrorCode};
#[test]
fn query_error_display_is_human_readable() {
let err = QueryError::ColumnNotFound {
table: "users".into(),
column: "name".into(),
};
assert_eq!(err.to_string(), "column 'name' not found in table 'users'");
}
#[test]
fn maps_aedb_table_not_found_to_structured_query_error() {
let err = QueryError::from(AedbError::NotFound {
resource_type: ResourceType::Table,
resource_id: "arcana.app.leaderboard_points".into(),
});
assert_eq!(
err,
QueryError::TableNotFound {
project_id: "arcana".into(),
table: "leaderboard_points".into(),
}
);
assert!(err.is_table_not_found());
assert_eq!(err.code(), QueryErrorCode::TableNotFound);
assert_eq!(err.code_str(), "table_not_found");
assert_eq!(err.class(), QueryErrorClass::Validation);
}
#[test]
fn query_error_code_strings_are_stable() {
let expected = [
(QueryErrorCode::TableNotFound, "table_not_found"),
(QueryErrorCode::ColumnNotFound, "column_not_found"),
(QueryErrorCode::TypeMismatch, "type_mismatch"),
(QueryErrorCode::ScanBoundExceeded, "scan_bound_exceeded"),
(QueryErrorCode::InvalidQuery, "invalid_query"),
(QueryErrorCode::PermissionDenied, "permission_denied"),
(QueryErrorCode::SeqNotYetVisible, "seq_not_yet_visible"),
(QueryErrorCode::SeqGarbageCollected, "seq_garbage_collected"),
(QueryErrorCode::CursorExpired, "cursor_expired"),
(QueryErrorCode::InvalidCursor, "invalid_cursor"),
(QueryErrorCode::SnapshotExpired, "snapshot_expired"),
(
QueryErrorCode::SnapshotLimitReached,
"snapshot_limit_reached",
),
(QueryErrorCode::InternalError, "internal_error"),
];
for (code, stable) in expected {
assert_eq!(code.as_str(), stable);
}
}
#[test]
fn query_error_code_classes_describe_caller_behavior() {
assert_eq!(
QueryErrorCode::SnapshotExpired.class(),
QueryErrorClass::Retryable
);
assert_eq!(
QueryErrorCode::PermissionDenied.class(),
QueryErrorClass::Permission
);
assert_eq!(
QueryErrorCode::ScanBoundExceeded.class(),
QueryErrorClass::Unavailable
);
assert_eq!(
QueryErrorCode::InternalError.class(),
QueryErrorClass::Integrity
);
assert!(QueryErrorCode::CursorExpired.is_retryable());
}
}