Skip to main content

sql_insight/
diagnostic.rs

1//! Diagnostics reported during SQL inspection.
2//!
3//! Diagnostics are split by extraction granularity:
4//! [`TableLevelDiagnostic`] for the table-level surfaces
5//! (`extract_table_operations` / `extract_crud_tables`)
6//! and [`ColumnLevelDiagnostic`] for `extract_column_operations`. The split
7//! is by *type* so a table-level result cannot even represent a column-only
8//! condition — e.g. a suppressed wildcard, which leaves column lineage
9//! incomplete but doesn't affect table-level completeness at all.
10
11use sqlparser::tokenizer::Span;
12
13/// A non-fatal diagnostic from table-level extraction.
14///
15/// `message` is a human-readable description; [`span`](Self::span) carries
16/// the source location when the offending node has one.
17#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize))]
19pub struct TableLevelDiagnostic {
20    pub kind: TableLevelDiagnosticKind,
21    pub message: String,
22    /// Source location of the offending token, when available. `None` when
23    /// the originating AST node carries no span.
24    #[cfg_attr(feature = "serde", serde(skip_serializing))]
25    pub span: Option<Span>,
26}
27
28/// Why a table-level extraction is incomplete.
29///
30/// Two conditions arise at table granularity: a whole statement the
31/// extractor can't process, and a table name too qualified to represent.
32/// Column-resolution gaps (ambiguity, unresolved names) and suppressed
33/// wildcards don't apply — a table's identity comes straight from the FROM
34/// clause and is unaffected by them.
35#[derive(Clone, Debug, PartialEq, Eq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize))]
37pub enum TableLevelDiagnosticKind {
38    /// Statement variant the extractor does not understand well enough to
39    /// extract operations from. `message` names the statement.
40    UnsupportedStatement,
41    /// A table reference with more identifiers than `catalog.schema.name`
42    /// (e.g. a SQL Server `server.db.schema.table`) that can't be
43    /// represented as a [`TableReference`](crate::TableReference), so the
44    /// relation is dropped from `reads` / `writes`. `message` names it.
45    TooManyTableQualifiers,
46}
47
48/// A non-fatal diagnostic from column-level extraction
49/// ([`extract_column_operations`](crate::extractor::extract_column_operations)).
50///
51/// Carries the same `message` / `span` shape as [`TableLevelDiagnostic`].
52#[derive(Clone, Debug, PartialEq, Eq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize))]
54pub struct ColumnLevelDiagnostic {
55    pub kind: ColumnLevelDiagnosticKind,
56    pub message: String,
57    /// Source location of the offending token, when available. `None` when
58    /// the originating AST node carries no span (sqlparser-rs coverage is
59    /// patchy outside `Ident` / `Value` / tokens), or when the resolver
60    /// couldn't reasonably attribute the diagnostic to a single span.
61    #[cfg_attr(feature = "serde", serde(skip_serializing))]
62    pub span: Option<Span>,
63}
64
65/// Why a column-level extraction is incomplete.
66///
67/// Every variant is a *tool-side coverage gap*: sql-insight chose not to
68/// (or couldn't) fully analyze the construct, and a more capable analyzer
69/// could do more. Per-reference resolution outcomes (ambiguous /
70/// unresolved columns) are *not* diagnostics — they surface on each
71/// [`ColumnRead::resolution`](crate::ColumnRead) instead, so the consumer
72/// reads them off the reference rather than cross-referencing a parallel
73/// diagnostic stream.
74#[derive(Clone, Debug, PartialEq, Eq)]
75#[cfg_attr(feature = "serde", derive(serde::Serialize))]
76pub enum ColumnLevelDiagnosticKind {
77    /// Statement variant the resolver / extractor does not understand
78    /// well enough to extract operations from. `message` names the
79    /// statement.
80    UnsupportedStatement,
81    /// A `SELECT *` / `t.*` that **couldn't be expanded** — its columns
82    /// aren't completely known (a catalog-free / unmatched table, an opaque
83    /// table function, a derived body with its own unexpanded wildcard), a
84    /// bare `*` sits over `USING` / `NATURAL` merge columns, or a wildcard
85    /// modifier (`EXCLUDE` / `REPLACE` / …) rides it. Expansion is
86    /// all-or-nothing per wildcard (see crate docs), so the flagged wildcard
87    /// contributes nothing and column lineage is incomplete for its
88    /// projection. An *expanded* wildcard raises no diagnostic — it is just
89    /// its columns.
90    WildcardSuppressed,
91    /// A table reference with more identifiers than `catalog.schema.name`
92    /// (e.g. a SQL Server `server.db.schema.table`) that can't be
93    /// represented as a [`TableReference`](crate::TableReference), so the
94    /// relation — and any column read / write through it — is dropped.
95    /// `message` names the offending identifier.
96    TooManyTableQualifiers,
97    /// A column-list-less `INSERT` / `MERGE … WHEN … INSERT` whose target
98    /// column list couldn't be determined without a catalog, so the source
99    /// columns can't be paired with target columns: column-level `writes`
100    /// and `lineage` are dropped (the table itself still surfaces in
101    /// `table_writes`). Supply a [`Catalog`](crate::catalog::Catalog) to
102    /// resolve it. `message` names the target.
103    InsertColumnsUnresolved,
104    /// An explicit target column list whose count differs from the source
105    /// query's projected column count — an `INSERT INTO t (a, b, c) <source>`,
106    /// or a `CREATE TABLE t (a, b, c) AS` / `CREATE VIEW` / `ALTER VIEW` with a
107    /// column list. The positional pairing zips to the shorter side, so the
108    /// surplus target columns get no `lineage` edge (their `writes` still
109    /// surface) — a silent truncation this flags. `message` states both counts.
110    InsertColumnsArityMismatch,
111    /// A `CREATE TABLE … AS` / `CREATE VIEW …` (without an explicit column
112    /// list) whose source projects one or more outputs with no derivable name —
113    /// unaliased expressions like `SELECT a + 1`. The created relation *does*
114    /// get those columns, but their names are engine-specific and not
115    /// recoverable from the SQL text (e.g. PostgreSQL `?column?`), so they can't
116    /// be named `writes` / `lineage` targets and are dropped. Alias the
117    /// expressions to surface them. `message` names the relation and the count.
118    AnonymousColumnsSuppressed,
119}
120
121impl ColumnLevelDiagnostic {
122    /// Project to a [`TableLevelDiagnostic`] when this diagnostic is also
123    /// meaningful at table granularity, else `None`.
124    ///
125    /// [`UnsupportedStatement`](ColumnLevelDiagnosticKind::UnsupportedStatement)
126    /// and [`TooManyTableQualifiers`](ColumnLevelDiagnosticKind::TooManyTableQualifiers)
127    /// carry over (both drop a whole relation from the table surfaces);
128    /// wildcard suppression, an unresolved INSERT column list, and
129    /// column-resolution gaps don't affect table-level completeness (the
130    /// table still surfaces). The `match` is exhaustive so a new
131    /// `ColumnLevelDiagnosticKind` variant forces an explicit table-level
132    /// decision here.
133    pub(crate) fn to_table_level(&self) -> Option<TableLevelDiagnostic> {
134        let kind = match self.kind {
135            ColumnLevelDiagnosticKind::UnsupportedStatement => {
136                TableLevelDiagnosticKind::UnsupportedStatement
137            }
138            ColumnLevelDiagnosticKind::TooManyTableQualifiers => {
139                TableLevelDiagnosticKind::TooManyTableQualifiers
140            }
141            ColumnLevelDiagnosticKind::WildcardSuppressed
142            | ColumnLevelDiagnosticKind::InsertColumnsUnresolved
143            | ColumnLevelDiagnosticKind::InsertColumnsArityMismatch
144            | ColumnLevelDiagnosticKind::AnonymousColumnsSuppressed => return None,
145        };
146        Some(TableLevelDiagnostic {
147            kind,
148            message: self.message.clone(),
149            span: self.span,
150        })
151    }
152}