Skip to main content

nodedb_lite/query/kv_ops/sorted/
def.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Building a sorted-index definition from a `RegisterSortedIndex` plan, and
3//! the entry a stored row contributes to it.
4
5use nodedb_types::value::Value;
6
7use crate::error::LiteError;
8use crate::index::sorted_def::{SortedIndexDef, WindowDef, WindowKind};
9use crate::query::kv_ops::body::decode_kv_map;
10use crate::query::kv_ops::reads::decode_value;
11
12use super::keys::{PkEntry, encode_score};
13use super::window::{encode_ts, extract_ts_from_value};
14
15/// The fields of a `RegisterSortedIndex` plan.
16#[derive(Debug, Clone, Copy)]
17pub struct RegisterSortedIndex<'a> {
18    pub collection: &'a str,
19    pub index_name: &'a str,
20    /// `(column, "ASC" | "DESC")` pairs. The first is the score column.
21    pub sort_columns: &'a [(String, String)],
22    pub key_column: &'a str,
23    /// `none`, `daily`, `weekly`, `monthly` or `custom`, any case. Empty
24    /// means `none`.
25    pub window_type: &'a str,
26    pub window_timestamp_column: &'a str,
27    pub window_start_ms: u64,
28    pub window_end_ms: u64,
29}
30
31fn is_desc(direction: &str) -> bool {
32    direction.eq_ignore_ascii_case("DESC")
33}
34
35fn bad_request(detail: String) -> LiteError {
36    LiteError::BadRequest { detail }
37}
38
39fn window_kind(window_type: &str) -> Result<WindowKind, LiteError> {
40    match window_type.to_ascii_lowercase().as_str() {
41        "" | "none" => Ok(WindowKind::None),
42        "daily" => Ok(WindowKind::Daily),
43        "weekly" => Ok(WindowKind::Weekly),
44        "monthly" => Ok(WindowKind::Monthly),
45        "custom" => Ok(WindowKind::Custom),
46        other => Err(bad_request(format!(
47            "RegisterSortedIndex: unknown window type '{other}'; \
48             expected none, daily, weekly, monthly or custom"
49        ))),
50    }
51}
52
53impl SortedIndexDef {
54    /// Validate `req` and build its definition.
55    ///
56    /// Lite ranks by one score column, plus the window timestamp column of a
57    /// windowed index. Any other sort column is `Unsupported`.
58    pub(crate) fn build(req: &RegisterSortedIndex<'_>) -> Result<Self, LiteError> {
59        if req.index_name.is_empty() {
60            return Err(bad_request(
61                "RegisterSortedIndex: index name is empty".into(),
62            ));
63        }
64        if req.collection.is_empty() {
65            return Err(bad_request(format!(
66                "RegisterSortedIndex '{}': collection is empty",
67                req.index_name
68            )));
69        }
70        let Some((score_column, score_direction)) = req.sort_columns.first() else {
71            return Err(bad_request(format!(
72                "RegisterSortedIndex '{}': no sort column; the first sort column is the score",
73                req.index_name
74            )));
75        };
76
77        let kind = window_kind(req.window_type)?;
78        let window = if kind == WindowKind::None {
79            WindowDef::none()
80        } else {
81            let column = req.window_timestamp_column;
82            let Some((_, direction)) = req.sort_columns.iter().find(|(name, _)| name == column)
83            else {
84                return Err(bad_request(format!(
85                    "RegisterSortedIndex '{}': WINDOW timestamp column '{column}' \
86                     must be included in sort columns",
87                    req.index_name
88                )));
89            };
90            WindowDef {
91                kind,
92                timestamp_column: column.to_string(),
93                timestamp_desc: is_desc(direction),
94                start_ms: req.window_start_ms,
95                end_ms: req.window_end_ms,
96            }
97        };
98
99        if let Some((extra, _)) = req.sort_columns[1..]
100            .iter()
101            .find(|(name, _)| !window.is_windowed() || *name != window.timestamp_column)
102        {
103            return Err(LiteError::Unsupported {
104                detail: format!(
105                    "RegisterSortedIndex '{}': sort column '{extra}' is not supported; \
106                     Lite ranks by one score column plus an optional window timestamp column",
107                    req.index_name
108                ),
109            });
110        }
111
112        Ok(Self {
113            name: req.index_name.to_string(),
114            collection: req.collection.to_string(),
115            score_column: score_column.clone(),
116            score_desc: is_desc(score_direction),
117            key_column: req.key_column.to_string(),
118            window,
119        })
120    }
121}
122
123/// The entry the stored KV value `stored` contributes to `def` at `now_ms`.
124///
125/// No entry for a row expired at `now_ms`, a raw body, a score column that
126/// is absent or not numeric, or, on a windowed index, a timestamp column
127/// that is absent, not numeric, or negative.
128pub(super) fn entry_for(
129    def: &SortedIndexDef,
130    stored: &[u8],
131    now_ms: u64,
132) -> Result<Option<PkEntry>, LiteError> {
133    let Some((deadline, body)) = decode_value(stored) else {
134        return Ok(None);
135    };
136    if deadline != 0 && now_ms >= deadline {
137        return Ok(None);
138    }
139    let Some(columns) = decode_kv_map(body)? else {
140        return Ok(None);
141    };
142    let score = match columns.get(&def.score_column) {
143        Some(Value::Integer(n)) => *n as f64,
144        Some(Value::Float(f)) => *f,
145        _ => return Ok(None),
146    };
147    let ts = if def.window.is_windowed() {
148        match extract_ts_from_value(&columns, &def.window.timestamp_column) {
149            Some(ts_ms) => Some(encode_ts(ts_ms, def.window.timestamp_desc)),
150            None => return Ok(None),
151        }
152    } else {
153        None
154    };
155    Ok(Some(PkEntry {
156        score: encode_score(score, def.score_desc),
157        ts,
158        deadline,
159    }))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn columns(list: &[(&str, &str)]) -> Vec<(String, String)> {
167        list.iter()
168            .map(|(c, d)| ((*c).to_string(), (*d).to_string()))
169            .collect()
170    }
171
172    fn request<'a>(sort: &'a [(String, String)], window_type: &'a str) -> RegisterSortedIndex<'a> {
173        RegisterSortedIndex {
174            collection: "players",
175            index_name: "lb",
176            sort_columns: sort,
177            key_column: "id",
178            window_type,
179            window_timestamp_column: "ts",
180            window_start_ms: 0,
181            window_end_ms: 0,
182        }
183    }
184
185    #[test]
186    fn the_first_sort_column_is_the_score() {
187        let sort = columns(&[("score", "DESC")]);
188        let def = SortedIndexDef::build(&request(&sort, "none")).expect("build");
189        assert_eq!(def.score_column, "score");
190        assert!(def.score_desc);
191        assert!(!def.window.is_windowed());
192    }
193
194    #[test]
195    fn a_window_takes_its_direction_from_the_sort_columns() {
196        let sort = columns(&[("score", "ASC"), ("ts", "DESC")]);
197        let def = SortedIndexDef::build(&request(&sort, "Weekly")).expect("build");
198        assert_eq!(def.window.kind, WindowKind::Weekly);
199        assert!(def.window.timestamp_desc);
200    }
201
202    #[test]
203    fn an_empty_sort_column_list_is_refused() {
204        let err = SortedIndexDef::build(&request(&[], "none")).expect_err("refused");
205        assert!(matches!(err, LiteError::BadRequest { .. }), "{err:?}");
206    }
207}