Skip to main content

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;