use athena_driver::postgresql::column_resolver::resolve_information_schema_targets;
use crate::{
GatewayDeleteRequest, delete_right_for_resource, normalize_column_name, sanitize_identifier,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GatewayDeleteResourceIdPlan {
pub lookup_table_name: String,
pub explicit_column_name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct GatewayDeleteRequestPlan {
pub request: GatewayDeleteRequest,
pub qualified_table_name: String,
pub resource_id_plan: GatewayDeleteResourceIdPlan,
pub required_delete_right: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GatewayDeleteRequestPlanError {
MissingTableName,
MissingResourceId,
InvalidSchemaTableSelector(String),
InvalidDeletePayload(String),
}
impl GatewayDeleteRequestPlanError {
pub const fn summary(&self) -> &'static str {
match self {
Self::MissingTableName | Self::MissingResourceId | Self::InvalidDeletePayload(_) => {
"Invalid delete payload"
}
Self::InvalidSchemaTableSelector(_) => "Invalid schema/table selector",
}
}
pub fn detail(&self) -> String {
match self {
Self::MissingTableName => "table_name is required".to_string(),
Self::MissingResourceId => "resource_id is required".to_string(),
Self::InvalidSchemaTableSelector(message) | Self::InvalidDeletePayload(message) => {
message.clone()
}
}
}
}
fn delete_right_resource_name(table_name: &str) -> Option<&str> {
let trimmed = table_name.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.rsplit('.').next().unwrap_or(trimmed).trim())
}
pub fn delete_request_required_right(request: &GatewayDeleteRequest) -> String {
delete_right_for_resource(delete_right_resource_name(&request.table_name))
}
pub fn normalize_delete_resource_id_lookup_table(
table_name: &str,
allow_schema_names_prefixed_as_table_name: bool,
) -> String {
let trimmed = table_name.trim();
if trimmed.is_empty() {
return String::new();
}
if !allow_schema_names_prefixed_as_table_name {
return trimmed.to_string();
}
match resolve_information_schema_targets(trimmed, true) {
Ok((schema, table)) if schema.eq_ignore_ascii_case("public") => table,
_ => trimmed.to_string(),
}
}
pub fn normalize_delete_resource_id_column_name(
requested_column_name: &str,
) -> Result<String, String> {
let trimmed = requested_column_name.trim();
if trimmed.is_empty() {
return Err("column_name must not be empty when provided".to_string());
}
let normalized = normalize_column_name(trimmed, true);
if sanitize_identifier(&normalized).is_none() {
return Err(format!("invalid resource_id column_name `{trimmed}`"));
}
Ok(normalized)
}
pub fn plan_delete_resource_id_resolution(
table_name: &str,
requested_column_name: Option<&str>,
allow_schema_names_prefixed_as_table_name: bool,
) -> Result<GatewayDeleteResourceIdPlan, String> {
Ok(GatewayDeleteResourceIdPlan {
lookup_table_name: normalize_delete_resource_id_lookup_table(
table_name,
allow_schema_names_prefixed_as_table_name,
),
explicit_column_name: requested_column_name
.map(normalize_delete_resource_id_column_name)
.transpose()?,
})
}
pub fn build_gateway_delete_request_plan(
request: GatewayDeleteRequest,
allow_schema_names_prefixed_as_table_name: bool,
) -> Result<GatewayDeleteRequestPlan, GatewayDeleteRequestPlanError> {
let table_name = request.table_name.trim().to_string();
if table_name.is_empty() {
return Err(GatewayDeleteRequestPlanError::MissingTableName);
}
let resource_id = request.resource_id.trim().to_string();
if resource_id.is_empty() {
return Err(GatewayDeleteRequestPlanError::MissingResourceId);
}
let normalized_request = GatewayDeleteRequest {
table_name: table_name.clone(),
schema_name: request.schema_name,
resource_id,
column_name: request.column_name,
};
let qualified_table_name = normalized_request
.qualified_table_name()
.map_err(GatewayDeleteRequestPlanError::InvalidSchemaTableSelector)?;
let resource_id_plan = plan_delete_resource_id_resolution(
&table_name,
normalized_request.column_name.as_deref(),
allow_schema_names_prefixed_as_table_name,
)
.map_err(GatewayDeleteRequestPlanError::InvalidDeletePayload)?;
Ok(GatewayDeleteRequestPlan {
request: GatewayDeleteRequest {
column_name: resource_id_plan.explicit_column_name.clone(),
..normalized_request
},
qualified_table_name,
resource_id_plan,
required_delete_right: delete_right_for_resource(delete_right_resource_name(&table_name)),
})
}
#[cfg(test)]
mod tests {
use super::{
GatewayDeleteRequestPlanError, build_gateway_delete_request_plan,
delete_request_required_right, normalize_delete_resource_id_column_name,
normalize_delete_resource_id_lookup_table, plan_delete_resource_id_resolution,
};
use crate::GatewayDeleteRequest;
#[test]
fn strips_public_prefix_when_enabled() {
let resolved =
normalize_delete_resource_id_lookup_table("public.forms_blocks_summary_sections", true);
assert_eq!(resolved, "forms_blocks_summary_sections");
}
#[test]
fn keeps_qualified_name_when_disabled() {
let resolved = normalize_delete_resource_id_lookup_table(
"public.forms_blocks_summary_sections",
false,
);
assert_eq!(resolved, "public.forms_blocks_summary_sections");
}
#[test]
fn explicit_delete_resource_id_column_is_used_as_alias() {
let resolved =
normalize_delete_resource_id_column_name("id").expect("explicit column should resolve");
assert_eq!(resolved, "id");
}
#[test]
fn explicit_delete_resource_id_column_is_normalized() {
let resolved = normalize_delete_resource_id_column_name("resourceId")
.expect("explicit column should resolve");
assert_eq!(resolved, "resource_id");
}
#[test]
fn explicit_delete_resource_id_column_is_validated() {
let err = normalize_delete_resource_id_column_name("id; DROP TABLE users")
.expect_err("unsafe column should fail");
assert!(err.contains("invalid resource_id column_name"));
}
#[test]
fn delete_resource_id_plan_preserves_lookup_and_explicit_column() {
let plan = plan_delete_resource_id_resolution("public.users", Some("userId"), true)
.expect("plan should resolve");
assert_eq!(plan.lookup_table_name, "users");
assert_eq!(plan.explicit_column_name.as_deref(), Some("user_id"));
}
#[test]
fn delete_request_right_uses_unqualified_table_name() {
let request = GatewayDeleteRequest {
table_name: "public.users".to_string(),
schema_name: None,
resource_id: "user-123".to_string(),
column_name: None,
};
assert_eq!(delete_request_required_right(&request), "users.delete");
}
#[test]
fn delete_request_plan_requires_table_name() {
let err = build_gateway_delete_request_plan(
GatewayDeleteRequest {
table_name: " ".to_string(),
schema_name: None,
resource_id: "user-123".to_string(),
column_name: None,
},
true,
)
.expect_err("missing table_name should fail");
assert_eq!(err, GatewayDeleteRequestPlanError::MissingTableName);
assert_eq!(err.summary(), "Invalid delete payload");
assert_eq!(err.detail(), "table_name is required");
}
#[test]
fn delete_request_plan_requires_resource_id() {
let err = build_gateway_delete_request_plan(
GatewayDeleteRequest {
table_name: "users".to_string(),
schema_name: None,
resource_id: " ".to_string(),
column_name: None,
},
true,
)
.expect_err("missing resource_id should fail");
assert_eq!(err, GatewayDeleteRequestPlanError::MissingResourceId);
}
#[test]
fn delete_request_plan_rejects_invalid_schema_selector() {
let err = build_gateway_delete_request_plan(
GatewayDeleteRequest {
table_name: "public.users".to_string(),
schema_name: Some("analytics".to_string()),
resource_id: "user-123".to_string(),
column_name: None,
},
true,
)
.expect_err("ambiguous schema/table should fail");
match err {
GatewayDeleteRequestPlanError::InvalidSchemaTableSelector(message) => {
assert!(message.contains("must not include a schema prefix"));
}
other => panic!("expected invalid schema selector, got {other:?}"),
}
}
#[test]
fn delete_request_plan_rejects_invalid_explicit_column_name() {
let err = build_gateway_delete_request_plan(
GatewayDeleteRequest {
table_name: "users".to_string(),
schema_name: None,
resource_id: "user-123".to_string(),
column_name: Some("id; DROP TABLE users".to_string()),
},
true,
)
.expect_err("invalid explicit column should fail");
match err {
GatewayDeleteRequestPlanError::InvalidDeletePayload(message) => {
assert!(message.contains("invalid resource_id column_name"));
}
other => panic!("expected invalid delete payload, got {other:?}"),
}
}
#[test]
fn delete_request_plan_normalizes_explicit_column_and_lookup_table() {
let plan = build_gateway_delete_request_plan(
GatewayDeleteRequest {
table_name: "users".to_string(),
schema_name: Some("public".to_string()),
resource_id: " user-123 ".to_string(),
column_name: Some("resourceId".to_string()),
},
true,
)
.expect("plan should resolve");
assert_eq!(plan.qualified_table_name, "public.users");
assert_eq!(plan.required_delete_right, "users.delete");
assert_eq!(plan.resource_id_plan.lookup_table_name, "users");
assert_eq!(
plan.resource_id_plan.explicit_column_name.as_deref(),
Some("resource_id")
);
assert_eq!(plan.request.resource_id, "user-123");
assert_eq!(plan.request.column_name.as_deref(), Some("resource_id"));
}
}