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}