Skip to main content

qql_core/parser/
config_validation.rs

1use super::ascii_equal;
2use crate::ast::{CollectionConfig, OptimizationThreads, Value};
3use crate::error::QqlError;
4use alloc::string::String;
5
6/// Looks up a config entry by key, comparing ASCII case-insensitively.
7pub fn config_value<'a>(config: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
8    for (k, v) in config {
9        if ascii_equal(k, key) {
10            return Some(v);
11        }
12    }
13    None
14}
15
16/// Returns true when the config contains the given key (case-insensitive).
17pub fn config_has_key(config: &[(String, Value)], key: &str) -> bool {
18    config_value(config, key).is_some()
19}
20
21/// Reads a boolean config value, returning `None` when absent or not a bool.
22pub fn config_bool(config: &[(String, Value)], key: &str) -> Option<bool> {
23    match config_value(config, key)? {
24        Value::Bool(b) => Some(*b),
25        _ => None,
26    }
27}
28
29use crate::error::Span;
30
31fn validation_err(message: impl Into<alloc::borrow::Cow<'static, str>>, span: Span) -> QqlError {
32    QqlError::validation("QQL-VALIDATION-CONFIG", message, Some(span))
33}
34
35/// Reads a positive integer config value; `None` when absent, error when invalid.
36pub fn config_positive_u64(
37    config: &[(String, Value)],
38    key: &str,
39    span: Span,
40) -> Result<Option<u64>, QqlError> {
41    match config_value(config, key) {
42        None => Ok(None),
43        Some(Value::Int(n)) if *n > 0 => Ok(Some(*n as u64)),
44        Some(Value::Float(n)) if *n > 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
45        _ => Err(validation_err(
46            alloc::format!("{} must be a positive integer", key),
47            span,
48        )),
49    }
50}
51
52/// Reads a non-negative integer config value; `None` when absent, error when invalid.
53pub fn config_non_negative_u64(
54    config: &[(String, Value)],
55    key: &str,
56    span: Span,
57) -> Result<Option<u64>, QqlError> {
58    match config_value(config, key) {
59        None => Ok(None),
60        Some(Value::Int(n)) if *n >= 0 => Ok(Some(*n as u64)),
61        Some(Value::Float(n)) if *n >= 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
62        _ => Err(validation_err(
63            alloc::format!("{} must be a non-negative integer", key),
64            span,
65        )),
66    }
67}
68
69/// Reads a numeric config value, or `None` when absent or outside `[min, max]`.
70pub fn config_float_range(
71    config: &[(String, Value)],
72    key: &str,
73    min: f64,
74    max: f64,
75) -> Option<f64> {
76    match config_value(config, key)? {
77        Value::Int(n) => {
78            let f = *n as f64;
79            if (min..=max).contains(&f) {
80                Some(f)
81            } else {
82                None
83            }
84        }
85        Value::Float(f) => {
86            if (min..=max).contains(f) {
87                Some(*f)
88            } else {
89                None
90            }
91        }
92        _ => None,
93    }
94}
95
96/// Reads a thread count as a positive integer or the string `auto`.
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(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
122/// Type-checks one HNSW config option (`m`, `ef_construct`, `on_disk`, `memory`, …).
123pub fn validate_hnsw_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
124    let lower = key.to_ascii_lowercase();
125    match lower.as_str() {
126        "m" | "ef_construct" | "full_scan_threshold" | "max_indexing_threads" | "payload_m" => {
127            if !is_integer_val(value) {
128                return Err(validation_err(
129                    alloc::format!("{} must be an integer", key),
130                    span,
131                ));
132            }
133        }
134        "on_disk" | "inline_storage" if !matches!(value, Value::Bool(_)) => {
135            return Err(validation_err(
136                alloc::format!("{} must be true or false", key),
137                span,
138            ));
139        }
140        "memory" => validate_memory_value(key, value, span, true)?,
141        _ => {}
142    }
143    Ok(())
144}
145
146/// Type-checks one vectors config option (`on_disk`, `memory`, `datatype`).
147pub fn validate_vectors_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
148    let lower = key.to_ascii_lowercase();
149    match lower.as_str() {
150        "on_disk" if !matches!(value, Value::Bool(_)) => {
151            return Err(validation_err(
152                alloc::format!("{} must be true or false", key),
153                span,
154            ));
155        }
156        "memory" => validate_memory_value(key, value, span, true)?,
157        "datatype" => match value {
158            Value::Str(s) if crate::ast::VectorDatatype::parse(s).is_some() => {}
159            Value::Str(_) => {
160                return Err(validation_err(
161                    alloc::format!("{key} must be float32, float16, uint8, or turbo4"),
162                    span,
163                ));
164            }
165            _ => {
166                return Err(validation_err(
167                    alloc::format!("{key} must be a string (float32, float16, uint8, or turbo4)"),
168                    span,
169                ));
170            }
171        },
172        _ => {}
173    }
174    Ok(())
175}
176
177fn validate_memory_value(
178    key: &str,
179    value: &Value,
180    span: Span,
181    allow_pinned: bool,
182) -> Result<(), QqlError> {
183    match value {
184        Value::Str(s) => match crate::ast::MemoryPlacement::parse(s) {
185            Some(crate::ast::MemoryPlacement::Pinned) if !allow_pinned => Err(validation_err(
186                alloc::format!("{key} does not support 'pinned'"),
187                span,
188            )),
189            Some(_) => Ok(()),
190            None => Err(validation_err(
191                alloc::format!("{key} must be 'cold', 'cached', or 'pinned'"),
192                span,
193            )),
194        },
195        _ => Err(validation_err(
196            alloc::format!("{key} must be a string ('cold', 'cached', or 'pinned')"),
197            span,
198        )),
199    }
200}
201
202/// Type-checks one optimizers config option (`deleted_threshold`, `memmap_threshold`, …).
203pub fn validate_optimizers_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
204    let lower = key.to_ascii_lowercase();
205    match lower.as_str() {
206        "deleted_threshold" => {
207            if !matches!(value, Value::Int(_) | Value::Float(_)) {
208                return Err(validation_err(
209                    alloc::format!("{} must be a number", key),
210                    span,
211                ));
212            }
213        }
214        "vacuum_min_vector_number"
215        | "default_segment_number"
216        | "max_segment_size"
217        | "memmap_threshold"
218        | "indexing_threshold"
219        | "flush_interval_sec" => {
220            if !is_integer_val(value) {
221                return Err(validation_err(
222                    alloc::format!("{} must be an integer", key),
223                    span,
224                ));
225            }
226        }
227        "max_optimization_threads" => {
228            if !is_integer_val(value) && !matches!(value, Value::Str(_)) {
229                return Err(validation_err(
230                    alloc::format!("{} must be a positive integer or 'auto'", key),
231                    span,
232                ));
233            }
234        }
235        "prevent_unoptimized" if !matches!(value, Value::Bool(_)) => {
236            return Err(validation_err(
237                alloc::format!("{} must be true or false", key),
238                span,
239            ));
240        }
241        _ => {}
242    }
243    Ok(())
244}
245
246/// Type-checks one collection `PARAMS` option (replication, sharding, memory, …).
247pub fn validate_params_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
248    let lower = key.to_ascii_lowercase();
249    match lower.as_str() {
250        "replication_factor"
251        | "write_consistency_factor"
252        | "read_fan_out_factor"
253        | "read_fan_out_delay_ms"
254        | "shard_number" => {
255            if !matches!(value, Value::Int(_)) {
256                return Err(validation_err(
257                    alloc::format!("{} must be an integer", key),
258                    span,
259                ));
260            }
261        }
262        "on_disk_payload" if !matches!(value, Value::Bool(_)) => {
263            return Err(validation_err(
264                alloc::format!("{} must be true or false", key),
265                span,
266            ));
267        }
268        "payload_memory" => validate_memory_value(key, value, span, false)?,
269        "sharding_method" => match value {
270            Value::Str(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("custom") => {
271            }
272            Value::Str(_) => {
273                return Err(validation_err(
274                    "sharding_method must be 'auto' or 'custom'",
275                    span,
276                ));
277            }
278            _ => {
279                return Err(validation_err(
280                    "sharding_method must be a string ('auto' or 'custom')",
281                    span,
282                ));
283            }
284        },
285        "shard_keys" => match value {
286            Value::List(items) if items.is_empty() => {
287                return Err(validation_err(
288                    "shard_keys must be a non-empty list of strings or non-negative integers",
289                    span,
290                ));
291            }
292            Value::List(items) => {
293                for item in items {
294                    let ok = match item {
295                        Value::Str(_) => true,
296                        Value::Int(n) => *n >= 0,
297                        Value::Param(..) | Value::PositionalParam(..) => true,
298                        _ => false,
299                    };
300                    if !ok {
301                        return Err(validation_err(
302                            "shard_keys entries must all be strings or non-negative integers",
303                            span,
304                        ));
305                    }
306                }
307            }
308            _ => {
309                return Err(validation_err(
310                    "shard_keys must be a list of strings or non-negative integers",
311                    span,
312                ));
313            }
314        },
315        _ => {}
316    }
317    Ok(())
318}
319
320/// Merges new collection config clauses into `current`, erroring on duplicates.
321pub fn merge_collection_config(
322    current: &mut CollectionConfig,
323    new: CollectionConfig,
324    span: Span,
325) -> Result<(), QqlError> {
326    if new.vectors.is_some() {
327        if current.vectors.is_some() {
328            return Err(validation_err("VECTOR clause may only appear once", span));
329        }
330        current.vectors = new.vectors;
331    }
332    if new.hnsw.is_some() {
333        if current.hnsw.is_some() {
334            return Err(validation_err("HNSW clause may only appear once", span));
335        }
336        current.hnsw = new.hnsw;
337    }
338    if new.optimizers.is_some() {
339        if current.optimizers.is_some() {
340            return Err(validation_err(
341                "OPTIMIZERS clause may only appear once",
342                span,
343            ));
344        }
345        current.optimizers = new.optimizers;
346    }
347    if new.params.is_some() {
348        if current.params.is_some() {
349            return Err(validation_err("PARAMS clause may only appear once", span));
350        }
351        current.params = new.params;
352    }
353    if new.quantization.is_some() {
354        if current.quantization.is_some() {
355            return Err(validation_err(
356                "QUANTIZATION clause may only appear once",
357                span,
358            ));
359        }
360        current.quantization = new.quantization;
361    }
362    if new.quantization_update.is_some() {
363        if current.quantization_update.is_some() {
364            return Err(validation_err(
365                "QUANTIZATION clause may only appear once",
366                span,
367            ));
368        }
369        current.quantization_update = new.quantization_update;
370    }
371    if new.wal.is_some() {
372        if current.wal.is_some() {
373            return Err(validation_err("WAL clause may only appear once", span));
374        }
375        current.wal = new.wal;
376    }
377    if new.strict_mode.is_some() {
378        if current.strict_mode.is_some() {
379            return Err(validation_err(
380                "STRICT_MODE clause may only appear once",
381                span,
382            ));
383        }
384        current.strict_mode = new.strict_mode;
385    }
386    if new.metadata.is_some() {
387        if current.metadata.is_some() {
388            return Err(validation_err("METADATA clause may only appear once", span));
389        }
390        current.metadata = new.metadata;
391    }
392    for diff in new.vector_diffs {
393        if current.vector_diffs.iter().any(|d| d.name == diff.name) {
394            return Err(validation_err(
395                alloc::format!("VECTOR diff '{}' may only appear once", diff.name),
396                span,
397            ));
398        }
399        current.vector_diffs.push(diff);
400    }
401    for diff in new.sparse_vector_diffs {
402        if current
403            .sparse_vector_diffs
404            .iter()
405            .any(|d| d.name == diff.name)
406        {
407            return Err(validation_err(
408                alloc::format!("SPARSE vector diff '{}' may only appear once", diff.name),
409                span,
410            ));
411        }
412        current.sparse_vector_diffs.push(diff);
413    }
414    Ok(())
415}
416
417/// Checks that `deleted_threshold` is a number between 0.0 and 1.0.
418pub fn check_deleted_threshold(value: &Value, span: Span) -> Result<(), QqlError> {
419    match value {
420        Value::Int(n) => {
421            let f = *n as f64;
422            if !(0.0..=1.0).contains(&f) {
423                return Err(validation_err(
424                    "deleted_threshold must be between 0.0 and 1.0",
425                    span,
426                ));
427            }
428        }
429        Value::Float(f) if !(0.0..=1.0).contains(f) => {
430            return Err(validation_err(
431                "deleted_threshold must be between 0.0 and 1.0",
432                span,
433            ));
434        }
435        _ => {}
436    }
437    Ok(())
438}
439
440/// Type-checks CREATE INDEX options, erroring on unknown keys or bad value types.
441pub fn validate_index_options(options: &[(String, Value)], span: Span) -> Result<(), QqlError> {
442    for (k, v) in options {
443        let lower = k.to_ascii_lowercase();
444        match lower.as_str() {
445            "is_tenant" | "on_disk" | "enable_hnsw" | "lowercase" | "ascii_folding"
446            | "phrase_matching" | "lookup" | "range" | "is_principal" | "prefix" => {
447                if !matches!(v, Value::Bool(_)) {
448                    return Err(validation_err(
449                        alloc::format!("{} must be true or false", k),
450                        span,
451                    ));
452                }
453            }
454            "min_token_len" | "max_token_len" => {
455                if !matches!(v, Value::Int(n) if *n >= 0) {
456                    return Err(validation_err(
457                        alloc::format!("{} must be a non-negative integer", k),
458                        span,
459                    ));
460                }
461            }
462            "tokenizer" | "stemmer" => {
463                if !matches!(v, Value::Str(_)) {
464                    return Err(validation_err(
465                        alloc::format!("{} must be a string", k),
466                        span,
467                    ));
468                }
469            }
470            "memory" => validate_memory_value(k, v, span, true)?,
471            "stopwords" => match v {
472                Value::List(items) => {
473                    for item in items {
474                        if !matches!(item, Value::Str(_)) {
475                            return Err(validation_err(
476                                alloc::format!("{} must be a list of strings", k),
477                                span,
478                            ));
479                        }
480                    }
481                }
482                // A bare language name (`stopwords = 'english'`) selects the
483                // predefined list; names are validated against the OpenAPI
484                // `Language` enum at plan time.
485                Value::Str(_) => {}
486                // `stopwords = {languages: […], custom: […]}` mirrors the
487                // OpenAPI `StopwordsSet` object.
488                Value::Dict(entries) => {
489                    for (entry_key, entry_value) in entries {
490                        if entry_key.eq_ignore_ascii_case("languages") {
491                            match entry_value {
492                                Value::List(items) => {
493                                    for item in items {
494                                        if !matches!(item, Value::Str(_)) {
495                                            return Err(validation_err(
496                                                "stopwords languages must be a list of strings",
497                                                span,
498                                            ));
499                                        }
500                                    }
501                                }
502                                _ => {
503                                    return Err(validation_err(
504                                        "stopwords languages must be a list of strings",
505                                        span,
506                                    ));
507                                }
508                            }
509                        } else if entry_key.eq_ignore_ascii_case("custom") {
510                            match entry_value {
511                                Value::List(items) => {
512                                    for item in items {
513                                        if !matches!(item, Value::Str(_)) {
514                                            return Err(validation_err(
515                                                "stopwords custom must be a list of strings",
516                                                span,
517                                            ));
518                                        }
519                                    }
520                                }
521                                _ => {
522                                    return Err(validation_err(
523                                        "stopwords custom must be a list of strings",
524                                        span,
525                                    ));
526                                }
527                            }
528                        } else {
529                            return Err(validation_err(
530                                alloc::format!(
531                                    "unknown stopwords set key '{entry_key}'. Expected: languages, custom"
532                                ),
533                                span,
534                            ));
535                        }
536                    }
537                }
538                _ => {
539                    return Err(validation_err(
540                        alloc::format!(
541                            "{} must be a list of strings, a language name, or {{languages: […], custom: […]}}",
542                            k
543                        ),
544                        span,
545                    ));
546                }
547            },
548            _ => {
549                return Err(validation_err(
550                    alloc::format!("unknown index option: {}", k),
551                    span,
552                ));
553            }
554        }
555    }
556    Ok(())
557}
558
559/// Closed key set of `WITH STRICT_MODE (…)` (OpenAPI `StrictModeConfig`).
560pub const STRICT_MODE_KEYS: &[&str] = &[
561    "enabled",
562    "max_query_limit",
563    "max_timeout",
564    "unindexed_filtering_retrieve",
565    "unindexed_filtering_update",
566    "search_max_hnsw_ef",
567    "search_allow_exact",
568    "search_max_oversampling",
569    "upsert_max_batchsize",
570    "search_max_batchsize",
571    "max_collection_vector_size_bytes",
572    "read_rate_limit",
573    "write_rate_limit",
574    "max_collection_payload_size_bytes",
575    "max_points_count",
576    "filter_max_conditions",
577    "condition_max_size",
578    "multivector_config",
579    "sparse_config",
580    "max_payload_index_count",
581    "max_resident_memory_percent",
582];
583
584/// True when `key` names a `STRICT_MODE` option (case-insensitive).
585pub fn is_strict_mode_key(key: &str) -> bool {
586    STRICT_MODE_KEYS.iter().any(|known| ascii_equal(known, key))
587}
588
589/// Type-checks one WAL config option (`wal_capacity_mb`, …).
590pub fn validate_wal_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
591    if !matches!(value, Value::Int(_)) {
592        return Err(validation_err(
593            alloc::format!("{} must be an integer", key),
594            span,
595        ));
596    }
597    Ok(())
598}
599
600/// Type-checks one strict-mode config option by shape (ranges are enforced at
601/// plan time so hand-built ASTs fail closed there too).
602pub fn validate_strict_mode_value(key: &str, value: &Value, span: Span) -> Result<(), QqlError> {
603    let lower = key.to_ascii_lowercase();
604    match lower.as_str() {
605        "enabled"
606        | "unindexed_filtering_retrieve"
607        | "unindexed_filtering_update"
608        | "search_allow_exact" => {
609            if !matches!(value, Value::Bool(_)) {
610                return Err(validation_err(
611                    alloc::format!("{} must be true or false", key),
612                    span,
613                ));
614            }
615        }
616        "search_max_oversampling" => {
617            if !matches!(value, Value::Int(_) | Value::Float(_)) {
618                return Err(validation_err(
619                    alloc::format!("{} must be a number", key),
620                    span,
621                ));
622            }
623        }
624        "multivector_config" | "sparse_config" => {
625            if !matches!(value, Value::Dict(_)) {
626                return Err(validation_err(
627                    alloc::format!("{} must be an object", key),
628                    span,
629                ));
630            }
631        }
632        _ => {
633            if !matches!(value, Value::Int(_)) {
634                return Err(validation_err(
635                    alloc::format!("{} must be an integer", key),
636                    span,
637                ));
638            }
639        }
640    }
641    Ok(())
642}