Skip to main content

sql_insight/extractor/
crud_table_extractor.rs

1//! CRUD-bucketed table extraction. See [`extract_crud_tables`] as
2//! the entry point.
3//!
4//! Buckets the tables touched by a statement into the four CRUD
5//! positions (Create / Read / Update / Delete). For finer detail —
6//! keeping the precise verb (Insert / Update / Delete / Merge),
7//! separating reads from writes, and per-statement lineage — see
8//! [`extract_table_operations`](crate::extractor::extract_table_operations).
9//!
10//! Write targets bucket by verb, so DDL lands where its effect is, not in
11//! `Read`: `INSERT`, `CREATE TABLE` / `CREATE VIEW`, and `SELECT … INTO` →
12//! Create (an upsert `INSERT … ON CONFLICT DO UPDATE` / `ON DUPLICATE KEY
13//! UPDATE` also → Update; `REPLACE INTO` / `INSERT OVERWRITE`, which delete the
14//! conflicting / existing rows first, also → Delete), `UPDATE` and `ALTER` →
15//! Update, `DELETE` / `DROP` / `TRUNCATE` → Delete, and `MERGE` to each bucket
16//! its WHEN actions imply. A
17//! statement's
18//! read-role tables (a `SELECT`, a CTAS / view source, an `UPDATE … FROM`)
19//! always go to Read. A `WITH … <DML>` parses as a `Query`-wrapped DML, so
20//! the verb is recovered through that wrapper.
21
22use std::fmt;
23
24use crate::casing::IdentifierStyle;
25use crate::catalog::Catalog;
26use crate::diagnostic::TableLevelDiagnostic;
27use crate::error::Error;
28use crate::extractor::{ExtractorOptions, StatementKind, TableOperationExtractor};
29use crate::reference::{TableRead, TableReference, TableWrite};
30use sqlparser::ast::{Insert, SetExpr, Statement};
31use sqlparser::dialect::Dialect;
32
33/// Parse `sql` under `dialect` and return one [`CrudTables`] per
34/// statement.
35///
36/// ## Example
37///
38/// ```rust
39/// use sql_insight::sqlparser::dialect::GenericDialect;
40///
41/// let dialect = GenericDialect {};
42/// let sql = "INSERT INTO t1 (a) SELECT a FROM t2";
43/// let result = sql_insight::extractor::extract_crud_tables(&dialect, sql).unwrap();
44/// println!("{:#?}", result);
45/// assert_eq!(result[0].as_ref().unwrap().to_string(), "Create: [t1], Read: [t2], Update: [], Delete: []");
46/// ```
47pub fn extract_crud_tables(
48    dialect: &dyn Dialect,
49    sql: &str,
50) -> Result<Vec<Result<CrudTables, Error>>, Error> {
51    CrudTableExtractor::extract(dialect, sql)
52}
53
54/// Like [`extract_crud_tables`] but with [`ExtractorOptions`] — a catalog
55/// and/or an identifier-casing override. With a catalog, the bucketed
56/// tables are canonicalized to their registered path.
57pub fn extract_crud_tables_with_options(
58    dialect: &dyn Dialect,
59    sql: &str,
60    options: ExtractorOptions,
61) -> Result<Vec<Result<CrudTables, Error>>, Error> {
62    CrudTableExtractor::extract_with_options(dialect, sql, options)
63}
64
65/// Per-statement output of [`extract_crud_tables`]: tables bucketed
66/// by CRUD position plus non-fatal diagnostics. `Display` renders
67/// `"Create: [...], Read: [...], Update: [...], Delete: [...]"`.
68#[derive(Default, Debug, PartialEq)]
69#[cfg_attr(feature = "serde", derive(serde::Serialize))]
70pub struct CrudTables {
71    /// Tables created (`INSERT` / `CREATE` / a MERGE INSERT action), each paired
72    /// with its catalog-match [`ResolutionKind`](crate::ResolutionKind).
73    pub create_tables: Vec<TableWrite>,
74    /// Tables read, each paired with its [`ResolutionKind`](crate::ResolutionKind).
75    pub read_tables: Vec<TableRead>,
76    /// Tables updated (`UPDATE` / `ALTER` / a MERGE UPDATE action / an upsert).
77    pub update_tables: Vec<TableWrite>,
78    /// Tables deleted (`DELETE` / `DROP` / `TRUNCATE` / a MERGE DELETE action).
79    pub delete_tables: Vec<TableWrite>,
80    /// Non-fatal diagnostics, forwarded from the underlying table-level
81    /// extraction (only [`UnsupportedStatement`](crate::diagnostic::TableLevelDiagnosticKind::UnsupportedStatement)
82    /// arises at this granularity).
83    pub diagnostics: Vec<TableLevelDiagnostic>,
84}
85
86impl fmt::Display for CrudTables {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        let write_refs = |ws: &[TableWrite]| -> Vec<TableReference> {
89            ws.iter().map(|w| w.reference.clone()).collect()
90        };
91        let read_refs = |rs: &[TableRead]| -> Vec<TableReference> {
92            rs.iter().map(|r| r.reference.clone()).collect()
93        };
94        write!(
95            f,
96            "Create: [{}], Read: [{}], Update: [{}], Delete: [{}]",
97            TableReference::format_list(&write_refs(&self.create_tables)),
98            TableReference::format_list(&read_refs(&self.read_tables)),
99            TableReference::format_list(&write_refs(&self.update_tables)),
100            TableReference::format_list(&write_refs(&self.delete_tables)),
101        )
102    }
103}
104
105/// Struct-style entry point. Equivalent to the free
106/// [`extract_crud_tables`] function. A thin shim over
107/// [`TableOperationExtractor`] that buckets `reads`/`writes` into the
108/// CRUD positions, consulting the bind's normalized MERGE clause summary
109/// (rather than re-walking the raw AST) for the one verb-aware case —
110/// whose target placement depends on the WHEN actions.
111#[derive(Default, Debug)]
112pub struct CrudTableExtractor;
113
114impl CrudTableExtractor {
115    /// Same as the free [`extract_crud_tables`] function — kept for
116    /// users who prefer the struct-style API.
117    pub fn extract(
118        dialect: &dyn Dialect,
119        sql: &str,
120    ) -> Result<Vec<Result<CrudTables, Error>>, Error> {
121        Self::extract_with_options(dialect, sql, ExtractorOptions::new())
122    }
123
124    /// Like [`extract`](Self::extract) but with [`ExtractorOptions`] — a
125    /// catalog and/or an identifier-casing override. `dialect` still
126    /// drives parsing; the options govern only the analysis.
127    pub fn extract_with_options(
128        dialect: &dyn Dialect,
129        sql: &str,
130        options: ExtractorOptions,
131    ) -> Result<Vec<Result<CrudTables, Error>>, Error> {
132        crate::extractor::extract_each(dialect, sql, options, Self::extract_from_statement)
133    }
134
135    fn extract_from_statement(
136        statement: &Statement,
137        catalog: Option<&Catalog>,
138        style: IdentifierStyle,
139    ) -> Result<CrudTables, Error> {
140        let (ops, merge_actions, insert_updates, cte_crud) =
141            TableOperationExtractor::extract_inner(statement, catalog, style)?;
142        // CRUD buckets carry the same `ResolutionKind` as the table operation:
143        // reads as `TableRead`, the create / update / delete buckets as
144        // `TableWrite`.
145        let reads = ops.reads;
146        // With data-modifying CTEs the flat write list spans several roots with
147        // different verbs, so the outer-kind match below must bucket only the
148        // outer root's *own* writes; each CTE's writes are added afterwards by
149        // that CTE's verb. Without them, the flat list is the outer's writes.
150        let writes = match &cte_crud {
151            Some(c) => c.outer_writes.clone(),
152            None => ops.writes,
153        };
154        let diagnostics = ops.diagnostics;
155
156        let mut crud = CrudTables {
157            diagnostics,
158            ..Default::default()
159        };
160        match ops.statement_kind {
161            StatementKind::Insert => {
162                // An upsert (`INSERT … ON CONFLICT DO UPDATE` / MySQL
163                // `ON DUPLICATE KEY UPDATE`) both inserts and updates the
164                // target, so it lands in both buckets; a plain INSERT (or
165                // `DO NOTHING`) is create-only.
166                if insert_updates {
167                    crud.update_tables = writes.clone();
168                }
169                // `REPLACE INTO` / `INSERT OVERWRITE` delete the conflicting /
170                // existing rows of the target before inserting, so the target is
171                // also a delete (unlike an upsert, which updates in place). Peel
172                // a `WITH … INSERT OVERWRITE …` wrapper (parsed as a Query-
173                // wrapped Insert) so the flags are read off the real insert.
174                if peel_to_insert(statement).is_some_and(|i| i.replace_into || i.overwrite) {
175                    crud.delete_tables = writes.clone();
176                }
177                crud.create_tables = writes;
178                crud.read_tables = reads;
179            }
180            StatementKind::Update => {
181                crud.update_tables = writes;
182                crud.read_tables = reads;
183            }
184            StatementKind::Delete => {
185                crud.delete_tables = writes;
186                crud.read_tables = reads;
187            }
188            StatementKind::Merge => {
189                // MERGE target placement depends on which WHEN actions
190                // appear — read that off the IR-derived `MergeActions` the
191                // bind produced, so this stays in step with the binder's
192                // model (and handles `WITH … MERGE` transparently; the
193                // facade peels the wrapper).
194                let actions = merge_actions.unwrap_or_default();
195                for target in &writes {
196                    if actions.has_insert {
197                        crud.create_tables.push(target.clone());
198                    }
199                    if actions.has_update {
200                        crud.update_tables.push(target.clone());
201                    }
202                    if actions.has_delete {
203                        crud.delete_tables.push(target.clone());
204                    }
205                }
206                crud.read_tables = reads;
207            }
208            // DDL write targets bucket by verb: CREATE → Create (a new
209            // object), ALTER → Update (modifies an existing one), DROP /
210            // TRUNCATE → Delete (removes it). A CTAS / CREATE-VIEW source
211            // still feeds `reads` (e.g. `CREATE TABLE t AS SELECT … FROM src`
212            // → Create: [t], Read: [src]).
213            StatementKind::CreateTable | StatementKind::CreateView => {
214                crud.create_tables = writes;
215                crud.read_tables = reads;
216            }
217            StatementKind::AlterTable | StatementKind::AlterView => {
218                crud.update_tables = writes;
219                crud.read_tables = reads;
220            }
221            StatementKind::Drop | StatementKind::Truncate => {
222                crud.delete_tables = writes;
223                crud.read_tables = reads;
224            }
225            // A plain `SELECT` writes nothing, so Create stays empty and the
226            // read-role tables go to Read. (`SELECT … INTO t` is *not* here — it
227            // classifies as `CreateTable`, handled above; its target surfaces in
228            // `writes` → Create. The `writes` passthrough here is harmless: a
229            // plain query has none.)
230            StatementKind::Select => {
231                crud.create_tables = writes;
232                crud.read_tables = reads;
233            }
234            // An unsupported statement has no reliable write placement — fold
235            // everything into `read_tables` (best-effort). Listed explicitly
236            // (rather than `_ =>`) so a new `StatementKind` variant becomes a
237            // compile error here and forces a bucket decision.
238            StatementKind::Unsupported => {
239                crud.read_tables = reads;
240                // Best-effort: fold any write targets in as reads (an
241                // unsupported statement builds no plan, so this is normally
242                // empty). Same fields, write → read role.
243                crud.read_tables
244                    .extend(writes.into_iter().map(|w| TableRead {
245                        reference: w.reference,
246                        resolution: w.resolution,
247                    }));
248            }
249        }
250
251        // Each data-modifying CTE's write target lands in its own verb's bucket
252        // (an INSERT CTE → create, DELETE → delete, UPDATE → update), regardless
253        // of the outer kind handled above.
254        if let Some(c) = cte_crud {
255            crud.create_tables.extend(c.create);
256            crud.update_tables.extend(c.update);
257            crud.delete_tables.extend(c.delete);
258        }
259
260        Ok(crud)
261    }
262}
263
264/// The underlying `INSERT` of a statement, peeling a `WITH` / parenthesis
265/// wrapper. `WITH … INSERT OVERWRITE …` parses as a `Query`-wrapped `Insert`
266/// (the verb rides `query.body`), so the REPLACE / OVERWRITE flags must be read
267/// off the real insert, not the outer `Statement::Query`. `None` for any
268/// non-INSERT statement.
269fn peel_to_insert(statement: &Statement) -> Option<&Insert> {
270    match statement {
271        Statement::Insert(insert) => Some(insert),
272        Statement::Query(query) => {
273            let mut body = query.body.as_ref();
274            loop {
275                match body {
276                    SetExpr::Insert(inner) => return peel_to_insert(inner),
277                    SetExpr::Query(inner) => body = inner.body.as_ref(),
278                    _ => return None,
279                }
280            }
281        }
282        _ => None,
283    }
284}