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}