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".
231#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
232#[cfg_attr(feature = "serde", derive(serde::Serialize))]
233pub enum ResolutionKind {
234    /// Backed by a known schema that lists the column / names the
235    /// table. On the public surface this means a catalog (or registry)
236    /// entry backed the reference. Internally a CTE / derived body's
237    /// known schema also yields this variant on a synthetic ref, but
238    /// the post-pass drops those — so consumers only ever see
239    /// `Cataloged` for catalog-backed real references.
240    Cataloged,
241    /// Resolution succeeded by assuming the reference exists where the
242    /// resolver placed it: an unknown-schema binding adopted as the
243    /// sole candidate, a qualified reference whose qualifier alone
244    /// determined the table, or a known witness winning over
245    /// unknown suspects in a multi-candidate scope. All defensible
246    /// inferences in catalog-less or partial-catalog mode, but not
247    /// proven.
248    Inferred,
249    /// Multiple plausible candidates and the resolver couldn't pick
250    /// one: either two-or-more known schemas confirmed the column
251    /// (genuine ambiguity), or every candidate was an unknown
252    /// suspect with no tiebreaker. `ColumnReference.table` is `None`.
253    Ambiguous,
254    /// No in-scope binding could plausibly own the column: either
255    /// every known schema in scope explicitly denied it, or the
256    /// scope chain held no bindings at all. `ColumnReference.table`
257    /// is `None`. Columns only.
258    Unresolved,
259}
260
261impl TableReference {
262    pub(crate) fn try_from_name(name: &ObjectName) -> Result<Self, Error> {
263        // Every part must be a plain identifier. A non-identifier part — e.g.
264        // Snowflake's `IDENTIFIER('t')`, a function-computed name — makes the
265        // reference unrepresentable; `as_ident` is `None` there, so the
266        // all-or-nothing `collect` yields `None` and we return `Err` rather
267        // than `unwrap`-panicking (callers drop it best-effort).
268        let parts = name
269            .0
270            .iter()
271            .map(|part| part.as_ident())
272            .collect::<Option<Vec<&Ident>>>()
273            .ok_or_else(|| {
274                Error::AnalysisError(format!(
275                    "table name `{name}` is not a plain identifier path"
276                ))
277            })?;
278        match parts.as_slice() {
279            [] => Err(Error::AnalysisError(
280                "ObjectName has no identifiers".to_string(),
281            )),
282            [n] => Ok(TableReference {
283                catalog: None,
284                schema: None,
285                name: (*n).clone(),
286            }),
287            [schema, n] => Ok(TableReference {
288                catalog: None,
289                schema: Some((*schema).clone()),
290                name: (*n).clone(),
291            }),
292            [catalog, schema, n] => Ok(TableReference {
293                catalog: Some((*catalog).clone()),
294                schema: Some((*schema).clone()),
295                name: (*n).clone(),
296            }),
297            _ => Err(Error::AnalysisError(
298                "Too many identifiers provided".to_string(),
299            )),
300        }
301    }
302
303    /// Format a slice of `TableReference`s as a comma-separated string
304    /// (e.g. `"t1, schema.t2, catalog.schema.t3"`). Shared by the
305    /// table-extractor `Display` surfaces.
306    pub(crate) fn format_list(tables: &[Self]) -> String {
307        tables
308            .iter()
309            .map(|t| t.to_string())
310            .collect::<Vec<_>>()
311            .join(", ")
312    }
313
314    /// Decode an `[Ident]` slice into a `TableReference`. 1 element =
315    /// bare name, 2 = `schema.name`, 3 = `catalog.schema.name`. Returns
316    /// `None` for 0 or 4+ parts. Use [`Self::try_from_name`] when the
317    /// input is an [`ObjectName`] (4+ parts surface as `Error` there).
318    pub(crate) fn try_from_parts(parts: &[Ident]) -> Option<Self> {
319        match parts {
320            [name] => Some(TableReference {
321                catalog: None,
322                schema: None,
323                name: name.clone(),
324            }),
325            [schema, name] => Some(TableReference {
326                catalog: None,
327                schema: Some(schema.clone()),
328                name: name.clone(),
329            }),
330            [catalog, schema, name] => Some(TableReference {
331                catalog: Some(catalog.clone()),
332                schema: Some(schema.clone()),
333                name: name.clone(),
334            }),
335            _ => None,
336        }
337    }
338
339    /// Parse an INSERT statement's target into (identity, alias) pair. An
340    /// Oracle inline-view target (`INSERT INTO (SELECT … FROM t) …`) resolves
341    /// through to its single base table; a view over no single base table (a
342    /// join, a set operation) surfaces as an `AnalysisError`.
343    pub(crate) fn from_insert_with_alias(value: &Insert) -> Result<(Self, Option<Ident>), Error> {
344        let name = match &value.table {
345            TableObject::TableName(object_name) => object_name,
346            TableObject::TableFunction(function) => &function.name,
347            // Only a single-table view is shape-determined; a join view's base
348            // table needs binder resolution (column attribution), out of reach
349            // of a plain identity parse.
350            TableObject::TableQuery(query) => {
351                match insert_target_view(query)
352                    .as_ref()
353                    .and_then(insert_target_base)
354                {
355                    Some(name) => name,
356                    None => {
357                        return Err(Error::AnalysisError(
358                            "INSERT target is a subquery over no single base table".to_string(),
359                        ))
360                    }
361                }
362            }
363        };
364        // `Insert::table_alias` is now a `TableAliasWithoutColumns`; the public
365        // pair still exposes just the alias identifier.
366        let alias = value.table_alias.as_ref().map(|a| a.alias.clone());
367        Ok((Self::try_from_name(name)?, alias))
368    }
369
370    /// Parse a `TableFactor::Table` into (identity, alias) pair. Other
371    /// `TableFactor` variants (Derived / NestedJoin / Pivot / Unpivot /
372    /// MatchRecognize / TableFunction / Function) do not name a stored
373    /// table, so they surface as an `AnalysisError`.
374    pub(crate) fn from_table_factor_with_alias(
375        table: &TableFactor,
376    ) -> Result<(Self, Option<Ident>), Error> {
377        match table {
378            TableFactor::Table { name, alias, .. } => Ok((
379                Self::try_from_name(name)?,
380                alias.as_ref().map(|a| a.name.clone()),
381            )),
382            _ => Err(Error::AnalysisError(
383                "TableFactor variant other than Table cannot be converted to a TableReference"
384                    .to_string(),
385            )),
386        }
387    }
388}
389
390impl fmt::Display for TableReference {
391    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392        let mut parts = Vec::new();
393        if let Some(catalog) = &self.catalog {
394            parts.push(catalog.to_string());
395        }
396        if let Some(schema) = &self.schema {
397            parts.push(schema.to_string());
398        }
399        parts.push(self.name.to_string());
400        write!(f, "{}", parts.join("."))
401    }
402}
403
404impl fmt::Display for ColumnReference {
405    /// `table.column` when the owning table is known (the table renders as
406    /// its own [`TableReference`] path), otherwise just `column`. Mirrors
407    /// [`TableReference`]'s `Display` for the column-identity case.
408    ///
409    /// ```rust
410    /// use sql_insight::{ColumnReference, TableReference};
411    ///
412    /// let qualified = ColumnReference {
413    ///     table: Some(TableReference {
414    ///         catalog: None,
415    ///         schema: Some("public".into()),
416    ///         name: "users".into(),
417    ///     }),
418    ///     name: "id".into(),
419    /// };
420    /// assert_eq!(qualified.to_string(), "public.users.id");
421    ///
422    /// let bare = ColumnReference { table: None, name: "id".into() };
423    /// assert_eq!(bare.to_string(), "id");
424    /// ```
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        match &self.table {
427            Some(table) => write!(f, "{table}.{}", self.name),
428            None => write!(f, "{}", self.name),
429        }
430    }
431}
432
433/// An opaque, dialect-aware identity key for a [`TableReference`].
434///
435/// Two references whose keys are equal denote the same table *under the
436/// given dialect's case-folding* — e.g. `users` and `USERS` share a key in
437/// PostgreSQL, but not in a case-sensitive dialect. Use it to deduplicate
438/// references catalog-free, where the structural `Eq` / `Hash` on
439/// `TableReference` (case-sensitive, quote-sensitive) would over-count
440/// fold-equivalent spellings. (With a catalog, matched references are
441/// already canonicalized, so structural dedup suffices.)
442///
443/// The key is **identity**, not wildcard matching: every present segment is
444/// significant, so a bare `users` and a qualified `public.users` have
445/// *different* keys (they are different identities). The folded text is not
446/// observable — only equality / hashing.
447#[derive(Clone, Debug, PartialEq, Eq, Hash)]
448pub struct TableIdentityKey {
449    catalog: Option<String>,
450    schema: Option<String>,
451    name: String,
452}
453
454/// An opaque, dialect-aware identity key for a [`ColumnReference`] — the
455/// [`TableIdentityKey`] of its owning table (if any, folded by the table
456/// rule) plus the column name folded by the column rule. See
457/// [`TableIdentityKey`] for the identity-vs-matching and opacity notes.
458#[derive(Clone, Debug, PartialEq, Eq, Hash)]
459pub struct ColumnIdentityKey {
460    table: Option<TableIdentityKey>,
461    name: String,
462}
463
464impl TableReference {
465    /// The dialect-aware [`TableIdentityKey`] for this reference: each
466    /// segment folded by `casing`'s table rule. Equal keys denote the same
467    /// table under that dialect's casing.
468    ///
469    /// This is the **catalog-free dedup key**. The structural `Eq` on
470    /// `TableReference` is exact (case- and quote-sensitive), so without a
471    /// catalog to canonicalize spellings it over-counts `users` and `USERS`
472    /// as two tables; folding by the dialect's casing collapses them.
473    ///
474    /// ```rust
475    /// use std::collections::HashSet;
476    /// use sql_insight::{CaseRule, IdentifierCasing, TableReference};
477    ///
478    /// let users = TableReference { catalog: None, schema: None, name: "users".into() };
479    /// let upper = TableReference { catalog: None, schema: None, name: "USERS".into() };
480    ///
481    /// // Structural equality is exact — these read as two different tables.
482    /// assert_ne!(users, upper);
483    ///
484    /// // Under a case-folding dialect (here lower-folding, e.g. PostgreSQL)
485    /// // they share one identity.
486    /// let casing = IdentifierCasing::uniform(CaseRule::Lower);
487    /// assert!(users.same_table(&upper, &casing));
488    ///
489    /// // So a fold-keyed set counts the table once, where a structural
490    /// // `HashSet<TableReference>` would count two.
491    /// let distinct: HashSet<_> = [&users, &upper]
492    ///     .iter()
493    ///     .map(|t| t.identity_key(&casing))
494    ///     .collect();
495    /// assert_eq!(distinct.len(), 1);
496    ///
497    /// // Identity, not wildcard: a bare name and a schema-qualified one stay
498    /// // distinct (different identities, not a prefix match).
499    /// let qualified = TableReference {
500    ///     catalog: None,
501    ///     schema: Some("public".into()),
502    ///     name: "users".into(),
503    /// };
504    /// assert!(!users.same_table(&qualified, &casing));
505    /// ```
506    pub fn identity_key(&self, casing: &IdentifierCasing) -> TableIdentityKey {
507        let fold = |ident: &Ident| casing.table.normalize(ident);
508        TableIdentityKey {
509            catalog: self.catalog.as_ref().map(&fold),
510            schema: self.schema.as_ref().map(&fold),
511            name: fold(&self.name),
512        }
513    }
514
515    /// Whether `self` and `other` denote the same table under `casing` —
516    /// equivalent to comparing their [`identity_key`](Self::identity_key)s.
517    pub fn same_table(&self, other: &Self, casing: &IdentifierCasing) -> bool {
518        self.identity_key(casing) == other.identity_key(casing)
519    }
520}
521
522impl ColumnReference {
523    /// The dialect-aware [`ColumnIdentityKey`] for this reference: the
524    /// owning table folded by the table rule, the column name by the column
525    /// rule. Equal keys denote the same column under that dialect's casing.
526    ///
527    /// Like [`TableReference::identity_key`] this is the catalog-free dedup
528    /// key — folding both the owning table and the column name (by their
529    /// separate rules, which a dialect can set apart). The owning table is
530    /// part of the identity: same column name, different table → different
531    /// column.
532    ///
533    /// ```rust
534    /// use sql_insight::{CaseRule, ColumnReference, IdentifierCasing, TableReference};
535    ///
536    /// let owned = |t: &str| Some(TableReference { catalog: None, schema: None, name: t.into() });
537    /// let lower = ColumnReference { table: owned("users"), name: "id".into() };
538    /// let upper = ColumnReference { table: owned("USERS"), name: "ID".into() };
539    ///
540    /// // Structural equality is exact; a case-folding casing merges them.
541    /// assert_ne!(lower, upper);
542    /// let casing = IdentifierCasing::uniform(CaseRule::Insensitive);
543    /// assert!(lower.same_column(&upper, &casing));
544    ///
545    /// // A different owning table is a different column, same name or not.
546    /// let other = ColumnReference { table: owned("accounts"), name: "id".into() };
547    /// assert!(!lower.same_column(&other, &casing));
548    /// ```
549    pub fn identity_key(&self, casing: &IdentifierCasing) -> ColumnIdentityKey {
550        ColumnIdentityKey {
551            table: self.table.as_ref().map(|t| t.identity_key(casing)),
552            name: casing.column.normalize(&self.name),
553        }
554    }
555
556    /// Whether `self` and `other` denote the same column under `casing` —
557    /// equivalent to comparing their [`identity_key`](Self::identity_key)s.
558    pub fn same_column(&self, other: &Self, casing: &IdentifierCasing) -> bool {
559        self.identity_key(casing) == other.identity_key(casing)
560    }
561}
562
563impl TryFrom<&Insert> for TableReference {
564    type Error = Error;
565
566    fn try_from(value: &Insert) -> Result<Self, Self::Error> {
567        Self::from_insert_with_alias(value).map(|(table, _)| table)
568    }
569}
570
571impl TryFrom<&TableFactor> for TableReference {
572    type Error = Error;
573
574    fn try_from(table: &TableFactor) -> Result<Self, Self::Error> {
575        Self::from_table_factor_with_alias(table).map(|(table, _)| table)
576    }
577}
578
579impl TryFrom<&ObjectName> for TableReference {
580    type Error = Error;
581
582    fn try_from(obj_name: &ObjectName) -> Result<Self, Self::Error> {
583        Self::try_from_name(obj_name)
584    }
585}
586
587/// A shape-gated Oracle inline-view INSERT target, handed over entirely as
588/// **gate-extracted data** — the FROM factors as `(name, alias)` pairs, the
589/// projection, the join operators, and the WHERE. The gate proves every
590/// factor is a plain table; carrying the proof as data means no downstream
591/// re-match (no unreachable fallback arm), and nothing re-reads the `Query`,
592/// so no consumer can pair `factors` with a clause it wasn\'t derived from.
593pub(crate) struct InsertTargetView<'a> {
594    /// Every FROM factor (each `TableWithJoins`' relation and joins, in
595    /// source order): its table name and optional alias.
596    pub(crate) factors: Vec<(&'a ObjectName, Option<&'a Ident>)>,
597    /// The projection items — the insertable target columns' material.
598    pub(crate) projection: &'a [SelectItem],
599    /// Every join's operator, the constraint carrier (no relation data — that
600    /// lives in `factors`): the binder binds each `ON` as filter reads.
601    pub(crate) join_operators: Vec<&'a JoinOperator>,
602    /// The WHERE predicate — filter reads over the view's relations.
603    pub(crate) selection: Option<&'a Expr>,
604}
605
606/// Shape-gate an Oracle inline-view INSERT target
607/// (`INSERT INTO (SELECT … FROM …) …`): only the minimal insertable-view shape
608/// passes — a projection, a FROM of **plain tables** (a single table, or a
609/// join / comma list of them; `ARRAY JOIN` operands are not tables), and an
610/// optional WHERE. `None` for everything else: a non-table factor, or any
611/// other clause (GROUP BY / HAVING / DISTINCT / ORDER BY / FETCH / CONNECT BY
612/// / …) makes the view non-insertable — and could carry column references
613/// that would otherwise drop silently. Which table the row lands in is
614/// [`insert_target_base`] for the single-table shape, and binder-side column
615/// attribution for a join view. Both destructures are exhaustive, so a new
616/// `Query` / `Select` clause forces a keep-or-reject decision here.
617pub(crate) fn insert_target_view(query: &Query) -> Option<InsertTargetView<'_>> {
618    let Query {
619        with: None,
620        body,
621        order_by: None,
622        limit_clause: None,
623        fetch: None,
624        locks,
625        for_clause: None,
626        settings: None,
627        format_clause: None,
628        pipe_operators,
629    } = query
630    else {
631        return None;
632    };
633    if !locks.is_empty() || !pipe_operators.is_empty() {
634        return None;
635    }
636    let SetExpr::Select(select) = body.as_ref() else {
637        return None;
638    };
639    let Select {
640        select_token: _,
641        optimizer_hints: _,
642        distinct: None,
643        select_modifiers: None,
644        top: None,
645        top_before_distinct: _,
646        projection,
647        exclude: None,
648        into: None,
649        from,
650        lateral_views,
651        prewhere: None,
652        selection,
653        connect_by,
654        group_by: GroupByExpr::Expressions(group_by, group_by_modifiers),
655        cluster_by,
656        distribute_by,
657        sort_by,
658        having: None,
659        named_window,
660        qualify: None,
661        window_before_qualify: _,
662        value_table_mode: None,
663        flavor: SelectFlavor::Standard,
664    } = select.as_ref()
665    else {
666        return None;
667    };
668    if !group_by.is_empty()
669        || !group_by_modifiers.is_empty()
670        || !cluster_by.is_empty()
671        || !distribute_by.is_empty()
672        || !sort_by.is_empty()
673        || !lateral_views.is_empty()
674        || !connect_by.is_empty()
675        || !named_window.is_empty()
676    {
677        return None;
678    }
679    if from.is_empty() {
680        return None;
681    }
682    fn plain_table(factor: &TableFactor) -> Option<(&ObjectName, Option<&Ident>)> {
683        match factor {
684            TableFactor::Table {
685                name,
686                alias,
687                args: None,
688                ..
689            } => Some((name, alias.as_ref().map(|a| &a.name))),
690            _ => None,
691        }
692    }
693    let mut factors = Vec::new();
694    let mut join_operators = Vec::new();
695    for twj in from {
696        factors.push(plain_table(&twj.relation)?);
697        for join in &twj.joins {
698            // An ARRAY JOIN operand parses as a table factor but is an array
699            // column, not a relation — never a view over base tables.
700            if matches!(
701                join.join_operator,
702                JoinOperator::ArrayJoin
703                    | JoinOperator::LeftArrayJoin
704                    | JoinOperator::InnerArrayJoin
705            ) {
706                return None;
707            }
708            factors.push(plain_table(&join.relation)?);
709            join_operators.push(&join.join_operator);
710        }
711    }
712    Some(InsertTargetView {
713        factors,
714        projection,
715        join_operators,
716        selection: selection.as_ref(),
717    })
718}
719
720/// The single base table of a shape-gated inline view
721/// ([`insert_target_view`]), when the FROM is exactly one plain table — the
722/// shape-determined case a plain identity parse can resolve. A join view
723/// returns `None`: its base table is whichever relation the projection's
724/// columns attribute to, which needs the binder (qualifier / catalog
725/// resolution).
726pub(crate) fn insert_target_base<'a>(view: &InsertTargetView<'a>) -> Option<&'a ObjectName> {
727    match view.factors.as_slice() {
728        [(name, _)] => Some(name),
729        _ => None,
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use sqlparser::ast::{SetExpr, Statement};
737    use sqlparser::dialect::GenericDialect;
738    use sqlparser::parser::Parser;
739
740    /// The first FROM factor of `SELECT 1 FROM <from>` — a handle on a parsed
741    /// `TableFactor` (and, for a `Table`, its `ObjectName`) to drive the public
742    /// `TryFrom` conversions.
743    fn first_table_factor(from: &str) -> TableFactor {
744        let sql = format!("SELECT 1 FROM {from}");
745        let mut stmts = Parser::parse_sql(&GenericDialect {}, &sql).unwrap();
746        let Statement::Query(query) = stmts.remove(0) else {
747            panic!("expected a query");
748        };
749        let SetExpr::Select(select) = *query.body else {
750            panic!("expected a SELECT");
751        };
752        select.from.into_iter().next().unwrap().relation
753    }
754
755    #[test]
756    fn try_from_object_name_keeps_catalog_schema_name_and_displays_all_parts() {
757        let factor = first_table_factor("cat.sch.tbl");
758        let TableFactor::Table { name, .. } = &factor else {
759            panic!("expected a table factor");
760        };
761        let reference = TableReference::try_from(name).unwrap();
762        assert_eq!(reference.catalog.as_ref().unwrap().value, "cat");
763        assert_eq!(reference.schema.as_ref().unwrap().value, "sch");
764        assert_eq!(reference.name.value, "tbl");
765        // Display renders every present part (the three-part / catalog branch).
766        assert_eq!(reference.to_string(), "cat.sch.tbl");
767    }
768
769    #[test]
770    fn try_from_table_factor_converts_a_table_and_rejects_a_derived_factor() {
771        let table = first_table_factor("a.b");
772        let reference = TableReference::try_from(&table).unwrap();
773        assert_eq!(reference.schema.as_ref().unwrap().value, "a");
774        assert_eq!(reference.name.value, "b");
775        // A non-`Table` factor names no stored table — an analysis error.
776        let derived = first_table_factor("(SELECT 1) AS d");
777        assert!(matches!(
778            TableReference::try_from(&derived),
779            Err(Error::AnalysisError(_))
780        ));
781    }
782
783    #[test]
784    fn try_from_insert_takes_the_target_name() {
785        let mut stmts =
786            Parser::parse_sql(&GenericDialect {}, "INSERT INTO a.b VALUES (1)").unwrap();
787        let Statement::Insert(insert) = stmts.remove(0) else {
788            panic!("expected an insert");
789        };
790        let reference = TableReference::try_from(&insert).unwrap();
791        assert_eq!(reference.schema.as_ref().unwrap().value, "a");
792        assert_eq!(reference.name.value, "b");
793    }
794
795    #[test]
796    fn from_insert_with_alias_extracts_the_target_alias_identifier() {
797        // `INSERT INTO t AS foo …` (PostgreSQL): the target alias is a
798        // `TableAliasWithoutColumns`. The pair keeps the base table (`t`) plus
799        // just the alias identifier (`foo`), dropping the `explicit`
800        // (`AS`-was-written) flag the analysis doesn't need.
801        use sqlparser::dialect::PostgreSqlDialect;
802        let mut stmts =
803            Parser::parse_sql(&PostgreSqlDialect {}, "INSERT INTO t AS foo (a) VALUES (1)")
804                .unwrap();
805        let Statement::Insert(insert) = stmts.remove(0) else {
806            panic!("expected an insert");
807        };
808        let (reference, alias) = TableReference::from_insert_with_alias(&insert).unwrap();
809        assert_eq!(reference.name.value, "t");
810        assert_eq!(alias.unwrap().value, "foo");
811    }
812
813    #[test]
814    fn try_from_insert_sees_through_an_inline_view_target() {
815        // Oracle `INSERT INTO (SELECT … FROM s.t) …` resolves to the view's
816        // single base table; a join view has no single base table → error.
817        use sqlparser::dialect::OracleDialect;
818        let parse = |sql: &str| {
819            let mut stmts = Parser::parse_sql(&OracleDialect {}, sql).unwrap();
820            let Statement::Insert(insert) = stmts.remove(0) else {
821                panic!("expected an insert");
822            };
823            insert
824        };
825        let insert = parse("INSERT INTO (SELECT a FROM s.t WHERE a > 0) VALUES (1)");
826        let reference = TableReference::try_from(&insert).unwrap();
827        assert_eq!(reference.schema.as_ref().unwrap().value, "s");
828        assert_eq!(reference.name.value, "t");
829        let join = parse("INSERT INTO (SELECT a.id FROM a JOIN b ON a.id = b.id) VALUES (1)");
830        assert!(matches!(
831            TableReference::try_from(&join),
832            Err(Error::AnalysisError(_))
833        ));
834    }
835
836    #[test]
837    fn insert_target_view_rejects_an_array_join_operand() {
838        // An ARRAY JOIN operand parses as a table factor but is an array
839        // column, not a relation — the gate must reject it so it can't
840        // masquerade as a companion table. No current dialect parses both an
841        // inline-view INSERT target *and* ARRAY JOIN, so this exercises the
842        // gate directly on a ClickHouse-parsed SELECT.
843        use sqlparser::dialect::ClickHouseDialect;
844        let parse = |sql: &str| {
845            let mut stmts = Parser::parse_sql(&ClickHouseDialect {}, sql).unwrap();
846            let Statement::Query(query) = stmts.remove(0) else {
847                panic!("expected a query");
848            };
849            query
850        };
851        let plain = parse("SELECT e.id FROM emp e JOIN dept d ON e.id = d.id");
852        let view = insert_target_view(&plain).unwrap();
853        // The gate hands everything over as extracted data: both factors
854        // (with aliases), the join's operator, and the projection.
855        assert_eq!(view.factors.len(), 2);
856        assert_eq!(view.factors[1].1.unwrap().value, "d");
857        assert_eq!(view.join_operators.len(), 1);
858        assert_eq!(view.projection.len(), 1);
859        assert!(view.selection.is_none());
860        let array_join = parse("SELECT e.id FROM emp e ARRAY JOIN arr");
861        assert!(insert_target_view(&array_join).is_none());
862    }
863}