nodedb_sql/catalog.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! `SqlCatalog` trait + descriptor-resolution error type.
4
5use nodedb_types::DatabaseId;
6use thiserror::Error;
7
8use crate::types::CollectionInfo;
9use crate::types_array::{ArrayAttrAst, ArrayDimAst};
10
11/// Normalize the PostgreSQL identifier spelling accepted by `regclass` input.
12///
13/// Unquoted identifiers fold to lowercase, quoted identifiers preserve case and
14/// support doubled quote escapes. NodeDB currently exposes user relations in
15/// `public` and catalog relations in `pg_catalog`; those qualifiers resolve to
16/// the same canonical relation name used by the catalog adapters.
17pub fn normalize_regclass_name(input: &str) -> Option<String> {
18 fn parse_part(raw: &str) -> Option<String> {
19 let raw = raw.trim();
20 if raw.is_empty() {
21 return None;
22 }
23 if !raw.starts_with('"') {
24 return (!raw.contains('"')).then(|| raw.to_ascii_lowercase());
25 }
26
27 let mut chars = raw.char_indices().peekable();
28 chars.next();
29 let mut out = String::new();
30 let mut closed_at = None;
31 while let Some((idx, ch)) = chars.next() {
32 if ch != '"' {
33 out.push(ch);
34 continue;
35 }
36 if chars.peek().is_some_and(|(_, next)| *next == '"') {
37 chars.next();
38 out.push('"');
39 } else {
40 closed_at = Some(idx + ch.len_utf8());
41 break;
42 }
43 }
44 let end = closed_at?;
45 raw[end..].trim().is_empty().then_some(out)
46 }
47
48 let mut parts = Vec::new();
49 let mut quoted = false;
50 let mut start = 0;
51 let chars: Vec<(usize, char)> = input.char_indices().collect();
52 let mut i = 0;
53 while i < chars.len() {
54 let (idx, ch) = chars[i];
55 if ch == '"' {
56 if quoted && i + 1 < chars.len() && chars[i + 1].1 == '"' {
57 i += 2;
58 continue;
59 }
60 quoted = !quoted;
61 } else if ch == '.' && !quoted {
62 parts.push(parse_part(&input[start..idx])?);
63 start = idx + 1;
64 }
65 i += 1;
66 }
67 if quoted {
68 return None;
69 }
70 parts.push(parse_part(&input[start..])?);
71
72 match parts.as_slice() {
73 [name] => Some(name.clone()),
74 [schema, name] if schema == "public" || schema == "pg_catalog" => Some(name.clone()),
75 _ => None,
76 }
77}
78
79/// Errors surfaced by `SqlCatalog` implementations.
80///
81/// Only one variant today — callers pattern-match directly and
82/// map the retryable case to `SqlError::RetryableSchemaChanged`
83/// via the `From` impl in `error.rs`. The enum shape is kept
84/// despite having a single variant so future variants can be
85/// added without a breaking change.
86#[derive(Debug, Clone, Error)]
87pub enum SqlCatalogError {
88 /// A DDL drain is in progress on the descriptor at the
89 /// version the planner wanted to acquire a lease on. Callers
90 /// should retry the whole plan after a short backoff — by
91 /// then either the drain has completed (new descriptor
92 /// version available in the cache) or the retry budget is
93 /// exhausted and a typed error surfaces to the client.
94 #[error("retryable schema change on {descriptor}")]
95 RetryableSchemaChanged {
96 /// Human-readable identifier for the descriptor, e.g.
97 /// `"collection orders"`. Used in log / trace output.
98 descriptor: String,
99 },
100
101 /// Collection is soft-deleted (`DROP COLLECTION` run, retention
102 /// window still active). Distinct from `Ok(None)` = absent so the
103 /// planner can surface an actionable error with an `UNDROP`
104 /// hint rather than a generic "unknown table".
105 #[error(
106 "collection '{name}' was dropped and is within its retention window; \
107 restore with UNDROP COLLECTION before {retention_expires_at_ns} ns"
108 )]
109 CollectionDeactivated {
110 name: String,
111 /// Wall-clock nanoseconds when retention elapses and the
112 /// collection is hard-deleted by the GC sweeper.
113 retention_expires_at_ns: u64,
114 },
115}
116
117/// A relation that resolves to planner metadata without a stored collection.
118/// Catalog tables (pg_class, _system.*) and array TVFs both fit this shape.
119pub trait TableProvider {
120 fn schema(&self) -> CollectionInfo;
121 fn rel_oid(&self) -> Option<i64> {
122 None
123 }
124}
125
126/// Trait for looking up collection metadata during planning.
127///
128/// Both Origin (via CredentialStore) and Lite (via the embedded
129/// redb catalog) implement this trait.
130///
131/// The return type is `Result<Option<CollectionInfo>, _>` with
132/// a three-way semantics:
133///
134/// - `Ok(Some(info))` — the collection exists and is usable.
135/// An Origin implementation will have acquired a descriptor
136/// lease at the current version before returning; subsequent
137/// planning against the same collection within the lease
138/// window is drain-safe.
139/// - `Ok(None)` — the collection does not exist. Callers should
140/// surface this as `SqlError::UnknownTable`.
141/// - `Err(SqlCatalogError::RetryableSchemaChanged { .. })` —
142/// the collection exists but a DDL drain is in progress.
143/// Callers propagate this up so the pgwire layer can retry
144/// the whole statement.
145pub trait SqlCatalog {
146 fn get_collection(
147 &self,
148 database_id: DatabaseId,
149 name: &str,
150 ) -> Result<Option<CollectionInfo>, SqlCatalogError>;
151
152 /// Resolve ANY relation name to planner metadata. Catalog tables override
153 /// this to surface synthetic relations by name; the default resolves only
154 /// stored collections, identical to `get_collection`.
155 fn resolve_relation(
156 &self,
157 database_id: DatabaseId,
158 name: &str,
159 ) -> Result<Option<CollectionInfo>, SqlCatalogError> {
160 self.get_collection(database_id, name)
161 }
162
163 /// Look up an array by name. Returns `None` if no array with that
164 /// name is registered. The default implementation returns `None` so
165 /// that catalog adapters predating array support compile without
166 /// change — the array DML planner falls back to "array not found"
167 /// in that case.
168 fn lookup_array(&self, _name: &str) -> Option<ArrayCatalogView> {
169 None
170 }
171
172 /// Cheap existence check; the default delegates to `lookup_array`.
173 fn array_exists(&self, name: &str) -> bool {
174 self.lookup_array(name).is_some()
175 }
176
177 /// Resolve a relation name to its catalog OID for `'name'::regclass`.
178 /// Returns `None` when the name is not found. The default returns `None`
179 /// so existing impls compile without change.
180 fn resolve_regclass(
181 &self,
182 _database_id: nodedb_types::DatabaseId,
183 _tenant_id: u64,
184 _name: &str,
185 ) -> Option<i64> {
186 None
187 }
188
189 /// Resolve a type name to its catalog OID for `'name'::regtype`.
190 /// Returns `None` when the name is not found. The default returns `None`
191 /// so existing impls compile without change.
192 fn resolve_regtype(&self, _name: &str) -> Option<i64> {
193 None
194 }
195}
196
197/// View of a registered array, surfaced to the SQL planner. Decoded by
198/// the runtime catalog adapter from its persisted msgpack schema blob;
199/// keeps `nodedb-sql` free of any dependency on `nodedb-array`.
200#[derive(Debug, Clone)]
201pub struct ArrayCatalogView {
202 pub name: String,
203 pub dims: Vec<ArrayDimAst>,
204 pub attrs: Vec<ArrayAttrAst>,
205 pub tile_extents: Vec<i64>,
206}