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, SqliteOnConflict, 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 — the table-granularity kinds:
82    /// [`UnsupportedStatement`](crate::diagnostic::TableLevelDiagnosticKind::UnsupportedStatement)
83    /// and
84    /// [`TooManyTableQualifiers`](crate::diagnostic::TableLevelDiagnosticKind::TooManyTableQualifiers).
85    pub diagnostics: Vec<TableLevelDiagnostic>,
86}
87
88impl fmt::Display for CrudTables {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        let write_refs = |ws: &[TableWrite]| -> Vec<TableReference> {
91            ws.iter().map(|w| w.reference.clone()).collect()
92        };
93        let read_refs = |rs: &[TableRead]| -> Vec<TableReference> {
94            rs.iter().map(|r| r.reference.clone()).collect()
95        };
96        write!(
97            f,
98            "Create: [{}], Read: [{}], Update: [{}], Delete: [{}]",
99            TableReference::format_list(&write_refs(&self.create_tables)),
100            TableReference::format_list(&read_refs(&self.read_tables)),
101            TableReference::format_list(&write_refs(&self.update_tables)),
102            TableReference::format_list(&write_refs(&self.delete_tables)),
103        )
104    }
105}
106
107/// Struct-style entry point. Equivalent to the free
108/// [`extract_crud_tables`] function. A thin shim over
109/// [`TableOperationExtractor`] that buckets `reads`/`writes` into the
110/// CRUD positions, consulting the bind's normalized MERGE clause summary
111/// (rather than re-walking the raw AST) for the one verb-aware case —
112/// whose target placement depends on the WHEN actions.
113#[derive(Default, Debug)]
114pub struct CrudTableExtractor;
115
116impl CrudTableExtractor {
117    /// Same as the free [`extract_crud_tables`] function — kept for
118    /// users who prefer the struct-style API.
119    pub fn extract(
120        dialect: &dyn Dialect,
121        sql: &str,
122    ) -> Result<Vec<Result<CrudTables, Error>>, Error> {
123        Self::extract_with_options(dialect, sql, ExtractorOptions::new())
124    }
125
126    /// Like [`extract`](Self::extract) but with [`ExtractorOptions`] — a
127    /// catalog and/or an identifier-casing override. `dialect` still
128    /// drives parsing; the options govern only the analysis.
129    pub fn extract_with_options(
130        dialect: &dyn Dialect,
131        sql: &str,
132        options: ExtractorOptions,
133    ) -> Result<Vec<Result<CrudTables, Error>>, Error> {
134        crate::extractor::extract_each(dialect, sql, options, Self::extract_from_statement)
135    }
136
137    fn extract_from_statement(
138        statement: &Statement,
139        catalog: Option<&Catalog>,
140        style: IdentifierStyle,
141        dialect: &dyn Dialect,
142    ) -> Result<CrudTables, Error> {
143        let (ops, merge_actions, insert_updates, cte_crud) =
144            TableOperationExtractor::extract_inner(statement, catalog, style, dialect)?;
145        // CRUD buckets carry the same `ResolutionKind` as the table operation:
146        // reads as `TableRead`, the create / update / delete buckets as
147        // `TableWrite`.
148        let reads = ops.reads;
149        // With data-modifying CTEs the flat write list spans several roots with
150        // different verbs, so the outer-kind match below must bucket only the
151        // outer root's *own* writes; each CTE's writes are added afterwards by
152        // that CTE's verb. Without them, the flat list is the outer's writes.
153        let writes = match &cte_crud {
154            Some(c) => c.outer_writes.clone(),
155            None => ops.writes,
156        };
157        let diagnostics = ops.diagnostics;
158
159        let mut crud = CrudTables {
160            diagnostics,
161            ..Default::default()
162        };
163        match ops.statement_kind {
164            StatementKind::Insert => {
165                // An upsert (`INSERT … ON CONFLICT DO UPDATE` / MySQL
166                // `ON DUPLICATE KEY UPDATE`) both inserts and updates the
167                // target, so it lands in both buckets; a plain INSERT (or
168                // `DO NOTHING`) is create-only.
169                if insert_updates {
170                    crud.update_tables = writes.clone();
171                }
172                // `REPLACE INTO` / `INSERT OVERWRITE` delete the conflicting /
173                // existing rows of the target before inserting, so the target is
174                // also a delete (unlike an upsert, which updates in place).
175                // SQLite spells it `INSERT OR REPLACE` (its bare `REPLACE INTO`
176                // parses to the same `or` field, not `replace_into`). Peel a
177                // `WITH … INSERT OVERWRITE …` wrapper (parsed as a Query-
178                // wrapped Insert) so the flags are read off the real insert.
179                if peel_to_insert(statement).is_some_and(|i| {
180                    i.replace_into || i.overwrite || i.or == Some(SqliteOnConflict::Replace)
181                }) {
182                    crud.delete_tables = writes.clone();
183                }
184                crud.create_tables = writes;
185                crud.read_tables = reads;
186            }
187            StatementKind::Update => {
188                crud.update_tables = writes;
189                crud.read_tables = reads;
190            }
191            StatementKind::Delete => {
192                crud.delete_tables = writes;
193                crud.read_tables = reads;
194            }
195            StatementKind::Merge => {
196                // MERGE target placement depends on which WHEN actions
197                // appear — read that off the IR-derived `MergeActions` the
198                // bind produced, so this stays in step with the binder's
199                // model (and handles `WITH … MERGE` transparently; the
200                // facade peels the wrapper).
201                let actions = merge_actions.unwrap_or_default();
202                for target in &writes {
203                    if actions.has_insert {
204                        crud.create_tables.push(target.clone());
205                    }
206                    if actions.has_update {
207                        crud.update_tables.push(target.clone());
208                    }
209                    if actions.has_delete {
210                        crud.delete_tables.push(target.clone());
211                    }
212                }
213                crud.read_tables = reads;
214            }
215            // DDL write targets bucket by verb: CREATE → Create (a new
216            // object), ALTER → Update (modifies an existing one), DROP /
217            // TRUNCATE → Delete (removes it). A CTAS / CREATE-VIEW source
218            // still feeds `reads` (e.g. `CREATE TABLE t AS SELECT … FROM src`
219            // → Create: [t], Read: [src]).
220            StatementKind::CreateTable | StatementKind::CreateView => {
221                crud.create_tables = writes;
222                crud.read_tables = reads;
223            }
224            StatementKind::AlterTable | StatementKind::AlterView => {
225                crud.update_tables = writes;
226                crud.read_tables = reads;
227            }
228            StatementKind::Drop | StatementKind::Truncate => {
229                crud.delete_tables = writes;
230                crud.read_tables = reads;
231            }
232            // A plain `SELECT` writes nothing, so Create stays empty and the
233            // read-role tables go to Read. (`SELECT … INTO t` is *not* here — it
234            // classifies as `CreateTable`, handled above; its target surfaces in
235            // `writes` → Create. The `writes` passthrough here is harmless: a
236            // plain query has none.)
237            StatementKind::Select => {
238                crud.create_tables = writes;
239                crud.read_tables = reads;
240            }
241            // An unsupported statement has no reliable write placement — fold
242            // everything into `read_tables` (best-effort). Listed explicitly
243            // (rather than `_ =>`) so a new `StatementKind` variant becomes a
244            // compile error here and forces a bucket decision.
245            StatementKind::Unsupported => {
246                crud.read_tables = reads;
247                // Best-effort: fold any write targets in as reads (an
248                // unsupported statement builds no plan, so this is normally
249                // empty). Same fields, write → read role.
250                crud.read_tables
251                    .extend(writes.into_iter().map(|w| TableRead {
252                        reference: w.reference,
253                        resolution: w.resolution,
254                    }));
255            }
256        }
257
258        // Each data-modifying CTE's write target lands in its own verb's bucket
259        // (an INSERT CTE → create, DELETE → delete, UPDATE → update), regardless
260        // of the outer kind handled above.
261        if let Some(c) = cte_crud {
262            crud.create_tables.extend(c.create);
263            crud.update_tables.extend(c.update);
264            crud.delete_tables.extend(c.delete);
265        }
266
267        Ok(crud)
268    }
269}
270
271/// The underlying `INSERT` of a statement, peeling a `WITH` / parenthesis
272/// wrapper. `WITH … INSERT OVERWRITE …` parses as a `Query`-wrapped `Insert`
273/// (the verb rides `query.body`), so the REPLACE / OVERWRITE flags must be read
274/// off the real insert, not the outer `Statement::Query`. `None` for any
275/// non-INSERT statement.
276fn peel_to_insert(statement: &Statement) -> Option<&Insert> {
277    match statement {
278        Statement::Insert(insert) => Some(insert),
279        Statement::Query(query) => {
280            let mut body = query.body.as_ref();
281            loop {
282                match body {
283                    SetExpr::Insert(inner) => return peel_to_insert(inner),
284                    SetExpr::Query(inner) => body = inner.body.as_ref(),
285                    _ => return None,
286                }
287            }
288        }
289        _ => None,
290    }
291}