Skip to main content

velesdb_core/database/
collection_ops.rs

1//! Collection CRUD dispatcher: create, delete, list, get, and diagnostics.
2//!
3//! Type-specific operations are in sibling modules:
4//! - [`vector_ops`](super::vector_ops) — vector collection create/get
5//! - [`graph_ops`](super::graph_ops) — graph collection create/get
6//! - [`metadata_ops`](super::metadata_ops) — metadata-only collection create/get
7
8use crate::collection::AnyCollection;
9use crate::{CollectionType, DistanceMetric, Error, Result, StorageMode};
10
11use super::Database;
12
13impl Database {
14    /// Ensures a collection name is valid, free in memory, and free on disk.
15    ///
16    /// Validates the name against path traversal and forbidden characters
17    /// **before** any filesystem operation, then checks that no collection
18    /// with the same name already exists in any registry or on disk, and
19    /// finally enforces the `LimitsConfig::max_collections` cap so that
20    /// callers are refused cleanly instead of filling the registry past
21    /// the configured ceiling.
22    pub(super) fn ensure_collection_name_available(&self, name: &str) -> Result<()> {
23        crate::validation::validate_collection_name(name)?;
24
25        if self.collection_exists_in_registry(name) {
26            return Err(Error::CollectionExists(name.to_string()));
27        }
28
29        let collection_path = self.data_dir.join(name);
30        if collection_path.exists() {
31            return Err(Error::CollectionExists(name.to_string()));
32        }
33
34        // Wave 3 Commit 7 — enforce `LimitsConfig::max_collections`.
35        //
36        // Counted across every typed registry (vector + graph + metadata)
37        // because the limit is tenant-wide, not per-type. Evaluated after
38        // the name validation and duplicate checks so the typed error
39        // precedence stays unchanged: invalid name and duplicate still
40        // win over the cap — callers that want to detect "too many
41        // collections" specifically rely on the `GuardRail` variant.
42        let total_collections = self.vector_colls.read().len()
43            + self.graph_colls.read().len()
44            + self.metadata_colls.read().len();
45        let cap = self.config.limits.max_collections;
46        if total_collections >= cap {
47            return Err(Error::GuardRail(format!(
48                "max_collections limit reached ({total_collections} / {cap}); \
49                 raise `limits.max_collections` in VelesConfig to create more"
50            )));
51        }
52
53        Ok(())
54    }
55
56    /// Pushes the live [`LimitsConfig`](crate::config::LimitsConfig) ingest/
57    /// search caps into a collection (parity item E).
58    ///
59    /// Single helper reused by the vector / graph / metadata registration and
60    /// disk-open paths so all three thread the same runtime limits into the
61    /// `Collection`. The limits are **not** persisted to `config.json`: they
62    /// are re-pushed on every open from the live `VelesConfig`.
63    pub(super) fn push_runtime_limits(&self, coll: &crate::collection::Collection) {
64        coll.set_runtime_limits(crate::collection::RuntimeLimits::from_config(
65            &self.config.limits,
66        ));
67        // `[search]` alongside `[limits]`, same contract: resolved here because
68        // `Database` is the only component holding a `VelesConfig`, pushed on
69        // every open, never persisted (#2087).
70        coll.set_runtime_search_quality(self.config.search.resolved_quality());
71    }
72
73    /// Checks whether a collection name exists in any of the typed registries.
74    fn collection_exists_in_registry(&self, name: &str) -> bool {
75        self.vector_colls.read().contains_key(name)
76            || self.graph_colls.read().contains_key(name)
77            || self.metadata_colls.read().contains_key(name)
78    }
79
80    /// Enforces `LimitsConfig::max_dimensions` on a prospective vector
81    /// collection creation.
82    ///
83    /// Complements [`crate::validation::validate_dimension`] (the static
84    /// `65_536` hard ceiling): the config-driven limit is typically tighter
85    /// — 4096 by default — and is consulted here so the guard-rail can
86    /// be relaxed per tenant via [`Database::open_with_config`] without
87    /// touching the static constant.
88    ///
89    /// Dimension `0` is accepted because it is the sentinel used by
90    /// metadata-only and graph-without-embeddings collections. Callers
91    /// that need to reject zero should do so upstream via
92    /// [`crate::validation::validate_dimension`].
93    pub(super) fn enforce_vector_dimension_limit(&self, dimension: usize) -> Result<()> {
94        if dimension == 0 {
95            return Ok(());
96        }
97        let cap = self.config.limits.max_dimensions;
98        if dimension > cap {
99            return Err(Error::GuardRail(format!(
100                "vector dimension {dimension} exceeds configured max_dimensions cap of {cap}; \
101                 raise `limits.max_dimensions` in VelesConfig to allow larger vectors"
102            )));
103        }
104        Ok(())
105    }
106
107    /// Creates a new collection with the specified parameters.
108    ///
109    /// # Arguments
110    ///
111    /// * `name` - Unique name for the collection
112    /// * `dimension` - Vector dimension (e.g., 768 for many embedding models)
113    /// * `metric` - Distance metric to use for similarity calculations
114    ///
115    /// # Errors
116    ///
117    /// - Returns `Error::CollectionExists` if a collection with the same name already exists.
118    /// - Returns an error if the directory cannot be created or storage initialization fails.
119    ///
120    /// # Examples
121    ///
122    /// ```rust,no_run
123    /// # use velesdb_core::{Database, DistanceMetric};
124    /// let db = Database::open("./data")?;
125    /// db.create_collection("documents", 768, DistanceMetric::Cosine)?;
126    /// # Ok::<(), velesdb_core::Error>(())
127    /// ```
128    pub fn create_collection(
129        &self,
130        name: &str,
131        dimension: usize,
132        metric: DistanceMetric,
133    ) -> Result<()> {
134        self.create_collection_with_options(name, dimension, metric, StorageMode::default())
135    }
136
137    /// Creates a new collection with custom storage options.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if a collection with the same name already exists.
142    pub fn create_collection_with_options(
143        &self,
144        name: &str,
145        dimension: usize,
146        metric: DistanceMetric,
147        storage_mode: StorageMode,
148    ) -> Result<()> {
149        self.create_vector_collection_with_options(name, dimension, metric, storage_mode)
150    }
151
152    /// Returns a type-erased collection handle by name.
153    ///
154    /// Checks vector → graph → metadata registries in order.
155    /// Returns `None` if no collection with the given name exists.
156    #[must_use]
157    pub fn get_any_collection(&self, name: &str) -> Option<AnyCollection> {
158        if let Some(c) = self.get_vector_collection(name) {
159            return Some(AnyCollection::Vector(c));
160        }
161        if let Some(c) = self.get_graph_collection(name) {
162            return Some(AnyCollection::Graph(c));
163        }
164        if let Some(c) = self.get_metadata_collection(name) {
165            return Some(AnyCollection::Metadata(c));
166        }
167        None
168    }
169
170    /// Returns the write generation for a named collection, if it exists.
171    #[must_use]
172    pub fn collection_write_generation(&self, name: &str) -> Option<u64> {
173        if let Some(vc) = self.vector_colls.read().get(name) {
174            return Some(vc.inner.write_generation());
175        }
176        if let Some(gc) = self.graph_colls.read().get(name) {
177            return Some(gc.inner.write_generation());
178        }
179        if let Some(mc) = self.metadata_colls.read().get(name) {
180            return Some(mc.inner.write_generation());
181        }
182        None
183    }
184
185    /// Returns the set of payload field names covered by a secondary index
186    /// for the named collection (issue #607). Empty set when the collection
187    /// has no indexes or does not exist.
188    ///
189    /// Used by `Database::build_plan_with_stats` to thread the real
190    /// indexed-field set into `QueryPlan::from_query_with_stats` so that
191    /// `IndexLookup` plan nodes are generated in the EXPLAIN tree when a
192    /// WHERE clause targets an indexed column.
193    #[must_use]
194    pub fn indexed_fields_for(&self, name: &str) -> std::collections::HashSet<String> {
195        if let Some(vc) = self.vector_colls.read().get(name) {
196            return vc.inner.indexed_field_names();
197        }
198        if let Some(gc) = self.graph_colls.read().get(name) {
199            return gc.inner.indexed_field_names();
200        }
201        if let Some(mc) = self.metadata_colls.read().get(name) {
202            return mc.inner.indexed_field_names();
203        }
204        std::collections::HashSet::new()
205    }
206
207    /// Returns the live graph `CollectionStats` (node/edge counts, average
208    /// degree, label selectivity) for the named collection, used by the
209    /// `MatchQueryPlanner` to choose a traversal strategy in EXPLAIN of a
210    /// MATCH query (backlog #14). `None` when the collection does not exist.
211    #[must_use]
212    pub(crate) fn match_stats_for(
213        &self,
214        name: &str,
215    ) -> Option<crate::velesql::match_planner::MatchGraphStats> {
216        if let Some(vc) = self.vector_colls.read().get(name) {
217            return Some(vc.inner.compute_match_collection_stats());
218        }
219        if let Some(gc) = self.graph_colls.read().get(name) {
220            return Some(gc.inner.compute_match_collection_stats());
221        }
222        if let Some(mc) = self.metadata_colls.read().get(name) {
223            return Some(mc.inner.compute_match_collection_stats());
224        }
225        None
226    }
227
228    /// Returns the analyze generation for a named collection, if it exists
229    /// (issue #608).
230    ///
231    /// Parallel to [`Self::collection_write_generation`], but tracks `ANALYZE`
232    /// invocations instead of data mutations. Threaded into the compiled plan
233    /// cache key so that an `ANALYZE` run alone invalidates cached plans whose
234    /// cost estimates pre-date the fresh calibrated statistics.
235    #[must_use]
236    pub fn collection_analyze_generation(&self, name: &str) -> Option<u64> {
237        if let Some(vc) = self.vector_colls.read().get(name) {
238            return Some(vc.inner.analyze_generation());
239        }
240        if let Some(gc) = self.graph_colls.read().get(name) {
241            return Some(gc.inner.analyze_generation());
242        }
243        if let Some(mc) = self.metadata_colls.read().get(name) {
244            return Some(mc.inner.analyze_generation());
245        }
246        None
247    }
248
249    /// Renders every graph collection's metrics as a Prometheus exposition.
250    ///
251    /// One `GraphMetrics` exists per edge store, so the samples are tagged
252    /// with the collection they came from and each family is declared once;
253    /// see `collection::graph::graph_metrics_to_prometheus`. Returns an empty
254    /// string when the database holds no graph collection.
255    #[must_use]
256    pub fn graph_metrics_prometheus(&self) -> String {
257        let graph_colls = self.graph_colls.read();
258        let mut sorted: Vec<_> = graph_colls
259            .iter()
260            .map(|(name, coll)| (name.as_str(), coll.metrics()))
261            .collect();
262        // Stable output: a scrape diff should reflect metric movement, not
263        // HashMap iteration order.
264        sorted.sort_by_key(|(name, _)| *name);
265        let rendered = crate::collection::graph::graph_metrics_to_prometheus(&sorted);
266        // `sorted` borrows names out of the guard, so it goes first.
267        drop(sorted);
268        drop(graph_colls);
269        rendered
270    }
271
272    /// Renders the process-wide MATCH query metrics as a Prometheus
273    /// exposition (EPIC-050 US-002).
274    ///
275    /// Unlike graph metrics, MATCH metrics are not tagged per collection:
276    /// `MATCH` queries currently record into one global collector regardless
277    /// of which collection they touch (`match_metrics::global_match_metrics`).
278    #[must_use]
279    pub fn match_metrics_prometheus(&self) -> String {
280        crate::collection::search::query::match_metrics::global_match_metrics().to_prometheus()
281    }
282
283    /// Lists all collection names in the database.
284    ///
285    /// Includes collections created via any typed API (vector, graph, metadata).
286    pub fn list_collections(&self) -> Vec<String> {
287        let vector_colls = self.vector_colls.read();
288        let graph_colls = self.graph_colls.read();
289        let metadata_colls = self.metadata_colls.read();
290
291        let mut names: std::collections::HashSet<String> = vector_colls.keys().cloned().collect();
292        for k in graph_colls.keys() {
293            names.insert(k.clone());
294        }
295        for k in metadata_colls.keys() {
296            names.insert(k.clone());
297        }
298        // Every key is cloned, so the three maps are read as one consistent
299        // snapshot and then released together before the sort below.
300        drop(metadata_colls);
301        drop(graph_colls);
302        drop(vector_colls);
303        let mut result: Vec<String> = names.into_iter().collect();
304        result.sort();
305        result
306    }
307
308    /// Deletes a collection by name.
309    ///
310    /// # Errors
311    ///
312    /// Returns an error if the name is invalid or the collection does not
313    /// exist in any registry.
314    pub fn delete_collection(&self, name: &str) -> Result<()> {
315        crate::validation::validate_collection_name(name)?;
316
317        if !self.collection_exists_in_registry(name) {
318            return Err(Error::CollectionNotFound(name.to_string()));
319        }
320
321        let collection_path = self.data_dir.join(name);
322        if collection_path.exists() {
323            std::fs::remove_dir_all(&collection_path)?;
324        }
325
326        self.remove_from_all_registries(name);
327
328        if let Some(ref obs) = self.observer {
329            obs.on_collection_deleted(name);
330        }
331
332        self.schema_version
333            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
334
335        Ok(())
336    }
337
338    /// Removes a collection from all registries and stats cache.
339    fn remove_from_all_registries(&self, name: &str) {
340        self.vector_colls.write().remove(name);
341        self.graph_colls.write().remove(name);
342        self.metadata_colls.write().remove(name);
343        self.collection_stats.write().remove(name);
344        self.join_store_cache.write().remove(name);
345    }
346
347    /// Creates a new collection with a specific type (Vector, Graph, or `MetadataOnly`).
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if a collection with the same name already exists.
352    pub fn create_collection_typed(
353        &self,
354        name: &str,
355        collection_type: &CollectionType,
356    ) -> Result<()> {
357        match collection_type {
358            CollectionType::Vector {
359                dimension,
360                metric,
361                storage_mode,
362            } => {
363                self.create_vector_collection_with_options(name, *dimension, *metric, *storage_mode)
364            }
365            CollectionType::MetadataOnly => self.create_metadata_collection(name),
366            CollectionType::Graph {
367                dimension,
368                metric,
369                schema,
370            } => self.create_graph_collection_from_type(name, *dimension, *metric, schema),
371        }
372    }
373
374    /// Reads and parses `config.json` from a collection directory.
375    ///
376    /// Returns `None` if the name is invalid, the config file does not exist,
377    /// or the config cannot be parsed.
378    pub(super) fn read_collection_config(
379        &self,
380        name: &str,
381    ) -> Option<crate::collection::CollectionConfig> {
382        if crate::validation::validate_collection_name(name).is_err() {
383            return None;
384        }
385        let path = self.data_dir.join(name);
386        let config_path = path.join("config.json");
387        if !config_path.exists() {
388            return None;
389        }
390        let data = std::fs::read_to_string(&config_path).ok()?;
391        serde_json::from_str(&data).ok()
392    }
393
394    /// Propagates updated query limits to all active collections.
395    pub fn update_guardrails(&self, limits: &crate::guardrails::QueryLimits) {
396        for vc in self.vector_colls.read().values() {
397            vc.guard_rails().update_limits(limits);
398        }
399        for gc in self.graph_colls.read().values() {
400            gc.inner.guard_rails().update_limits(limits);
401        }
402        for mc in self.metadata_colls.read().values() {
403            mc.inner.guard_rails().update_limits(limits);
404        }
405    }
406
407    /// Returns diagnostics for a named collection.
408    ///
409    /// # Errors
410    ///
411    /// Returns `Error::CollectionNotFound` if the collection does not exist.
412    pub fn collection_diagnostics(
413        &self,
414        name: &str,
415    ) -> Result<crate::collection::CollectionDiagnostics> {
416        if let Some(c) = self.get_vector_collection(name) {
417            return Ok(c.diagnostics());
418        }
419        if let Some(c) = self.get_graph_collection(name) {
420            return Ok(c.diagnostics());
421        }
422        if let Some(c) = self.get_metadata_collection(name) {
423            return Ok(c.diagnostics());
424        }
425        Err(Error::CollectionNotFound(name.to_string()))
426    }
427}