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    /// `SELECT *` / `t.*` left unexpanded — the extractor does not
82    /// perform wildcard expansion (see crate docs), so column lineage
83    /// is incomplete for projections that include a wildcard.
84    WildcardSuppressed,
85    /// A table reference with more identifiers than `catalog.schema.name`
86    /// (e.g. a SQL Server `server.db.schema.table`) that can't be
87    /// represented as a [`TableReference`](crate::TableReference), so the
88    /// relation — and any column read / write through it — is dropped.
89    /// `message` names the offending identifier.
90    TooManyTableQualifiers,
91    /// A column-list-less `INSERT` / `MERGE … WHEN … INSERT` whose target
92    /// column list couldn't be determined without a catalog, so the source
93    /// columns can't be paired with target columns: column-level `writes`
94    /// and `lineage` are dropped (the table itself still surfaces in
95    /// `table_writes`). Supply a [`Catalog`](crate::catalog::Catalog) to
96    /// resolve it. `message` names the target.
97    InsertColumnsUnresolved,
98    /// An explicit target column list whose count differs from the source
99    /// query's projected column count — an `INSERT INTO t (a, b, c) <source>`,
100    /// or a `CREATE TABLE t (a, b, c) AS` / `CREATE VIEW` / `ALTER VIEW` with a
101    /// column list. The positional pairing zips to the shorter side, so the
102    /// surplus target columns get no `lineage` edge (their `writes` still
103    /// surface) — a silent truncation this flags. `message` states both counts.
104    InsertColumnsArityMismatch,
105    /// A `CREATE TABLE … AS` / `CREATE VIEW …` (without an explicit column
106    /// list) whose source projects one or more outputs with no derivable name —
107    /// unaliased expressions like `SELECT a + 1`. The created relation *does*
108    /// get those columns, but their names are engine-specific and not
109    /// recoverable from the SQL text (e.g. PostgreSQL `?column?`), so they can't
110    /// be named `writes` / `lineage` targets and are dropped. Alias the
111    /// expressions to surface them. `message` names the relation and the count.
112    AnonymousColumnsSuppressed,
113}
114
115impl ColumnLevelDiagnostic {
116    /// Project to a [`TableLevelDiagnostic`] when this diagnostic is also
117    /// meaningful at table granularity, else `None`.
118    ///
119    /// [`UnsupportedStatement`](ColumnLevelDiagnosticKind::UnsupportedStatement)
120    /// and [`TooManyTableQualifiers`](ColumnLevelDiagnosticKind::TooManyTableQualifiers)
121    /// carry over (both drop a whole relation from the table surfaces);
122    /// wildcard suppression, an unresolved INSERT column list, and
123    /// column-resolution gaps don't affect table-level completeness (the
124    /// table still surfaces). The `match` is exhaustive so a new
125    /// `ColumnLevelDiagnosticKind` variant forces an explicit table-level
126    /// decision here.
127    pub(crate) fn to_table_level(&self) -> Option<TableLevelDiagnostic> {
128        let kind = match self.kind {
129            ColumnLevelDiagnosticKind::UnsupportedStatement => {
130                TableLevelDiagnosticKind::UnsupportedStatement
131            }
132            ColumnLevelDiagnosticKind::TooManyTableQualifiers => {
133                TableLevelDiagnosticKind::TooManyTableQualifiers
134            }
135            ColumnLevelDiagnosticKind::WildcardSuppressed
136            | ColumnLevelDiagnosticKind::InsertColumnsUnresolved
137            | ColumnLevelDiagnosticKind::InsertColumnsArityMismatch
138            | ColumnLevelDiagnosticKind::AnonymousColumnsSuppressed => return None,
139        };
140        Some(TableLevelDiagnostic {
141            kind,
142            message: self.message.clone(),
143            span: self.span,
144        })
145    }
146}