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}