qql-core 0.1.2

Parser, typed AST, validation, and transformations for the Qdrant Query Language
Documentation
use super::ascii_equal_lower;
use crate::ast::{CollectionConfig, OptimizationThreads, Value};
use crate::error::QqlError;
use alloc::string::String;

pub fn config_value<'a>(config: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
    for (k, v) in config {
        if ascii_equal_lower(k, key) {
            return Some(v);
        }
    }
    None
}

pub fn config_has_key(config: &[(String, Value)], key: &str) -> bool {
    config_value(config, key).is_some()
}

pub fn config_bool(config: &[(String, Value)], key: &str) -> Option<bool> {
    match config_value(config, key)? {
        Value::Bool(b) => Some(*b),
        _ => None,
    }
}

use crate::error::Span;

fn validation_err(
    message: impl Into<alloc::borrow::Cow<'static, str>>,
    position: usize,
) -> QqlError {
    QqlError::validation(
        "QQL-VALIDATION-CONFIG",
        message,
        Some(Span::point(position)),
    )
}

pub fn config_positive_u64(
    config: &[(String, Value)],
    key: &str,
    pos: usize,
) -> Result<Option<u64>, QqlError> {
    match config_value(config, key) {
        None => Ok(None),
        Some(Value::Int(n)) if *n > 0 => Ok(Some(*n as u64)),
        Some(Value::Float(n)) if *n > 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
        _ => Err(validation_err(
            alloc::format!("{} must be a positive integer", key),
            pos,
        )),
    }
}

pub fn config_non_negative_u64(
    config: &[(String, Value)],
    key: &str,
    pos: usize,
) -> Result<Option<u64>, QqlError> {
    match config_value(config, key) {
        None => Ok(None),
        Some(Value::Int(n)) if *n >= 0 => Ok(Some(*n as u64)),
        Some(Value::Float(n)) if *n >= 0.0 && *n == (*n as u64) as f64 => Ok(Some(*n as u64)),
        _ => Err(validation_err(
            alloc::format!("{} must be a non-negative integer", key),
            pos,
        )),
    }
}

pub fn config_float_range(
    config: &[(String, Value)],
    key: &str,
    min: f64,
    max: f64,
) -> Option<f64> {
    match config_value(config, key)? {
        Value::Int(n) => {
            let f = *n as f64;
            if (min..=max).contains(&f) {
                Some(f)
            } else {
                None
            }
        }
        Value::Float(f) => {
            if (min..=max).contains(f) {
                Some(*f)
            } else {
                None
            }
        }
        _ => None,
    }
}

pub fn config_max_optimization_threads(
    config: &[(String, Value)],
    key: &str,
) -> Option<OptimizationThreads> {
    match config_value(config, key)? {
        Value::Int(n) if *n > 0 => Some(OptimizationThreads {
            auto_: false,
            value: *n as u64,
        }),
        Value::Str(s) if ascii_equal_lower(s, "auto") => Some(OptimizationThreads {
            auto_: true,
            value: 0,
        }),
        _ => None,
    }
}

pub fn is_integer_val(value: &Value) -> bool {
    match value {
        Value::Int(_) => true,
        Value::Float(f) => *f >= 0.0 && *f == (*f as u64) as f64,
        _ => false,
    }
}

pub fn validate_hnsw_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
    let lower = key.to_ascii_lowercase();
    match lower.as_str() {
        "m" | "ef_construct" | "full_scan_threshold" | "max_indexing_threads" | "payload_m" => {
            if !is_integer_val(value) {
                return Err(validation_err(
                    alloc::format!("{} must be an integer", key),
                    pos,
                ));
            }
        }
        "on_disk" | "inline_storage" if !matches!(value, Value::Bool(_)) => {
            return Err(validation_err(
                alloc::format!("{} must be true or false", key),
                pos,
            ));
        }
        _ => {}
    }
    Ok(())
}

pub fn validate_vectors_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
    if ascii_equal_lower(key, "on_disk") && !matches!(value, Value::Bool(_)) {
        return Err(validation_err(
            alloc::format!("{} must be true or false", key),
            pos,
        ));
    }
    Ok(())
}

pub fn validate_optimizers_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
    let lower = key.to_ascii_lowercase();
    match lower.as_str() {
        "deleted_threshold" => {
            if !matches!(value, Value::Int(_) | Value::Float(_)) {
                return Err(validation_err(
                    alloc::format!("{} must be a number", key),
                    pos,
                ));
            }
        }
        "vacuum_min_vector_number"
        | "default_segment_number"
        | "max_segment_size"
        | "memmap_threshold"
        | "indexing_threshold"
        | "flush_interval_sec" => {
            if !is_integer_val(value) {
                return Err(validation_err(
                    alloc::format!("{} must be an integer", key),
                    pos,
                ));
            }
        }
        "max_optimization_threads" => {
            if !is_integer_val(value) && !matches!(value, Value::Str(_)) {
                return Err(validation_err(
                    alloc::format!("{} must be a positive integer or 'auto'", key),
                    pos,
                ));
            }
        }
        "prevent_unoptimized" if !matches!(value, Value::Bool(_)) => {
            return Err(validation_err(
                alloc::format!("{} must be true or false", key),
                pos,
            ));
        }
        _ => {}
    }
    Ok(())
}

pub fn validate_params_value(key: &str, value: &Value, pos: usize) -> Result<(), QqlError> {
    let lower = key.to_ascii_lowercase();
    match lower.as_str() {
        "replication_factor"
        | "write_consistency_factor"
        | "read_fan_out_factor"
        | "read_fan_out_delay_ms"
        | "shard_number" => {
            if !matches!(value, Value::Int(_)) {
                return Err(validation_err(
                    alloc::format!("{} must be an integer", key),
                    pos,
                ));
            }
        }
        "on_disk_payload" if !matches!(value, Value::Bool(_)) => {
            return Err(validation_err(
                alloc::format!("{} must be true or false", key),
                pos,
            ));
        }
        "sharding_method" => match value {
            Value::Str(s) if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("custom") => {
            }
            Value::Str(_) => {
                return Err(validation_err(
                    "sharding_method must be 'auto' or 'custom'",
                    pos,
                ));
            }
            _ => {
                return Err(validation_err(
                    "sharding_method must be a string ('auto' or 'custom')",
                    pos,
                ));
            }
        },
        "shard_keys" => match value {
            Value::List(items) if items.is_empty() => {
                return Err(validation_err(
                    "shard_keys must be a non-empty list of strings",
                    pos,
                ));
            }
            Value::List(items) => {
                for item in items {
                    if !matches!(item, Value::Str(_)) {
                        return Err(validation_err(
                            "shard_keys entries must all be strings",
                            pos,
                        ));
                    }
                }
            }
            _ => {
                return Err(validation_err("shard_keys must be a list of strings", pos));
            }
        },
        _ => {}
    }
    Ok(())
}

pub fn merge_collection_config(
    current: &mut CollectionConfig,
    new: CollectionConfig,
    pos: usize,
) -> Result<(), QqlError> {
    if new.vectors.is_some() {
        if current.vectors.is_some() {
            return Err(QqlError::syntax("VECTOR clause may only appear once", pos));
        }
        current.vectors = new.vectors;
    }
    if new.hnsw.is_some() {
        if current.hnsw.is_some() {
            return Err(QqlError::syntax("HNSW clause may only appear once", pos));
        }
        current.hnsw = new.hnsw;
    }
    if new.optimizers.is_some() {
        if current.optimizers.is_some() {
            return Err(QqlError::syntax(
                "OPTIMIZERS clause may only appear once",
                pos,
            ));
        }
        current.optimizers = new.optimizers;
    }
    if new.params.is_some() {
        if current.params.is_some() {
            return Err(QqlError::syntax("PARAMS clause may only appear once", pos));
        }
        current.params = new.params;
    }
    if new.quantization.is_some() {
        if current.quantization.is_some() {
            return Err(QqlError::syntax(
                "QUANTIZATION clause may only appear once",
                pos,
            ));
        }
        current.quantization = new.quantization;
    }
    if new.quantization_update.is_some() {
        if current.quantization_update.is_some() {
            return Err(QqlError::syntax(
                "QUANTIZATION clause may only appear once",
                pos,
            ));
        }
        current.quantization_update = new.quantization_update;
    }
    Ok(())
}

pub fn check_deleted_threshold(value: &Value, pos: usize) -> Result<(), QqlError> {
    match value {
        Value::Int(n) => {
            let f = *n as f64;
            if !(0.0..=1.0).contains(&f) {
                return Err(QqlError::syntax(
                    "deleted_threshold must be between 0.0 and 1.0",
                    pos,
                ));
            }
        }
        Value::Float(f) if !(0.0..=1.0).contains(f) => {
            return Err(QqlError::syntax(
                "deleted_threshold must be between 0.0 and 1.0",
                pos,
            ));
        }
        _ => {}
    }
    Ok(())
}

pub fn validate_index_options(options: &[(String, Value)], pos: usize) -> Result<(), QqlError> {
    for (k, v) in options {
        let lower = k.to_ascii_lowercase();
        match lower.as_str() {
            "is_tenant" | "on_disk" | "enable_hnsw" | "lowercase" | "ascii_folding"
            | "phrase_matching" | "lookup" | "range" | "is_principal" => {
                if !matches!(v, Value::Bool(_)) {
                    return Err(QqlError::syntax(
                        alloc::format!("{} must be true or false", k),
                        pos,
                    ));
                }
            }
            "min_token_len" | "max_token_len" => {
                if !matches!(v, Value::Int(n) if *n >= 0) {
                    return Err(QqlError::syntax(
                        alloc::format!("{} must be a non-negative integer", k),
                        pos,
                    ));
                }
            }
            "tokenizer" => {
                if !matches!(v, Value::Str(_)) {
                    return Err(QqlError::syntax(
                        alloc::format!("{} must be a string", k),
                        pos,
                    ));
                }
            }
            "stopwords" => match v {
                Value::List(items) => {
                    for item in items {
                        if !matches!(item, Value::Str(_)) {
                            return Err(QqlError::syntax(
                                alloc::format!("{} must be a list of strings", k),
                                pos,
                            ));
                        }
                    }
                }
                _ => {
                    return Err(QqlError::syntax(
                        alloc::format!("{} must be a list of strings", k),
                        pos,
                    ));
                }
            },
            _ => {
                return Err(QqlError::syntax(
                    alloc::format!("unknown index option: {}", k),
                    pos,
                ));
            }
        }
    }
    Ok(())
}