use crate::db::{
QueryError,
session::sql::write_policy::{
DEFAULT_PUBLIC_BOUNDED_WRITE_LIMIT, DEFAULT_PUBLIC_WRITE_RETURNING_RESPONSE_BYTES,
SqlWriteOrderProof, SqlWriteReturningShape, SqlWriteWhereProof, classify_write_order_proof,
classify_write_returning_shape, classify_write_where_proof, combined_optional_row_bound,
},
sql::parser::{SqlDeleteStatement, SqlStatement, parse_sql_with_attribution},
};
#[doc(hidden)]
pub(in crate::db) const DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT: u32 =
DEFAULT_PUBLIC_BOUNDED_WRITE_LIMIT;
#[doc(hidden)]
pub(in crate::db) const DEFAULT_PUBLIC_DELETE_RETURNING_RESPONSE_BYTES: u32 =
DEFAULT_PUBLIC_WRITE_RETURNING_RESPONSE_BYTES;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlDeleteExposurePolicy {
SessionWriteCurrent,
GeneratedQuery,
GeneratedDdl,
PublicPrimaryKeyOnly,
PublicBoundedDeterministic,
AdminBulk,
}
#[derive(Clone, Copy, Debug)]
#[doc(hidden)]
pub struct SqlDeletePolicyContext<'a> {
pub primary_key_fields: &'a [&'a str],
pub max_public_bounded_limit: u32,
pub max_returning_rows: Option<u32>,
pub max_returning_response_bytes: Option<u32>,
}
impl<'a> SqlDeletePolicyContext<'a> {
#[must_use]
pub const fn new(primary_key_fields: &'a [&'a str]) -> Self {
Self {
primary_key_fields,
max_public_bounded_limit: DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT,
max_returning_rows: None,
max_returning_response_bytes: None,
}
}
#[must_use]
pub const fn public_generated(primary_key_fields: &'a [&'a str]) -> Self {
Self {
primary_key_fields,
max_public_bounded_limit: DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT,
max_returning_rows: None,
max_returning_response_bytes: Some(DEFAULT_PUBLIC_DELETE_RETURNING_RESPONSE_BYTES),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlDeleteWherePolicy {
Missing,
PrimaryKeyEquality,
Other,
}
impl SqlDeleteWherePolicy {
#[must_use]
pub const fn has_where(self) -> bool {
!matches!(self, Self::Missing)
}
#[must_use]
pub const fn is_primary_key_equality(self) -> bool {
matches!(self, Self::PrimaryKeyEquality)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlDeleteOrderPolicy {
Missing,
CanonicalPrimaryKey,
DescendingPrimaryKey,
Other,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlDeleteReturningPolicy {
None,
NarrowAll,
NarrowFields,
}
impl SqlDeleteReturningPolicy {
#[must_use]
pub const fn is_requested(self) -> bool {
!matches!(self, Self::None)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlDeleteReturningBounds {
pub max_rows: Option<u32>,
pub max_response_bytes: Option<u32>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlDeleteExecutionBounds {
pub max_staged_rows: Option<u32>,
pub returning: SqlDeleteReturningBounds,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlDeleteStatementClassification {
pub target_entity: String,
pub where_policy: SqlDeleteWherePolicy,
pub order_policy: SqlDeleteOrderPolicy,
pub limit: Option<u32>,
pub offset: Option<u32>,
pub returning_policy: SqlDeleteReturningPolicy,
}
impl SqlDeleteStatementClassification {
#[must_use]
pub const fn is_bounded(&self) -> bool {
matches!(self.limit, Some(limit) if limit > 0)
}
#[must_use]
pub const fn has_explicit_canonical_primary_key_order(&self) -> bool {
matches!(self.order_policy, SqlDeleteOrderPolicy::CanonicalPrimaryKey)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlValidatedDeletePlan {
SessionCurrent(SqlSessionCurrentDeletePlan),
PublicPrimaryKeyOnly(SqlPublicPrimaryKeyDeletePlan),
PublicBoundedDeterministic(SqlPublicBoundedDeletePlan),
AdminBulk(SqlAdminBulkDeletePlan),
}
impl SqlValidatedDeletePlan {
#[must_use]
pub const fn classification(&self) -> &SqlDeleteStatementClassification {
match self {
Self::SessionCurrent(plan) => &plan.classification,
Self::PublicPrimaryKeyOnly(plan) => &plan.classification,
Self::PublicBoundedDeterministic(plan) => &plan.classification,
Self::AdminBulk(plan) => &plan.classification,
}
}
#[must_use]
pub const fn execution_bounds(&self) -> SqlDeleteExecutionBounds {
match self {
Self::SessionCurrent(plan) => plan.execution_bounds,
Self::PublicPrimaryKeyOnly(plan) => plan.execution_bounds,
Self::PublicBoundedDeterministic(plan) => plan.execution_bounds,
Self::AdminBulk(plan) => plan.execution_bounds,
}
}
#[must_use]
pub const fn returning_bounds(&self) -> SqlDeleteReturningBounds {
self.execution_bounds().returning
}
#[must_use]
pub const fn statement_entity(&self) -> &str {
match self {
Self::SessionCurrent(plan) => plan.statement.entity.as_str(),
Self::PublicPrimaryKeyOnly(plan) => plan.statement.entity.as_str(),
Self::PublicBoundedDeterministic(plan) => plan.statement.entity.as_str(),
Self::AdminBulk(plan) => plan.statement.entity.as_str(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlSessionCurrentDeletePlan {
statement: SqlDeleteStatement,
pub classification: SqlDeleteStatementClassification,
pub execution_bounds: SqlDeleteExecutionBounds,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlPublicPrimaryKeyDeletePlan {
statement: SqlDeleteStatement,
pub classification: SqlDeleteStatementClassification,
pub primary_key_fields: Vec<String>,
pub execution_bounds: SqlDeleteExecutionBounds,
}
impl SqlPublicPrimaryKeyDeletePlan {
pub(in crate::db::session::sql) const fn statement(&self) -> &SqlDeleteStatement {
&self.statement
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlPublicBoundedDeletePlan {
statement: SqlDeleteStatement,
pub classification: SqlDeleteStatementClassification,
pub limit: u32,
pub ordered_primary_key_fields: Vec<String>,
pub execution_bounds: SqlDeleteExecutionBounds,
}
impl SqlPublicBoundedDeletePlan {
pub(in crate::db::session::sql) const fn statement(&self) -> &SqlDeleteStatement {
&self.statement
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlAdminBulkDeletePlan {
statement: SqlDeleteStatement,
pub classification: SqlDeleteStatementClassification,
pub execution_bounds: SqlDeleteExecutionBounds,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub enum SqlDeletePolicyRejection {
NotDelete,
GeneratedQueryRejectsDelete,
GeneratedDdlRejectsDelete,
MissingWhere,
PrimaryKeyProofFailed,
MissingCanonicalPrimaryKeyOrder,
DescendingOrder,
MissingLimit,
OffsetUnsupported,
LimitTooHigh,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[doc(hidden)]
pub struct SqlDeletePolicyReport {
pub classification: Option<SqlDeleteStatementClassification>,
pub plan: Option<SqlValidatedDeletePlan>,
pub rejection: Option<SqlDeletePolicyRejection>,
}
impl SqlDeletePolicyReport {
#[must_use]
pub const fn is_admitted(&self) -> bool {
self.rejection.is_none()
}
const fn rejected(rejection: SqlDeletePolicyRejection) -> Self {
Self {
classification: None,
plan: None,
rejection: Some(rejection),
}
}
}
pub fn classify_sql_delete_policy(
sql: &str,
policy: SqlDeleteExposurePolicy,
context: SqlDeletePolicyContext<'_>,
) -> Result<SqlDeletePolicyReport, QueryError> {
let (statement, _) =
parse_sql_with_attribution(sql).map_err(QueryError::from_sql_parse_error)?;
Ok(classify_sql_delete_statement_policy(
&statement, policy, context,
))
}
pub(in crate::db) fn classify_sql_delete_statement_policy(
statement: &SqlStatement,
policy: SqlDeleteExposurePolicy,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeletePolicyReport {
let SqlStatement::Delete(statement) = statement else {
return SqlDeletePolicyReport::rejected(SqlDeletePolicyRejection::NotDelete);
};
let classification = classify_delete_statement(statement, context);
let rejection = delete_policy_rejection(policy, &classification, context);
let plan = rejection
.is_none()
.then(|| validated_delete_plan(statement, policy, &classification, context));
SqlDeletePolicyReport {
classification: Some(classification),
plan,
rejection,
}
}
fn classify_delete_statement(
statement: &SqlDeleteStatement,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeleteStatementClassification {
SqlDeleteStatementClassification {
target_entity: statement.entity.clone(),
where_policy: where_policy(statement, context),
order_policy: order_policy(statement, context),
limit: statement.limit,
offset: statement.offset,
returning_policy: returning_policy(statement),
}
}
fn delete_policy_rejection(
policy: SqlDeleteExposurePolicy,
classification: &SqlDeleteStatementClassification,
context: SqlDeletePolicyContext<'_>,
) -> Option<SqlDeletePolicyRejection> {
match policy {
SqlDeleteExposurePolicy::GeneratedQuery => {
return Some(SqlDeletePolicyRejection::GeneratedQueryRejectsDelete);
}
SqlDeleteExposurePolicy::GeneratedDdl => {
return Some(SqlDeletePolicyRejection::GeneratedDdlRejectsDelete);
}
SqlDeleteExposurePolicy::SessionWriteCurrent
| SqlDeleteExposurePolicy::PublicPrimaryKeyOnly
| SqlDeleteExposurePolicy::PublicBoundedDeterministic
| SqlDeleteExposurePolicy::AdminBulk => {}
}
match policy {
SqlDeleteExposurePolicy::SessionWriteCurrent | SqlDeleteExposurePolicy::AdminBulk => None,
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly => {
if let Some(rejection) = public_delete_where_rejection(classification) {
return Some(rejection);
}
if !classification.where_policy.is_primary_key_equality() {
return Some(SqlDeletePolicyRejection::PrimaryKeyProofFailed);
}
None
}
SqlDeleteExposurePolicy::PublicBoundedDeterministic => {
public_delete_where_rejection(classification)
.or_else(|| bounded_policy_rejection(classification, context))
}
SqlDeleteExposurePolicy::GeneratedQuery | SqlDeleteExposurePolicy::GeneratedDdl => {
unreachable!("generated policies returned before shared checks")
}
}
}
const fn public_delete_where_rejection(
classification: &SqlDeleteStatementClassification,
) -> Option<SqlDeletePolicyRejection> {
if classification.where_policy.has_where() {
None
} else {
Some(SqlDeletePolicyRejection::MissingWhere)
}
}
const fn bounded_policy_rejection(
classification: &SqlDeleteStatementClassification,
context: SqlDeletePolicyContext<'_>,
) -> Option<SqlDeletePolicyRejection> {
if classification.offset.is_some() {
return Some(SqlDeletePolicyRejection::OffsetUnsupported);
}
let Some(limit) = classification.limit else {
return Some(SqlDeletePolicyRejection::MissingLimit);
};
if limit == 0 {
return Some(SqlDeletePolicyRejection::MissingLimit);
}
if limit > context.max_public_bounded_limit {
return Some(SqlDeletePolicyRejection::LimitTooHigh);
}
match classification.order_policy {
SqlDeleteOrderPolicy::CanonicalPrimaryKey => None,
SqlDeleteOrderPolicy::DescendingPrimaryKey => {
Some(SqlDeletePolicyRejection::DescendingOrder)
}
SqlDeleteOrderPolicy::Missing | SqlDeleteOrderPolicy::Other => {
Some(SqlDeletePolicyRejection::MissingCanonicalPrimaryKeyOrder)
}
}
}
fn validated_delete_plan(
statement: &SqlDeleteStatement,
policy: SqlDeleteExposurePolicy,
classification: &SqlDeleteStatementClassification,
context: SqlDeletePolicyContext<'_>,
) -> SqlValidatedDeletePlan {
let execution_bounds = execution_bounds(policy, classification, context);
match policy {
SqlDeleteExposurePolicy::SessionWriteCurrent => {
SqlValidatedDeletePlan::SessionCurrent(SqlSessionCurrentDeletePlan {
statement: statement.clone(),
classification: classification.clone(),
execution_bounds,
})
}
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly => {
SqlValidatedDeletePlan::PublicPrimaryKeyOnly(SqlPublicPrimaryKeyDeletePlan {
statement: statement.clone(),
classification: classification.clone(),
primary_key_fields: context
.primary_key_fields
.iter()
.map(|field| (*field).to_string())
.collect(),
execution_bounds,
})
}
SqlDeleteExposurePolicy::PublicBoundedDeterministic => {
SqlValidatedDeletePlan::PublicBoundedDeterministic(SqlPublicBoundedDeletePlan {
statement: statement.clone(),
classification: classification.clone(),
limit: classification
.limit
.expect("bounded policy admitted a limit"),
ordered_primary_key_fields: context
.primary_key_fields
.iter()
.map(|field| (*field).to_string())
.collect(),
execution_bounds,
})
}
SqlDeleteExposurePolicy::AdminBulk => {
SqlValidatedDeletePlan::AdminBulk(SqlAdminBulkDeletePlan {
statement: statement.clone(),
classification: classification.clone(),
execution_bounds,
})
}
SqlDeleteExposurePolicy::GeneratedQuery | SqlDeleteExposurePolicy::GeneratedDdl => {
unreachable!("generated policies never produce validated delete plans")
}
}
}
fn execution_bounds(
policy: SqlDeleteExposurePolicy,
classification: &SqlDeleteStatementClassification,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeleteExecutionBounds {
SqlDeleteExecutionBounds {
max_staged_rows: staged_row_bound(policy, classification),
returning: returning_bounds(policy, classification, context),
}
}
fn staged_row_bound(
policy: SqlDeleteExposurePolicy,
classification: &SqlDeleteStatementClassification,
) -> Option<u32> {
match policy {
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly => Some(1),
SqlDeleteExposurePolicy::PublicBoundedDeterministic => classification.limit,
SqlDeleteExposurePolicy::SessionWriteCurrent | SqlDeleteExposurePolicy::AdminBulk => None,
SqlDeleteExposurePolicy::GeneratedQuery | SqlDeleteExposurePolicy::GeneratedDdl => {
unreachable!("generated policies never produce validated delete plans")
}
}
}
fn returning_bounds(
policy: SqlDeleteExposurePolicy,
classification: &SqlDeleteStatementClassification,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeleteReturningBounds {
let max_rows = if classification.returning_policy.is_requested() {
let policy_max_rows = match policy {
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly => Some(1),
SqlDeleteExposurePolicy::PublicBoundedDeterministic => classification.limit,
SqlDeleteExposurePolicy::SessionWriteCurrent | SqlDeleteExposurePolicy::AdminBulk => {
None
}
SqlDeleteExposurePolicy::GeneratedQuery | SqlDeleteExposurePolicy::GeneratedDdl => {
unreachable!("generated policies never produce validated delete plans")
}
};
combined_optional_row_bound(policy_max_rows, context.max_returning_rows)
} else {
None
};
SqlDeleteReturningBounds {
max_rows,
max_response_bytes: context.max_returning_response_bytes,
}
}
fn where_policy(
statement: &SqlDeleteStatement,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeleteWherePolicy {
match classify_write_where_proof(
statement.predicate.as_ref(),
statement.entity.as_str(),
statement.table_alias.as_deref(),
context.primary_key_fields,
) {
SqlWriteWhereProof::Missing => SqlDeleteWherePolicy::Missing,
SqlWriteWhereProof::PrimaryKeyEquality => SqlDeleteWherePolicy::PrimaryKeyEquality,
SqlWriteWhereProof::Other => SqlDeleteWherePolicy::Other,
}
}
fn order_policy(
statement: &SqlDeleteStatement,
context: SqlDeletePolicyContext<'_>,
) -> SqlDeleteOrderPolicy {
match classify_write_order_proof(
statement.order_by.as_slice(),
statement.entity.as_str(),
statement.table_alias.as_deref(),
context.primary_key_fields,
) {
SqlWriteOrderProof::Missing => SqlDeleteOrderPolicy::Missing,
SqlWriteOrderProof::CanonicalPrimaryKey => SqlDeleteOrderPolicy::CanonicalPrimaryKey,
SqlWriteOrderProof::DescendingPrimaryKey => SqlDeleteOrderPolicy::DescendingPrimaryKey,
SqlWriteOrderProof::Other => SqlDeleteOrderPolicy::Other,
}
}
const fn returning_policy(statement: &SqlDeleteStatement) -> SqlDeleteReturningPolicy {
match classify_write_returning_shape(statement.returning.as_ref()) {
SqlWriteReturningShape::None => SqlDeleteReturningPolicy::None,
SqlWriteReturningShape::NarrowAll => SqlDeleteReturningPolicy::NarrowAll,
SqlWriteReturningShape::NarrowFields => SqlDeleteReturningPolicy::NarrowFields,
}
}
#[cfg(test)]
mod tests {
use super::*;
const PRIMARY_KEY: &[&str] = &["id"];
fn context() -> SqlDeletePolicyContext<'static> {
SqlDeletePolicyContext::new(PRIMARY_KEY)
}
fn classify(sql: &str, policy: SqlDeleteExposurePolicy) -> SqlDeletePolicyReport {
classify_sql_delete_policy(sql, policy, context()).expect("SQL should parse")
}
fn expect_plan(report: &SqlDeletePolicyReport) -> &SqlValidatedDeletePlan {
report
.plan
.as_ref()
.expect("admitted policy should produce a validated plan")
}
fn assert_no_plan(report: &SqlDeletePolicyReport) {
assert!(
report.plan.is_none(),
"rejected policy should not expose a partially usable plan",
);
}
#[test]
fn delete_policy_session_write_current_admits_broad_current_shape() {
let report = classify(
"DELETE FROM Character",
SqlDeleteExposurePolicy::SessionWriteCurrent,
);
assert!(report.is_admitted());
let classification = report
.classification
.as_ref()
.expect("admitted DELETE should include classification");
assert_eq!(classification.target_entity, "Character");
assert_eq!(classification.where_policy, SqlDeleteWherePolicy::Missing);
assert!(matches!(
expect_plan(&report),
SqlValidatedDeletePlan::SessionCurrent(_),
));
assert_eq!(expect_plan(&report).statement_entity(), "Character");
}
#[test]
fn delete_policy_rejects_non_delete_statement() {
let report = classify(
"SELECT id FROM Character",
SqlDeleteExposurePolicy::SessionWriteCurrent,
);
assert_eq!(report.classification, None);
assert_eq!(report.rejection, Some(SqlDeletePolicyRejection::NotDelete),);
assert_no_plan(&report);
}
#[test]
fn delete_policy_generated_query_rejects_delete() {
let report = classify(
"DELETE FROM Character WHERE id = 1",
SqlDeleteExposurePolicy::GeneratedQuery,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::GeneratedQueryRejectsDelete),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_generated_ddl_rejects_delete() {
let report = classify(
"DELETE FROM Character WHERE id = 1",
SqlDeleteExposurePolicy::GeneratedDdl,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::GeneratedDdlRejectsDelete),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_primary_key_only_accepts_primary_key_equality() {
let report = classify(
"DELETE FROM Character WHERE id = 1",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert!(report.is_admitted());
assert_eq!(
report
.classification
.as_ref()
.expect("classification should be present")
.where_policy,
SqlDeleteWherePolicy::PrimaryKeyEquality,
);
let SqlValidatedDeletePlan::PublicPrimaryKeyOnly(plan) = expect_plan(&report) else {
panic!("primary-key policy should produce only the primary-key plan variant");
};
assert_eq!(plan.primary_key_fields, ["id"]);
}
#[test]
fn delete_policy_public_primary_key_only_accepts_alias_qualified_primary_key_equality() {
let report = classify(
"DELETE FROM Character c WHERE c.id = 1",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert!(report.is_admitted());
assert_eq!(
report
.classification
.as_ref()
.expect("classification should be present")
.where_policy,
SqlDeleteWherePolicy::PrimaryKeyEquality,
);
}
#[test]
fn delete_policy_public_primary_key_only_rejects_missing_where() {
let report = classify(
"DELETE FROM Character",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::MissingWhere),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_primary_key_only_rejects_non_primary_key_where() {
let report = classify(
"DELETE FROM Character WHERE age = 21",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::PrimaryKeyProofFailed),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_primary_key_only_rejects_extra_where_guard() {
let report = classify(
"DELETE FROM Character WHERE id = 1 AND active = true",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::PrimaryKeyProofFailed),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_primary_key_only_accepts_complete_composite_primary_key() {
let context = SqlDeletePolicyContext::new(&["tenant_id", "id"]);
let report = classify_sql_delete_policy(
"DELETE FROM Character WHERE tenant_id = 7 AND id = 1",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
context,
)
.expect("SQL should parse");
assert!(report.is_admitted());
let SqlValidatedDeletePlan::PublicPrimaryKeyOnly(plan) = expect_plan(&report) else {
panic!("composite primary-key proof should produce a primary-key plan");
};
assert_eq!(plan.primary_key_fields, ["tenant_id", "id"]);
}
#[test]
fn delete_policy_public_bounded_accepts_explicit_primary_key_order_and_limit() {
let report = classify(
"DELETE FROM Character WHERE age = 21 ORDER BY id LIMIT 10",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert!(report.is_admitted());
let classification = report
.classification
.as_ref()
.expect("admitted DELETE should include classification");
assert!(classification.is_bounded());
assert!(classification.has_explicit_canonical_primary_key_order());
let SqlValidatedDeletePlan::PublicBoundedDeterministic(plan) = expect_plan(&report) else {
panic!("bounded policy should produce only the bounded plan variant");
};
assert_eq!(plan.limit, 10);
assert_eq!(plan.ordered_primary_key_fields, ["id"]);
}
#[test]
fn delete_policy_public_bounded_rejects_missing_where() {
let report = classify(
"DELETE FROM Character ORDER BY id LIMIT 10",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::MissingWhere),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_bounded_rejects_implicit_primary_key_fallback() {
let report = classify(
"DELETE FROM Character WHERE age = 21 LIMIT 10",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::MissingCanonicalPrimaryKeyOrder),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_bounded_rejects_non_primary_key_ordering() {
let report = classify(
"DELETE FROM Character WHERE age = 21 ORDER BY age LIMIT 10",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::MissingCanonicalPrimaryKeyOrder),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_bounded_rejects_descending_order() {
let report = classify(
"DELETE FROM Character WHERE age = 21 ORDER BY id DESC LIMIT 10",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::DescendingOrder),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_bounded_rejects_excessive_limit() {
let excessive_limit = DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT
.checked_add(1)
.expect("test default public bounded delete limit should fit u32");
let report = classify(
format!("DELETE FROM Character WHERE age = 21 ORDER BY id LIMIT {excessive_limit}")
.as_str(),
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::LimitTooHigh),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_public_bounded_rejects_offset() {
let report = classify(
"DELETE FROM Character WHERE age = 21 ORDER BY id LIMIT 10 OFFSET 1",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
);
assert_eq!(
report.rejection,
Some(SqlDeletePolicyRejection::OffsetUnsupported),
);
assert_no_plan(&report);
}
#[test]
fn delete_policy_classifies_narrow_returning_shapes() {
let returning_all = classify(
"DELETE FROM Character WHERE id = 1 RETURNING *",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
let returning_fields = classify(
"DELETE FROM Character WHERE id = 1 RETURNING id, age",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
);
assert!(returning_all.is_admitted());
assert_eq!(
returning_all
.classification
.as_ref()
.expect("classification should be present")
.returning_policy,
SqlDeleteReturningPolicy::NarrowAll,
);
assert!(returning_fields.is_admitted());
assert_eq!(
returning_fields
.classification
.as_ref()
.expect("classification should be present")
.returning_policy,
SqlDeleteReturningPolicy::NarrowFields,
);
}
#[test]
fn delete_policy_validated_plans_carry_execution_and_returning_bounds() {
let context = SqlDeletePolicyContext {
primary_key_fields: PRIMARY_KEY,
max_public_bounded_limit: DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT,
max_returning_rows: None,
max_returning_response_bytes: Some(4096),
};
let primary_key = classify_sql_delete_policy(
"DELETE FROM Character WHERE id = 1 RETURNING id",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
context,
)
.expect("SQL should parse");
let bounded = classify_sql_delete_policy(
"DELETE FROM Character WHERE age = 21 ORDER BY id LIMIT 10 RETURNING id",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
context,
)
.expect("SQL should parse");
assert_eq!(
expect_plan(&primary_key).returning_bounds(),
SqlDeleteReturningBounds {
max_rows: Some(1),
max_response_bytes: Some(4096),
},
);
assert_eq!(
expect_plan(&bounded).returning_bounds(),
SqlDeleteReturningBounds {
max_rows: Some(10),
max_response_bytes: Some(4096),
},
);
assert_eq!(
expect_plan(&primary_key).execution_bounds().max_staged_rows,
Some(1),
);
assert_eq!(
expect_plan(&bounded).execution_bounds().max_staged_rows,
Some(10),
);
}
#[test]
fn delete_policy_public_generated_context_carries_default_returning_byte_bound() {
let context = SqlDeletePolicyContext::public_generated(PRIMARY_KEY);
let report = classify_sql_delete_policy(
"DELETE FROM Character WHERE id = 1 RETURNING id",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
context,
)
.expect("SQL should parse");
assert_eq!(
expect_plan(&report).returning_bounds(),
SqlDeleteReturningBounds {
max_rows: Some(1),
max_response_bytes: Some(DEFAULT_PUBLIC_DELETE_RETURNING_RESPONSE_BYTES),
},
);
}
#[test]
fn delete_policy_validated_plans_lower_configured_returning_row_bound() {
let context = SqlDeletePolicyContext {
primary_key_fields: PRIMARY_KEY,
max_public_bounded_limit: DEFAULT_PUBLIC_BOUNDED_DELETE_LIMIT,
max_returning_rows: Some(2),
max_returning_response_bytes: None,
};
let primary_key = classify_sql_delete_policy(
"DELETE FROM Character WHERE id = 1 RETURNING id",
SqlDeleteExposurePolicy::PublicPrimaryKeyOnly,
context,
)
.expect("SQL should parse");
let bounded = classify_sql_delete_policy(
"DELETE FROM Character WHERE age = 21 ORDER BY id LIMIT 10 RETURNING id",
SqlDeleteExposurePolicy::PublicBoundedDeterministic,
context,
)
.expect("SQL should parse");
assert_eq!(
expect_plan(&primary_key).returning_bounds(),
SqlDeleteReturningBounds {
max_rows: Some(1),
max_response_bytes: None,
},
);
assert_eq!(
expect_plan(&bounded).returning_bounds(),
SqlDeleteReturningBounds {
max_rows: Some(2),
max_response_bytes: None,
},
);
}
#[test]
fn delete_policy_admin_bulk_produces_only_admin_plan_variant() {
let report = classify("DELETE FROM Character", SqlDeleteExposurePolicy::AdminBulk);
assert!(report.is_admitted());
assert!(matches!(
expect_plan(&report),
SqlValidatedDeletePlan::AdminBulk(_),
));
}
}