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}