Skip to main content

sequel_mcp/sql/
ddl.rs

1//! DDL semantics (D4): protection modelling and absent-target preflight.
2//!
3//! DDL in MySQL/MariaDB commonly performs an implicit commit, so a
4//! preceding backup transaction cannot be atomic with the DDL itself.
5//! DDL protection is therefore modelled as **a durable pre-operation
6//! snapshot with explicit nontransactional warnings** — never as
7//! transactional DML protection. Absent targets are resolved by preflight
8//! (existence checked with bound parameters) instead of converting
9//! `ER_NO_SUCH_TABLE` at backup time into "empty backup, proceed".
10
11use mysql_async::Conn;
12use mysql_async::prelude::Queryable;
13use thiserror::Error;
14
15/// How a statement's backup/protection guarantee is modelled.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ProtectionModel {
18    /// START TRANSACTION → lock+capture pre-image → durable backup →
19    /// mutation → COMMIT all on one physical connection; rollback-grade
20    /// protection.
21    TransactionalDmlProtection,
22    /// Durable pre-operation snapshot persisted BEFORE the DDL; implicit
23    /// commit may occur; snapshot and DDL are not one atomic transaction;
24    /// automatic rollback is not guaranteed.
25    NonTransactionalDdlSnapshot,
26    /// No protection can be provided (nontransactional engine, unresolved
27    /// targets, or an unrewritable backup query) — deny by default.
28    UnprotectedDenied,
29}
30
31impl ProtectionModel {
32    /// Warnings that must appear in the operation plan, confirmation, and
33    /// audit event for this protection model.
34    pub fn warnings(&self) -> &'static [&'static str] {
35        match self {
36            ProtectionModel::TransactionalDmlProtection => &[],
37            ProtectionModel::NonTransactionalDdlSnapshot => &[
38                "pre-operation snapshot only",
39                "implicit commit may occur",
40                "snapshot and DDL are not one atomic transaction",
41                "automatic rollback is not guaranteed",
42            ],
43            ProtectionModel::UnprotectedDenied => &["no backup protection can be guaranteed"],
44        }
45    }
46
47    pub fn is_transactional(&self) -> bool {
48        matches!(self, ProtectionModel::TransactionalDmlProtection)
49    }
50}
51
52/// Map a classified statement to its protection model. DDL categories get
53/// snapshot semantics; writes get transactional semantics when a backup is
54/// captured (or none is required); everything else is by definition not a
55/// mutation.
56pub fn protection_model_for(
57    category: crate::policy::model::SqlCategory,
58    ast_type: &str,
59) -> ProtectionModel {
60    use crate::policy::model::SqlCategory;
61    match category {
62        SqlCategory::Ddl => ProtectionModel::NonTransactionalDdlSnapshot,
63        SqlCategory::Write => {
64            if crate::backup::extractor::is_backup_required(ast_type) {
65                ProtectionModel::TransactionalDmlProtection
66            } else {
67                // Writes without a backup strategy (e.g. classified but
68                // unhandled forms) still execute in one transaction.
69                ProtectionModel::TransactionalDmlProtection
70            }
71        }
72        _ => ProtectionModel::TransactionalDmlProtection,
73    }
74}
75
76#[derive(Debug, Clone, Error, PartialEq)]
77pub enum DdlPreflightError {
78    #[error("table {schema}.{table} does not exist: {hint}")]
79    NotFound {
80        schema: String,
81        table: String,
82        hint: &'static str,
83    },
84    #[error("table {schema}.{table} already exists: {hint}")]
85    Conflict {
86        schema: String,
87        table: String,
88        hint: &'static str,
89    },
90    #[error("preflight query failed: {0}")]
91    Query(String),
92}
93
94/// Result of checking DDL targets for existence before execution.
95#[derive(Debug, Clone, PartialEq)]
96pub enum DdlPreflight {
97    /// All mutated targets exist; proceed with snapshot + DDL.
98    Present,
99    /// All mutated targets are missing AND the statement says IF EXISTS:
100    /// audited local no-op — no DDL is sent to the server.
101    MissingNoOp(Vec<(String, String)>),
102    /// Some targets exist and some are missing under IF EXISTS: execute
103    /// ONLY the preflight-approved existing subset — as a REWRITTEN
104    /// statement naming exactly those targets (never the original
105    /// multi-target statement, which would also drop any target created
106    /// between preflight and execution); the missing list must be audited
107    /// as absent (never silently suppressed).
108    Mixed {
109        existing: Vec<(String, String)>,
110        missing: Vec<(String, String)>,
111    },
112}
113
114/// Build the fail-closed rewrite of a Mixed multi-target DROP: only the
115/// preflight-approved existing targets, fully qualified, backtick-escaped,
116/// with IF EXISTS retained. Returns `None` for object types whose drop
117/// cannot be safely reconstructed (`DROP INDEX` and friends) — the caller
118/// fails closed in that case. This closes the plan→execute TOCTOU: a
119/// table created after the preflight is not named by the rewritten
120/// statement, so it cannot be dropped without a fresh plan and approval.
121pub fn rewrite_drop_subset(object_type: &str, existing: &[(String, String)]) -> Option<String> {
122    let keyword = match object_type {
123        "table" => "DROP TABLE IF EXISTS",
124        "view" => "DROP VIEW IF EXISTS",
125        _ => return None,
126    };
127    if existing.is_empty() {
128        return None;
129    }
130    let targets = existing
131        .iter()
132        .map(|(schema, table)| {
133            format!(
134                "`{}`.`{}`",
135                schema.replace('`', "``"),
136                table.replace('`', "``")
137            )
138        })
139        .collect::<Vec<_>>()
140        .join(", ");
141    Some(format!("{keyword} {targets}"))
142}
143
144/// Check every mutated table of a DDL statement for existence using
145/// `information_schema.tables` with bound parameters. `fallback_db`
146/// resolves unqualified names (connection default).
147pub async fn preflight_ddl(
148    conn: &mut Conn,
149    classified: &crate::policy::classifier::ClassifiedStatement,
150    fallback_db: Option<&str>,
151) -> Result<DdlPreflight, DdlPreflightError> {
152    use crate::policy::model::SqlCategory;
153    if classified.category != SqlCategory::Ddl || classified.mutated_tables.is_empty() {
154        return Ok(DdlPreflight::Present);
155    }
156
157    let resolve_schema = |target: &crate::policy::classifier::TableRef| -> Option<String> {
158        target
159            .database
160            .clone()
161            .or_else(|| fallback_db.map(str::to_string))
162    };
163
164    async fn table_exists(
165        conn: &mut Conn,
166        schema: &str,
167        table: &str,
168    ) -> Result<bool, DdlPreflightError> {
169        let found: Option<i8> = conn
170            .exec_first(
171                "SELECT 1 FROM information_schema.tables
172                  WHERE table_schema = ? AND table_name = ?",
173                (schema, table),
174            )
175            .await
176            .map_err(|e| DdlPreflightError::Query(e.to_string()))?;
177        Ok(found.is_some())
178    }
179
180    match classified.ast_type {
181        // CREATE statements create their targets: existence is not an
182        // error either way; skip gating entirely.
183        "create" => Ok(DdlPreflight::Present),
184
185        // RENAME chains execute left-to-right. Model the chain locally:
186        // each source must exist (after earlier steps), each destination
187        // must be absent (after earlier steps) — this correctly allows
188        // swap chains (a→tmp, b→a, tmp→b).
189        "rename" => {
190            let pairs: Vec<(
191                &crate::policy::classifier::TableRef,
192                &crate::policy::classifier::TableRef,
193            )> = classified
194                .mutated_tables
195                .chunks(2)
196                .map(|c| (&c[0], &c[1]))
197                .collect();
198            let mut known: Vec<((String, String), bool)> = Vec::new();
199            for (src, dst) in &pairs {
200                let src_schema = resolve_schema(src).ok_or(DdlPreflightError::NotFound {
201                    schema: "?".into(),
202                    table: src.table.clone(),
203                    hint: "no database in scope for the RENAME source",
204                })?;
205                let dst_schema = resolve_schema(dst).ok_or(DdlPreflightError::NotFound {
206                    schema: "?".into(),
207                    table: dst.table.clone(),
208                    hint: "no database in scope for the RENAME destination",
209                })?;
210                let src_id = (src_schema.clone(), src.table.clone());
211                let dst_id = (dst_schema.clone(), dst.table.clone());
212
213                // Source present? (chain-aware: earlier renames move ids)
214                let src_present =
215                    if let Some(p) = known.iter().find(|(id, _p)| *id == src_id).map(|(_, p)| *p) {
216                        p
217                    } else {
218                        table_exists(conn, &src_schema, &src.table).await?
219                    };
220                if !src_present {
221                    return Err(DdlPreflightError::NotFound {
222                        schema: src_schema,
223                        table: src.table.clone(),
224                        hint: "RENAME source does not exist",
225                    });
226                }
227
228                // Destination absent? (chain-aware)
229                let dst_present =
230                    if let Some(p) = known.iter().find(|(id, _p)| *id == dst_id).map(|(_, p)| *p) {
231                        p
232                    } else {
233                        table_exists(conn, &dst_schema, &dst.table).await?
234                    };
235                if dst_present {
236                    return Err(DdlPreflightError::Conflict {
237                        schema: dst_schema,
238                        table: dst.table.clone(),
239                        hint: "RENAME destination already exists",
240                    });
241                }
242
243                known.retain(|(id, _)| *id != src_id);
244                known.push((src_id, false));
245                known.push((dst_id, true));
246            }
247            Ok(DdlPreflight::Present)
248        }
249
250        // DROP / TRUNCATE: multi-target normalization across engines.
251        // Without IF EXISTS: ANY missing target → typed not-found, nothing
252        // executed (stricter than MariaDB's partial behaviour, matching
253        // MySQL 8.4). With IF EXISTS: all missing → audited no-op; some
254        // missing → proceed on the existing complete approved set with the
255        // absent list attached for audit.
256        _ => {
257            let mut existing: Vec<(String, String)> = Vec::new();
258            let mut missing: Vec<(String, String)> = Vec::new();
259            for target in &classified.mutated_tables {
260                let schema = resolve_schema(target).ok_or(DdlPreflightError::NotFound {
261                    schema: "?".into(),
262                    table: target.table.clone(),
263                    hint: "no database in scope for the DDL target",
264                })?;
265                if table_exists(conn, &schema, &target.table).await? {
266                    existing.push((schema, target.table.clone()));
267                } else {
268                    missing.push((schema, target.table.clone()));
269                }
270            }
271            if missing.is_empty() {
272                return Ok(DdlPreflight::Present);
273            }
274            if !classified.if_exists {
275                let (schema, table) = missing[0].clone();
276                return Err(DdlPreflightError::NotFound {
277                    schema,
278                    table,
279                    hint: "DDL target does not exist (no IF EXISTS)",
280                });
281            }
282            if existing.is_empty() {
283                Ok(DdlPreflight::MissingNoOp(missing))
284            } else {
285                Ok(DdlPreflight::Mixed { existing, missing })
286            }
287        }
288    }
289}