Skip to main content

qql_core/parser/
config_validation.rs

1use super::ascii_equal_lower;
2use crate::ast::{CollectionConfig, OptimizationThreads, Value};
3use crate::error::QqlError;
4use alloc::string::String;
5
6pub fn config_value<'a>(config: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
7    for (k, v) in config {
8        if ascii_equal_lower(k, key) {
9            return Some(v);
10        }
11    }
12    None
13}
14
15pub fn config_has_key(config: &[(String, Value)], key: &str) -> bool {
16    config_value(config, key).is_some()
17}
18
19pub fn config_bool(config: &[(String, Value)], key: &str) -> Option<bool> {
20    match config_value(config, key)? {
21        Value::Bool(b) => Some(*b),
22        _ => None,
23    }
24}
25
26use crate::error::Span;
27
28fn validation_err(
29    message: impl Into<alloc::borrow::Cow<'static, str>>,
30    position: usize,
31) -> QqlError {
32    QqlError::validation(
33        "QQL-VALIDATION-CONFIG",
34        message,
35        Some(Span::point(position)),
36    )
37}
38
39pub fn config_positive_u64(
40    config: &[(String, Value)],
41    key: &str,
42    pos: usize,
43) -> Result<Option<u64>, QqlError> {
44    match config_value(config, key) {
45        None => Ok(None),
46        Some(Value::Int(n)) if *n > 0 => Ok(Some(*n as u64)),
47        Some(Value::Float(n)) if *n > 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
48        _ => Err(validation_err(
49            alloc::format!("{} must be a positive integer", key),
50            pos,
51        )),
52    }
53}
54
55pub fn config_non_negative_u64(
56    config: &[(String, Value)],
57    key: &str,
58    pos: usize,
59) -> Result<Option<u64>, QqlError> {
60    match config_value(config, key) {
61        None => Ok(None),
62        Some(Value::Int(n)) if *n >= 0 => Ok(Some(*n as u64)),
63        Some(Value::Float(n)) if *n >= 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
64        _ => Err(validation_err(
65            alloc::format!("{} must be a non-negative integer", key),
66            pos,
67        )),
68    }
69}
70
71pub fn config_float_range(
72    config: &[(String, Value)],
73    key: &str,
74    min: f64,
75    max: f64,
76) -> Option<f64> {
77    match config_value(config, key)? {
78        Value::Int(n) => {
79            let f = *n as f64;
80            if (min..=max).contains(&f) {
81                Some(f)
82            } else {
83                None
84            }
85        }
86        Value::Float(f) => {
87            if (min..=max).contains(f) {
88                Some(*f)
89            } else {
90                None
91            }
92        }
93        _ => None,
94    }
95}
96
97pub fn config_max_optimization_threads(
98    config: &[(String, Value)],
99    key: &str,
100) -> Option<OptimizationThreads> {
101    match config_value(config, key)? {
102        Value::Int(n) if *n > 0 => Some(OptimizationThreads {
103            auto_: false,
104            value: *n as u64,
105        }),
106        Value::Str(s) if ascii_equal_lower(s, "auto") => Some(OptimizationThreads {
107            auto_: true,
108            value: 0,
109        }),
110        _ => None,
111    }
112}
113
114pub fn is_integer_val(value: &Value) -> bool {
115    match value {
116        Value::Int(_) => true,
117        Value::Float(f) => *f >= 0.0 && *f == (*f as u64) as f64,
118        _ => false,
119    }
120}
121
122pub fn validate_hnsw_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
123    let lower = key.to_ascii_lowercase();
124    match lower.as_str() {
125        "m" | "ef_construct" | "full_scan_threshold" | "max_indexing_threads" | "payload_m" => {
126            if !is_integer_val(value) {
127                return Err(validation_err(
128                    alloc::format!("{} must be an integer", key),
129                    pos,
130                ));
131            }
132        }
133        "on_disk" | "inline_storage" if !matches!(value, Value::Bool(_)) => {
134            return Err(validation_err(
135                alloc::format!("{} must be true or false", key),
136                pos,
137            ));
138        }
139        _ => {}
140    }
141    Ok(())
142}
143
144pub fn validate_vectors_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
145    if ascii_equal_lower(key, "on_disk") && !matches!(value, Value::Bool(_)) {
146        return Err(validation_err(
147            alloc::format!("{} must be true or false", key),
148            pos,
149        ));
150    }
151    Ok(())
152}
153
154pub fn validate_optimizers_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
155    let lower = key.to_ascii_lowercase();
156    match lower.as_str() {
157        "deleted_threshold" => {
158            if !matches!(value, Value::Int(_) | Value::Float(_)) {
159                return Err(validation_err(
160                    alloc::format!("{} must be a number", key),
161                    pos,
162                ));
163            }
164        }
165        "vacuum_min_vector_number"
166        | "default_segment_number"
167        | "max_segment_size"
168        | "memmap_threshold"
169        | "indexing_threshold"
170        | "flush_interval_sec" => {
171            if !is_integer_val(value) {
172                return Err(validation_err(
173                    alloc::format!("{} must be an integer", key),
174                    pos,
175                ));
176            }
177        }
178        "max_optimization_threads" => {
179            if !is_integer_val(value) && !matches!(value, Value::Str(_)) {
180                return Err(validation_err(
181                    alloc::format!("{} must be a positive integer or 'auto'", key),
182                    pos,
183                ));
184            }
185        }
186        "prevent_unoptimized" if !matches!(value, Value::Bool(_)) => {
187            return Err(validation_err(
188                alloc::format!("{} must be true or false", key),
189                pos,
190            ));
191        }
192        _ => {}
193    }
194    Ok(())
195}
196
197pub fn validate_params_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
198    let lower = key.to_ascii_lowercase();
199    match lower.as_str() {
200        "replication_factor"
201        | "write_consistency_factor"
202        | "read_fan_out_factor"
203        | "read_fan_out_delay_ms"
204        | "shard_number" => {
205            if !matches!(value, Value::Int(_)) {
206                return Err(validation_err(
207                    alloc::format!("{} must be an integer", key),
208                    pos,
209                ));
210            }
211        }
212        "on_disk_payload" if !matches!(value, Value::Bool(_)) => {
213            return Err(validation_err(
214                alloc::format!("{} must be true or false", key),
215                pos,
216            ));
217        }
218        "sharding_method" => match value {
219            Value::Str(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("custom") => {
220            }
221            Value::Str(_) => {
222                return Err(validation_err(
223                    "sharding_method must be 'auto' or 'custom'",
224                    pos,
225                ));
226            }
227            _ => {
228                return Err(validation_err(
229                    "sharding_method must be a string ('auto' or 'custom')",
230                    pos,
231                ));
232            }
233        },
234        "shard_keys" => match value {
235            Value::List(items) if items.is_empty() => {
236                return Err(validation_err(
237                    "shard_keys must be a non-empty list of strings",
238                    pos,
239                ));
240            }
241            Value::List(items) => {
242                for item in items {
243                    if !matches!(item, Value::Str(_)) {
244                        return Err(validation_err(
245                            "shard_keys entries must all be strings",
246                            pos,
247                        ));
248                    }
249                }
250            }
251            _ => {
252                return Err(validation_err("shard_keys must be a list of strings", pos));
253            }
254        },
255        _ => {}
256    }
257    Ok(())
258}
259
260pub fn merge_collection_config(
261    current: &mut CollectionConfig,
262    new: CollectionConfig,
263    pos: usize,
264) -> Result<(), QqlError> {
265    if new.vectors.is_some() {
266        if current.vectors.is_some() {
267            return Err(QqlError::syntax("VECTOR clause may only appear once", pos));
268        }
269        current.vectors = new.vectors;
270    }
271    if new.hnsw.is_some() {
272        if current.hnsw.is_some() {
273            return Err(QqlError::syntax("HNSW clause may only appear once", pos));
274        }
275        current.hnsw = new.hnsw;
276    }
277    if new.optimizers.is_some() {
278        if current.optimizers.is_some() {
279            return Err(QqlError::syntax(
280                "OPTIMIZERS clause may only appear once",
281                pos,
282            ));
283        }
284        current.optimizers = new.optimizers;
285    }
286    if new.params.is_some() {
287        if current.params.is_some() {
288            return Err(QqlError::syntax("PARAMS clause may only appear once", pos));
289        }
290        current.params = new.params;
291    }
292    if new.quantization.is_some() {
293        if current.quantization.is_some() {
294            return Err(QqlError::syntax(
295                "QUANTIZATION clause may only appear once",
296                pos,
297            ));
298        }
299        current.quantization = new.quantization;
300    }
301    if new.quantization_update.is_some() {
302        if current.quantization_update.is_some() {
303            return Err(QqlError::syntax(
304                "QUANTIZATION clause may only appear once",
305                pos,
306            ));
307        }
308        current.quantization_update = new.quantization_update;
309    }
310    Ok(())
311}
312
313pub fn check_deleted_threshold(value: &Value, pos: usize) -> Result<(), QqlError> {
314    match value {
315        Value::Int(n) => {
316            let f = *n as f64;
317            if !(0.0..=1.0).contains(&f) {
318                return Err(QqlError::syntax(
319                    "deleted_threshold must be between 0.0 and 1.0",
320                    pos,
321                ));
322            }
323        }
324        Value::Float(f) if !(0.0..=1.0).contains(f) => {
325            return Err(QqlError::syntax(
326                "deleted_threshold must be between 0.0 and 1.0",
327                pos,
328            ));
329        }
330        _ => {}
331    }
332    Ok(())
333}
334
335pub fn validate_index_options(options: &[(String, Value)], pos: usize) -> Result<(), QqlError> {
336    for (k, v) in options {
337        let lower = k.to_ascii_lowercase();
338        match lower.as_str() {
339            "is_tenant" | "on_disk" | "enable_hnsw" | "lowercase" | "ascii_folding"
340            | "phrase_matching" | "lookup" | "range" | "is_principal" => {
341                if !matches!(v, Value::Bool(_)) {
342                    return Err(QqlError::syntax(
343                        alloc::format!("{} must be true or false", k),
344                        pos,
345                    ));
346                }
347            }
348            "min_token_len" | "max_token_len" => {
349                if !matches!(v, Value::Int(n) if *n >= 0) {
350                    return Err(QqlError::syntax(
351                        alloc::format!("{} must be a non-negative integer", k),
352                        pos,
353                    ));
354                }
355            }
356            "tokenizer" | "stemmer" => {
357                if !matches!(v, Value::Str(_)) {
358                    return Err(QqlError::syntax(
359                        alloc::format!("{} must be a string", k),
360                        pos,
361                    ));
362                }
363            }
364            "stopwords" => match v {
365                Value::List(items) => {
366                    for item in items {
367                        if !matches!(item, Value::Str(_)) {
368                            return Err(QqlError::syntax(
369                                alloc::format!("{} must be a list of strings", k),
370                                pos,
371                            ));
372                        }
373                    }
374                }
375                _ => {
376                    return Err(QqlError::syntax(
377                        alloc::format!("{} must be a list of strings", k),
378                        pos,
379                    ));
380                }
381            },
382            _ => {
383                return Err(QqlError::syntax(
384                    alloc::format!("unknown index option: {}", k),
385                    pos,
386                ));
387            }
388        }
389    }
390    Ok(())
391}