sql_insight/extractor.rs
1//! Extraction APIs at three granularities of "what does this SQL touch?"
2//!
3//! Each sub-extractor is a thin wrapper around the bound-plan analysis
4//! engine, projecting the resolved plan into a different surface:
5//!
6//! - [`extract_crud_tables`] — tables bucketed by CRUD verb
7//! (Create / Read / Update / Delete).
8//! - [`extract_table_operations`] — per-statement
9//! `TableOperation` with `reads` / `writes` / `lineage` at table
10//! granularity.
11//! - [`extract_column_operations`] — same shape at column
12//! granularity, plus per-column lineage kinds
13//! (Passthrough / Transformation).
14//!
15//! Each extractor returns `Vec<Result<X, Error>>` so one statement that
16//! fails to extract doesn't sink the rest of a multi-statement string. (A
17//! *parse* error fails the whole call — the outer `Result` — since
18//! statements can't be separated before parsing.) Sub-modules are private;
19//! the public items reach users through the wildcard re-exports below.
20
21mod column_operation_extractor;
22mod crud_table_extractor;
23mod table_operation_extractor;
24
25pub use column_operation_extractor::*;
26pub use crud_table_extractor::*;
27pub use table_operation_extractor::*;
28
29use crate::casing::{IdentifierCasing, IdentifierStyle};
30use crate::catalog::Catalog;
31use crate::error::Error;
32use sqlparser::ast::Statement;
33use sqlparser::dialect::Dialect;
34use sqlparser::parser::Parser;
35
36/// Optional inputs shared by every `*_with_options` extractor. Defaults
37/// to no catalog and the dialect-derived identifier casing — i.e. the
38/// plain `extract_*(dialect, sql)` behaviour.
39///
40/// ```rust
41/// use sql_insight::sqlparser::dialect::GenericDialect;
42/// use sql_insight::extractor::{extract_table_operations_with_options, ExtractorOptions};
43/// use sql_insight::{CaseRule, IdentifierCasing};
44///
45/// let dialect = GenericDialect {};
46/// let options = ExtractorOptions::new().with_casing(IdentifierCasing::uniform(CaseRule::Sensitive));
47/// let result = extract_table_operations_with_options(&dialect, "SELECT * FROM users", options).unwrap();
48/// assert_eq!(result[0].as_ref().unwrap().reads.len(), 1);
49/// ```
50#[derive(Default, Clone, Debug)]
51pub struct ExtractorOptions<'a> {
52 /// The schema to resolve against. With a catalog, matched tables are
53 /// canonicalized to their registered path and column resolution is
54 /// strict; without one (the default), references stay as written and
55 /// resolution is inferred.
56 pub catalog: Option<&'a Catalog>,
57 /// Override the dialect-derived identifier casing. `None` (the
58 /// default) derives it from the dialect via
59 /// [`IdentifierCasing::for_dialect`] — set this to model a
60 /// deployment-specific collation.
61 pub casing: Option<IdentifierCasing>,
62}
63
64impl<'a> ExtractorOptions<'a> {
65 /// Default options: no catalog, dialect-derived casing.
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 /// Resolve against `catalog`.
71 pub fn with_catalog(mut self, catalog: &'a Catalog) -> Self {
72 self.catalog = Some(catalog);
73 self
74 }
75
76 /// Override the identifier casing (otherwise derived from the dialect).
77 pub fn with_casing(mut self, casing: IdentifierCasing) -> Self {
78 self.casing = Some(casing);
79 self
80 }
81
82 /// The effective casing: the override if set, else the dialect default.
83 fn casing_for(&self, dialect: &dyn Dialect) -> IdentifierCasing {
84 self.casing
85 .unwrap_or_else(|| IdentifierCasing::for_dialect(dialect))
86 }
87
88 /// The full identifier style for the binder: the effective casing plus
89 /// the dialect's canonical quote (always dialect-derived — quoting a
90 /// catalog-confirmed identity is a surface concern, not a user knob).
91 pub(crate) fn identifier_style(&self, dialect: &dyn Dialect) -> IdentifierStyle {
92 IdentifierStyle {
93 casing: self.casing_for(dialect),
94 quote: crate::casing::canonical_quote(dialect),
95 }
96 }
97}
98
99/// What a statement does, at a coarse level. The *verb* of the statement
100/// — INSERT vs CREATE TABLE vs MERGE vs … — combined with the
101/// `reads` / `writes` split recovers every distinction the project needs
102/// to make at table granularity. Shared by every extractor (each surfaces
103/// it as `statement_kind`).
104#[derive(Debug, Clone, PartialEq, Eq)]
105#[cfg_attr(feature = "serde", derive(serde::Serialize))]
106pub enum StatementKind {
107 /// `SELECT ...` (and other read-only queries: `VALUES (...)`,
108 /// `WITH ... SELECT ...`; a bare `TABLE foo` is read-only too but only
109 /// parses as a set-operation branch, not a standalone statement). Reads
110 /// only — no writes, no lineage.
111 Select,
112 /// `INSERT INTO ...`. Writes to one target table; reads from the
113 /// `VALUES` / `SELECT` source. Emits source → target lineage.
114 Insert,
115 /// `UPDATE ... SET ...`. The target is a write; it *also* reads when its
116 /// own data is referenced — a SET right-hand side or `WHERE` column
117 /// (`SET a = a + 1`, `WHERE id = 5`) surfaces it in `reads` at both column
118 /// and table granularity, while a constant `SET a = 1` keeps it write-only.
119 /// Joined / sub-query sources are reads. A multi-table `UPDATE t1 JOIN t2
120 /// SET t2.col = …` writes (and lineage-targets) the relation the qualifier
121 /// resolves to, not the root. Emits lineage from SET sources into the
122 /// target columns.
123 Update,
124 /// `DELETE FROM ...`. Removes whole rows: the target is in `writes` with no
125 /// column-level writes and no lineage. It reads when its own data is
126 /// referenced — a `WHERE` column surfaces it in `reads` (column and table);
127 /// a bare `DELETE FROM t` reads nothing.
128 Delete,
129 /// `MERGE INTO ... USING ...`. The target is a write; it also reads — its
130 /// columns in `ON` / a `WHEN` predicate or `SET` surface in `reads` (column
131 /// and table granularity). Each `WHEN` clause may emit lineage from the
132 /// source into the target's update / insert columns.
133 Merge,
134 /// `CREATE TABLE ...`. The new table is a write target. CREATE
135 /// TABLE AS (CTAS) also reads from its SELECT and emits per-column
136 /// lineage into the new table's columns.
137 CreateTable,
138 /// `CREATE VIEW ... AS SELECT ...`. The new view is a write
139 /// target; reads come from the SELECT body. Per-column lineage
140 /// pairs the SELECT projections with the view's columns.
141 CreateView,
142 /// `ALTER TABLE ...`. The altered table is a write target.
143 /// Column-level changes are not modelled in detail.
144 AlterTable,
145 /// `ALTER VIEW ... AS SELECT ...`. Treated like CREATE VIEW for
146 /// extraction purposes — the view is a write target, the new
147 /// SELECT body supplies reads and per-column lineage.
148 AlterView,
149 /// `DROP TABLE` / `DROP VIEW` / `DROP MATERIALIZED VIEW`. The
150 /// dropped relation is a write target. Other DROP variants
151 /// (functions, schemas, indexes, etc.) classify as
152 /// [`Unsupported`](StatementKind::Unsupported).
153 Drop,
154 /// `TRUNCATE TABLE ...`. The truncated table is a write target.
155 Truncate,
156 /// Statement is outside the operation-extraction scope. The
157 /// accompanying `diagnostics` list explains why.
158 Unsupported,
159}
160
161/// The shared "Unsupported statement: …" diagnostic message — embeds the
162/// statement's `Display`. Both granularity-specific extractors use this so
163/// the wording stays in step (the kinds themselves split table- vs
164/// column-level via the [`diagnostic`](crate::diagnostic) types).
165pub(crate) fn unsupported_message(statement: &Statement) -> String {
166 format!("Unsupported statement: {statement}")
167}
168
169/// The shared `*_with_options` driver: parse `sql` once, then run
170/// `extract_from` on each parsed statement. A *parse* failure fails the whole
171/// call (`Err` on the outer `Result`) because statements can't be separated
172/// without the parser; a per-statement failure stays inside that statement's
173/// inner `Result` so the rest of a multi-statement batch still surfaces. Each
174/// sub-extractor's public `extract*_with_options` is a one-liner over this.
175pub(crate) fn extract_each<T, F>(
176 dialect: &dyn Dialect,
177 sql: &str,
178 options: ExtractorOptions,
179 extract_from: F,
180) -> Result<Vec<Result<T, Error>>, Error>
181where
182 F: Fn(&Statement, Option<&Catalog>, IdentifierStyle) -> Result<T, Error>,
183{
184 let statements = Parser::parse_sql(dialect, sql)?;
185 let style = options.identifier_style(dialect);
186 Ok(statements
187 .iter()
188 .map(|s| extract_from(s, options.catalog, style))
189 .collect())
190}
191
192/// Classify a parsed statement into its [`StatementKind`]. Shared by the
193/// column / flat / CRUD extractors to pick the verb before assembling
194/// their surfaces.
195pub(crate) fn classify_statement(statement: &Statement) -> StatementKind {
196 use sqlparser::ast::ObjectType;
197 match statement {
198 // `WITH cte AS (...) INSERT/UPDATE/DELETE/MERGE ...` and a
199 // parenthesised DML `(DELETE FROM t)` are both parsed by sqlparser as a
200 // top-level Query whose body wraps the actual DML. Reclassify against
201 // the inner statement so the public StatementKind matches the verb the
202 // user wrote, not the parser-level wrapper.
203 Statement::Query(query) => classify_query_body(query.body.as_ref()),
204 Statement::Insert(_) => StatementKind::Insert,
205 Statement::Update(_) => StatementKind::Update,
206 Statement::Delete(_) => StatementKind::Delete,
207 Statement::Merge(_) => StatementKind::Merge,
208 Statement::CreateTable(_) | Statement::CreateVirtualTable { .. } => {
209 StatementKind::CreateTable
210 }
211 Statement::CreateView(_) => StatementKind::CreateView,
212 Statement::AlterTable(_) => StatementKind::AlterTable,
213 Statement::AlterView { .. } => StatementKind::AlterView,
214 Statement::Drop {
215 object_type: ObjectType::Table | ObjectType::View | ObjectType::MaterializedView,
216 ..
217 } => StatementKind::Drop,
218 Statement::Truncate(_) => StatementKind::Truncate,
219 // Drop variants that don't target relations (DROP FUNCTION,
220 // DROP SCHEMA, etc.) — and every other unsupported variant —
221 // fall through to Unsupported so the caller still gets a clear
222 // diagnostic.
223 _ => StatementKind::Unsupported,
224 }
225}
226
227/// Classify a top-level `Query`'s body to the verb the user wrote. A DML body
228/// (`WITH … <DML>`) reclassifies against the inner statement; a parenthesised
229/// body (`(DELETE …)`, even nested) recurses through the wrapper to find the
230/// DML / `SELECT … INTO` verb — mirroring [`has_leading_select_into`], which
231/// already recurses `SetExpr::Query`, so the two stay in step. Anything else is
232/// read-only / row-producing → `Select`. The match is exhaustive so a new
233/// write-bearing `SetExpr` variant is a compile error rather than a silent
234/// `Select`.
235fn classify_query_body(body: &sqlparser::ast::SetExpr) -> StatementKind {
236 use sqlparser::ast::SetExpr;
237 match body {
238 SetExpr::Insert(stmt)
239 | SetExpr::Update(stmt)
240 | SetExpr::Delete(stmt)
241 | SetExpr::Merge(stmt) => classify_statement(stmt),
242 // A parenthesised query: peel the wrapper (nested parens included) and
243 // classify the inner body, so `(DELETE …)` keeps its verb.
244 SetExpr::Query(inner) => classify_query_body(inner.body.as_ref()),
245 // A leading `SELECT … INTO t` lowers to `CreateTableAs` in the binder;
246 // keep the verb (and the table-lineage gate that keys on it) in step.
247 body if has_leading_select_into(body) => StatementKind::CreateTable,
248 SetExpr::Select(_)
249 | SetExpr::SetOperation { .. }
250 | SetExpr::Values(_)
251 | SetExpr::Table(_) => StatementKind::Select,
252 }
253}
254
255/// Whether a query body carries a leading `SELECT … INTO t` — the table-creating
256/// `SELECT INTO` (T-SQL / Postgres), which the binder lowers to `CreateTableAs`.
257/// `INTO` rides the left spine (through a parenthesised query and the left
258/// branch of a set operation, where it targets the combined result), so this
259/// mirrors the binder's `leading_select_into`; the two must stay in step. The
260/// match is exhaustive so a new `SetExpr` variant forces a decision here too.
261fn has_leading_select_into(body: &sqlparser::ast::SetExpr) -> bool {
262 use sqlparser::ast::SetExpr;
263 match body {
264 SetExpr::Select(select) => select.into.is_some(),
265 SetExpr::Query(query) => has_leading_select_into(&query.body),
266 SetExpr::SetOperation { left, .. } => has_leading_select_into(left),
267 SetExpr::Values(_)
268 | SetExpr::Insert(_)
269 | SetExpr::Update(_)
270 | SetExpr::Delete(_)
271 | SetExpr::Merge(_)
272 | SetExpr::Table(_) => false,
273 }
274}