Skip to main content

genegraph_storage/
catalog.rs

1//! M-C1: catalog contract for table discovery and registration (#75).
2//!
3//! The [`TableDescriptor`] shape and the [`Catalog`] operations mirror the
4//! **Lance Namespace** client spec whose Apache Polaris implementation maps
5//! onto Polaris' **Generic Table API** (`name`, `format`, `base-location`,
6//! `properties`); see the Polaris + Lance integration announcement
7//! (2026-01-06). Per decision D1 in #75 this is a standards-hygiene
8//! contract only: no catalog server client is provided here.
9//!
10//! [`LocalRegistry`] implements the contract over the existing JSON metadata
11//! registry (a `GeneMetadata` instance), mirroring Lance Namespace's
12//! *Directory* semantics: every `*.lance` dataset registered in the
13//! metadata's files map is a table.
14
15use std::collections::BTreeMap;
16use std::path::PathBuf;
17
18use crate::StorageError;
19use crate::StorageResult;
20pub use crate::metadata::CollectionKind;
21use crate::metadata::{FileInfo, GeneMetadata};
22
23/// Computed descriptor properties that mirror `FileInfo` fields; user
24/// properties cannot shadow them (enforced both in `register_table` and on
25/// the direct `save_*_with` write paths).
26pub(crate) const RESERVED_PROPERTIES: [&str; 6] = [
27    "filetype",
28    "storage_format",
29    "rows",
30    "cols",
31    "nnz",
32    "size_bytes",
33];
34
35/// Generic Table API-compatible table descriptor.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct TableDescriptor {
38    /// Unique table name within the registry (the metadata files-map key).
39    pub name: String,
40    /// Table format; always `lance` for this crate.
41    pub format: String,
42    /// Location of the table root (a `*.lance` dataset directory).
43    pub base_location: PathBuf,
44    /// First-class collection kind (RFC #81-P1): `vector-space`, `graph` or
45    /// `table`.
46    pub kind: CollectionKind,
47    /// Free-form key/value properties (filetype, shape, ...), merged with
48    /// the collection's user properties (RFC #81-P1).
49    pub properties: BTreeMap<String, String>,
50}
51
52/// Vector-space descriptor plus its linked graph collection (RFC #81-P4).
53///
54/// A vector space references a graph collection by name through its `graph`
55/// user property; [`Catalog::describe_vector_space`] resolves both in one
56/// call.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct VectorSpaceDescriptor {
59    /// The vector-space collection itself.
60    pub vectors: TableDescriptor,
61    /// The linked graph collection, if the `graph` property is set.
62    pub graph: Option<TableDescriptor>,
63}
64
65/// Registry of Lance tables backing a storage instance.
66pub trait Catalog {
67    /// All tables known to this registry.
68    fn list_tables(&self) -> StorageResult<Vec<TableDescriptor>>;
69    /// Whether a table with `name` is registered.
70    fn table_exists(&self, name: &str) -> StorageResult<bool>;
71    /// Descriptor for `name`, or `StorageError::Invalid` if absent.
72    fn describe_table(&self, name: &str) -> StorageResult<TableDescriptor>;
73    /// Adds `table` to the registry (replaces an existing entry of the same
74    /// name, mirroring Generic Table create-or-replace semantics).
75    fn register_table(&mut self, table: TableDescriptor) -> StorageResult<()>;
76    /// Removes `name` from the registry without deleting the dataset.
77    fn deregister_table(&mut self, name: &str) -> StorageResult<()>;
78
79    /// Describes a vector-space collection together with the graph
80    /// collection it references through the `graph` user property
81    /// (RFC #81-P4). Fails with [`StorageError::Invalid`] if `name` is not a
82    /// vector-space collection or the linked graph is not registered.
83    fn describe_vector_space(&self, name: &str) -> StorageResult<VectorSpaceDescriptor> {
84        let vectors = self.describe_table(name)?;
85        if vectors.kind != CollectionKind::VectorSpace {
86            return Err(StorageError::Invalid(format!(
87                "collection '{name}' has kind '{}', not a vector space",
88                vectors.kind.as_str()
89            )));
90        }
91        let graph = match vectors.properties.get("graph") {
92            Some(graph_name) => Some(self.describe_table(graph_name)?),
93            None => None,
94        };
95        Ok(VectorSpaceDescriptor { vectors, graph })
96    }
97}
98
99/// [`Catalog`] implementation over the existing JSON metadata registry.
100///
101/// The registry wraps an owned [`GeneMetadata`]; mutate through the
102/// [`Catalog`] methods, then persist the instance with
103/// `StorageBackend::save_metadata` (or take it back with
104/// [`LocalRegistry::into_metadata`]).
105#[derive(Debug, Clone)]
106pub struct LocalRegistry {
107    metadata: GeneMetadata,
108    base: PathBuf,
109}
110
111impl LocalRegistry {
112    /// Wraps `metadata`, resolving table locations under `base`.
113    pub fn new(metadata: GeneMetadata, base: PathBuf) -> Self {
114        Self { metadata, base }
115    }
116
117    /// The wrapped metadata (for persistence).
118    pub fn metadata(&self) -> &GeneMetadata {
119        &self.metadata
120    }
121
122    /// Consumes the registry, returning the mutated metadata.
123    pub fn into_metadata(self) -> GeneMetadata {
124        self.metadata
125    }
126
127    fn descriptor(&self, key: &str, info: &FileInfo) -> TableDescriptor {
128        let kind = info
129            .kind
130            .or_else(|| CollectionKind::for_filetype(&info.filetype))
131            .unwrap_or(CollectionKind::Table);
132        let mut properties = BTreeMap::new();
133        properties.insert("filetype".to_string(), info.filetype.clone());
134        properties.insert("storage_format".to_string(), info.storage_format.clone());
135        properties.insert("rows".to_string(), info.rows.to_string());
136        properties.insert("cols".to_string(), info.cols.to_string());
137        if let Some(nnz) = info.nnz {
138            properties.insert("nnz".to_string(), nnz.to_string());
139        }
140        if let Some(size) = info.size_bytes {
141            properties.insert("size_bytes".to_string(), size.to_string());
142        }
143        // User properties overlay the computed ones; `kind` is always the
144        // authoritative typed value.
145        for (k, v) in &info.properties {
146            properties.insert(k.clone(), v.clone());
147        }
148        properties.insert("kind".to_string(), kind.as_str().to_string());
149        TableDescriptor {
150            name: key.to_string(),
151            format: "lance".to_string(),
152            base_location: self.base.join(&info.filename),
153            kind,
154            properties,
155        }
156    }
157}
158
159impl Catalog for LocalRegistry {
160    fn list_tables(&self) -> StorageResult<Vec<TableDescriptor>> {
161        let mut tables: Vec<TableDescriptor> = self
162            .metadata
163            .files
164            .iter()
165            .map(|(key, info)| self.descriptor(key, info))
166            .collect();
167        tables.sort_by(|a, b| a.name.cmp(&b.name));
168        Ok(tables)
169    }
170
171    fn table_exists(&self, name: &str) -> StorageResult<bool> {
172        Ok(self.metadata.files.contains_key(name))
173    }
174
175    fn describe_table(&self, name: &str) -> StorageResult<TableDescriptor> {
176        let info =
177            self.metadata.files.get(name).ok_or_else(|| {
178                StorageError::Invalid(format!("table '{name}' is not registered"))
179            })?;
180        Ok(self.descriptor(name, info))
181    }
182
183    fn register_table(&mut self, table: TableDescriptor) -> StorageResult<()> {
184        if table.format != "lance" {
185            return Err(StorageError::UnsupportedFormat(format!(
186                "unsupported table format '{}'",
187                table.format
188            )));
189        }
190        let prop = |key: &str| table.properties.get(key).cloned();
191        let parse = |key: &str| -> StorageResult<Option<usize>> {
192            prop(key)
193                .map(|v| {
194                    v.parse::<usize>().map_err(|_| {
195                        StorageError::Invalid(format!(
196                            "property '{key}' = '{v}' is not a valid integer"
197                        ))
198                    })
199                })
200                .transpose()
201        };
202        // RFC #81-P1: an explicit `kind` property wins; otherwise the typed
203        // `kind` field is authoritative. Unknown kinds and disagreements
204        // between the two are rejected (review PR #96) — silently picking
205        // one side would let a property contradict the descriptor.
206        let kind = match prop("kind") {
207            Some(k) => {
208                let prop_kind = CollectionKind::parse(&k)?;
209                if prop_kind != table.kind {
210                    return Err(StorageError::Invalid(format!(
211                        "kind mismatch for table '{}': typed field is '{}', \
212                         'kind' property is '{}'",
213                        table.name,
214                        table.kind.as_str(),
215                        prop_kind.as_str()
216                    )));
217                }
218                Some(prop_kind)
219            }
220            None => Some(table.kind),
221        };
222        // User properties (everything not computed above) are persisted on
223        // the FileInfo so they survive the descriptor -> registry -> describe
224        // round-trip.
225        let user_properties: BTreeMap<String, String> = table
226            .properties
227            .iter()
228            .filter(|(k, _)| !RESERVED_PROPERTIES.contains(&k.as_str()) && k.as_str() != "kind")
229            .map(|(k, v)| (k.clone(), v.clone()))
230            .collect();
231        let mut info = FileInfo::new(
232            table
233                .base_location
234                .file_name()
235                .map(|n| n.to_string_lossy().to_string())
236                .unwrap_or_else(|| {
237                    crate::generations::artifact_file_name(&self.metadata.name_id, &table.name)
238                }),
239            prop("filetype").as_deref().unwrap_or("vector"),
240            (parse("rows")?.unwrap_or(0), parse("cols")?.unwrap_or(0)),
241            parse("nnz")?,
242            None,
243        )?;
244        info.kind = kind;
245        info.properties = user_properties;
246        self.metadata.files.insert(table.name, info);
247        Ok(())
248    }
249
250    fn deregister_table(&mut self, name: &str) -> StorageResult<()> {
251        if self.metadata.files.remove(name).is_none() {
252            return Err(StorageError::Invalid(format!(
253                "table '{name}' is not registered"
254            )));
255        }
256        Ok(())
257    }
258}