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    /// Geographic radius predicate over a metadata field.
260    GeoRadius {
261        key: String,
262        center_lat: f64,
263        center_lon: f64,
264        radius_km: f64,
265    },
266    /// Key exists
267    Exists(String),
268    /// Key does not exist
269    NotExists(String),
270    /// Logical AND of filters
271    And(Vec<MetadataFilter>),
272    /// Logical OR of filters
273    Or(Vec<MetadataFilter>),
274    /// Logical NOT of filter
275    Not(Box<MetadataFilter>),
276}
277
278impl MetadataFilter {
279    /// Create an equality filter
280    pub fn eq(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
281        MetadataFilter::Eq(key.into(), value.into())
282    }
283
284    /// Create a not-equal filter
285    pub fn ne(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
286        MetadataFilter::Ne(key.into(), value.into())
287    }
288
289    /// Create a greater-than filter
290    pub fn gt(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
291        MetadataFilter::Gt(key.into(), value.into())
292    }
293
294    /// Create a greater-than-or-equal filter
295    pub fn gte(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
296        MetadataFilter::Gte(key.into(), value.into())
297    }
298
299    /// Create a less-than filter
300    pub fn lt(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
301        MetadataFilter::Lt(key.into(), value.into())
302    }
303
304    /// Create a less-than-or-equal filter
305    pub fn lte(key: impl Into<String>, value: impl Into<MetadataValue>) -> Self {
306        MetadataFilter::Lte(key.into(), value.into())
307    }
308
309    /// Create an AND filter
310    pub fn and(filters: Vec<MetadataFilter>) -> Self {
311        MetadataFilter::And(filters)
312    }
313
314    /// Create an OR filter
315    pub fn or(filters: Vec<MetadataFilter>) -> Self {
316        MetadataFilter::Or(filters)
317    }
318
319    /// Create a NOT filter
320    // Constructor wrapping a value in `MetadataFilter::Not`; unrelated to
321    // `std::ops::Not`, so that trait is intentionally not implemented.
322    #[allow(clippy::should_implement_trait)]
323    pub fn not(filter: MetadataFilter) -> Self {
324        MetadataFilter::Not(Box::new(filter))
325    }
326
327    /// Check if a metadata entry matches this filter
328    pub fn matches(&self, entry: &MetadataEntry) -> bool {
329        match self {
330            MetadataFilter::Eq(key, value) => {
331                entry.get(key).map(|v| v.matches_eq(value)).unwrap_or(false)
332            }
333            MetadataFilter::Ne(key, value) => {
334                entry.get(key).map(|v| !v.matches_eq(value)).unwrap_or(true)
335            }
336            MetadataFilter::Gt(key, value) => entry
337                .get(key)
338                .and_then(|v| v.compare(value))
339                .map(|ord| ord == std::cmp::Ordering::Greater)
340                .unwrap_or(false),
341            MetadataFilter::Gte(key, value) => entry
342                .get(key)
343                .and_then(|v| v.compare(value))
344                .map(|ord| ord != std::cmp::Ordering::Less)
345                .unwrap_or(false),
346            MetadataFilter::Lt(key, value) => entry
347                .get(key)
348                .and_then(|v| v.compare(value))
349                .map(|ord| ord == std::cmp::Ordering::Less)
350                .unwrap_or(false),
351            MetadataFilter::Lte(key, value) => entry
352                .get(key)
353                .and_then(|v| v.compare(value))
354                .map(|ord| ord != std::cmp::Ordering::Greater)
355                .unwrap_or(false),
356            MetadataFilter::In(key, values) => entry
357                .get(key)
358                .map(|v| values.iter().any(|val| v.matches_eq(val)))
359                .unwrap_or(false),
360            MetadataFilter::NotIn(key, values) => entry
361                .get(key)
362                .map(|v| !values.iter().any(|val| v.matches_eq(val)))
363                .unwrap_or(true),
364            MetadataFilter::Contains(key, needle) => entry
365                .get(key)
366                .map(|v| v.contains_str(needle))
367                .unwrap_or(false),
368            MetadataFilter::StartsWith(key, prefix) => entry
369                .get(key)
370                .map(|v| v.starts_with(prefix))
371                .unwrap_or(false),
372            MetadataFilter::EndsWith(key, suffix) => {
373                entry.get(key).map(|v| v.ends_with(suffix)).unwrap_or(false)
374            }
375            MetadataFilter::GeoRadius { .. } => false,
376            MetadataFilter::Exists(key) => entry.contains_key(key),
377            MetadataFilter::NotExists(key) => !entry.contains_key(key),
378            MetadataFilter::And(filters) => filters.iter().all(|f| f.matches(entry)),
379            MetadataFilter::Or(filters) => filters.iter().any(|f| f.matches(entry)),
380            MetadataFilter::Not(filter) => !filter.matches(entry),
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use std::cmp::Ordering;
389
390    #[test]
391    fn metadata_values_compare_and_match_by_type() {
392        assert!(MetadataValue::from("red database").contains_str("data"));
393        assert!(MetadataValue::from("red database").starts_with("red"));
394        assert!(MetadataValue::from("red database").ends_with("base"));
395        assert!(!MetadataValue::from(42_i64).contains_str("42"));
396
397        assert!(MetadataValue::from(10_i64).matches_eq(&MetadataValue::from(10_i64)));
398        assert!(!MetadataValue::from(10_i64).matches_eq(&MetadataValue::from(11_i64)));
399        assert_eq!(
400            MetadataValue::from(10_i64).compare(&MetadataValue::from(11_i64)),
401            Some(Ordering::Less)
402        );
403        assert_eq!(
404            MetadataValue::from(true).compare(&MetadataValue::from("true")),
405            None
406        );
407    }
408
409    #[test]
410    fn metadata_values_convert_to_canonical_keys() {
411        assert!(metadata_value_to_canonical_key(&MetadataValue::from("alpha")).is_some());
412        assert!(metadata_value_to_canonical_key(&MetadataValue::from(7_i64)).is_some());
413        assert!(metadata_value_to_canonical_key(&MetadataValue::from(1.5_f64)).is_some());
414        assert!(metadata_value_to_canonical_key(&MetadataValue::from(true)).is_some());
415    }
416
417    #[test]
418    fn metadata_entry_inserts_gets_keys_and_removes_nulls() {
419        let mut entry = MetadataEntry::new();
420        assert!(entry.is_empty());
421
422        entry.insert("title", MetadataValue::from("Graph Guide"));
423        entry.insert("pages", MetadataValue::from(100_i64));
424        entry.insert("score", MetadataValue::from(0.75_f64));
425        entry.insert("published", MetadataValue::from(true));
426
427        assert_eq!(entry.get("title"), Some(MetadataValue::from("Graph Guide")));
428        assert_eq!(entry.get("pages"), Some(MetadataValue::from(100_i64)));
429        assert_eq!(entry.get("score"), Some(MetadataValue::from(0.75_f64)));
430        assert_eq!(entry.get("published"), Some(MetadataValue::from(true)));
431        assert!(entry.contains_key("title"));
432        assert!(!entry.is_empty());
433
434        let mut keys = entry.keys();
435        keys.sort();
436        assert_eq!(keys, vec!["pages", "published", "score", "title"]);
437
438        entry.insert("title", MetadataValue::Null);
439        assert_eq!(entry.get("title"), None);
440        assert!(!entry.contains_key("title"));
441    }
442
443    #[test]
444    fn metadata_filters_cover_comparison_membership_and_strings() {
445        let mut entry = MetadataEntry::new();
446        entry.insert("title", MetadataValue::from("Graph Guide"));
447        entry.insert("pages", MetadataValue::from(100_i64));
448
449        assert!(MetadataFilter::eq("title", "Graph Guide").matches(&entry));
450        assert!(!MetadataFilter::eq("title", "Other").matches(&entry));
451        assert!(MetadataFilter::ne("title", "Other").matches(&entry));
452        assert!(MetadataFilter::ne("missing", "anything").matches(&entry));
453
454        assert!(MetadataFilter::gt("pages", 99_i64).matches(&entry));
455        assert!(MetadataFilter::gte("pages", 100_i64).matches(&entry));
456        assert!(MetadataFilter::lt("pages", 101_i64).matches(&entry));
457        assert!(MetadataFilter::lte("pages", 100_i64).matches(&entry));
458        assert!(!MetadataFilter::gt("missing", 1_i64).matches(&entry));
459
460        assert!(MetadataFilter::In(
461            "pages".to_string(),
462            vec![MetadataValue::from(1_i64), MetadataValue::from(100_i64)]
463        )
464        .matches(&entry));
465        assert!(MetadataFilter::NotIn(
466            "pages".to_string(),
467            vec![MetadataValue::from(1_i64), MetadataValue::from(2_i64)]
468        )
469        .matches(&entry));
470        assert!(
471            MetadataFilter::NotIn("missing".to_string(), vec![MetadataValue::from(1_i64)])
472                .matches(&entry)
473        );
474
475        assert!(MetadataFilter::Contains("title".to_string(), "Guide".to_string()).matches(&entry));
476        assert!(
477            MetadataFilter::StartsWith("title".to_string(), "Graph".to_string()).matches(&entry)
478        );
479        assert!(MetadataFilter::EndsWith("title".to_string(), "Guide".to_string()).matches(&entry));
480    }
481
482    #[test]
483    fn metadata_filters_cover_existence_and_boolean_composition() {
484        let mut entry = MetadataEntry::new();
485        entry.insert("title", MetadataValue::from("Graph Guide"));
486        entry.insert("pages", MetadataValue::from(100_i64));
487
488        assert!(MetadataFilter::Exists("title".to_string()).matches(&entry));
489        assert!(MetadataFilter::NotExists("missing".to_string()).matches(&entry));
490        assert!(MetadataFilter::and(vec![
491            MetadataFilter::eq("title", "Graph Guide"),
492            MetadataFilter::gte("pages", 100_i64),
493        ])
494        .matches(&entry));
495        assert!(MetadataFilter::or(vec![
496            MetadataFilter::eq("title", "Other"),
497            MetadataFilter::eq("pages", 100_i64),
498        ])
499        .matches(&entry));
500        assert!(MetadataFilter::not(MetadataFilter::eq("title", "Other")).matches(&entry));
501    }
502}