1use super::ascii_equal;
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> {
8 for (k, v) in config {
9 if ascii_equal(k, key) {
10 return Some(v);
11 }
12 }
13 None
14}
15
16pub fn config_has_key(config: &[(String, Value)], key: &str) -> bool {
18 config_value(config, key).is_some()
19}
20
21pub 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
35pub 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
52pub 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
69pub 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
96pub 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
122pub 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
146pub 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
202pub 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
246pub 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
320pub 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
417pub 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
440pub 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 Value::Str(_) => {}
486 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
559pub 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
584pub fn is_strict_mode_key(key: &str) -> bool {
586 STRICT_MODE_KEYS.iter().any(|known| ascii_equal(known, key))
587}
588
589pub 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
600pub 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}