Skip to main content

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; only
103    /// `UnsupportedStatement` arises at this granularity.
104    pub diagnostics: Vec<TableLevelDiagnostic>,
105}
106
107/// A source-to-target table lineage edge inferred from the statement
108/// structure.
109///
110/// Emitted only for statements that physically move data into a target
111/// (`INSERT`, `UPDATE`, `MERGE`, `CREATE TABLE AS SELECT`, `CREATE VIEW`).
112/// `DELETE`, `DROP`, `TRUNCATE`, `ALTER`, and bare `SELECT` produce no
113/// lineage even when they reference other tables — the touched tables are
114/// still visible through [`TableOperation::reads`] and
115/// [`TableOperation::writes`].
116///
117/// Each `TableLineageEdge` is a single directed edge — a statement that derives
118/// `t` from `a JOIN b` emits two edges (`a → t`, `b → t`), not one entry
119/// with both sources.
120///
121/// **Occurrence on the source side**: a statement reading the same real
122/// table twice (`FROM s AS x JOIN s AS y`, repeated `FROM s` across UNION
123/// branches) emits one edge per occurrence. A CTE body — the one
124/// declaration shared by every `CteRef` — contributes once instead (it
125/// materializes once and feeds once), matching how `reads` walks the body
126/// at its declaration rather than at each reference. Consumers wanting
127/// set-union semantics dedup explicitly via `HashSet::from_iter`. Matches
128/// [`ColumnLineageEdge`](crate::extractor::ColumnLineageEdge) on the
129/// non-CTE multiplicity rule.
130///
131/// Tables referenced only inside a predicate subquery are excluded:
132/// `INSERT INTO t SELECT FROM s WHERE id IN (SELECT id FROM x)` emits
133/// `s → t` but not `x → t`. `x` remains visible via `reads`.
134///
135/// CTE transitivity: `WITH cte AS (SELECT ... FROM s) INSERT INTO t
136/// SELECT ... FROM cte` emits `s → t` because `s` sits in a
137/// data-feeding chain from the CTE body up through the INSERT target.
138/// An unreferenced CTE contributes nothing — `WITH cte AS (SELECT a
139/// FROM s) INSERT INTO t SELECT 1` emits no edge (the `cte` is bound
140/// but never `FROM`-used, so `s` doesn't feed `t`).
141///
142/// Recursive CTEs collapse the same way: the anchor branch's real
143/// tables feed the target, and the self-reference terminates against
144/// the pre-bind stub without re-emitting the cycle.
145#[derive(Debug, Clone, PartialEq, Eq, Hash)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize))]
147pub struct TableLineageEdge {
148    /// The feeding source table, paired with its catalog-match
149    /// [`ResolutionKind`](crate::ResolutionKind).
150    pub source: TableRead,
151    /// The write target, paired with its catalog-match
152    /// [`ResolutionKind`](crate::ResolutionKind) — the write-side counterpart
153    /// of `source` ([`TableWrite`]).
154    pub target: TableWrite,
155}
156
157/// Struct-style entry point. Equivalent to the free
158/// [`extract_table_operations`] function.
159#[derive(Default, Debug)]
160pub struct TableOperationExtractor;
161
162impl TableOperationExtractor {
163    /// Same as the free [`extract_table_operations`] function — kept
164    /// for users who prefer the struct-style API.
165    pub fn extract(
166        dialect: &dyn Dialect,
167        sql: &str,
168    ) -> Result<Vec<Result<TableOperation, Error>>, Error> {
169        Self::extract_with_options(dialect, sql, ExtractorOptions::new())
170    }
171
172    /// Like [`extract`](Self::extract) but with [`ExtractorOptions`] — a
173    /// catalog and/or an identifier-casing override. `dialect` still
174    /// drives parsing; the options govern only the analysis.
175    pub fn extract_with_options(
176        dialect: &dyn Dialect,
177        sql: &str,
178        options: ExtractorOptions,
179    ) -> Result<Vec<Result<TableOperation, Error>>, Error> {
180        crate::extractor::extract_each(dialect, sql, options, Self::extract_from_statement)
181    }
182
183    /// Assemble the table operation from the bound plan: see
184    /// [`extract_inner`](Self::extract_inner). Public-facing callers want the
185    /// `TableOperation` alone.
186    pub(crate) fn extract_from_statement(
187        statement: &Statement,
188        catalog: Option<&Catalog>,
189        style: IdentifierStyle,
190    ) -> Result<TableOperation, Error> {
191        Self::extract_inner(statement, catalog, style).map(|(op, ..)| op)
192    }
193
194    /// Bind the statement and walk the plan for `reads` / `writes` / (for
195    /// data-moving statements only) `lineage`; classify the verb; project the
196    /// column-level diagnostics down. Returns the assembled `TableOperation`
197    /// plus the bound plan's MERGE clause summary — `None` outside MERGE —
198    /// so a same-crate caller (the CRUD extractor) can bucket the target by
199    /// WHEN actions without re-walking the raw AST. An `Unsupported` kind
200    /// yields an empty operation with an `UnsupportedStatement` diagnostic
201    /// and no merge summary (the plan is never built).
202    pub(crate) fn extract_inner(
203        statement: &Statement,
204        catalog: Option<&Catalog>,
205        style: IdentifierStyle,
206    ) -> Result<
207        (
208            TableOperation,
209            Option<MergeActions>,
210            bool,
211            Option<crate::resolver::DataModifyingCteCrud>,
212        ),
213        Error,
214    > {
215        let statement_kind = classify_statement(statement);
216        if statement_kind == StatementKind::Unsupported {
217            return Ok((
218                unsupported_table_operation(statement_kind, statement),
219                None,
220                false,
221                None,
222            ));
223        }
224        let (plan, column_diagnostics) = crate::resolver::build(statement, catalog, style);
225        let merge_actions = crate::resolver::merge_actions(&plan);
226        // An upsert (`INSERT … ON CONFLICT DO UPDATE`) both inserts and updates
227        // its target, so the CRUD extractor places it in both buckets.
228        let insert_updates = crate::resolver::insert_updates_on_conflict(&plan);
229        // Lineage is only for statements that move data into a target. A
230        // column-less INSERT and a DELETE both bind to a `Write`, so the
231        // structural walk can't tell them apart — gate on the kind. A MERGE
232        // whose WHEN clauses are only DELETEs uses its source solely to pick
233        // target rows, so it moves no data even though the source is a
234        // feeding input — read that off the IR-derived `MergeActions` rather
235        // than re-walking the raw `Statement::Merge`.
236        let outer_moves_data =
237            writes_data(&statement_kind) && merge_actions.is_none_or(|a| a.writes_data());
238        // A data-modifying CTE (`WITH c AS (INSERT …) SELECT …`) moves data even
239        // though the statement classifies by its read outer verb, so it emits
240        // lineage independently of the outer kind / MERGE gate above.
241        let emits_lineage = outer_moves_data || crate::resolver::has_data_modifying_cte(&plan);
242        let lineage = if emits_lineage {
243            crate::resolver::table_lineage(&plan, style.casing)
244        } else {
245            Vec::new()
246        };
247        let op = TableOperation {
248            statement_kind,
249            reads: crate::resolver::table_reads(&plan),
250            writes: crate::resolver::table_writes(&plan),
251            lineage,
252            // Table-level diagnostics are the column-level ones projected
253            // down (only `UnsupportedStatement` / `TooManyTableQualifiers`
254            // survive the projection).
255            diagnostics: column_diagnostics
256                .iter()
257                .filter_map(|d| d.to_table_level())
258                .collect(),
259        };
260        let cte_crud = crate::resolver::data_modifying_cte_crud(&plan);
261        Ok((op, merge_actions, insert_updates, cte_crud))
262    }
263}
264
265/// Whether a statement physically moves data into its target (so it emits
266/// table lineage). `DELETE` / `DROP` / `TRUNCATE` / `ALTER TABLE` touch a
267/// target but feed it nothing; a bare `SELECT` has no target.
268fn writes_data(kind: &StatementKind) -> bool {
269    matches!(
270        kind,
271        StatementKind::Insert
272            | StatementKind::Update
273            | StatementKind::Merge
274            | StatementKind::CreateTable
275            | StatementKind::CreateView
276            | StatementKind::AlterView
277    )
278}
279
280fn unsupported_table_operation(
281    statement_kind: StatementKind,
282    statement: &Statement,
283) -> TableOperation {
284    TableOperation {
285        statement_kind,
286        reads: Vec::new(),
287        writes: Vec::new(),
288        lineage: Vec::new(),
289        diagnostics: vec![TableLevelDiagnostic {
290            kind: TableLevelDiagnosticKind::UnsupportedStatement,
291            message: crate::extractor::unsupported_message(statement),
292            span: None,
293        }],
294    }
295}