Skip to main content

uqa_storage/
catalog.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Backend-neutral persistent catalog facade.
8//!
9//! The engine depends on this trait for table metadata, analyzers, models,
10//! graph registries, and planner statistics. Concrete storage layers such as
11//! `SQLite` or a future RocksDB-backed catalog implement it behind the same
12//! object-safe boundary.
13
14use serde::{Deserialize, Serialize};
15
16use crate::backend::StorageBackendResult;
17
18/// Durable identity of a SQL relation.
19///
20/// The schema and local name are stored separately so `foo` and
21/// `public.foo` can never become two physical catalog identities for the
22/// same SQL object.
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct RelationIdentity {
25    pub schema: String,
26    pub name: String,
27}
28
29impl RelationIdentity {
30    pub fn new(schema: impl Into<String>, name: impl Into<String>) -> Self {
31        Self {
32            schema: schema.into(),
33            name: name.into(),
34        }
35    }
36
37    pub fn qualified_name(&self) -> String {
38        format!(
39            "{}.{}",
40            render_relation_component(&self.schema),
41            render_relation_component(&self.name)
42        )
43    }
44
45    /// Physical owner keys that can refer to this relation. New writes use
46    /// only the canonical qualified name. Catalog cleanup also accepts the
47    /// former unqualified key for `public` relations so data written before
48    /// relation identities became schema-aware cannot survive its owner.
49    pub(crate) fn canonical_and_legacy_public_names(&self) -> Vec<String> {
50        let canonical = self.qualified_name();
51        if self.schema != "public" {
52            return vec![canonical];
53        }
54        let mut names = vec![canonical];
55        let rendered_alias = render_relation_component(&self.name);
56        if !names.contains(&rendered_alias) {
57            names.push(rendered_alias);
58        }
59        // The direct Rust API historically accepted a decoded local name as
60        // well as SQL-rendered text. Include that spelling only when parsing
61        // it maps back to this exact relation; for example, raw `a.b` must not
62        // be removed while dropping the distinct public relation `"a.b"`.
63        if RelationIdentity::from_legacy_name(&self.name).is_ok_and(|raw| raw == *self)
64            && !names.contains(&self.name)
65        {
66            names.push(self.name.clone());
67        }
68        names
69    }
70
71    /// Decode a SQL relation reference or a former flat catalog key.
72    /// Unqualified objects belong to `public`. Quoted components preserve
73    /// embedded dots and escaped quotes, so `public.\"a.b\"` is distinct from
74    /// `\"public.a\".b` all the way down to physical storage keys.
75    pub fn from_legacy_name(value: &str) -> Result<Self, String> {
76        let (schema, name) = Self::parse_reference(value)?;
77        Ok(Self::new(
78            schema.unwrap_or_else(|| "public".to_string()),
79            name,
80        ))
81    }
82
83    /// Parse a possibly-unqualified SQL relation reference without choosing a
84    /// search-path schema. Components use `PostgreSQL` double-quote escaping.
85    pub fn parse_reference(value: &str) -> Result<(Option<String>, String), String> {
86        let components = parse_relation_components(value)?;
87        match components.as_slice() {
88            [name] => Ok((None, name.clone())),
89            [schema, name] => Ok((Some(schema.clone()), name.clone())),
90            _ => Err(format!("invalid persisted relation name `{value}`")),
91        }
92    }
93}
94
95fn render_relation_component(component: &str) -> String {
96    let can_render_bare = component
97        .bytes()
98        .enumerate()
99        .all(|(index, byte)| match byte {
100            b'a'..=b'z' | b'_' => true,
101            b'0'..=b'9' | b'$' => index != 0,
102            _ => false,
103        });
104    if can_render_bare && !component.is_empty() {
105        component.to_string()
106    } else {
107        format!("\"{}\"", component.replace('"', "\"\""))
108    }
109}
110
111fn parse_relation_components(value: &str) -> Result<Vec<String>, String> {
112    if value.is_empty() {
113        return Err("persisted relation name is empty".to_string());
114    }
115    let mut components = Vec::with_capacity(2);
116    let mut chars = value.char_indices().peekable();
117    while chars.peek().is_some() {
118        let mut component = String::new();
119        if chars.peek().is_some_and(|(_, ch)| *ch == '"') {
120            chars.next();
121            let mut terminated = false;
122            while let Some((_, ch)) = chars.next() {
123                if ch != '"' {
124                    component.push(ch);
125                    continue;
126                }
127                if chars.peek().is_some_and(|(_, next)| *next == '"') {
128                    chars.next();
129                    component.push('"');
130                } else {
131                    terminated = true;
132                    break;
133                }
134            }
135            if !terminated {
136                return Err(format!("unterminated quoted relation name `{value}`"));
137            }
138            if chars.peek().is_some_and(|(_, ch)| *ch != '.') {
139                return Err(format!("invalid persisted relation name `{value}`"));
140            }
141        } else {
142            while let Some((_, ch)) = chars.peek() {
143                if *ch == '.' {
144                    break;
145                }
146                if *ch == '"' {
147                    return Err(format!("invalid persisted relation name `{value}`"));
148                }
149                component.push(*ch);
150                chars.next();
151            }
152        }
153        if component.is_empty() {
154            return Err(format!("invalid persisted relation name `{value}`"));
155        }
156        components.push(component);
157        if components.len() > 2 {
158            return Err(format!("invalid persisted relation name `{value}`"));
159        }
160        match chars.next() {
161            Some((_, '.')) if chars.peek().is_some() => {}
162            Some(_) => return Err(format!("invalid persisted relation name `{value}`")),
163            None => break,
164        }
165    }
166    Ok(components)
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum RelationKind {
172    Table,
173    View,
174    Sequence,
175    ForeignTable,
176}
177
178impl RelationKind {
179    pub fn as_str(self) -> &'static str {
180        match self {
181            Self::Table => "table",
182            Self::View => "view",
183            Self::Sequence => "sequence",
184            Self::ForeignTable => "foreign_table",
185        }
186    }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct TableSchema {
191    pub relation: RelationIdentity,
192    /// Stable logical relation identity. `CREATE TABLE` allocates a new value, while renames, schema changes, `TRUNCATE`, and reopen preserve it. A zero value marks a legacy catalog row that the engine upgrades during open.
193    #[serde(default)]
194    pub object_id: [u8; 16],
195    /// Stable physical-storage identity. `CREATE TABLE` and `TRUNCATE` allocate a new value, while schema-only alterations and renames preserve it. A zero value marks a legacy catalog row that the engine upgrades during open.
196    #[serde(default)]
197    pub storage_generation: [u8; 16],
198    pub analyzer_json: String,
199    pub fts_fields: Vec<String>,
200    pub vector_fields: Vec<VectorFieldSchema>,
201    /// Serialized `Vec<uqa_sql::ast::ColumnDef>` capturing the schema
202    /// columns (name, type, `auto_increment`, flags). Empty for
203    /// tables created by the legacy code path before column tracking
204    /// existed.
205    #[serde(default)]
206    pub columns_json: String,
207    /// Serialized `uqa_sql::ast::TableConstraintSet`. Empty for catalogs
208    /// created before durable table constraints were introduced.
209    #[serde(default)]
210    pub constraints_json: String,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct VectorFieldSchema {
215    pub field: String,
216    pub dimensions: u32,
217}
218
219/// One row from graph-edge persistence, represented as a typed struct so the
220/// catalog API stays explicit and clippy-clean.
221#[derive(Debug, Clone)]
222pub struct EdgeRow {
223    pub edge_id: u64,
224    pub source_id: u64,
225    pub target_id: u64,
226    pub label: String,
227    pub properties_json: String,
228}
229
230/// One vertex in an atomic named-graph snapshot replacement.
231#[derive(Debug, Clone)]
232pub struct GraphVertexRow {
233    pub vertex_id: u64,
234    pub label: String,
235    pub properties_json: String,
236}
237
238/// Complete persisted shape of one named graph. Catalog implementations
239/// replace the graph membership and entity rows as one atomic operation.
240#[derive(Debug, Clone)]
241pub struct GraphSnapshot {
242    pub vertices: Vec<GraphVertexRow>,
243    pub edges: Vec<EdgeRow>,
244    pub label_registry_json: String,
245}
246
247/// One row from the foreign-table registry.
248#[derive(Debug, Clone)]
249pub struct ForeignTableRow {
250    pub relation: RelationIdentity,
251    pub server_name: String,
252    pub columns_json: String,
253    pub options_json: String,
254}
255
256/// One durable view definition. `definition_json` contains a serialized
257/// planner query plan, while ownership remains a typed catalog relation.
258#[derive(Debug, Clone)]
259pub struct ViewRow {
260    pub relation: RelationIdentity,
261    pub definition_json: String,
262}
263
264/// One row from the secondary-index registry.
265#[derive(Debug, Clone)]
266pub struct CatalogIndexRow {
267    pub name: String,
268    pub index_type: String,
269    pub table_name: String,
270    pub columns_json: String,
271    pub parameters_json: String,
272}
273
274/// Values persisted into one column-statistics row.
275#[derive(Debug, Clone, Copy)]
276pub struct ColumnStatsInput<'a> {
277    pub table_name: &'a str,
278    pub column_name: &'a str,
279    pub distinct_count: i64,
280    pub null_count: i64,
281    pub min_value: Option<&'a str>,
282    pub max_value: Option<&'a str>,
283    pub row_count: i64,
284    pub histogram_json: &'a str,
285    pub mcv_values_json: &'a str,
286    pub mcv_frequencies_json: &'a str,
287}
288
289impl<'a> ColumnStatsInput<'a> {
290    pub fn basic(
291        table_name: &'a str,
292        column_name: &'a str,
293        distinct_count: i64,
294        null_count: i64,
295        min_value: Option<&'a str>,
296        max_value: Option<&'a str>,
297        row_count: i64,
298    ) -> Self {
299        Self {
300            table_name,
301            column_name,
302            distinct_count,
303            null_count,
304            min_value,
305            max_value,
306            row_count,
307            histogram_json: "[]",
308            mcv_values_json: "[]",
309            mcv_frequencies_json: "[]",
310        }
311    }
312}
313
314/// One row from persisted column statistics.
315#[derive(Debug, Clone, PartialEq)]
316pub struct ColumnStatsRow {
317    pub column_name: String,
318    pub distinct_count: i64,
319    pub null_count: i64,
320    pub min_value: Option<String>,
321    pub max_value: Option<String>,
322    pub row_count: i64,
323    pub histogram_json: String,
324    pub mcv_values_json: String,
325    pub mcv_frequencies_json: String,
326}
327
328/// Durable SQL sequence state. Sequence allocation is implemented by the
329/// catalog backend as one atomic mutation so independent engine sessions (and
330/// independently opened engines) cannot return the same value.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct SequenceRow {
333    pub relation: RelationIdentity,
334    pub start: i64,
335    pub increment: i64,
336    pub current: i64,
337    /// False until the first allocation returns `current` verbatim.
338    pub called: bool,
339    /// `PostgreSQL` `pg_class.relpersistence` code. Durable sequence rows accept only permanent (`p`) and unlogged (`u`) values.
340    pub persistence: String,
341}
342
343/// Engine-facing catalog facade for persistent metadata.
344pub trait CatalogFacade: Send + Sync {
345    fn set_metadata(&self, key: &str, value: &str) -> StorageBackendResult<()>;
346    fn get_metadata(&self, key: &str) -> StorageBackendResult<Option<String>>;
347    fn fts_storage_was_reset(&self) -> bool {
348        false
349    }
350
351    /// Atomically migrate the former flat relation namespace into typed,
352    /// schema-owned catalog objects. Implementations must reject normalized
353    /// or cross-kind collisions instead of merging either object.
354    fn migrate_relation_namespace(&self) -> StorageBackendResult<()>;
355
356    fn save_schema(&self, name: &str) -> StorageBackendResult<()>;
357    fn drop_schema(&self, name: &str) -> StorageBackendResult<()>;
358    fn load_schemas(&self) -> StorageBackendResult<Vec<String>>;
359
360    fn save_table(&self, schema: &TableSchema) -> StorageBackendResult<()>;
361    fn load_tables(&self) -> StorageBackendResult<Vec<TableSchema>>;
362    fn drop_table(&self, name: &str) -> StorageBackendResult<()>;
363    /// Remove a table definition and every catalog/data row owned by it as
364    /// one atomic catalog operation.
365    fn drop_table_and_data(&self, name: &str) -> StorageBackendResult<()>;
366    fn purge_table_data(&self, name: &str) -> StorageBackendResult<()>;
367    fn rename_table_data(&self, from: &str, to: &str) -> StorageBackendResult<()>;
368    fn drop_column_data(&self, table_name: &str, column_name: &str) -> StorageBackendResult<()>;
369    fn rename_column_data(
370        &self,
371        table_name: &str,
372        from: &str,
373        to: &str,
374    ) -> StorageBackendResult<()>;
375
376    fn save_model(&self, name: &str, json: &str) -> StorageBackendResult<()>;
377    fn load_models(&self) -> StorageBackendResult<Vec<(String, String)>>;
378    fn load_model(&self, name: &str) -> StorageBackendResult<Option<String>>;
379    fn drop_model(&self, name: &str) -> StorageBackendResult<()>;
380
381    fn save_scoring_params(&self, name: &str, params_json: &str) -> StorageBackendResult<()>;
382    fn load_scoring_params(&self, name: &str) -> StorageBackendResult<Option<String>>;
383    fn load_all_scoring_params(&self) -> StorageBackendResult<Vec<(String, String)>>;
384    fn drop_scoring_params(&self, name: &str) -> StorageBackendResult<()>;
385
386    fn create_sequence_row(&self, sequence: &SequenceRow) -> StorageBackendResult<bool>;
387    fn replace_sequence_row(&self, sequence: &SequenceRow) -> StorageBackendResult<bool>;
388    fn drop_sequence_row(&self, name: &str) -> StorageBackendResult<bool>;
389    fn load_sequence_rows(&self) -> StorageBackendResult<Vec<SequenceRow>>;
390    fn next_sequence_value(&self, name: &str) -> StorageBackendResult<Option<i64>>;
391    fn set_sequence_value(&self, name: &str, value: i64) -> StorageBackendResult<Option<i64>>;
392
393    fn save_view(&self, view: &ViewRow) -> StorageBackendResult<()>;
394    fn drop_view(&self, relation: &RelationIdentity) -> StorageBackendResult<bool>;
395    fn load_views(&self) -> StorageBackendResult<Vec<ViewRow>>;
396
397    fn save_named_graph(&self, name: &str) -> StorageBackendResult<()>;
398    fn drop_named_graph(&self, name: &str) -> StorageBackendResult<()>;
399    fn load_named_graphs(&self) -> StorageBackendResult<Vec<String>>;
400    fn save_vertex(
401        &self,
402        vertex_id: u64,
403        label: &str,
404        properties_json: &str,
405    ) -> StorageBackendResult<()>;
406    fn delete_vertex(&self, vertex_id: u64) -> StorageBackendResult<()>;
407    fn load_vertices(&self) -> StorageBackendResult<Vec<(u64, String, String)>>;
408    fn save_edge(
409        &self,
410        edge_id: u64,
411        source_id: u64,
412        target_id: u64,
413        label: &str,
414        properties_json: &str,
415    ) -> StorageBackendResult<()>;
416    fn delete_edge(&self, edge_id: u64) -> StorageBackendResult<()>;
417    fn load_edges(&self) -> StorageBackendResult<Vec<EdgeRow>>;
418    fn save_graph_membership(
419        &self,
420        entity_type: &str,
421        entity_id: u64,
422        graph_name: &str,
423    ) -> StorageBackendResult<()>;
424    fn delete_graph_membership(
425        &self,
426        entity_type: &str,
427        entity_id: u64,
428        graph_name: &str,
429    ) -> StorageBackendResult<()>;
430    fn delete_graph_membership_for_graph(&self, graph_name: &str) -> StorageBackendResult<()>;
431    fn load_graph_memberships(&self) -> StorageBackendResult<Vec<(String, u64, String)>>;
432    fn purge_orphan_graph_entities(&self) -> StorageBackendResult<()>;
433    fn replace_named_graph(
434        &self,
435        graph_name: &str,
436        snapshot: &GraphSnapshot,
437    ) -> StorageBackendResult<()>;
438    fn drop_named_graph_data(&self, graph_name: &str) -> StorageBackendResult<()>;
439
440    fn save_analyzer(&self, name: &str, config_json: &str) -> StorageBackendResult<()>;
441    fn drop_analyzer(&self, name: &str) -> StorageBackendResult<()>;
442    fn load_analyzers(&self) -> StorageBackendResult<Vec<(String, String)>>;
443
444    fn save_table_field_analyzer(
445        &self,
446        table_name: &str,
447        field: &str,
448        phase: &str,
449        analyzer_name: &str,
450    ) -> StorageBackendResult<()>;
451    fn replace_table_field_analyzer(
452        &self,
453        table_name: &str,
454        field: &str,
455        phase: &str,
456        analyzer_name: &str,
457    ) -> StorageBackendResult<()>;
458    fn drop_table_field_analyzer_field(
459        &self,
460        table_name: &str,
461        field: &str,
462    ) -> StorageBackendResult<()>;
463    fn drop_table_field_analyzers(&self, table_name: &str) -> StorageBackendResult<()>;
464    fn load_table_field_analyzers(
465        &self,
466    ) -> StorageBackendResult<Vec<(String, String, String, String)>>;
467
468    fn save_foreign_server(
469        &self,
470        name: &str,
471        fdw_type: &str,
472        options_json: &str,
473    ) -> StorageBackendResult<()>;
474    fn drop_foreign_server(&self, name: &str) -> StorageBackendResult<()>;
475    fn load_foreign_servers(&self) -> StorageBackendResult<Vec<(String, String, String)>>;
476
477    fn save_foreign_table(
478        &self,
479        relation: &RelationIdentity,
480        server_name: &str,
481        columns_json: &str,
482        options_json: &str,
483    ) -> StorageBackendResult<()>;
484    fn drop_foreign_table(&self, relation: &RelationIdentity) -> StorageBackendResult<()>;
485    fn load_foreign_tables(&self) -> StorageBackendResult<Vec<ForeignTableRow>>;
486
487    fn save_catalog_index(
488        &self,
489        name: &str,
490        index_type: &str,
491        table_name: &str,
492        columns_json: &str,
493        parameters_json: &str,
494    ) -> StorageBackendResult<()>;
495    fn drop_catalog_index(&self, name: &str) -> StorageBackendResult<()>;
496    fn drop_catalog_indexes_for_table(&self, table_name: &str) -> StorageBackendResult<()>;
497    fn load_catalog_indexes(&self) -> StorageBackendResult<Vec<CatalogIndexRow>>;
498
499    fn save_path_index(
500        &self,
501        graph_name: &str,
502        label_sequences_json: &str,
503    ) -> StorageBackendResult<()>;
504    fn drop_path_index(&self, graph_name: &str) -> StorageBackendResult<()>;
505    fn load_path_indexes(&self) -> StorageBackendResult<Vec<(String, String)>>;
506
507    fn save_column_stats(&self, stats: ColumnStatsInput<'_>) -> StorageBackendResult<()>;
508    /// Atomically replace the complete statistics snapshot for one table.
509    /// Implementations must leave the prior snapshot intact if any row fails.
510    fn replace_column_stats(
511        &self,
512        table_name: &str,
513        stats: &[ColumnStatsInput<'_>],
514    ) -> StorageBackendResult<()>;
515    fn load_column_stats(&self, table_name: &str) -> StorageBackendResult<Vec<ColumnStatsRow>>;
516    fn delete_column_stats(&self, table_name: &str) -> StorageBackendResult<()>;
517}
518
519#[cfg(test)]
520mod tests {
521    use super::RelationIdentity;
522
523    #[test]
524    fn relation_identity_rendering_is_reversible_and_collision_free() {
525        let left = RelationIdentity::new("a.b", "c");
526        let right = RelationIdentity::new("a", "b.c");
527        assert_eq!(left.qualified_name(), "\"a.b\".c");
528        assert_eq!(right.qualified_name(), "a.\"b.c\"");
529        assert_ne!(left.qualified_name(), right.qualified_name());
530        assert_eq!(
531            RelationIdentity::from_legacy_name(&left.qualified_name()).unwrap(),
532            left
533        );
534        assert_eq!(
535            RelationIdentity::from_legacy_name(&right.qualified_name()).unwrap(),
536            right
537        );
538    }
539
540    #[test]
541    fn relation_identity_preserves_quotes_and_unqualified_public_alias() {
542        let quoted = RelationIdentity::new("public", "a\"b.c");
543        assert_eq!(quoted.qualified_name(), "public.\"a\"\"b.c\"");
544        assert_eq!(
545            quoted.canonical_and_legacy_public_names(),
546            vec![
547                "public.\"a\"\"b.c\"".to_string(),
548                "\"a\"\"b.c\"".to_string()
549            ]
550        );
551        assert_eq!(
552            RelationIdentity::from_legacy_name(&quoted.qualified_name()).unwrap(),
553            quoted
554        );
555        assert_eq!(
556            RelationIdentity::from_legacy_name("plain").unwrap(),
557            RelationIdentity::new("public", "plain")
558        );
559        assert_eq!(
560            RelationIdentity::new("app", "plain").canonical_and_legacy_public_names(),
561            vec!["app.plain".to_string()]
562        );
563        assert_eq!(
564            RelationIdentity::new("public", "Upper").canonical_and_legacy_public_names(),
565            vec![
566                "public.\"Upper\"".to_string(),
567                "\"Upper\"".to_string(),
568                "Upper".to_string()
569            ]
570        );
571    }
572}