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}