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 not expanded**: the `*` / `t.*` itself contributes
121//!   nothing to `reads` / `lineage` (expanding it safely would require
122//!   modelling USING / NATURAL JOIN merge, EXCLUDE / EXCEPT / RENAME, and
123//!   multi-level aliases — too much rigor for a SQL-text-only library).
124//!   Surfaced as
125//!   [`WildcardSuppressed`](diagnostic::ColumnLevelDiagnosticKind::WildcardSuppressed)
126//!   so consumers can detect incomplete projections. A `REPLACE (expr AS
127//!   col)` clause *is* extracted — each replacement's `expr` contributes
128//!   reads and a `col` lineage edge, exactly like a standalone `expr AS col`
129//!   — but its **output position** is best-effort, since the wildcard's own
130//!   columns aren't enumerated to place it among them.
131//! - **Table functions are opaque**: `UNNEST` / `generate_series` /
132//!   `JSON_TABLE` / `PIVOT` etc. produce dynamic columns that aren't
133//!   enumerated. Their argument expressions surface as reads, but a
134//!   reference *through* such a relation (`u.col`) is a synthetic
135//!   lineage source named by the alias, not a cataloged real-table read.
136//! - **Recursive CTEs aren't unrolled**: the recursive self-reference
137//!   terminates against the anchor branch's columns (via an active-set),
138//!   so lineage traces through to the anchor's real tables — it doesn't
139//!   enumerate per-iteration contributions.
140//! - **Column-list-less `INSERT` needs a catalog for column lineage**: an
141//!   `INSERT INTO t SELECT …` (or `MERGE … INSERT VALUES …`) without an
142//!   explicit column list can only pair source columns to target columns
143//!   when a catalog supplies `t`'s columns. Catalog-free, the column-level
144//!   `writes` / `lineage` are dropped (the table still surfaces in
145//!   `table_writes`), flagged
146//!   [`InsertColumnsUnresolved`](diagnostic::ColumnLevelDiagnosticKind::InsertColumnsUnresolved)
147//!   so the empty surfaces read as "couldn't analyze", not "nothing written".
148//! - **Lineage kind is coarse** (`Passthrough` vs `Transformation`).
149//!   Aggregates, window functions, arithmetic, casts, etc. are all
150//!   `Transformation` — the model deliberately does not sub-classify
151//!   "changed" values (that distinction is lossy for edge cases like
152//!   window aggregates and value-preserving `STRING_AGG`, and not
153//!   needed for the core dependency / impact-analysis use case).
154//! - **Qualifier matching is right-anchored**: a partial qualifier
155//!   (`users.col`) matches a fuller registered path (`mydb.users`),
156//!   and a bare name does not merge into a schema-qualified one. A
157//!   table reference with more than `catalog.schema.name` segments
158//!   can't be represented, so it's dropped and flagged
159//!   [`TooManyTableQualifiers`](diagnostic::ColumnLevelDiagnosticKind::TooManyTableQualifiers).
160//! - **No type checking**: the catalog is an enrichment input,
161//!   not a validator. Type compatibility, coercion, nullability, and
162//!   structural well-formedness (e.g. an `INSERT`'s column / value count
163//!   matching) are out of scope — a malformed statement is analysed as
164//!   written (columns and values pair positionally, extras dropped), not
165//!   rejected.
166//!
167//! ## Behavior notes
168//!
169//! - **Catalog is optional, but load-bearing for column lineage**.
170//!   Table-level extraction is robust catalog-free — a table's
171//!   identity comes straight from the FROM clause. Column-level
172//!   extraction degrades without one: an unqualified column across
173//!   multiple in-scope tables (`SELECT x FROM a JOIN b`) is not
174//!   determinable from the SQL text alone, so it resolves to
175//!   `table: None`. Qualified (`t.col`) and single-table refs resolve
176//!   fine catalog-free. Those `None`s carry their status on
177//!   [`ColumnRead::resolution`] (`Ambiguous` / `Unresolved`), not a
178//!   diagnostic stream — the consumer reads it off the reference. A
179//!   catalog makes resolution strict: a confirmed hit is
180//!   [`ResolutionKind::Cataloged`], a denied ref [`ResolutionKind::Unresolved`],
181//!   and INSERT without an explicit column list pairs source
182//!   projections with the target's catalog columns. Catalog-free, every
183//!   relation is open (anything could belong), so reads are best-effort
184//!   [`ResolutionKind::Inferred`] / [`ResolutionKind::Ambiguous`].
185//! - **Per-statement isolation (post-parse)**: every extractor returns
186//!   `Vec<Result<X, Error>>` so one statement that fails to *extract*
187//!   doesn't sink the rest. A *parse* error is different — it fails the
188//!   whole call (the outer `Result`), since statements can't be separated
189//!   before parsing.
190//! - **Fatal vs non-fatal split**: a parse error or a per-statement
191//!   extraction failure is an `Err`; tool-side coverage gaps (unsupported
192//!   statement, suppressed wildcards, over-qualified table names) surface
193//!   in the per-statement `diagnostics` list instead. Per-reference
194//!   resolution outcomes (ambiguous / unresolved columns) are not
195//!   diagnostics — they live on [`ColumnRead::resolution`].
196//! - **[`TableReference`] / [`ColumnReference`] are identity-only**.
197//!   No `alias` field — alias is use-site decoration. `HashSet`
198//!   dedup behaves intuitively across statements.
199//! - **Set operations follow the left side**: the result schema of
200//!   `UNION` / `INTERSECT` / `EXCEPT` takes its column names from
201//!   the left branch, mirroring SQL's conventional behaviour.
202//! - **Public enums are exhaustive while the crate is pre-1.0.** Adding
203//!   a variant to [`extractor::StatementKind`] /
204//!   [`extractor::ColumnLineageKind`] / [`extractor::ColumnTarget`] /
205//!   the diagnostic-kind enums is therefore a visible breaking change —
206//!   deliberate, so consumers re-acknowledge each new case rather than
207//!   silently routing it to a wildcard arm. They will likely gain
208//!   `#[non_exhaustive]` at the 1.0 freeze, once the variant sets
209//!   stabilize.
210
211pub mod catalog;
212pub mod diagnostic;
213pub mod error;
214pub mod extractor;
215pub mod formatter;
216pub mod normalizer;
217
218// The analysis engine: binds a `Statement` into a standard bound logical
219// plan (`LogicalPlan`) and walks it with a column-origin traversal for the
220// extraction surfaces. Backs every public extractor.
221mod resolver;
222
223// `serde::Serialize` helpers for the sqlparser types (`Ident` / `Span`)
224// embedded in the public result types. Gated on the `serde` feature.
225#[cfg(feature = "serde")]
226mod serde_support;
227
228// Dialect-aware identifier casing (case folding for table / alias /
229// column matching). Threaded into the binder and the extractors. The
230// module stays private; the two configuration types are re-exported at
231// the crate root so consumers can override the dialect default via the
232// `*_with_options` extractors (through `ExtractorOptions::with_casing`).
233pub(crate) mod casing;
234pub use casing::{CaseRule, IdentifierCasing};
235
236// `reference` is intentionally private: the module name itself is not
237// stable enough to commit to as part of the public API. The two
238// identity types it contains (`TableReference` / `ColumnReference`)
239// are re-exported at the crate root because they thread through
240// every other module's public surface.
241mod reference;
242pub use reference::{
243    ColumnIdentityKey, ColumnRead, ColumnReference, ColumnWrite, ResolutionKind, TableIdentityKey,
244    TableRead, TableReference, TableWrite,
245};
246
247// `sqlparser` is re-exported so consumers can name `Dialect` /
248// `Ident` / etc. via `sql_insight::sqlparser::...` without taking a
249// direct dependency (and risking a version mismatch).
250pub use sqlparser;
251
252#[doc(hidden)]
253// Internal module for testing. Made public for use in integration tests.
254pub mod test_utils;