Skip to main content

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    /// The relation's catalog record exists but could not be read: a
117    /// stored definition that does not decode, or a catalog read error.
118    #[error("relation '{relation}' could not be read: {detail}")]
119    Unreadable { relation: String, detail: String },
120}
121
122/// A relation that resolves to planner metadata without a stored collection.
123/// Catalog tables (pg_class, _system.*) and array TVFs both fit this shape.
124pub trait TableProvider {
125    fn schema(&self) -> CollectionInfo;
126    fn rel_oid(&self) -> Option<i64> {
127        None
128    }
129}
130
131/// Trait for looking up collection metadata during planning.
132///
133/// Both Origin (via CredentialStore) and Lite (via the embedded
134/// redb catalog) implement this trait.
135///
136/// The return type is `Result<Option<CollectionInfo>, _>` with
137/// a three-way semantics:
138///
139/// - `Ok(Some(info))` — the collection exists and is usable.
140///   An Origin implementation will have acquired a descriptor
141///   lease at the current version before returning; subsequent
142///   planning against the same collection within the lease
143///   window is drain-safe.
144/// - `Ok(None)` — the collection does not exist. Callers should
145///   surface this as `SqlError::UnknownTable`.
146/// - `Err(SqlCatalogError::RetryableSchemaChanged { .. })` —
147///   the collection exists but a DDL drain is in progress.
148///   Callers propagate this up so the pgwire layer can retry
149///   the whole statement.
150pub trait SqlCatalog {
151    fn get_collection(
152        &self,
153        database_id: DatabaseId,
154        name: &str,
155    ) -> Result<Option<CollectionInfo>, SqlCatalogError>;
156
157    /// Resolve ANY relation name to planner metadata. Catalog tables override
158    /// this to surface synthetic relations by name; the default resolves only
159    /// stored collections, identical to `get_collection`.
160    fn resolve_relation(
161        &self,
162        database_id: DatabaseId,
163        name: &str,
164    ) -> Result<Option<CollectionInfo>, SqlCatalogError> {
165        self.get_collection(database_id, name)
166    }
167
168    /// Look up an array by name. Returns `None` if no array with that
169    /// name is registered. The default implementation returns `None` so
170    /// that catalog adapters predating array support compile without
171    /// change — the array DML planner falls back to "array not found"
172    /// in that case.
173    fn lookup_array(&self, _name: &str) -> Option<ArrayCatalogView> {
174        None
175    }
176
177    /// Cheap existence check; the default delegates to `lookup_array`.
178    fn array_exists(&self, name: &str) -> bool {
179        self.lookup_array(name).is_some()
180    }
181
182    /// Resolve a relation name to its catalog OID for `'name'::regclass`.
183    /// Returns `None` when the name is not found. The default returns `None`
184    /// so existing impls compile without change.
185    fn resolve_regclass(
186        &self,
187        _database_id: nodedb_types::DatabaseId,
188        _tenant_id: u64,
189        _name: &str,
190    ) -> Option<i64> {
191        None
192    }
193
194    /// Resolve a type name to its catalog OID for `'name'::regtype`.
195    /// Returns `None` when the name is not found. The default returns `None`
196    /// so existing impls compile without change.
197    fn resolve_regtype(&self, _name: &str) -> Option<i64> {
198        None
199    }
200
201    /// Advance a sequence and return the allocated value, recording it as this
202    /// session's `currval`. Scoping mirrors `resolve_regclass`.
203    ///
204    /// An unknown name returns [`SqlError::UndefinedObject`] (SQLSTATE
205    /// `42704`). The default returns "sequence access unavailable" so
206    /// implementors with no sequence state compile without change.
207    fn sequence_nextval(
208        &self,
209        _database_id: nodedb_types::DatabaseId,
210        _tenant_id: u64,
211        _name: &str,
212    ) -> Result<i64, crate::SqlError> {
213        Err(sequence_access_unavailable())
214    }
215
216    /// Return the last value this session obtained from `sequence_nextval`.
217    ///
218    /// A sequence this session never advanced returns
219    /// [`SqlError::ObjectNotInPrerequisiteState`] (SQLSTATE `55000`).
220    fn sequence_currval(
221        &self,
222        _database_id: nodedb_types::DatabaseId,
223        _tenant_id: u64,
224        _name: &str,
225    ) -> Result<i64, crate::SqlError> {
226        Err(sequence_access_unavailable())
227    }
228
229    /// Position a sequence so the next `sequence_nextval` returns
230    /// `value + increment`. Returns `value`.
231    fn sequence_setval(
232        &self,
233        _database_id: nodedb_types::DatabaseId,
234        _tenant_id: u64,
235        _name: &str,
236        _value: i64,
237    ) -> Result<i64, crate::SqlError> {
238        Err(sequence_access_unavailable())
239    }
240}
241
242/// The error a catalog with no sequence state returns for every accessor.
243fn sequence_access_unavailable() -> crate::SqlError {
244    crate::SqlError::ObjectNotInPrerequisiteState {
245        object: "sequence".into(),
246        detail: "sequence access unavailable: this catalog carries no sequence registry".into(),
247    }
248}
249
250/// View of a registered array, surfaced to the SQL planner. Decoded by
251/// the runtime catalog adapter from its persisted msgpack schema blob;
252/// keeps `nodedb-sql` free of any dependency on `nodedb-array`.
253#[derive(Debug, Clone)]
254pub struct ArrayCatalogView {
255    pub name: String,
256    pub dims: Vec<ArrayDimAst>,
257    pub attrs: Vec<ArrayAttrAst>,
258    pub tile_extents: Vec<i64>,
259}