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}