Skip to main content

sql_insight/
catalog.rs

1//! Optional schema provider plugged into the resolver.
2//!
3//! A [`Catalog`] is an *enrichment* input: structural resolution (CTE /
4//! derived table schemas, FROM alias bindings) works catalog-free, and
5//! a catalog only fills in the columns — and canonical identity — of
6//! real tables the resolver could not derive from the SQL alone. With
7//! no catalog those holes stay schema-unknown and surface as
8//! [`Inferred`](crate::ResolutionKind::Inferred).
9//!
10//! It is a **concrete, eager registry**, not a callback: the consumer
11//! builds it up front (typically from an `information_schema` dump,
12//! migration files, or `CREATE TABLE` statements) and the resolver
13//! matches query table references against it. The resolver — not the
14//! consumer — owns identifier matching: a query reference matches a
15//! registered table by **right-anchored, dialect-cased** comparison
16//! (a bare `users` matches a registered `mydb.users`), so consumers
17//! don't reimplement that subtlety.
18//!
19//! **Open-world.** A table the catalog doesn't contain is taken as
20//! *schema unknown*, not *nonexistent* — it still surfaces as an
21//! ordinary read / write, just `Inferred`. A misspelled / unregistered
22//! table is never flagged at table granularity.
23//!
24//! **Identifiers are exact.** Registered names are the catalog's ground
25//! truth (the stored identifiers), so they compare *exactly* under
26//! case-sensitive dialect folds and fold only under case-insensitive
27//! ones — i.e. they behave like quoted identifiers. Register the names
28//! as actually stored (e.g. what `information_schema` reports); the
29//! resolver's dialect-casing policy governs the comparison.
30
31use crate::casing::IdentifierCasing;
32use crate::error::Error;
33use sqlparser::ast::{Ident, Statement};
34use sqlparser::dialect::Dialect;
35use sqlparser::parser::Parser;
36use std::fmt;
37
38/// A concrete, eager schema registry. Build it with [`Catalog::new`]
39/// and [`Catalog::table`] (or collect an iterator of [`CatalogTable`]),
40/// then hand `Some(&catalog)` to an extractor.
41///
42/// Internally a flat list of [`CatalogTable`]s — the resolver scans it
43/// with right-anchored, cased matching, so there is no name-keyed
44/// index (a bare `users` may match several `*.users` entries, which is
45/// not a hashable equivalence). The optional default catalog / schema
46/// fill a bare or partially-qualified query reference before matching
47/// (like a single-entry search path); when unset, matching stays
48/// best-effort right-anchored.
49#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct Catalog {
51    tables: Vec<CatalogTable>,
52    default_catalog: Option<String>,
53    default_schema: Option<String>,
54}
55
56impl Catalog {
57    /// An empty catalog.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Add one registered table. Returns `self` for chaining.
63    pub fn table(mut self, table: CatalogTable) -> Self {
64        self.tables.push(table);
65        self
66    }
67
68    /// Set the default catalog used to fill a query reference that
69    /// omits its catalog segment before matching. Returns `self`.
70    pub fn default_catalog(mut self, catalog: impl Into<String>) -> Self {
71        self.default_catalog = Some(catalog.into());
72        self
73    }
74
75    /// Set the default schema used to fill a bare query reference
76    /// before matching. Returns `self`.
77    pub fn default_schema(mut self, schema: impl Into<String>) -> Self {
78        self.default_schema = Some(schema.into());
79        self
80    }
81
82    /// Build a catalog from SQL DDL: every `CREATE TABLE` with an explicit
83    /// column list becomes a registered table — its `[catalog.]schema.name`
84    /// path and column names. Column *types* and constraints are ignored
85    /// (only names are needed), so dialect-specific type syntax doesn't
86    /// matter as long as the statement parses. `ddl` is parsed with
87    /// `dialect`, so pass the dialect matching your schema dump.
88    ///
89    /// An unqualified `CREATE TABLE t` registers *schema-less* (no schema
90    /// is fabricated); right-anchored matching still lets a bare query `t`
91    /// — or even a qualified `s.t` — resolve to it. Skipped (not
92    /// registered): statements that aren't `CREATE TABLE`, `CREATE TABLE`s
93    /// with no column definitions (`... AS SELECT`, `... LIKE ...`), and
94    /// names with more than three `catalog.schema.name` segments. A parse
95    /// failure (the DDL is invalid for `dialect`) returns `Err`.
96    ///
97    /// **Only `CREATE TABLE` is interpreted.** Session-default statements
98    /// (`USE ...`, `SET search_path ...`) are *not* read — query-side
99    /// defaults are the caller's responsibility via
100    /// [`Catalog::default_schema`] / [`Catalog::default_catalog`].
101    ///
102    /// ```rust
103    /// use sql_insight::catalog::Catalog;
104    /// use sql_insight::sqlparser::dialect::GenericDialect;
105    ///
106    /// let ddl = "CREATE TABLE users (id INT, name TEXT); \
107    ///            CREATE TABLE app.orders (id INT, total NUMERIC);";
108    /// let catalog = Catalog::from_ddl(&GenericDialect {}, ddl).unwrap();
109    /// // `users` registered schema-less; `app.orders` keeps its schema.
110    /// ```
111    pub fn from_ddl(dialect: &dyn Dialect, ddl: &str) -> Result<Self, Error> {
112        Self::from_ddl_with_casing(dialect, ddl, IdentifierCasing::for_dialect(dialect))
113    }
114
115    /// Like [`from_ddl`](Self::from_ddl) but normalizes the stored identifiers
116    /// with an explicit `casing` instead of the dialect default. Pass the same
117    /// [`IdentifierCasing`] you give the extractor via
118    /// [`ExtractorOptions::with_casing`](crate::extractor::ExtractorOptions::with_casing),
119    /// so the catalog's canonical form matches what a query reference folds to:
120    /// `from_ddl` (dialect default) only matches when the extraction also uses
121    /// the default, so a case-sensitive override needs this to resolve.
122    pub fn from_ddl_with_casing(
123        dialect: &dyn Dialect,
124        ddl: &str,
125        casing: IdentifierCasing,
126    ) -> Result<Self, Error> {
127        let statements = Parser::parse_sql(dialect, ddl)?;
128        // Normalize each DDL identifier to its stored form the way the dialect
129        // would: an unquoted name folds (e.g. Postgres `Users` → `users`), a
130        // quoted name stays exact. So the registered identity matches what a
131        // query reference folds to — otherwise an unquoted mixed-case
132        // `CREATE TABLE Users` would register `Users` and miss a folded query
133        // `users` under a case-sensitive dialect. (Catalog names compare like
134        // quoted identifiers, so they must already be in canonical form.)
135        let mut catalog = Catalog::new();
136        for statement in &statements {
137            let Statement::CreateTable(create) = statement else {
138                continue;
139            };
140            // No column definitions → CTAS / LIKE: nothing to register.
141            if create.columns.is_empty() {
142                continue;
143            }
144            // Every name segment must be a plain identifier. A non-identifier
145            // segment (e.g. Snowflake `IDENTIFIER('t')`) makes the table's
146            // identity unrepresentable — skip the whole CREATE rather than
147            // dropping the segment and registering a mis-segmented phantom (an
148            // `s.IDENTIFIER('t')` would otherwise register as table `s`).
149            let Some(parts) = create
150                .name
151                .0
152                .iter()
153                .map(|p| p.as_ident())
154                .collect::<Option<Vec<&Ident>>>()
155            else {
156                continue;
157            };
158            let name = |id: &Ident| casing.table.normalize(id);
159            let table = match parts.as_slice() {
160                [n] => CatalogTable::unqualified(name(n)),
161                [schema, n] => CatalogTable::new(name(schema), name(n)),
162                [catalog_seg, schema, n] => {
163                    CatalogTable::new(name(schema), name(n)).catalog(name(catalog_seg))
164                }
165                // 0 or 4+ segments — unrepresentable identity.
166                _ => continue,
167            };
168            let columns = create
169                .columns
170                .iter()
171                .map(|c| casing.column.normalize(&c.name));
172            catalog = catalog.table(table.columns(columns));
173        }
174        Ok(catalog)
175    }
176
177    /// The registered tables, in registration order.
178    pub(crate) fn tables(&self) -> &[CatalogTable] {
179        &self.tables
180    }
181
182    /// The default catalog segment, if configured.
183    pub(crate) fn default_catalog_segment(&self) -> Option<&str> {
184        self.default_catalog.as_deref()
185    }
186
187    /// The default schema segment, if configured.
188    pub(crate) fn default_schema_segment(&self) -> Option<&str> {
189        self.default_schema.as_deref()
190    }
191}
192
193impl FromIterator<CatalogTable> for Catalog {
194    fn from_iter<I: IntoIterator<Item = CatalogTable>>(iter: I) -> Self {
195        Self {
196            tables: iter.into_iter().collect(),
197            default_catalog: None,
198            default_schema: None,
199        }
200    }
201}
202
203/// One table registered in a [`Catalog`]: a `(catalog?, schema?, name)`
204/// identity plus its column names.
205///
206/// `name` is mandatory; `schema` and `catalog` are optional — a bare
207/// table (e.g. from unqualified DDL) has neither, and engines without a
208/// catalog layer omit the catalog. An omitted segment matches *any* query
209/// value there (right-anchored, the same wildcard rule a query reference
210/// gets for its own omitted qualifiers), so a schema-less `users` matches
211/// both a bare `users` and a qualified `public.users`. Identifiers are
212/// stored verbatim; a *present* segment compares exactly (folding only
213/// under case-insensitive dialects — see the module docs). `columns` may
214/// be empty when the table is known but its columns aren't.
215#[derive(Clone, Debug, PartialEq, Eq)]
216pub struct CatalogTable {
217    catalog: Option<String>,
218    schema: Option<String>,
219    name: String,
220    columns: Vec<String>,
221}
222
223impl CatalogTable {
224    /// A table identified by `schema.name`, with no columns yet and no
225    /// catalog segment. Add columns with [`Self::columns`] and a
226    /// catalog with [`Self::catalog`].
227    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
228        Self {
229            catalog: None,
230            schema: Some(schema.into()),
231            name: name.into(),
232            columns: Vec::new(),
233        }
234    }
235
236    /// A table with no schema (e.g. from unqualified DDL). The omitted
237    /// schema is a wildcard, so it matches a query by name alone — bare
238    /// `users` and qualified `public.users` both match. Add columns with
239    /// [`Self::columns`].
240    pub fn unqualified(name: impl Into<String>) -> Self {
241        Self {
242            catalog: None,
243            schema: None,
244            name: name.into(),
245            columns: Vec::new(),
246        }
247    }
248
249    /// Set the catalog segment (for engines with a catalog layer).
250    pub fn catalog(mut self, catalog: impl Into<String>) -> Self {
251        self.catalog = Some(catalog.into());
252        self
253    }
254
255    /// Set the column names. Replaces any previously set columns.
256    pub fn columns<I, S>(mut self, columns: I) -> Self
257    where
258        I: IntoIterator<Item = S>,
259        S: Into<String>,
260    {
261        self.columns = columns.into_iter().map(Into::into).collect();
262        self
263    }
264
265    pub(crate) fn catalog_segment(&self) -> Option<&str> {
266        self.catalog.as_deref()
267    }
268
269    pub(crate) fn schema_segment(&self) -> Option<&str> {
270        self.schema.as_deref()
271    }
272
273    pub(crate) fn name_segment(&self) -> &str {
274        &self.name
275    }
276
277    pub(crate) fn column_names(&self) -> &[String] {
278        &self.columns
279    }
280}
281
282impl fmt::Display for CatalogTable {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        let path = [
285            self.catalog.as_deref(),
286            self.schema.as_deref(),
287            Some(self.name.as_str()),
288        ]
289        .into_iter()
290        .flatten()
291        .collect::<Vec<_>>()
292        .join(".");
293        write!(f, "{path}")
294    }
295}