Skip to main content

reddb_types/
vector_metadata.rs

1//! Vector-metadata AST leaves (ADR 0053, RQL Phase 2 S4b).
2//!
3//! [`MetadataFilter`] is referenced by the canonical SQL AST
4//! (`VectorQuery.filter`). It and its data dependencies — [`MetadataValue`] and
5//! [`MetadataEntry`] — are re-homed here so the AST resolves entirely against
6//! `reddb-io-types`. Their inherent comparison methods depend only on [`Value`],
7//! the [`canonical_key`](crate::canonical_key) ordering, and
8//! [`partial_compare_values`](crate::value_compare) — all already neutral — so
9//! the move is byte-faithful and does **not** drag the vector engine across.
10//!
11//! The server-side inverted index (`KeyIndex` / `MetadataStore`) stays in
12//! `storage::engine::vector_metadata`, which keeps a re-export shim and
13//! consumes [`metadata_value_to_canonical_key`] from here.
14
15use crate::canonical_key::{value_to_canonical_key, CanonicalKey};
16use crate::types::Value;
17use crate::value_compare::partial_compare_values;
18
19/// A metadata value that can be one of several types
20#[derive(Debug, Clone, PartialEq)]
21pub enum MetadataValue {
22    /// String value
23    String(String),
24    /// Integer value
25    Integer(i64),
26    /// Floating point value
27    Float(f64),
28    /// Boolean value
29    Bool(bool),
30    /// Null value
31    Null,
32}
33
34impl MetadataValue {
35    /// Check if this value matches another for equality
36    pub fn matches_eq(&self, other: &MetadataValue) -> bool {
37        compare_metadata_values(self, other)
38            .map(|ord| ord == std::cmp::Ordering::Equal)
39            .unwrap_or(false)
40    }
41
42    /// Compare for ordering (returns None for incompatible types)
43    pub fn compare(&self, other: &MetadataValue) -> Option<std::cmp::Ordering> {
44        compare_metadata_values(self, other)
45    }
46
47    /// Check if this string value contains a substring
48    pub fn contains_str(&self, needle: &str) -> bool {
49        match self {
50            MetadataValue::String(s) => s.contains(needle),
51            _ => false,
52        }
53    }
54
55    /// Check if this string value starts with a prefix
56    pub fn starts_with(&self, prefix: &str) -> bool {
57        match self {
58            MetadataValue::String(s) => s.starts_with(prefix),
59            _ => false,
60        }
61    }
62
63    /// Check if this string value ends with a suffix
64    pub fn ends_with(&self, suffix: &str) -> bool {
65        match self {
66            MetadataValue::String(s) => s.ends_with(suffix),
67            _ => false,
68        }
69    }
70}
71
72impl From<String> for MetadataValue {
73    fn from(s: String) -> Self {
74        MetadataValue::String(s)
75    }
76}
77
78impl From<&str> for MetadataValue {
79    fn from(s: &str) -> Self {
80        MetadataValue::String(s.to_string())
81    }
82}
83
84impl From<i64> for MetadataValue {
85    fn from(i: i64) -> Self {
86        MetadataValue::Integer(i)
87    }
88}
89
90impl From<i32> for MetadataValue {
91    fn from(i: i32) -> Self {
92        MetadataValue::Integer(i as i64)
93    }
94}
95
96impl From<f64> for MetadataValue {
97    fn from(f: f64) -> Self {
98        MetadataValue::Float(f)
99    }
100}
101
102impl From<f32> for MetadataValue {
103    fn from(f: f32) -> Self {
104        MetadataValue::Float(f as f64)
105    }
106}
107
108impl From<bool> for MetadataValue {
109    fn from(b: bool) -> Self {
110        MetadataValue::Bool(b)
111    }
112}
113
114fn metadata_value_to_storage_value(value: &MetadataValue) -> Value {
115    match value {
116        MetadataValue::String(s) => Value::text(s.clone()),
117        MetadataValue::Integer(i) => Value::Integer(*i),
118        MetadataValue::Float(f) => Value::Float(*f),
119        MetadataValue::Bool(b) => Value::Boolean(*b),
120        MetadataValue::Null => Value::Null,
121    }
122}
123
124/// Map a [`MetadataValue`] to its canonical secondary-index key, when the
125/// value participates in the ordered index. Consumed by the server-side
126/// inverted index (`KeyIndex`).
127pub fn metadata_value_to_canonical_key(value: &MetadataValue) -> Option<CanonicalKey> {
128    let storage_value = metadata_value_to_storage_value(value);
129    value_to_canonical_key(&storage_value)
130}
131
132fn compare_metadata_values(
133    left: &MetadataValue,
134    right: &MetadataValue,
135) -> Option<std::cmp::Ordering> {
136    let left_value = metadata_value_to_storage_value(left);
137    let right_value = metadata_value_to_storage_value(right);
138    partial_compare_values(&left_value, &right_value).or_else(|| {
139        let left_key = value_to_canonical_key(&left_value)?;
140        let right_key = value_to_canonical_key(&right_value)?;
141        (left_key.family() == right_key.family()).then(|| left_key.cmp(&right_key))
142    })
143}
144
145/// A metadata entry containing key-value pairs organized by type
146#[derive(Debug, Clone, Default)]
147pub struct MetadataEntry {
148    /// String metadata values
149    pub strings: std::collections::HashMap<String, String>,
150    /// Integer metadata values
151    pub integers: std::collections::HashMap<String, i64>,
152    /// Float metadata values
153    pub floats: std::collections::HashMap<String, f64>,
154    /// Boolean metadata values
155    pub bools: std::collections::HashMap<String, bool>,
156}
157
158impl MetadataEntry {
159    /// Create a new empty metadata entry
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Insert a metadata value
165    pub fn insert(&mut self, key: impl Into<String>, value: MetadataValue) {
166        let key = key.into();
167        match value {
168            MetadataValue::String(s) => {
169                self.strings.insert(key, s);
170            }
171            MetadataValue::Integer(i) => {
172                self.integers.insert(key, i);
173            }
174            MetadataValue::Float(f) => {
175                self.floats.insert(key, f);
176            }
177            MetadataValue::Bool(b) => {
178                self.bools.insert(key, b);
179            }
180            MetadataValue::Null => {
181                // Remove from all maps
182                self.strings.remove(&key);
183                self.integers.remove(&key);
184                self.floats.remove(&key);
185                self.bools.remove(&key);
186            }
187        }
188    }
189
190    /// Get a metadata value by key
191    pub fn get(&self, key: &str) -> Option<MetadataValue> {
192        if let Some(s) = self.strings.get(key) {
193            return Some(MetadataValue::String(s.clone()));
194        }
195        if let Some(i) = self.integers.get(key) {
196            return Some(MetadataValue::Integer(*i));
197        }
198        if let Some(f) = self.floats.get(key) {
199            return Some(MetadataValue::Float(*f));
200        }
201        if let Some(b) = self.bools.get(key) {
202            return Some(MetadataValue::Bool(*b));
203        }
204        None
205    }
206
207    /// Check if a key exists
208    pub fn contains_key(&self, key: &str) -> bool {
209        self.strings.contains_key(key)
210            || self.integers.contains_key(key)
211            || self.floats.contains_key(key)
212            || self.bools.contains_key(key)
213    }
214
215    /// Get all keys
216    pub fn keys(&self) -> Vec<String> {
217        let mut keys: Vec<String> = Vec::new();
218        keys.extend(self.strings.keys().cloned());
219        keys.extend(self.integers.keys().cloned());
220        keys.extend(self.floats.keys().cloned());
221        keys.extend(self.bools.keys().cloned());
222        keys
223    }
224
225    /// Check if empty
226    pub fn is_empty(&self) -> bool {
227        self.strings.is_empty()
228            && self.integers.is_empty()
229            && self.floats.is_empty()
230            && self.bools.is_empty()
231    }
232}
233
234/// Metadata filter operators
235#[derive(Debug, Clone)]
236pub enum MetadataFilter {
237    /// Equal: key == value
238    Eq(String, MetadataValue),
239    /// Not equal: key != value
240    Ne(String, MetadataValue),
241    /// Greater than: key > value
242    Gt(String, MetadataValue),
243    /// Greater than or equal: key >= value
244    Gte(String, MetadataValue),
245    /// Less than: key < value
246    Lt(String, MetadataValue),
247    /// Less than or equal: key <= value
248    Lte(String, MetadataValue),
249    /// In set: key in [values]
250    In(String, Vec<MetadataValue>),
251    /// Not in set: key not in [values]
252    NotIn(String, Vec<MetadataValue>),
253    /// String contains: key contains substring
254    Contains(String, String),
255    /// String starts with: key starts with prefix
256    StartsWith(String, String),
257    /// String ends with: key ends with suffix
258    EndsWith(String, String),
259    /// Key exists
260    Exists(String),
261    /// Key does not exist
262    NotExists(String),
263    /// Logical AND of filters
264    And(Vec<MetadataFilter>),
265    /// Logical OR of filters
266    Or(Vec<MetadataFilter>),
267    /// Logical NOT of filter
268    Not(Box<MetadataFilter>),
269}
270
271impl MetadataFilter {
272    /// Create an equality filter
273    pub fn eq(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
274        MetadataFilter::Eq(key.into(), value.into())
275    }
276
277    /// Create a not-equal filter
278    pub fn ne(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
279        MetadataFilter::Ne(key.into(), value.into())
280    }
281
282    /// Create a greater-than filter
283    pub fn gt(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
284        MetadataFilter::Gt(key.into(), value.into())
285    }
286
287    /// Create a greater-than-or-equal filter
288    pub fn gte(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
289        MetadataFilter::Gte(key.into(), value.into())
290    }
291
292    /// Create a less-than filter
293    pub fn lt(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
294        MetadataFilter::Lt(key.into(), value.into())
295    }
296
297    /// Create a less-than-or-equal filter
298    pub fn lte(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
299        MetadataFilter::Lte(key.into(), value.into())
300    }
301
302    /// Create an AND filter
303    pub fn and(filters: Vec<MetadataFilter>) -> Self {
304        MetadataFilter::And(filters)
305    }
306
307    /// Create an OR filter
308    pub fn or(filters: Vec<MetadataFilter>) -> Self {
309        MetadataFilter::Or(filters)
310    }
311
312    /// Create a NOT filter
313    // Constructor wrapping a value in `MetadataFilter::Not`; unrelated to
314    // `std::ops::Not`, so that trait is intentionally not implemented.
315    #[allow(clippy::should_implement_trait)]
316    pub fn not(filter: MetadataFilter) -> Self {
317        MetadataFilter::Not(Box::new(filter))
318    }
319
320    /// Check if a metadata entry matches this filter
321    pub fn matches(&self, entry: &MetadataEntry) -> bool {
322        match self {
323            MetadataFilter::Eq(key, value) => {
324                entry.get(key).map(|v| v.matches_eq(value)).unwrap_or(false)
325            }
326            MetadataFilter::Ne(key, value) => {
327                entry.get(key).map(|v| !v.matches_eq(value)).unwrap_or(true)
328            }
329            MetadataFilter::Gt(key, value) => entry
330                .get(key)
331                .and_then(|v| v.compare(value))
332                .map(|ord| ord == std::cmp::Ordering::Greater)
333                .unwrap_or(false),
334            MetadataFilter::Gte(key, value) => entry
335                .get(key)
336                .and_then(|v| v.compare(value))
337                .map(|ord| ord != std::cmp::Ordering::Less)
338                .unwrap_or(false),
339            MetadataFilter::Lt(key, value) => entry
340                .get(key)
341                .and_then(|v| v.compare(value))
342                .map(|ord| ord == std::cmp::Ordering::Less)
343                .unwrap_or(false),
344            MetadataFilter::Lte(key, value) => entry
345                .get(key)
346                .and_then(|v| v.compare(value))
347                .map(|ord| ord != std::cmp::Ordering::Greater)
348                .unwrap_or(false),
349            MetadataFilter::In(key, values) => entry
350                .get(key)
351                .map(|v| values.iter().any(|val| v.matches_eq(val)))
352                .unwrap_or(false),
353            MetadataFilter::NotIn(key, values) => entry
354                .get(key)
355                .map(|v| !values.iter().any(|val| v.matches_eq(val)))
356                .unwrap_or(true),
357            MetadataFilter::Contains(key, needle) => entry
358                .get(key)
359                .map(|v| v.contains_str(needle))
360                .unwrap_or(false),
361            MetadataFilter::StartsWith(key, prefix) => entry
362                .get(key)
363                .map(|v| v.starts_with(prefix))
364                .unwrap_or(false),
365            MetadataFilter::EndsWith(key, suffix) => {
366                entry.get(key).map(|v| v.ends_with(suffix)).unwrap_or(false)
367            }
368            MetadataFilter::Exists(key) => entry.contains_key(key),
369            MetadataFilter::NotExists(key) => !entry.contains_key(key),
370            MetadataFilter::And(filters) => filters.iter().all(|f| f.matches(entry)),
371            MetadataFilter::Or(filters) => filters.iter().any(|f| f.matches(entry)),
372            MetadataFilter::Not(filter) => !filter.matches(entry),
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use std::cmp::Ordering;
381
382    #[test]
383    fn metadata_values_compare_and_match_by_type() {
384        assert!(MetadataValue::from("red database").contains_str("data"));
385        assert!(MetadataValue::from("red database").starts_with("red"));
386        assert!(MetadataValue::from("red database").ends_with("base"));
387        assert!(!MetadataValue::from(42_i64).contains_str("42"));
388
389        assert!(MetadataValue::from(10_i64).matches_eq(&MetadataValue::from(10_i64)));
390        assert!(!MetadataValue::from(10_i64).matches_eq(&MetadataValue::from(11_i64)));
391        assert_eq!(
392            MetadataValue::from(10_i64).compare(&MetadataValue::from(11_i64)),
393            Some(Ordering::Less)
394        );
395        assert_eq!(
396            MetadataValue::from(true).compare(&MetadataValue::from("true")),
397            None
398        );
399    }
400
401    #[test]
402    fn metadata_values_convert_to_canonical_keys() {
403        assert!(metadata_value_to_canonical_key(&MetadataValue::from("alpha")).is_some());
404        assert!(metadata_value_to_canonical_key(&MetadataValue::from(7_i64)).is_some());
405        assert!(metadata_value_to_canonical_key(&MetadataValue::from(1.5_f64)).is_some());
406        assert!(metadata_value_to_canonical_key(&MetadataValue::from(true)).is_some());
407    }
408
409    #[test]
410    fn metadata_entry_inserts_gets_keys_and_removes_nulls() {
411        let mut entry = MetadataEntry::new();
412        assert!(entry.is_empty());
413
414        entry.insert("title", MetadataValue::from("Graph Guide"));
415        entry.insert("pages", MetadataValue::from(100_i64));
416        entry.insert("score", MetadataValue::from(0.75_f64));
417        entry.insert("published", MetadataValue::from(true));
418
419        assert_eq!(entry.get("title"), Some(MetadataValue::from("Graph Guide")));
420        assert_eq!(entry.get("pages"), Some(MetadataValue::from(100_i64)));
421        assert_eq!(entry.get("score"), Some(MetadataValue::from(0.75_f64)));
422        assert_eq!(entry.get("published"), Some(MetadataValue::from(true)));
423        assert!(entry.contains_key("title"));
424        assert!(!entry.is_empty());
425
426        let mut keys = entry.keys();
427        keys.sort();
428        assert_eq!(keys, vec!["pages", "published", "score", "title"]);
429
430        entry.insert("title", MetadataValue::Null);
431        assert_eq!(entry.get("title"), None);
432        assert!(!entry.contains_key("title"));
433    }
434
435    #[test]
436    fn metadata_filters_cover_comparison_membership_and_strings() {
437        let mut entry = MetadataEntry::new();
438        entry.insert("title", MetadataValue::from("Graph Guide"));
439        entry.insert("pages", MetadataValue::from(100_i64));
440
441        assert!(MetadataFilter::eq("title", "Graph Guide").matches(&entry));
442        assert!(!MetadataFilter::eq("title", "Other").matches(&entry));
443        assert!(MetadataFilter::ne("title", "Other").matches(&entry));
444        assert!(MetadataFilter::ne("missing", "anything").matches(&entry));
445
446        assert!(MetadataFilter::gt("pages", 99_i64).matches(&entry));
447        assert!(MetadataFilter::gte("pages", 100_i64).matches(&entry));
448        assert!(MetadataFilter::lt("pages", 101_i64).matches(&entry));
449        assert!(MetadataFilter::lte("pages", 100_i64).matches(&entry));
450        assert!(!MetadataFilter::gt("missing", 1_i64).matches(&entry));
451
452        assert!(MetadataFilter::In(
453            "pages".to_string(),
454            vec![MetadataValue::from(1_i64), MetadataValue::from(100_i64)]
455        )
456        .matches(&entry));
457        assert!(MetadataFilter::NotIn(
458            "pages".to_string(),
459            vec![MetadataValue::from(1_i64), MetadataValue::from(2_i64)]
460        )
461        .matches(&entry));
462        assert!(
463            MetadataFilter::NotIn("missing".to_string(), vec![MetadataValue::from(1_i64)])
464                .matches(&entry)
465        );
466
467        assert!(MetadataFilter::Contains("title".to_string(), "Guide".to_string()).matches(&entry));
468        assert!(
469            MetadataFilter::StartsWith("title".to_string(), "Graph".to_string()).matches(&entry)
470        );
471        assert!(MetadataFilter::EndsWith("title".to_string(), "Guide".to_string()).matches(&entry));
472    }
473
474    #[test]
475    fn metadata_filters_cover_existence_and_boolean_composition() {
476        let mut entry = MetadataEntry::new();
477        entry.insert("title", MetadataValue::from("Graph Guide"));
478        entry.insert("pages", MetadataValue::from(100_i64));
479
480        assert!(MetadataFilter::Exists("title".to_string()).matches(&entry));
481        assert!(MetadataFilter::NotExists("missing".to_string()).matches(&entry));
482        assert!(MetadataFilter::and(vec![
483            MetadataFilter::eq("title", "Graph Guide"),
484            MetadataFilter::gte("pages", 100_i64),
485        ])
486        .matches(&entry));
487        assert!(MetadataFilter::or(vec![
488            MetadataFilter::eq("title", "Other"),
489            MetadataFilter::eq("pages", 100_i64),
490        ])
491        .matches(&entry));
492        assert!(MetadataFilter::not(MetadataFilter::eq("title", "Other")).matches(&entry));
493    }
494}