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}