Skip to main content

sql_insight/extractor/
column_operation_extractor.rs

1//! Extracts the column-level operations a SQL statement performs.
2//!
3//! The column-granularity counterpart to
4//! [`extract_table_operations`](crate::extractor::extract_table_operations):
5//! the same three surfaces — `reads`, `writes`, `lineage` — as
6//! [`ColumnOperation`], each lineage edge tagged
7//! [`Passthrough` or `Transformation`](ColumnLineageKind). Per-surface
8//! detail (which constructs fill each) lives on `ColumnOperation`'s fields.
9//!
10//! Two cross-cutting behaviours, stated once here:
11//!
12//! - **Value vs filter is structural.** A column that contributes a value
13//!   is a `lineage` source; one that only influences the result (e.g. a
14//!   `WHERE` predicate) is in `reads` but not `lineage`.
15//! - **Strictness scales with the catalog.** Catalog-free, a `Table`
16//!   binding's schema is `Unknown` and an unqualified ref to a single-table
17//!   scope resolves unconditionally (best-effort). With a catalog it's
18//!   `Cataloged`, so an unqualified ref resolves only when that schema
19//!   lists the column — a typo that would silently resolve becomes
20//!   `Unresolved`. Synthetic-origin refs (CTE / derived / table function)
21//!   drop from `reads`; only real-table or unresolved names surface.
22//! - **Table-only statements have no column surface.** A statement that
23//!   names a relation but no columns — `DROP` / `TRUNCATE`, an unconditional
24//!   `DELETE`, an `INSERT … DEFAULT VALUES` — yields empty `reads` / `writes`
25//!   / `lineage` here: there are no columns to enumerate (wildcards stay
26//!   unexpanded). The target relation is a *table*-level fact; read it from
27//!   [`extract_table_operations`](crate::extractor::extract_table_operations)
28//!   (its `writes`).
29
30use crate::casing::IdentifierStyle;
31use crate::catalog::Catalog;
32use crate::diagnostic::{ColumnLevelDiagnostic, ColumnLevelDiagnosticKind};
33use crate::error::Error;
34use crate::extractor::{classify_statement, ExtractorOptions, StatementKind};
35use crate::reference::{ColumnRead, ColumnWrite};
36use sqlparser::ast::{Ident, Statement};
37use sqlparser::dialect::Dialect;
38
39/// Convenience function to extract column-level operations from SQL using
40/// the dialect defaults (no catalog, dialect-derived casing). For a
41/// catalog or a casing override, use
42/// [`extract_column_operations_with_options`]; with a catalog, table
43/// references are matched against it (right-anchored, dialect-cased) and
44/// column resolution turns strict.
45///
46/// ## Example
47///
48/// ```rust
49/// use sql_insight::sqlparser::dialect::GenericDialect;
50/// use sql_insight::ResolutionKind;
51/// use sql_insight::extractor::{
52///     extract_column_operations, ColumnLineageKind, ColumnTarget, StatementKind,
53/// };
54///
55/// let dialect = GenericDialect {};
56/// let result =
57///     extract_column_operations(&dialect, "SELECT a FROM t1").unwrap();
58/// let ops = result[0].as_ref().unwrap();
59///
60/// // SELECT contributes reads + lineage but no writes.
61/// assert_eq!(ops.statement_kind, StatementKind::Select);
62/// assert!(ops.writes.is_empty());
63///
64/// // `t1.a` surfaces as a single read, walk-time resolved to t1.
65/// // Catalog-less mode → resolution is `Inferred` (we adopted the
66/// // sole `Unknown`-schema candidate without firm evidence).
67/// assert_eq!(ops.reads.len(), 1);
68/// let read = &ops.reads[0];
69/// assert_eq!(read.reference.name.value, "a");
70/// assert_eq!(read.reference.table.as_ref().unwrap().name.value, "t1");
71/// assert_eq!(read.resolution, ResolutionKind::Inferred);
72///
73/// // The projection emits one lineage edge into the SELECT's QueryOutput slot,
74/// // marked Passthrough (no expression wrapping the column).
75/// assert_eq!(ops.lineage.len(), 1);
76/// let edge = &ops.lineage[0];
77/// assert_eq!(edge.kind, ColumnLineageKind::Passthrough);
78/// match &edge.target {
79///     ColumnTarget::QueryOutput { name, position } => {
80///         assert_eq!(name.as_ref().unwrap().value, "a");
81///         assert_eq!(*position, 0);
82///     }
83///     other => panic!("expected QueryOutput, got {other:?}"),
84/// }
85/// ```
86pub fn extract_column_operations(
87    dialect: &dyn Dialect,
88    sql: &str,
89) -> Result<Vec<Result<ColumnOperation, Error>>, Error> {
90    ColumnOperationExtractor::extract(dialect, sql)
91}
92
93/// Like [`extract_column_operations`] but with [`ExtractorOptions`] — a
94/// catalog and/or an identifier-casing override. `dialect` still drives
95/// parsing; the options govern only the analysis.
96pub fn extract_column_operations_with_options(
97    dialect: &dyn Dialect,
98    sql: &str,
99    options: ExtractorOptions,
100) -> Result<Vec<Result<ColumnOperation, Error>>, Error> {
101    ColumnOperationExtractor::extract_with_options(dialect, sql, options)
102}
103
104/// Column-level operations performed by a single SQL statement.
105///
106/// Mirrors [`TableOperation`](crate::extractor::TableOperation)
107/// with the same three surfaces — `reads`, `writes`, `lineage` — at
108/// column granularity.
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct ColumnOperation {
112    pub statement_kind: StatementKind,
113    /// Columns read by the statement. Occurrence-based: a column
114    /// referenced more than once appears more than once (e.g.
115    /// `SELECT a FROM t WHERE a > 0` yields `t.a` twice). Each entry pairs
116    /// the [`ColumnReference`](crate::ColumnReference) identity with its
117    /// [`ResolutionKind`](crate::ResolutionKind). **In source order** — by
118    /// each read's written token span (`reference.name.span`), a deterministic
119    /// function of the SQL rather than the internal traversal. References that
120    /// share a token (a `USING` fan-in) keep a stable relative order. For the
121    /// distinct identity set, dedup `reads.iter().map(|r| &r.reference)` via a
122    /// `HashSet` (or, catalog-free, by
123    /// [`ColumnReference::identity_key`](crate::ColumnReference::identity_key)
124    /// to fold case-equivalent spellings).
125    pub reads: Vec<ColumnRead>,
126    /// Columns written by the statement, in source (column-list) order.
127    /// Occurrence-based like `reads`. Each [`ColumnWrite`] pairs the identity
128    /// with the column's catalog match against its target —
129    /// [`Cataloged`](crate::ResolutionKind::Cataloged) when the column is in
130    /// the target's catalog columns, else
131    /// [`Inferred`](crate::ResolutionKind::Inferred) (catalog-free, target
132    /// columns unknown, or a freshly created / altered relation).
133    pub writes: Vec<ColumnWrite>,
134    /// Lineage edges. Statements that physically move data emit collapsed
135    /// end-to-end edges (source → `ColumnTarget::Relation`); a bare
136    /// `SELECT` emits source → `ColumnTarget::QueryOutput` edges. **In source
137    /// order** of the contributing source column (by its written token span);
138    /// occurrence / multiplicity is preserved.
139    pub lineage: Vec<ColumnLineageEdge>,
140    /// Column-level diagnostics: wildcard suppression plus the
141    /// `UnsupportedStatement` projection inherited from table
142    /// granularity. Per-reference resolution outcomes surface on
143    /// `reads[i].resolution` instead.
144    pub diagnostics: Vec<ColumnLevelDiagnostic>,
145}
146
147/// A column-level lineage edge: data from `source` contributes to
148/// `target`. Emitted for both relation-target statements (INSERT /
149/// UPDATE / MERGE / CTAS / CREATE VIEW, target = `ColumnTarget::Relation`)
150/// and bare SELECT (target = `ColumnTarget::QueryOutput`).
151///
152/// One edge per (source, target) pair: `SELECT a + b FROM t1` emits two
153/// edges, from `t1.a` and `t1.b` to the same query-output target, each
154/// tagged `Transformation`.
155///
156/// Statements that physically move data emit collapsed end-to-end lineage
157/// — `INSERT INTO t1 (col) SELECT b FROM t2` emits `t2.b → t1.col`
158/// directly, with no intermediate query-output entry.
159#[derive(Debug, Clone, PartialEq, Eq, Hash)]
160#[cfg_attr(feature = "serde", derive(serde::Serialize))]
161pub struct ColumnLineageEdge {
162    /// The column the lineage edge flows from, paired with the
163    /// resolver's [`ResolutionKind`](crate::ResolutionKind) in that placement.
164    /// `source.reference` is the inner (post-collapse) real-table
165    /// reference; `source.resolution` follows that inner reference's
166    /// classification rather than the outer synthetic step's.
167    pub source: ColumnRead,
168    pub target: ColumnTarget,
169    pub kind: ColumnLineageKind,
170}
171
172/// The target endpoint of a [`ColumnLineageEdge`] — a column in a named
173/// relation ([`Relation`](Self::Relation)) or a transient SELECT output
174/// ([`QueryOutput`](Self::QueryOutput)).
175#[derive(Clone, Debug, PartialEq, Eq, Hash)]
176#[cfg_attr(feature = "serde", derive(serde::Serialize))]
177pub enum ColumnTarget {
178    /// A column in a real relation receiving the inbound lineage edge — INSERT /
179    /// UPDATE / MERGE target columns, or columns of the new relation
180    /// produced by CTAS / CREATE VIEW / ALTER VIEW. Carries the column's
181    /// catalog [`ResolutionKind`](crate::ResolutionKind) (via [`ColumnWrite`]),
182    /// the write-side counterpart of [`ColumnLineageEdge::source`]'s
183    /// [`ColumnRead`].
184    Relation(ColumnWrite),
185    /// A transient column produced by a top-level SELECT projection
186    /// that is not piped into a named relation. `name` follows
187    /// the projection's explicit alias or inferred single-column name
188    /// (`None` for expressions without a clear name); `position` is
189    /// always set so anonymous outputs remain identifiable.
190    QueryOutput {
191        #[cfg_attr(
192            feature = "serde",
193            serde(serialize_with = "crate::serde_support::opt_ident")
194        )]
195        name: Option<Ident>,
196        position: usize,
197    },
198}
199
200/// How a source column contributes to its target — the one clean,
201/// exclusive distinction: is the value forwarded unchanged, or derived?
202///
203/// Finer sub-classification of `Transformation` (aggregate vs scalar,
204/// cardinality, etc.) is deliberately not modelled — it is lossy for edge
205/// cases (window aggregates, value-preserving `STRING_AGG`) and not
206/// load-bearing for the core dependency / impact-analysis use case. A finer
207/// variant can be added later if a concrete consumer needs it (a breaking
208/// change while the crate is pre-1.0).
209#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
210#[cfg_attr(feature = "serde", derive(serde::Serialize))]
211pub enum ColumnLineageKind {
212    /// Source value forwarded unchanged (`SELECT a FROM t1`,
213    /// `INSERT INTO t1 (a) SELECT b FROM t2`). A rename (`SELECT a AS b`)
214    /// is still `Passthrough` — detect it by comparing source / target
215    /// `name`. Composition stays `Passthrough` only when every step in the
216    /// chain is.
217    Passthrough,
218    /// Source feeds an expression that changes the value: arithmetic,
219    /// function calls, CASE branches, casts, aggregates (`SUM`,
220    /// `STRING_AGG`), window functions, etc. Composition yields
221    /// `Transformation` whenever any step in the chain is one.
222    Transformation,
223}
224
225/// Struct-style entry point. Equivalent to the free
226/// [`extract_column_operations`] function.
227#[derive(Default, Debug)]
228pub struct ColumnOperationExtractor;
229
230impl ColumnOperationExtractor {
231    /// Same as the free [`extract_column_operations`] function — kept
232    /// for users who prefer the struct-style API.
233    pub fn extract(
234        dialect: &dyn Dialect,
235        sql: &str,
236    ) -> Result<Vec<Result<ColumnOperation, Error>>, Error> {
237        Self::extract_with_options(dialect, sql, ExtractorOptions::new())
238    }
239
240    /// Like [`extract`](Self::extract) but with [`ExtractorOptions`] — a
241    /// catalog and/or an identifier-casing override. `dialect` still
242    /// drives parsing; the options govern only the analysis.
243    pub fn extract_with_options(
244        dialect: &dyn Dialect,
245        sql: &str,
246        options: ExtractorOptions,
247    ) -> Result<Vec<Result<ColumnOperation, Error>>, Error> {
248        crate::extractor::extract_each(dialect, sql, options, Self::extract_from_statement)
249    }
250
251    /// Assemble the column operation from the bound plan: classify the
252    /// verb, bind the statement, and walk the plan for `reads` / `writes` /
253    /// `lineage`. A kind the binder can't model yields an empty operation
254    /// with an `UnsupportedStatement` diagnostic; a supported but
255    /// structure-only kind (e.g. `DROP`) is empty without a diagnostic.
256    fn extract_from_statement(
257        statement: &Statement,
258        catalog: Option<&Catalog>,
259        style: IdentifierStyle,
260    ) -> Result<ColumnOperation, Error> {
261        let statement_kind = classify_statement(statement);
262        if statement_kind == StatementKind::Unsupported {
263            return Ok(unsupported_column_operation(statement_kind, statement));
264        }
265        let (plan, diagnostics) = crate::resolver::build(statement, catalog, style);
266        Ok(ColumnOperation {
267            statement_kind,
268            reads: crate::resolver::reads(&plan),
269            writes: crate::resolver::writes(&plan),
270            lineage: crate::resolver::column_lineage(&plan, style.casing),
271            // The bind accumulates `WildcardSuppressed` / `TooManyTableQualifiers`.
272            diagnostics,
273        })
274    }
275}
276
277fn unsupported_column_operation(
278    statement_kind: StatementKind,
279    statement: &Statement,
280) -> ColumnOperation {
281    ColumnOperation {
282        statement_kind,
283        reads: Vec::new(),
284        writes: Vec::new(),
285        lineage: Vec::new(),
286        diagnostics: vec![ColumnLevelDiagnostic {
287            kind: ColumnLevelDiagnosticKind::UnsupportedStatement,
288            message: crate::extractor::unsupported_message(statement),
289            span: None,
290        }],
291    }
292}