Skip to main content

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}