sql_insight/extractor/table_operation_extractor.rs
1//! Extracts the application-level operations a SQL statement performs.
2//!
3//! Where [`extract_crud_tables`](crate::extractor::extract_crud_tables())
4//! answers "what tables does this SQL touch?" in CRUD buckets, this module
5//! answers "what operations does this SQL perform, on which tables, and how do
6//! those tables relate?".
7//!
8//! The output is per-statement: one [`TableOperation`] per parsed
9//! statement, since a single application call (e.g. an ORM `execute()`)
10//! typically corresponds to a single statement.
11//!
12//! Three parallel surfaces describe the statement:
13//! - `reads` — every table the statement reads from.
14//! - `writes` — every table the statement writes to.
15//! - `lineage` — directed `source → target` edges for statements that
16//! physically move data.
17//!
18//! A single table can appear in both `reads` and `writes` when it plays
19//! both roles (e.g. `DELETE t1 FROM t1` — t1 is the deletion target and
20//! a row source).
21
22use crate::casing::IdentifierStyle;
23use crate::catalog::Catalog;
24use crate::diagnostic::{TableLevelDiagnostic, TableLevelDiagnosticKind};
25use crate::error::Error;
26use crate::extractor::{classify_statement, ExtractorOptions, StatementKind};
27use crate::reference::{TableRead, TableWrite};
28use crate::resolver::MergeActions;
29use sqlparser::ast::Statement;
30use sqlparser::dialect::Dialect;
31
32/// Convenience function to extract table-level operations from SQL using
33/// the dialect defaults (no catalog, dialect-derived casing). For a
34/// catalog or a casing override, use
35/// [`extract_table_operations_with_options`].
36///
37/// ## Example
38///
39/// ```rust
40/// use sql_insight::sqlparser::dialect::GenericDialect;
41/// use sql_insight::extractor::{extract_table_operations, StatementKind};
42///
43/// let dialect = GenericDialect {};
44/// let result = extract_table_operations(&dialect, "SELECT * FROM users").unwrap();
45/// let ops = result[0].as_ref().unwrap();
46/// assert_eq!(ops.statement_kind, StatementKind::Select);
47/// assert_eq!(ops.reads.len(), 1);
48/// assert_eq!(ops.reads[0].reference.name.value, "users");
49/// assert!(ops.writes.is_empty());
50/// ```
51pub fn extract_table_operations(
52 dialect: &dyn Dialect,
53 sql: &str,
54) -> Result<Vec<Result<TableOperation, Error>>, Error> {
55 TableOperationExtractor::extract(dialect, sql)
56}
57
58/// Like [`extract_table_operations`] but with [`ExtractorOptions`] — a
59/// catalog and/or an identifier-casing override. `dialect` still drives
60/// parsing; the options govern only the analysis.
61pub fn extract_table_operations_with_options(
62 dialect: &dyn Dialect,
63 sql: &str,
64 options: ExtractorOptions,
65) -> Result<Vec<Result<TableOperation, Error>>, Error> {
66 TableOperationExtractor::extract_with_options(dialect, sql, options)
67}
68
69/// Operations performed by a single SQL statement.
70#[derive(Debug, Clone, PartialEq, Eq)]
71#[cfg_attr(feature = "serde", derive(serde::Serialize))]
72pub struct TableOperation {
73 /// What the statement does at a coarse level (Insert / Update /
74 /// Merge / CTAS / …).
75 pub statement_kind: StatementKind,
76 /// Tables read by the statement. Occurrence-based: a table referenced
77 /// more than once appears more than once. Each [`TableRead`] pairs the
78 /// identity with the catalog-match
79 /// [`ResolutionKind`](crate::ResolutionKind). **In source order** — by
80 /// each read's written token span (`reference.name.span`), a deterministic
81 /// function of the SQL rather than the internal traversal. For the distinct
82 /// identity set, dedup `reads.iter().map(|r| &r.reference)` via a `HashSet`
83 /// (or, catalog-free, by
84 /// [`TableReference::identity_key`](crate::TableReference::identity_key)
85 /// to fold case-equivalent spellings).
86 pub reads: Vec<TableRead>,
87 /// Tables written by the statement, in source order. Occurrence-based
88 /// like `reads`. Each [`TableWrite`] pairs the target identity with its
89 /// catalog-match [`ResolutionKind`](crate::ResolutionKind) — so a write
90 /// target carries the same `Cataloged` / `Inferred` / `Ambiguous` signal
91 /// a scanned source does (and the `Cataloged`-detects-catalog-aware
92 /// invariant holds on writes too).
93 pub writes: Vec<TableWrite>,
94 /// Lineage edges, only for statements that physically move data
95 /// (`INSERT`, `UPDATE`, `MERGE` with an Insert / Update WHEN
96 /// clause, CTAS, `CREATE VIEW`, `ALTER VIEW`). **In source order** of the
97 /// feeding source table (by its written token span); occurrence is
98 /// preserved on the source side — a real table joined twice contributes
99 /// two edges — but a CTE body, the one declaration shared by every
100 /// `CteRef`, contributes once (it materializes once, so it feeds once).
101 pub lineage: Vec<TableLineageEdge>,
102 /// Non-fatal diagnostics from the walk — the table-granularity kinds:
103 /// `UnsupportedStatement` and `TooManyTableQualifiers` (see
104 /// [`TableLevelDiagnosticKind`]).
105 pub diagnostics: Vec<TableLevelDiagnostic>,
106}
107
108/// A source-to-target table lineage edge inferred from the statement
109/// structure.
110///
111/// Emitted only for statements that physically move data into a target
112/// (`INSERT`, `UPDATE`, `MERGE`, `CREATE TABLE AS SELECT`, `CREATE VIEW`).
113/// `DELETE`, `DROP`, `TRUNCATE`, `ALTER`, and bare `SELECT` produce no
114/// lineage even when they reference other tables — the touched tables are
115/// still visible through [`TableOperation::reads`] and
116/// [`TableOperation::writes`].
117///
118/// Each `TableLineageEdge` is a single directed edge — a statement that derives
119/// `t` from `a JOIN b` emits two edges (`a → t`, `b → t`), not one entry
120/// with both sources.
121///
122/// **Occurrence on the source side**: a statement reading the same real
123/// table twice (`FROM s AS x JOIN s AS y`, repeated `FROM s` across UNION
124/// branches) emits one edge per occurrence. A CTE body — the one
125/// declaration shared by every `CteRef` — contributes once instead (it
126/// materializes once and feeds once), matching how `reads` walks the body
127/// at its declaration rather than at each reference. Consumers wanting
128/// set-union semantics dedup explicitly via `HashSet::from_iter`. Matches
129/// [`ColumnLineageEdge`](crate::extractor::ColumnLineageEdge) on the
130/// non-CTE multiplicity rule.
131///
132/// Tables referenced only inside a predicate subquery are excluded:
133/// `INSERT INTO t SELECT FROM s WHERE id IN (SELECT id FROM x)` emits
134/// `s → t` but not `x → t`. `x` remains visible via `reads`.
135///
136/// CTE transitivity: `WITH cte AS (SELECT ... FROM s) INSERT INTO t
137/// SELECT ... FROM cte` emits `s → t` because `s` sits in a
138/// data-feeding chain from the CTE body up through the INSERT target.
139/// An unreferenced CTE contributes nothing — `WITH cte AS (SELECT a
140/// FROM s) INSERT INTO t SELECT 1` emits no edge (the `cte` is bound
141/// but never `FROM`-used, so `s` doesn't feed `t`).
142///
143/// Recursive CTEs collapse the same way: the anchor branch's real
144/// tables feed the target, and the self-reference terminates against
145/// the pre-bind stub without re-emitting the cycle.
146#[derive(Debug, Clone, PartialEq, Eq, Hash)]
147#[cfg_attr(feature = "serde", derive(serde::Serialize))]
148pub struct TableLineageEdge {
149 /// The feeding source table, paired with its catalog-match
150 /// [`ResolutionKind`](crate::ResolutionKind).
151 pub source: TableRead,
152 /// The write target, paired with its catalog-match
153 /// [`ResolutionKind`](crate::ResolutionKind) — the write-side counterpart
154 /// of `source` ([`TableWrite`]).
155 pub target: TableWrite,
156}
157
158/// Struct-style entry point. Equivalent to the free
159/// [`extract_table_operations`] function.
160#[derive(Default, Debug)]
161pub struct TableOperationExtractor;
162
163impl TableOperationExtractor {
164 /// Same as the free [`extract_table_operations`] function — kept
165 /// for users who prefer the struct-style API.
166 pub fn extract(
167 dialect: &dyn Dialect,
168 sql: &str,
169 ) -> Result<Vec<Result<TableOperation, Error>>, Error> {
170 Self::extract_with_options(dialect, sql, ExtractorOptions::new())
171 }
172
173 /// Like [`extract`](Self::extract) but with [`ExtractorOptions`] — a
174 /// catalog and/or an identifier-casing override. `dialect` still
175 /// drives parsing; the options govern only the analysis.
176 pub fn extract_with_options(
177 dialect: &dyn Dialect,
178 sql: &str,
179 options: ExtractorOptions,
180 ) -> Result<Vec<Result<TableOperation, Error>>, Error> {
181 crate::extractor::extract_each(dialect, sql, options, Self::extract_from_statement)
182 }
183
184 /// Assemble the table operation from the bound plan: see
185 /// [`extract_inner`](Self::extract_inner). Public-facing callers want the
186 /// `TableOperation` alone.
187 pub(crate) fn extract_from_statement(
188 statement: &Statement,
189 catalog: Option<&Catalog>,
190 style: IdentifierStyle,
191 dialect: &dyn Dialect,
192 ) -> Result<TableOperation, Error> {
193 Self::extract_inner(statement, catalog, style, dialect).map(|(op, ..)| op)
194 }
195
196 /// Bind the statement and walk the plan for `reads` / `writes` / (for
197 /// data-moving statements only) `lineage`; classify the verb; project the
198 /// column-level diagnostics down. Returns the assembled `TableOperation`
199 /// plus the bound plan's MERGE clause summary — `None` outside MERGE —
200 /// so a same-crate caller (the CRUD extractor) can bucket the target by
201 /// WHEN actions without re-walking the raw AST. An `Unsupported` kind
202 /// yields an empty operation with an `UnsupportedStatement` diagnostic
203 /// and no merge summary (the plan is never built).
204 pub(crate) fn extract_inner(
205 statement: &Statement,
206 catalog: Option<&Catalog>,
207 style: IdentifierStyle,
208 dialect: &dyn Dialect,
209 ) -> Result<
210 (
211 TableOperation,
212 Option<MergeActions>,
213 bool,
214 Option<crate::resolver::DataModifyingCteCrud>,
215 ),
216 Error,
217 > {
218 let statement_kind = classify_statement(statement);
219 if statement_kind == StatementKind::Unsupported {
220 return Ok((
221 unsupported_table_operation(statement_kind, statement),
222 None,
223 false,
224 None,
225 ));
226 }
227 let (plan, column_diagnostics) = crate::resolver::build(statement, catalog, style, dialect);
228 let merge_actions = crate::resolver::merge_actions(&plan);
229 // An upsert (`INSERT … ON CONFLICT DO UPDATE`) both inserts and updates
230 // its target, so the CRUD extractor places it in both buckets.
231 let insert_updates = crate::resolver::insert_updates_on_conflict(&plan);
232 // Lineage is only for statements that move data into a target. A
233 // column-less INSERT and a DELETE both bind to a `Write`, so the
234 // structural walk can't tell them apart — gate on the kind. A MERGE
235 // whose WHEN clauses are only DELETEs uses its source solely to pick
236 // target rows, so it moves no data even though the source is a
237 // feeding input — read that off the IR-derived `MergeActions` rather
238 // than re-walking the raw `Statement::Merge`.
239 let outer_moves_data =
240 writes_data(&statement_kind) && merge_actions.is_none_or(|a| a.writes_data());
241 // A data-modifying CTE (`WITH c AS (INSERT …) SELECT …`) moves data even
242 // though the statement classifies by its read outer verb, so it emits
243 // lineage independently of the outer kind / MERGE gate above.
244 let emits_lineage = outer_moves_data || crate::resolver::has_data_modifying_cte(&plan);
245 let lineage = if emits_lineage {
246 crate::resolver::table_lineage(&plan, style.casing)
247 } else {
248 Vec::new()
249 };
250 let op = TableOperation {
251 statement_kind,
252 reads: crate::resolver::table_reads(&plan),
253 writes: crate::resolver::table_writes(&plan),
254 lineage,
255 // Table-level diagnostics are the column-level ones projected
256 // down (only `UnsupportedStatement` / `TooManyTableQualifiers`
257 // survive the projection).
258 diagnostics: column_diagnostics
259 .iter()
260 .filter_map(|d| d.to_table_level())
261 .collect(),
262 };
263 let cte_crud = crate::resolver::data_modifying_cte_crud(&plan);
264 Ok((op, merge_actions, insert_updates, cte_crud))
265 }
266}
267
268/// Whether a statement physically moves data into its target (so it emits
269/// table lineage). `DELETE` / `DROP` / `TRUNCATE` / `ALTER TABLE` touch a
270/// target but feed it nothing; a bare `SELECT` has no target.
271fn writes_data(kind: &StatementKind) -> bool {
272 matches!(
273 kind,
274 StatementKind::Insert
275 | StatementKind::Update
276 | StatementKind::Merge
277 | StatementKind::CreateTable
278 | StatementKind::CreateView
279 | StatementKind::AlterView
280 )
281}
282
283fn unsupported_table_operation(
284 statement_kind: StatementKind,
285 statement: &Statement,
286) -> TableOperation {
287 TableOperation {
288 statement_kind,
289 reads: Vec::new(),
290 writes: Vec::new(),
291 lineage: Vec::new(),
292 diagnostics: vec![TableLevelDiagnostic {
293 kind: TableLevelDiagnosticKind::UnsupportedStatement,
294 message: crate::extractor::unsupported_message(statement),
295 span: None,
296 }],
297 }
298}