Skip to main content

sql_insight/
reference.rs

1//! Reference (identity) types shared by SQL inspection features.
2//!
3//! [`TableReference`] / [`ColumnReference`] are *qualified names* that
4//! denote a table / column in a catalog or schema — pure identity, not
5//! a relation (no tuples) nor a schema (no attribute types). They carry
6//! only enough to name the thing and compare two names for equality.
7
8use core::fmt;
9
10use crate::casing::IdentifierCasing;
11use crate::error::Error;
12use sqlparser::ast::{
13    Expr, GroupByExpr, Ident, Insert, JoinOperator, ObjectName, Query, Select, SelectFlavor,
14    SelectItem, SetExpr, TableFactor, TableObject,
15};
16
17/// Physical table identity — the `catalog.schema.name` triplet.
18///
19/// `TableReference` deliberately carries no alias: aliasing is a
20/// use-site decoration, not part of a table's identity. Use-site alias
21/// information, when needed, is carried by the structures that wrap a
22/// `TableReference` (e.g. resolver bindings).
23///
24/// **Equality has two levels.** The derived `Eq` / `Hash` are
25/// *structural* — case- and quote-sensitive, exact segments. That is the
26/// right dedup when references come from catalog-backed analysis (matched
27/// tables are canonicalized, so equal tables produce equal references) and
28/// for direct cross-statement comparison. For catalog-free dedup, where
29/// the same table may appear under fold-equivalent spellings (`users` vs
30/// `USERS`), use [`identity_key`](Self::identity_key) /
31/// [`same_table`](Self::same_table), which fold by a dialect's
32/// [`IdentifierCasing`].
33#[derive(Clone, Debug, PartialEq, Eq, Hash)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize))]
35pub struct TableReference {
36    #[cfg_attr(
37        feature = "serde",
38        serde(serialize_with = "crate::serde_support::opt_ident")
39    )]
40    pub catalog: Option<Ident>,
41    #[cfg_attr(
42        feature = "serde",
43        serde(serialize_with = "crate::serde_support::opt_ident")
44    )]
45    pub schema: Option<Ident>,
46    #[cfg_attr(
47        feature = "serde",
48        serde(serialize_with = "crate::serde_support::ident")
49    )]
50    pub name: Ident,
51}
52
53/// One read-side occurrence of a [`TableReference`], pairing the
54/// identity with how the resolver resolved it ([`ResolutionKind`]).
55///
56/// The table-granularity mirror of [`ColumnRead`]. Read-side surfaces
57/// ([`TableOperation::reads`] and [`TableLineageEdge::source`]) use this
58/// wrapper so each occurrence can carry resolution metadata while
59/// [`TableReference`] stays identity-only. The write-side counterpart is
60/// [`TableWrite`] ([`TableOperation::writes`], [`TableLineageEdge::target`]).
61///
62/// Unlike [`ColumnRead`], `reference` is **always present**: a table's
63/// name is written out in the SQL, so even an
64/// [`Ambiguous`](ResolutionKind::Ambiguous) table read (the catalog
65/// holds several tables matching an under-qualified name) still surfaces
66/// the reference as written. [`Unresolved`](ResolutionKind::Unresolved)
67/// therefore never arises at table granularity — it is columns-only.
68/// The resolution records how the catalog matched the table:
69/// [`Cataloged`](ResolutionKind::Cataloged) for a unique registered hit,
70/// [`Ambiguous`](ResolutionKind::Ambiguous) for several, and
71/// [`Inferred`](ResolutionKind::Inferred) for a catalog miss or
72/// catalog-less mode.
73///
74/// [`TableOperation::reads`]: crate::extractor::TableOperation::reads
75/// [`TableOperation::writes`]: crate::extractor::TableOperation::writes
76/// [`TableLineageEdge::source`]: crate::extractor::TableLineageEdge::source
77/// [`TableLineageEdge::target`]: crate::extractor::TableLineageEdge::target
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79#[cfg_attr(feature = "serde", derive(serde::Serialize))]
80pub struct TableRead {
81    pub reference: TableReference,
82    pub resolution: ResolutionKind,
83}
84
85/// One write-side occurrence of a [`TableReference`] — a DML / DDL write
86/// target — pairing the identity with how the catalog matched it
87/// ([`ResolutionKind`]).
88///
89/// The write-role counterpart of [`TableRead`], kept a distinct type so a
90/// read can't be passed where a write is meant (and so the write side can
91/// diverge later). The `resolution` carries the same catalog-match outcome a
92/// scanned source would: [`Cataloged`](ResolutionKind::Cataloged) for a unique
93/// registered hit, [`Ambiguous`](ResolutionKind::Ambiguous) for several, and
94/// [`Inferred`](ResolutionKind::Inferred) for a catalog miss or catalog-less
95/// mode — so the [`Cataloged`](ResolutionKind::Cataloged)-detects-catalog-aware
96/// invariant holds on writes too. `reference` is always present (a target's
97/// name is written out), so [`Unresolved`](ResolutionKind::Unresolved) never
98/// arises here, exactly as for [`TableRead`].
99///
100/// [`TableOperation::writes`]: crate::extractor::TableOperation::writes
101#[derive(Clone, Debug, PartialEq, Eq, Hash)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103pub struct TableWrite {
104    pub reference: TableReference,
105    pub resolution: ResolutionKind,
106}
107
108/// A column-level identity reference: an optional owning table plus the
109/// column name.
110///
111/// `table` is `Option` because a column the resolver couldn't pin to a
112/// single owning table — [`Ambiguous`](ResolutionKind::Ambiguous) or
113/// [`Unresolved`](ResolutionKind::Unresolved) (see
114/// [`ColumnRead::resolution`] for *why*) — still surfaces its name with
115/// `table: None`. Identity is name-based: two `ColumnReference`s with the
116/// same `table` and `name` compare equal, independent of where they
117/// appeared in the SQL or how the resolver placed them. (For dialect-aware
118/// equality, see [`identity_key`](Self::identity_key).)
119#[derive(Clone, Debug, PartialEq, Eq, Hash)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize))]
121pub struct ColumnReference {
122    pub table: Option<TableReference>,
123    #[cfg_attr(
124        feature = "serde",
125        serde(serialize_with = "crate::serde_support::ident")
126    )]
127    pub name: Ident,
128}
129
130/// One read-side occurrence of a [`ColumnReference`], pairing the
131/// identity with how the resolver resolved it ([`ResolutionKind`]).
132///
133/// Read-side surfaces ([`ColumnOperation::reads`] and
134/// [`ColumnLineageEdge::source`]) use this wrapper so the same column
135/// referenced twice can carry per-occurrence resolution metadata
136/// without breaking [`ColumnReference`]'s identity-only contract. The
137/// write-side counterpart is [`ColumnWrite`].
138///
139/// [`ColumnOperation::reads`]: crate::extractor::ColumnOperation::reads
140/// [`ColumnLineageEdge::source`]: crate::extractor::ColumnLineageEdge::source
141#[derive(Clone, Debug, PartialEq, Eq, Hash)]
142#[cfg_attr(feature = "serde", derive(serde::Serialize))]
143pub struct ColumnRead {
144    pub reference: ColumnReference,
145    pub resolution: ResolutionKind,
146}
147
148/// One write-side occurrence of a [`ColumnReference`] — a written column —
149/// pairing the identity with how the resolver resolved it against the target
150/// ([`ResolutionKind`]). The write-role counterpart of [`ColumnRead`], kept a
151/// distinct type so a read can't be passed where a write is meant.
152///
153/// `resolution` is the column's catalog match against its write target:
154/// [`Cataloged`](ResolutionKind::Cataloged) when the column is in the target's
155/// catalog column list, else [`Inferred`](ResolutionKind::Inferred)
156/// (catalog-free, the target's columns aren't known, the column isn't listed,
157/// or a freshly created / altered relation).
158///
159/// The owning table is pinned whenever the statement names the sink — every
160/// INSERT / DDL write, a qualified `SET t2.col`, and an unqualified SET with
161/// one writable relation. Only an **unqualified SET among several writable
162/// relations** (a multi-table `UPDATE t1 JOIN t2 SET col = …`) is *inferred*,
163/// with the same rules as a read: a sole candidate pins its owner, several
164/// candidates surface [`Ambiguous`](ResolutionKind::Ambiguous) and none
165/// [`Unresolved`](ResolutionKind::Unresolved) — `table: None`, the column
166/// still named, exactly like an unattributed [`ColumnRead`]. An unattributed
167/// write contributes no table-level write.
168///
169/// [`ColumnOperation::writes`]: crate::extractor::ColumnOperation::writes
170/// [`ColumnTarget::Relation`]: crate::extractor::ColumnTarget::Relation
171#[derive(Clone, Debug, PartialEq, Eq, Hash)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize))]
173pub struct ColumnWrite {
174    pub reference: ColumnReference,
175    pub resolution: ResolutionKind,
176}
177
178/// How a reference was resolved — "what kind of resolution backs this
179/// `(table, name)` placement?".
180///
181/// Catalog-less mode runs as an *inference mode*: every real-table
182/// binding's schema is unknown, so a single-candidate resolution
183/// is best-effort, not catalog-backed. CTE and derived bodies do carry
184/// known schemas (the resolver derives them from the body's
185/// projection), but those refs are synthetic and dropped from the
186/// public reads / lineage by the resolver's post-pass.
187///
188/// `Ambiguous` and `Unresolved` are the two failure modes. Both come
189/// with `table: None` on the [`ColumnReference`]; the variant tells
190/// the consumer *why* the resolver gave up. (`Unresolved` arises only
191/// for columns — a table reference always has a name present.)
192///
193/// # Invariants
194///
195/// - **Catalog-less mode → no public `Cataloged`**: every surviving
196///   non-synthetic ref points at an unknown real table, so the
197///   strongest claim the resolver can make is
198///   [`Inferred`](Self::Inferred). Catalog-aware analysis is
199///   therefore detectable by the presence of `Cataloged`.
200/// - **Catalog-aware mode does not imply `Cataloged`**: catalogs are
201///   often partial. Refs against tables the catalog doesn't cover,
202///   or against a real unknown table that won a multi-candidate
203///   tiebreaker over known ones, both still come back as
204///   [`Inferred`](Self::Inferred).
205///
206/// # How each variant arises
207///
208/// | Situation | ResolutionKind |
209/// |---|---|
210/// | catalog-less, real unknown table, sole candidate | [`Inferred`](Self::Inferred) |
211/// | catalog-less, two real unknown tables in scope | [`Ambiguous`](Self::Ambiguous) |
212/// | catalog-less, CTE known body confirms the column | (internal `Cataloged`; synthetic, dropped) |
213/// | catalog-less, CTE known body denies the column (`SELECT typo FROM cte` where cte = `[id]`) | [`Unresolved`](Self::Unresolved) |
214/// | catalog-aware, known binding lists the column | [`Cataloged`](Self::Cataloged) |
215/// | catalog-aware, known binding *doesn't* list the column | [`Unresolved`](Self::Unresolved) |
216/// | catalog-aware, one known confirms + one unknown suspect (known-witness-over-unknown-suspects) | [`Inferred`](Self::Inferred) |
217/// | catalog-aware, two or more known schemas confirm | [`Ambiguous`](Self::Ambiguous) |
218/// | qualified `t.col` where `t` is unknown | [`Inferred`](Self::Inferred) |
219/// | qualified `t.col` where `t` is known and lists `col` | [`Cataloged`](Self::Cataloged) |
220///
221/// # Consumer guidance
222///
223/// - **Strict mode validation**: a fully resolved, catalog-confirmed
224///   statement satisfies
225///   `op.diagnostics.is_empty() && op.reads.iter().all(|r| r.resolution == ResolutionKind::Cataloged)`.
226/// - **DFD / CRUD comprehension**: treat
227///   [`Cataloged`](Self::Cataloged) and [`Inferred`](Self::Inferred)
228///   interchangeably as "resolved" (use the `(table, name)` pair);
229///   treat [`Ambiguous`](Self::Ambiguous) and
230///   [`Unresolved`](Self::Unresolved) as "incomplete". A lineage source
231///   is always a *written* reference (a base column, or an
232///   `Ambiguous` / `Unresolved` one with `table: None`) — the resolver
233///   never fabricates a source named after a statement-local relation
234///   (a table function's output traces to the function's arguments, a
235///   `VALUES` column to its row cells).
236#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
237#[cfg_attr(feature = "serde", derive(serde::Serialize))]
238pub enum ResolutionKind {
239    /// Backed by a known schema that lists the column / names the
240    /// table. On the public surface this means a catalog (or registry)
241    /// entry backed the reference. Internally a CTE / derived body's
242    /// known schema also yields this variant on a synthetic ref, but
243    /// the post-pass drops those — so consumers only ever see
244    /// `Cataloged` for catalog-backed real references.
245    Cataloged,
246    /// Resolution succeeded by assuming the reference exists where the
247    /// resolver placed it: an unknown-schema binding adopted as the
248    /// sole candidate, a qualified reference whose qualifier alone
249    /// determined the table, or a known witness winning over
250    /// unknown suspects in a multi-candidate scope. All defensible
251    /// inferences in catalog-less or partial-catalog mode, but not
252    /// proven.
253    Inferred,
254    /// Multiple plausible candidates and the resolver couldn't pick
255    /// one: either two-or-more known schemas confirmed the column
256    /// (genuine ambiguity), or every candidate was an unknown
257    /// suspect with no tiebreaker. `ColumnReference.table` is `None`.
258    Ambiguous,
259    /// No in-scope binding could plausibly own the column: either
260    /// every known schema in scope explicitly denied it, or the
261    /// scope chain held no bindings at all. `ColumnReference.table`
262    /// is `None`. Columns only.
263    Unresolved,
264}
265
266impl TableReference {
267    pub(crate) fn try_from_name(name: &ObjectName) -> Result<Self, Error> {
268        // Every part must be a plain identifier. A non-identifier part — e.g.
269        // Snowflake's `IDENTIFIER('t')`, a function-computed name — makes the
270        // reference unrepresentable; `as_ident` is `None` there, so the
271        // all-or-nothing `collect` yields `None` and we return `Err` rather
272        // than `unwrap`-panicking (callers drop it best-effort).
273        let parts = name
274            .0
275            .iter()
276            .map(|part| part.as_ident())
277            .collect::<Option<Vec<&Ident>>>()
278            .ok_or_else(|| {
279                Error::AnalysisError(format!(
280                    "table name `{name}` is not a plain identifier path"
281                ))
282            })?;
283        match parts.as_slice() {
284            [] => Err(Error::AnalysisError(
285                "ObjectName has no identifiers".to_string(),
286            )),
287            [n] => Ok(TableReference {
288                catalog: None,
289                schema: None,
290                name: (*n).clone(),
291            }),
292            [schema, n] => Ok(TableReference {
293                catalog: None,
294                schema: Some((*schema).clone()),
295                name: (*n).clone(),
296            }),
297            [catalog, schema, n] => Ok(TableReference {
298                catalog: Some((*catalog).clone()),
299                schema: Some((*schema).clone()),
300                name: (*n).clone(),
301            }),
302            _ => Err(Error::AnalysisError(
303                "Too many identifiers provided".to_string(),
304            )),
305        }
306    }
307
308    /// Format a slice of `TableReference`s as a comma-separated string
309    /// (e.g. `"t1, schema.t2, catalog.schema.t3"`). Shared by the
310    /// table-extractor `Display` surfaces.
311    pub(crate) fn format_list(tables: &[Self]) -> String {
312        tables
313            .iter()
314            .map(|t| t.to_string())
315            .collect::<Vec<_>>()
316            .join(", ")
317    }
318
319    /// Decode an `[Ident]` slice into a `TableReference`. 1 element =
320    /// bare name, 2 = `schema.name`, 3 = `catalog.schema.name`. Returns
321    /// `None` for 0 or 4+ parts. Use [`Self::try_from_name`] when the
322    /// input is an [`ObjectName`] (4+ parts surface as `Error` there).
323    pub(crate) fn try_from_parts(parts: &[Ident]) -> Option<Self> {
324        match parts {
325            [name] => Some(TableReference {
326                catalog: None,
327                schema: None,
328                name: name.clone(),
329            }),
330            [schema, name] => Some(TableReference {
331                catalog: None,
332                schema: Some(schema.clone()),
333                name: name.clone(),
334            }),
335            [catalog, schema, name] => Some(TableReference {
336                catalog: Some(catalog.clone()),
337                schema: Some(schema.clone()),
338                name: name.clone(),
339            }),
340            _ => None,
341        }
342    }
343
344    /// Parse an INSERT statement's target into (identity, alias) pair. An
345    /// Oracle inline-view target (`INSERT INTO (SELECT … FROM t) …`) resolves
346    /// through to its single base table; a view over no single base table (a
347    /// join, a set operation) surfaces as an `AnalysisError`.
348    pub(crate) fn from_insert_with_alias(value: &Insert) -> Result<(Self, Option<Ident>), Error> {
349        let name = match &value.table {
350            TableObject::TableName(object_name) => object_name,
351            TableObject::TableFunction(function) => &function.name,
352            // Only a single-table view is shape-determined; a join view's base
353            // table needs binder resolution (column attribution), out of reach
354            // of a plain identity parse.
355            TableObject::TableQuery(query) => {
356                match insert_target_view(query)
357                    .as_ref()
358                    .and_then(insert_target_base)
359                {
360                    Some(name) => name,
361                    None => {
362                        return Err(Error::AnalysisError(
363                            "INSERT target is a subquery over no single base table".to_string(),
364                        ))
365                    }
366                }
367            }
368        };
369        // `Insert::table_alias` is now a `TableAliasWithoutColumns`; the public
370        // pair still exposes just the alias identifier.
371        let alias = value.table_alias.as_ref().map(|a| a.alias.clone());
372        Ok((Self::try_from_name(name)?, alias))
373    }
374
375    /// Parse a `TableFactor::Table` into (identity, alias) pair. Other
376    /// `TableFactor` variants (Derived / NestedJoin / Pivot / Unpivot /
377    /// MatchRecognize / TableFunction / Function) do not name a stored
378    /// table, so they surface as an `AnalysisError`.
379    pub(crate) fn from_table_factor_with_alias(
380        table: &TableFactor,
381    ) -> Result<(Self, Option<Ident>), Error> {
382        match table {
383            TableFactor::Table { name, alias, .. } => Ok((
384                Self::try_from_name(name)?,
385                alias.as_ref().map(|a| a.name.clone()),
386            )),
387            _ => Err(Error::AnalysisError(
388                "TableFactor variant other than Table cannot be converted to a TableReference"
389                    .to_string(),
390            )),
391        }
392    }
393}
394
395impl fmt::Display for TableReference {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        let mut parts = Vec::new();
398        if let Some(catalog) = &self.catalog {
399            parts.push(catalog.to_string());
400        }
401        if let Some(schema) = &self.schema {
402            parts.push(schema.to_string());
403        }
404        parts.push(self.name.to_string());
405        write!(f, "{}", parts.join("."))
406    }
407}
408
409impl fmt::Display for ColumnReference {
410    /// `table.column` when the owning table is known (the table renders as
411    /// its own [`TableReference`] path), otherwise just `column`. Mirrors
412    /// [`TableReference`]'s `Display` for the column-identity case.
413    ///
414    /// ```rust
415    /// use sql_insight::{ColumnReference, TableReference};
416    ///
417    /// let qualified = ColumnReference {
418    ///     table: Some(TableReference {
419    ///         catalog: None,
420    ///         schema: Some("public".into()),
421    ///         name: "users".into(),
422    ///     }),
423    ///     name: "id".into(),
424    /// };
425    /// assert_eq!(qualified.to_string(), "public.users.id");
426    ///
427    /// let bare = ColumnReference { table: None, name: "id".into() };
428    /// assert_eq!(bare.to_string(), "id");
429    /// ```
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        match &self.table {
432            Some(table) => write!(f, "{table}.{}", self.name),
433            None => write!(f, "{}", self.name),
434        }
435    }
436}
437
438/// An opaque, dialect-aware identity key for a [`TableReference`].
439///
440/// Two references whose keys are equal denote the same table *under the
441/// given dialect's case-folding* — e.g. `users` and `USERS` share a key in
442/// PostgreSQL, but not in a case-sensitive dialect. Use it to deduplicate
443/// references catalog-free, where the structural `Eq` / `Hash` on
444/// `TableReference` (case-sensitive, quote-sensitive) would over-count
445/// fold-equivalent spellings. (With a catalog, matched references are
446/// already canonicalized, so structural dedup suffices.)
447///
448/// The key is **identity**, not wildcard matching: every present segment is
449/// significant, so a bare `users` and a qualified `public.users` have
450/// *different* keys (they are different identities). The folded text is not
451/// observable — only equality / hashing.
452#[derive(Clone, Debug, PartialEq, Eq, Hash)]
453pub struct TableIdentityKey {
454    catalog: Option<String>,
455    schema: Option<String>,
456    name: String,
457}
458
459/// An opaque, dialect-aware identity key for a [`ColumnReference`] — the
460/// [`TableIdentityKey`] of its owning table (if any, folded by the table
461/// rule) plus the column name folded by the column rule. See
462/// [`TableIdentityKey`] for the identity-vs-matching and opacity notes.
463#[derive(Clone, Debug, PartialEq, Eq, Hash)]
464pub struct ColumnIdentityKey {
465    table: Option<TableIdentityKey>,
466    name: String,
467}
468
469impl TableReference {
470    /// The dialect-aware [`TableIdentityKey`] for this reference: each
471    /// segment folded by `casing`'s table rule. Equal keys denote the same
472    /// table under that dialect's casing.
473    ///
474    /// This is the **catalog-free dedup key**. The structural `Eq` on
475    /// `TableReference` is exact (case- and quote-sensitive), so without a
476    /// catalog to canonicalize spellings it over-counts `users` and `USERS`
477    /// as two tables; folding by the dialect's casing collapses them.
478    ///
479    /// ```rust
480    /// use std::collections::HashSet;
481    /// use sql_insight::{CaseRule, IdentifierCasing, TableReference};
482    ///
483    /// let users = TableReference { catalog: None, schema: None, name: "users".into() };
484    /// let upper = TableReference { catalog: None, schema: None, name: "USERS".into() };
485    ///
486    /// // Structural equality is exact — these read as two different tables.
487    /// assert_ne!(users, upper);
488    ///
489    /// // Under a case-folding dialect (here lower-folding, e.g. PostgreSQL)
490    /// // they share one identity.
491    /// let casing = IdentifierCasing::uniform(CaseRule::Lower);
492    /// assert!(users.same_table(&upper, &casing));
493    ///
494    /// // So a fold-keyed set counts the table once, where a structural
495    /// // `HashSet<TableReference>` would count two.
496    /// let distinct: HashSet<_> = [&users, &upper]
497    ///     .iter()
498    ///     .map(|t| t.identity_key(&casing))
499    ///     .collect();
500    /// assert_eq!(distinct.len(), 1);
501    ///
502    /// // Identity, not wildcard: a bare name and a schema-qualified one stay
503    /// // distinct (different identities, not a prefix match).
504    /// let qualified = TableReference {
505    ///     catalog: None,
506    ///     schema: Some("public".into()),
507    ///     name: "users".into(),
508    /// };
509    /// assert!(!users.same_table(&qualified, &casing));
510    /// ```
511    pub fn identity_key(&self, casing: &IdentifierCasing) -> TableIdentityKey {
512        let fold = |ident: &Ident| casing.table.normalize(ident);
513        TableIdentityKey {
514            catalog: self.catalog.as_ref().map(&fold),
515            schema: self.schema.as_ref().map(&fold),
516            name: fold(&self.name),
517        }
518    }
519
520    /// Whether `self` and `other` denote the same table under `casing` —
521    /// equivalent to comparing their [`identity_key`](Self::identity_key)s.
522    pub fn same_table(&self, other: &Self, casing: &IdentifierCasing) -> bool {
523        self.identity_key(casing) == other.identity_key(casing)
524    }
525}
526
527impl ColumnReference {
528    /// The dialect-aware [`ColumnIdentityKey`] for this reference: the
529    /// owning table folded by the table rule, the column name by the column
530    /// rule. Equal keys denote the same column under that dialect's casing.
531    ///
532    /// Like [`TableReference::identity_key`] this is the catalog-free dedup
533    /// key — folding both the owning table and the column name (by their
534    /// separate rules, which a dialect can set apart). The owning table is
535    /// part of the identity: same column name, different table → different
536    /// column.
537    ///
538    /// ```rust
539    /// use sql_insight::{CaseRule, ColumnReference, IdentifierCasing, TableReference};
540    ///
541    /// let owned = |t: &str| Some(TableReference { catalog: None, schema: None, name: t.into() });
542    /// let lower = ColumnReference { table: owned("users"), name: "id".into() };
543    /// let upper = ColumnReference { table: owned("USERS"), name: "ID".into() };
544    ///
545    /// // Structural equality is exact; a case-folding casing merges them.
546    /// assert_ne!(lower, upper);
547    /// let casing = IdentifierCasing::uniform(CaseRule::Insensitive);
548    /// assert!(lower.same_column(&upper, &casing));
549    ///
550    /// // A different owning table is a different column, same name or not.
551    /// let other = ColumnReference { table: owned("accounts"), name: "id".into() };
552    /// assert!(!lower.same_column(&other, &casing));
553    /// ```
554    pub fn identity_key(&self, casing: &IdentifierCasing) -> ColumnIdentityKey {
555        ColumnIdentityKey {
556            table: self.table.as_ref().map(|t| t.identity_key(casing)),
557            name: casing.column.normalize(&self.name),
558        }
559    }
560
561    /// Whether `self` and `other` denote the same column under `casing` —
562    /// equivalent to comparing their [`identity_key`](Self::identity_key)s.
563    pub fn same_column(&self, other: &Self, casing: &IdentifierCasing) -> bool {
564        self.identity_key(casing) == other.identity_key(casing)
565    }
566}
567
568impl TryFrom<&Insert> for TableReference {
569    type Error = Error;
570
571    fn try_from(value: &Insert) -> Result<Self, Self::Error> {
572        Self::from_insert_with_alias(value).map(|(table, _)| table)
573    }
574}
575
576impl TryFrom<&TableFactor> for TableReference {
577    type Error = Error;
578
579    fn try_from(table: &TableFactor) -> Result<Self, Self::Error> {
580        Self::from_table_factor_with_alias(table).map(|(table, _)| table)
581    }
582}
583
584impl TryFrom<&ObjectName> for TableReference {
585    type Error = Error;
586
587    fn try_from(obj_name: &ObjectName) -> Result<Self, Self::Error> {
588        Self::try_from_name(obj_name)
589    }
590}
591
592/// A shape-gated Oracle inline-view INSERT target, handed over entirely as
593/// **gate-extracted data** — the FROM factors as `(name, alias)` pairs, the
594/// projection, the join operators, and the WHERE. The gate proves every
595/// factor is a plain table; carrying the proof as data means no downstream
596/// re-match (no unreachable fallback arm), and nothing re-reads the `Query`,
597/// so no consumer can pair `factors` with a clause it wasn\'t derived from.
598pub(crate) struct InsertTargetView<'a> {
599    /// Every FROM factor (each `TableWithJoins`' relation and joins, in
600    /// source order): its table name and optional alias.
601    pub(crate) factors: Vec<(&'a ObjectName, Option<&'a Ident>)>,
602    /// The projection items — the insertable target columns' material.
603    pub(crate) projection: &'a [SelectItem],
604    /// Every join's operator, the constraint carrier (no relation data — that
605    /// lives in `factors`): the binder binds each `ON` as filter reads.
606    pub(crate) join_operators: Vec<&'a JoinOperator>,
607    /// The WHERE predicate — filter reads over the view's relations.
608    pub(crate) selection: Option<&'a Expr>,
609}
610
611/// Shape-gate an Oracle inline-view INSERT target
612/// (`INSERT INTO (SELECT … FROM …) …`): only the minimal insertable-view shape
613/// passes — a projection, a FROM of **plain tables** (a single table, or a
614/// join / comma list of them; `ARRAY JOIN` operands are not tables), and an
615/// optional WHERE. `None` for everything else: a non-table factor, or any
616/// other clause (GROUP BY / HAVING / DISTINCT / ORDER BY / FETCH / CONNECT BY
617/// / …) makes the view non-insertable — and could carry column references
618/// that would otherwise drop silently. Which table the row lands in is
619/// [`insert_target_base`] for the single-table shape, and binder-side column
620/// attribution for a join view. Both destructures are exhaustive, so a new
621/// `Query` / `Select` clause forces a keep-or-reject decision here.
622pub(crate) fn insert_target_view(query: &Query) -> Option<InsertTargetView<'_>> {
623    let Query {
624        with: None,
625        body,
626        order_by: None,
627        limit_clause: None,
628        fetch: None,
629        locks,
630        for_clause: None,
631        settings: None,
632        format_clause: None,
633        pipe_operators,
634    } = query
635    else {
636        return None;
637    };
638    if !locks.is_empty() || !pipe_operators.is_empty() {
639        return None;
640    }
641    let SetExpr::Select(select) = body.as_ref() else {
642        return None;
643    };
644    let Select {
645        select_token: _,
646        optimizer_hints: _,
647        distinct: None,
648        select_modifiers: None,
649        top: None,
650        top_before_distinct: _,
651        projection,
652        exclude: None,
653        into: None,
654        from,
655        lateral_views,
656        prewhere: None,
657        selection,
658        connect_by,
659        group_by: GroupByExpr::Expressions(group_by, group_by_modifiers),
660        cluster_by,
661        distribute_by,
662        sort_by,
663        having: None,
664        named_window,
665        qualify: None,
666        window_before_qualify: _,
667        value_table_mode: None,
668        flavor: SelectFlavor::Standard,
669    } = select.as_ref()
670    else {
671        return None;
672    };
673    if !group_by.is_empty()
674        || !group_by_modifiers.is_empty()
675        || !cluster_by.is_empty()
676        || !distribute_by.is_empty()
677        || !sort_by.is_empty()
678        || !lateral_views.is_empty()
679        || !connect_by.is_empty()
680        || !named_window.is_empty()
681    {
682        return None;
683    }
684    if from.is_empty() {
685        return None;
686    }
687    fn plain_table(factor: &TableFactor) -> Option<(&ObjectName, Option<&Ident>)> {
688        match factor {
689            TableFactor::Table {
690                name,
691                alias,
692                args: None,
693                ..
694            } => Some((name, alias.as_ref().map(|a| &a.name))),
695            _ => None,
696        }
697    }
698    let mut factors = Vec::new();
699    let mut join_operators = Vec::new();
700    for twj in from {
701        factors.push(plain_table(&twj.relation)?);
702        for join in &twj.joins {
703            // An ARRAY JOIN operand parses as a table factor but is an array
704            // column, not a relation — never a view over base tables.
705            if matches!(
706                join.join_operator,
707                JoinOperator::ArrayJoin
708                    | JoinOperator::LeftArrayJoin
709                    | JoinOperator::InnerArrayJoin
710            ) {
711                return None;
712            }
713            factors.push(plain_table(&join.relation)?);
714            join_operators.push(&join.join_operator);
715        }
716    }
717    Some(InsertTargetView {
718        factors,
719        projection,
720        join_operators,
721        selection: selection.as_ref(),
722    })
723}
724
725/// The single base table of a shape-gated inline view
726/// ([`insert_target_view`]), when the FROM is exactly one plain table — the
727/// shape-determined case a plain identity parse can resolve. A join view
728/// returns `None`: its base table is whichever relation the projection's
729/// columns attribute to, which needs the binder (qualifier / catalog
730/// resolution).
731pub(crate) fn insert_target_base<'a>(view: &InsertTargetView<'a>) -> Option<&'a ObjectName> {
732    match view.factors.as_slice() {
733        [(name, _)] => Some(name),
734        _ => None,
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use sqlparser::ast::{SetExpr, Statement};
742    use sqlparser::dialect::GenericDialect;
743    use sqlparser::parser::Parser;
744
745    /// The first FROM factor of `SELECT 1 FROM <from>` — a handle on a parsed
746    /// `TableFactor` (and, for a `Table`, its `ObjectName`) to drive the public
747    /// `TryFrom` conversions.
748    fn first_table_factor(from: &str) -> TableFactor {
749        let sql = format!("SELECT 1 FROM {from}");
750        let mut stmts = Parser::parse_sql(&GenericDialect {}, &sql).unwrap();
751        let Statement::Query(query) = stmts.remove(0) else {
752            panic!("expected a query");
753        };
754        let SetExpr::Select(select) = *query.body else {
755            panic!("expected a SELECT");
756        };
757        select.from.into_iter().next().unwrap().relation
758    }
759
760    #[test]
761    fn try_from_object_name_keeps_catalog_schema_name_and_displays_all_parts() {
762        let factor = first_table_factor("cat.sch.tbl");
763        let TableFactor::Table { name, .. } = &factor else {
764            panic!("expected a table factor");
765        };
766        let reference = TableReference::try_from(name).unwrap();
767        assert_eq!(reference.catalog.as_ref().unwrap().value, "cat");
768        assert_eq!(reference.schema.as_ref().unwrap().value, "sch");
769        assert_eq!(reference.name.value, "tbl");
770        // Display renders every present part (the three-part / catalog branch).
771        assert_eq!(reference.to_string(), "cat.sch.tbl");
772    }
773
774    #[test]
775    fn try_from_table_factor_converts_a_table_and_rejects_a_derived_factor() {
776        let table = first_table_factor("a.b");
777        let reference = TableReference::try_from(&table).unwrap();
778        assert_eq!(reference.schema.as_ref().unwrap().value, "a");
779        assert_eq!(reference.name.value, "b");
780        // A non-`Table` factor names no stored table — an analysis error.
781        let derived = first_table_factor("(SELECT 1) AS d");
782        assert!(matches!(
783            TableReference::try_from(&derived),
784            Err(Error::AnalysisError(_))
785        ));
786    }
787
788    #[test]
789    fn try_from_insert_takes_the_target_name() {
790        let mut stmts =
791            Parser::parse_sql(&GenericDialect {}, "INSERT INTO a.b VALUES (1)").unwrap();
792        let Statement::Insert(insert) = stmts.remove(0) else {
793            panic!("expected an insert");
794        };
795        let reference = TableReference::try_from(&insert).unwrap();
796        assert_eq!(reference.schema.as_ref().unwrap().value, "a");
797        assert_eq!(reference.name.value, "b");
798    }
799
800    #[test]
801    fn from_insert_with_alias_extracts_the_target_alias_identifier() {
802        // `INSERT INTO t AS foo …` (PostgreSQL): the target alias is a
803        // `TableAliasWithoutColumns`. The pair keeps the base table (`t`) plus
804        // just the alias identifier (`foo`), dropping the `explicit`
805        // (`AS`-was-written) flag the analysis doesn't need.
806        use sqlparser::dialect::PostgreSqlDialect;
807        let mut stmts =
808            Parser::parse_sql(&PostgreSqlDialect {}, "INSERT INTO t AS foo (a) VALUES (1)")
809                .unwrap();
810        let Statement::Insert(insert) = stmts.remove(0) else {
811            panic!("expected an insert");
812        };
813        let (reference, alias) = TableReference::from_insert_with_alias(&insert).unwrap();
814        assert_eq!(reference.name.value, "t");
815        assert_eq!(alias.unwrap().value, "foo");
816    }
817
818    #[test]
819    fn try_from_insert_sees_through_an_inline_view_target() {
820        // Oracle `INSERT INTO (SELECT … FROM s.t) …` resolves to the view's
821        // single base table; a join view has no single base table → error.
822        use sqlparser::dialect::OracleDialect;
823        let parse = |sql: &str| {
824            let mut stmts = Parser::parse_sql(&OracleDialect {}, sql).unwrap();
825            let Statement::Insert(insert) = stmts.remove(0) else {
826                panic!("expected an insert");
827            };
828            insert
829        };
830        let insert = parse("INSERT INTO (SELECT a FROM s.t WHERE a > 0) VALUES (1)");
831        let reference = TableReference::try_from(&insert).unwrap();
832        assert_eq!(reference.schema.as_ref().unwrap().value, "s");
833        assert_eq!(reference.name.value, "t");
834        let join = parse("INSERT INTO (SELECT a.id FROM a JOIN b ON a.id = b.id) VALUES (1)");
835        assert!(matches!(
836            TableReference::try_from(&join),
837            Err(Error::AnalysisError(_))
838        ));
839    }
840
841    #[test]
842    fn insert_target_view_rejects_an_array_join_operand() {
843        // An ARRAY JOIN operand parses as a table factor but is an array
844        // column, not a relation — the gate must reject it so it can't
845        // masquerade as a companion table. No current dialect parses both an
846        // inline-view INSERT target *and* ARRAY JOIN, so this exercises the
847        // gate directly on a ClickHouse-parsed SELECT.
848        use sqlparser::dialect::ClickHouseDialect;
849        let parse = |sql: &str| {
850            let mut stmts = Parser::parse_sql(&ClickHouseDialect {}, sql).unwrap();
851            let Statement::Query(query) = stmts.remove(0) else {
852                panic!("expected a query");
853            };
854            query
855        };
856        let plain = parse("SELECT e.id FROM emp e JOIN dept d ON e.id = d.id");
857        let view = insert_target_view(&plain).unwrap();
858        // The gate hands everything over as extracted data: both factors
859        // (with aliases), the join's operator, and the projection.
860        assert_eq!(view.factors.len(), 2);
861        assert_eq!(view.factors[1].1.unwrap().value, "d");
862        assert_eq!(view.join_operators.len(), 1);
863        assert_eq!(view.projection.len(), 1);
864        assert!(view.selection.is_none());
865        let array_join = parse("SELECT e.id FROM emp e ARRAY JOIN arr");
866        assert!(insert_target_view(&array_join).is_none());
867    }
868}