nodedb_lite/query/kv_ops/sorted/
def.rs1use 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#[derive(Debug, Clone, Copy)]
17pub struct RegisterSortedIndex<'a> {
18 pub collection: &'a str,
19 pub index_name: &'a str,
20 pub sort_columns: &'a [(String, String)],
22 pub key_column: &'a str,
23 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 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
123pub(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}