sql_insight/lib.rs
1//! # sql-insight
2//!
3//! Operation extraction for SQL, built on
4//! [`sqlparser-rs`](https://crates.io/crates/sqlparser). Turn a SQL
5//! string into structured facts about what a statement does —
6//! which tables and columns it reads, which it writes, and how data
7//! moves from sources to targets — alongside utilities for
8//! formatting and normalization.
9//!
10//! ## Main Functionalities
11//!
12//! - **SQL Formatting** — pretty-print SQL with a standardized
13//! layout. See [`formatter`].
14//! - **SQL Normalization** — abstract literals into placeholders so
15//! structurally identical queries hash to the same shape. See
16//! [`normalizer`].
17//! - **CRUD Table Extraction** — CRUD-bucketed table sets per
18//! statement. See [`extractor::extract_crud_tables`].
19//! - **Table-level Operation Extraction** — `reads` / `writes` /
20//! `lineage` surfaces with [`extractor::StatementKind`] classification.
21//! See [`extractor::extract_table_operations`].
22//! - **Column-level Operation Extraction** — the same three surfaces at
23//! column granularity, with `lineage` carrying
24//! [`extractor::ColumnLineageKind`] (`Passthrough` vs `Transformation`).
25//! The value-vs-filter distinction is structural: a value contributor is
26//! a `lineage` source, a filter-only column is in `reads` but not
27//! `lineage`. See [`extractor::extract_column_operations`].
28//! - **Optional [`catalog::Catalog`]** — supply a schema provider to make
29//! resolution strict (each read's [`ResolutionKind`] records how it
30//! matched); every extractor also works catalog-free in best-effort mode.
31//! - **Diagnostics** ([`diagnostic::TableLevelDiagnostic`] /
32//! [`diagnostic::ColumnLevelDiagnostic`]) — non-fatal coverage gaps
33//! surface alongside the result rather than failing the call.
34//! Per-reference resolution outcomes live on [`ColumnRead::resolution`]
35//! instead, keeping the diagnostic stream for tool-side gaps.
36//!
37//! ## Quick Start
38//!
39//! Table-level operation extraction — get `reads` / `writes` /
40//! `lineage` and the statement kind from a single call:
41//!
42//! ```rust
43//! use sql_insight::sqlparser::dialect::GenericDialect;
44//! use sql_insight::extractor::{extract_table_operations, StatementKind};
45//!
46//! let dialect = GenericDialect {};
47//! let result = extract_table_operations(
48//! &dialect,
49//! "INSERT INTO orders (id) SELECT id FROM staging",
50//! ).unwrap();
51//! let ops = result[0].as_ref().unwrap();
52//! assert_eq!(ops.statement_kind, StatementKind::Insert);
53//! assert_eq!(ops.reads.len(), 1); // staging
54//! assert_eq!(ops.writes.len(), 1); // orders
55//! assert_eq!(ops.lineage.len(), 1); // staging → orders
56//! ```
57//!
58//! SQL formatting:
59//!
60//! ```rust
61//! use sql_insight::sqlparser::dialect::GenericDialect;
62//!
63//! let dialect = GenericDialect {};
64//! let formatted = sql_insight::formatter::format(
65//! &dialect, "SELECT * \n from users WHERE id = 1"
66//! ).unwrap();
67//! assert_eq!(formatted, ["SELECT * FROM users WHERE id = 1"]);
68//! ```
69//!
70//! ## API Layout
71//!
72//! Public types live in domain-named modules ([`catalog`],
73//! [`diagnostic`], [`error`], [`extractor`], [`formatter`],
74//! [`normalizer`]); access them via their module path
75//! (`sql_insight::extractor::extract_table_operations`,
76//! `sql_insight::formatter::format`, etc.). The two identity types
77//! [`TableReference`] / [`ColumnReference`] are re-exported at the
78//! crate root because they show up across modules; their containing
79//! module is internal and may be reshaped without an API change.
80//! [`sqlparser`] is re-exported so consumers can name `Dialect` /
81//! `Ident` / etc. without depending on the crate directly.
82//!
83//! ## Vocabulary
84//!
85//! Operation extraction returns three parallel surfaces per
86//! statement:
87//!
88//! - `reads` — every table (or column) the statement reads from.
89//! - `writes` — every table (or column) the statement writes to. A
90//! table that plays both roles (e.g. `DELETE t1 FROM t1`) appears
91//! in both.
92//! - `lineage` — directed `source → target` edges, emitted only for
93//! statements that physically move data (`INSERT` / `UPDATE` /
94//! `MERGE` / `CREATE TABLE AS` / `CREATE VIEW`).
95//!
96//! `reads` / `writes` follow a relation's **syntactic role in the
97//! written SQL**, not what is physically touched at runtime: an
98//! unreferenced CTE body's tables, a `SELECT COUNT(*) FROM t`, and a
99//! `CREATE TABLE t LIKE src` source all read, even though no row data is
100//! consumed. The actual data-flow precision lives in `lineage` — e.g.
101//! `LIKE` (schema only) emits none, while `CLONE` (data copied) feeds
102//! `src → t`.
103//!
104//! For column-level lineage, [`extractor::ColumnLineageKind`] makes one
105//! clean distinction: `Passthrough` (the value is forwarded unchanged; a
106//! rename still counts) vs `Transformation` (any expression that
107//! changes the value — arithmetic, function calls, aggregates,
108//! window functions, CASE, casts, …). `reads` / `writes` are plain
109//! occurrence lists of column references with no clause tag; whether
110//! a column contributes a value or merely influences the result
111//! (e.g. a `WHERE` predicate) is recovered structurally — value
112//! contributors appear as `lineage` sources, filter-only columns do
113//! not.
114//!
115//! ## Limitations
116//!
117//! Intentional non-support and known gaps — set expectations before
118//! relying on a given output:
119//!
120//! - **Wildcards expand only when complete**: a `*` / `t.*` whose columns
121//! are *fully* known — a cataloged table, or a derived table / CTE whose
122//! output slots the SQL text itself determines — expands into per-column
123//! outputs, exactly as if the list were written at the `*` (one read per
124//! expanded column per occurrence, lineage, determinate positions).
125//! Anything less than fully known keeps the wildcard unexpanded
126//! (all-or-nothing per wildcard — never a partial expansion posing as the
127//! whole set): a catalog-free / unmatched table, an opaque table
128//! function, a derived body whose own wildcard didn't expand, a bare `*`
129//! over `USING` / `NATURAL` merge columns (their coalesced positions
130//! depend on join structure), or any wildcard modifier (`EXCLUDE` /
131//! `EXCEPT` / `RENAME` / `REPLACE` / `ILIKE` — expanding while ignoring
132//! one would misreport). An unexpanded wildcard contributes nothing to
133//! `reads` / `lineage` and is surfaced as
134//! [`WildcardSuppressed`](diagnostic::ColumnLevelDiagnosticKind::WildcardSuppressed)
135//! so consumers can detect the incomplete projection. A `REPLACE (expr AS
136//! col)` clause *is* extracted even then — each replacement's `expr`
137//! contributes reads and a `col` lineage edge, exactly like a standalone
138//! `expr AS col` — but its **output position** is best-effort, since the
139//! suppressed wildcard's columns aren't enumerated to place it among them.
140//! - **Table-function lineage is function-grained**: `UNNEST` /
141//! `generate_series` / `JSON_TABLE` / `PIVOT` etc. produce dynamic
142//! columns that aren't enumerated, so a reference *through* such a
143//! relation (`u.col`) traces to the origins of the function's
144//! **arguments** (its data inputs), as a `Transformation` — every output
145//! column derives from every argument, the same coarseness as a scalar
146//! `f(a, b)`. Which argument feeds which output column is per-function
147//! semantics the SQL text doesn't carry (a multi-array `UNNEST(a, b)`
148//! zips column i from array i), so the fan may over-attribute there.
149//! Constant arguments contribute nothing — a `generate_series(1, 10)`
150//! output has no lineage source, exactly like `SELECT 1`. The same
151//! into-the-inputs rule covers `VALUES` relations (a reference traces to
152//! the like-positioned row cells), so a lineage source is always a
153//! *written* reference — never a name fabricated from a statement-local
154//! alias.
155//! - **Recursive CTEs aren't unrolled**: the recursive self-reference
156//! terminates against the anchor branch's columns (via an active-set),
157//! so lineage traces through to the anchor's real tables — it doesn't
158//! enumerate per-iteration contributions.
159//! - **Column-list-less `INSERT` needs a catalog for column lineage**: an
160//! `INSERT INTO t SELECT …` (or `MERGE … INSERT VALUES …`) without an
161//! explicit column list can only pair source columns to target columns
162//! when a catalog supplies `t`'s columns. Catalog-free, the column-level
163//! `writes` / `lineage` are dropped (the table still surfaces in
164//! `table_writes`), flagged
165//! [`InsertColumnsUnresolved`](diagnostic::ColumnLevelDiagnosticKind::InsertColumnsUnresolved)
166//! so the empty surfaces read as "couldn't analyze", not "nothing written".
167//! - **Lineage kind is coarse** (`Passthrough` vs `Transformation`).
168//! Aggregates, window functions, arithmetic, casts, etc. are all
169//! `Transformation` — the model deliberately does not sub-classify
170//! "changed" values (that distinction is lossy for edge cases like
171//! window aggregates and value-preserving `STRING_AGG`, and not
172//! needed for the core dependency / impact-analysis use case).
173//! - **Qualifier matching is right-anchored**: a partial qualifier
174//! (`users.col`) matches a fuller registered path (`mydb.users`),
175//! and a bare name does not merge into a schema-qualified one. A
176//! table reference with more than `catalog.schema.name` segments
177//! can't be represented, so it's dropped and flagged
178//! [`TooManyTableQualifiers`](diagnostic::ColumnLevelDiagnosticKind::TooManyTableQualifiers).
179//! - **No type checking**: the catalog is an enrichment input,
180//! not a validator. Type compatibility, coercion, nullability, and
181//! structural well-formedness (e.g. an `INSERT`'s column / value count
182//! matching) are out of scope — a malformed statement is analysed as
183//! written (columns and values pair positionally, extras dropped), not
184//! rejected.
185//!
186//! ## Behavior notes
187//!
188//! - **Catalog is optional, but load-bearing for column lineage**.
189//! Table-level extraction is robust catalog-free — a table's
190//! identity comes straight from the FROM clause. Column-level
191//! extraction degrades without one: an unqualified column across
192//! multiple in-scope tables (`SELECT x FROM a JOIN b`) is not
193//! determinable from the SQL text alone, so it resolves to
194//! `table: None`. Qualified (`t.col`) and single-table refs resolve
195//! fine catalog-free. Those `None`s carry their status on
196//! [`ColumnRead::resolution`] (`Ambiguous` / `Unresolved`), not a
197//! diagnostic stream — the consumer reads it off the reference. A
198//! catalog makes resolution strict: a confirmed hit is
199//! [`ResolutionKind::Cataloged`], a denied ref [`ResolutionKind::Unresolved`],
200//! and INSERT without an explicit column list pairs source
201//! projections with the target's catalog columns. Catalog-free, every
202//! relation is open (anything could belong), so reads are best-effort
203//! [`ResolutionKind::Inferred`] / [`ResolutionKind::Ambiguous`].
204//! - **Per-statement isolation (post-parse)**: every extractor returns
205//! `Vec<Result<X, Error>>` so one statement that fails to *extract*
206//! doesn't sink the rest. A *parse* error is different — it fails the
207//! whole call (the outer `Result`), since statements can't be separated
208//! before parsing.
209//! - **Fatal vs non-fatal split**: a parse error or a per-statement
210//! extraction failure is an `Err`; tool-side coverage gaps (unsupported
211//! statement, suppressed wildcards, over-qualified table names) surface
212//! in the per-statement `diagnostics` list instead. Per-reference
213//! resolution outcomes (ambiguous / unresolved columns) are not
214//! diagnostics — they live on [`ColumnRead::resolution`].
215//! - **[`TableReference`] / [`ColumnReference`] are identity-only**.
216//! No `alias` field — alias is use-site decoration. `HashSet`
217//! dedup behaves intuitively across statements.
218//! - **Set operations follow the left side**: the result schema of
219//! `UNION` / `INTERSECT` / `EXCEPT` takes its column names from
220//! the left branch, mirroring SQL's conventional behaviour.
221//! - **Public enums are exhaustive while the crate is pre-1.0.** Adding
222//! a variant to [`extractor::StatementKind`] /
223//! [`extractor::ColumnLineageKind`] / [`extractor::ColumnTarget`] /
224//! the diagnostic-kind enums is therefore a visible breaking change —
225//! deliberate, so consumers re-acknowledge each new case rather than
226//! silently routing it to a wildcard arm. They will likely gain
227//! `#[non_exhaustive]` at the 1.0 freeze, once the variant sets
228//! stabilize.
229
230pub mod catalog;
231pub mod diagnostic;
232pub mod error;
233pub mod extractor;
234pub mod formatter;
235pub mod normalizer;
236
237// The analysis engine: binds a `Statement` into a standard bound logical
238// plan (`LogicalPlan`) and walks it with a column-origin traversal for the
239// extraction surfaces. Backs every public extractor.
240mod resolver;
241
242// `serde::Serialize` helpers for the sqlparser types (`Ident` / `Span`)
243// embedded in the public result types. Gated on the `serde` feature.
244#[cfg(feature = "serde")]
245mod serde_support;
246
247// Dialect-aware identifier casing (case folding for table / alias /
248// column matching). Threaded into the binder and the extractors. The
249// module stays private; the two configuration types are re-exported at
250// the crate root so consumers can override the dialect default via the
251// `*_with_options` extractors (through `ExtractorOptions::with_casing`).
252pub(crate) mod casing;
253pub use casing::{CaseRule, IdentifierCasing};
254
255// `reference` is intentionally private: the module name itself is not
256// stable enough to commit to as part of the public API. The two
257// identity types it contains (`TableReference` / `ColumnReference`)
258// are re-exported at the crate root because they thread through
259// every other module's public surface.
260mod reference;
261pub use reference::{
262 ColumnIdentityKey, ColumnRead, ColumnReference, ColumnWrite, ResolutionKind, TableIdentityKey,
263 TableRead, TableReference, TableWrite,
264};
265
266// `sqlparser` is re-exported so consumers can name `Dialect` /
267// `Ident` / etc. via `sql_insight::sqlparser::...` without taking a
268// direct dependency (and risking a version mismatch).
269pub use sqlparser;
270
271#[doc(hidden)]
272// Internal module for testing. Made public for use in integration tests.
273pub mod test_utils;