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}