use crate::core::{LuciError, Result};
use serde_json::Value;
use super::ast::{
FieldValueModifier, FunctionBoostMode, FunctionScoreMode, FusionMethod, GeoShapeValue,
QueryExpression, RankingExpression, ScoreFunction, ScoringExpression, SpanExpression,
SpatialRelation,
};
fn validate_keys<'a>(
node: &'a Value,
expected: &[&str],
ctx: &str,
) -> Result<&'a serde_json::Map<String, Value>> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: must be an object")))?;
for key in obj.keys() {
if !expected.contains(&key.as_str()) {
let expected_list = expected
.iter()
.map(|k| format!("`{k}`"))
.collect::<Vec<_>>()
.join(", ");
return Err(LuciError::InvalidQuery(format!(
"{ctx}: unknown field `{key}`, expected one of {expected_list}"
)));
}
}
Ok(obj)
}
pub(crate) fn opt_u64(
obj: &serde_json::Map<String, Value>,
key: &str,
ctx: &str,
) -> Result<Option<u64>> {
match obj.get(key) {
Some(v) if !v.is_null() => v.as_u64().map(Some).ok_or_else(|| {
LuciError::InvalidQuery(format!(
"{ctx}: \"{key}\" must be a non-negative integer, got {v}"
))
}),
_ => Ok(None),
}
}
pub(crate) fn opt_f64(
obj: &serde_json::Map<String, Value>,
key: &str,
ctx: &str,
) -> Result<Option<f64>> {
match obj.get(key) {
Some(v) if !v.is_null() => v.as_f64().map(Some).ok_or_else(|| {
LuciError::InvalidQuery(format!("{ctx}: \"{key}\" must be a number, got {v}"))
}),
_ => Ok(None),
}
}
pub(crate) fn opt_str<'a>(
obj: &'a serde_json::Map<String, Value>,
key: &str,
ctx: &str,
) -> Result<Option<&'a str>> {
match obj.get(key) {
Some(v) if !v.is_null() => v.as_str().map(Some).ok_or_else(|| {
LuciError::InvalidQuery(format!("{ctx}: \"{key}\" must be a string, got {v}"))
}),
_ => Ok(None),
}
}
pub(crate) fn opt_bool(
obj: &serde_json::Map<String, Value>,
key: &str,
ctx: &str,
) -> Result<Option<bool>> {
match obj.get(key) {
Some(v) if !v.is_null() => v.as_bool().map(Some).ok_or_else(|| {
LuciError::InvalidQuery(format!("{ctx}: \"{key}\" must be a boolean, got {v}"))
}),
_ => Ok(None),
}
}
pub fn parse_query_expression(json: &Value) -> Result<QueryExpression> {
let query_obj = if let Some(q) = json.get("query") {
q
} else {
json
};
parse_query_expression_node(query_obj)
}
fn parse_query_expression_node(node: &Value) -> Result<QueryExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("query must be a JSON object".into()))?;
if let Some(v) = obj.get("fusion") {
return parse_fusion_query(v);
}
Ok(QueryExpression::Scoring(parse_query_node(node)?))
}
pub fn parse_query(json: &Value) -> Result<ScoringExpression> {
let query_obj = if let Some(q) = json.get("query") {
q
} else {
json
};
parse_query_node(query_obj)
}
fn parse_query_node(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("query must be a JSON object".into()))?;
if obj.is_empty() {
return Err(LuciError::InvalidQuery("empty query object".into()));
}
if let Some(v) = obj.get("term") {
return parse_term_query(v);
}
if let Some(v) = obj.get("terms") {
return parse_terms_query(v);
}
if let Some(v) = obj.get("match") {
return parse_match_query(v);
}
if let Some(v) = obj.get("match_phrase") {
return parse_match_phrase_query(v);
}
if let Some(v) = obj.get("match_bool_prefix") {
return parse_match_bool_prefix_query(v);
}
if let Some(v) = obj.get("multi_match") {
return parse_multi_match_query(v);
}
if let Some(v) = obj.get("bool") {
return parse_bool_query(v);
}
if let Some(v) = obj.get("dis_max") {
return parse_dis_max_query(v);
}
if let Some(v) = obj.get("exists") {
return parse_exists_query(v);
}
if let Some(v) = obj.get("prefix") {
return parse_prefix_query(v);
}
if let Some(v) = obj.get("script_score") {
return parse_script_score_query(v);
}
if let Some(v) = obj.get("function_score") {
return parse_function_score_query(v);
}
if let Some(v) = obj.get("boosting") {
return parse_boosting_query(v);
}
if let Some(v) = obj.get("fuzzy") {
return parse_fuzzy_query(v);
}
if let Some(v) = obj.get("regexp") {
return parse_regexp_query(v);
}
if let Some(v) = obj.get("wildcard") {
return parse_wildcard_query(v);
}
if let Some(v) = obj.get("range") {
return parse_range_query(v);
}
if let Some(v) = obj.get("span_term") {
return parse_span_term_query(v);
}
if let Some(v) = obj.get("span_near") {
return parse_span_near_query(v);
}
if let Some(v) = obj.get("span_not") {
return parse_span_not_query(v);
}
if let Some(v) = obj.get("span_first") {
return parse_span_first_query(v);
}
if let Some(v) = obj.get("constant_score") {
return parse_constant_score_query(v);
}
if let Some(v) = obj.get("nested") {
return parse_nested_query(v);
}
if let Some(v) = obj.get("geo_distance") {
return parse_geo_distance_query(v);
}
if let Some(v) = obj.get("geo_bounding_box") {
return parse_geo_bbox_query(v);
}
if let Some(v) = obj.get("geo_shape") {
return parse_geo_shape_query(v);
}
if let Some(v) = obj.get("knn") {
return parse_knn_query(v);
}
if obj.contains_key("match_all") {
return Ok(ScoringExpression::MatchAll);
}
if obj.contains_key("match_none") {
return Ok(ScoringExpression::MatchNone);
}
let key = obj.keys().next().unwrap();
Err(LuciError::InvalidQuery(format!(
"unknown query type: {key}"
)))
}
fn maybe_boost(ast: ScoringExpression, boost: Option<f64>) -> ScoringExpression {
match boost {
Some(b) if (b - 1.0).abs() > f64::EPSILON => ScoringExpression::Boost {
query: Box::new(ast),
boost: b as f32,
},
_ => ast,
}
}
fn parse_term_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("term query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("term query: missing field".into()))?;
let (value, boost) = match field_val {
Value::String(s) => (s.clone(), None),
Value::Number(n) => (n.to_string(), None),
Value::Bool(b) => (b.to_string(), None),
Value::Object(_) => {
let ctx = format!("term[{field}]");
let inner = validate_keys(field_val, &["value", "boost"], &ctx)?;
let v = inner
.get("value")
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'value' field")))?;
let value = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => {
return Err(LuciError::InvalidQuery(format!(
"{ctx}: 'value' must be a string, number, or bool"
)));
}
};
(value, opt_f64(inner, "boost", &ctx)?)
}
_ => {
return Err(LuciError::InvalidQuery(
"term query: invalid value type".into(),
));
}
};
Ok(maybe_boost(
ScoringExpression::Term {
field: field.clone(),
value,
},
boost,
))
}
fn parse_terms_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("terms query must be an object".into()))?;
let (field, values_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("terms query: missing field".into()))?;
let arr = values_val
.as_array()
.ok_or_else(|| LuciError::InvalidQuery("terms query: values must be an array".into()))?;
let values: Vec<String> = arr
.iter()
.map(|v| match v {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
_ => Err(LuciError::InvalidQuery(
"terms query: invalid value type in array".into(),
)),
})
.collect::<Result<_>>()?;
Ok(ScoringExpression::Terms {
field: field.clone(),
values,
})
}
fn parse_match_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("match query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("match query: missing field".into()))?;
let (query, analyzer, boost) = match field_val {
Value::String(s) => (s.clone(), None, None),
Value::Object(_) => {
let ctx = format!("match[{field}]");
let inner = validate_keys(field_val, &["query", "analyzer", "boost"], &ctx)?;
let q = inner
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'query' field")))?
.to_string();
let a = opt_str(inner, "analyzer", &ctx)?.map(String::from);
(q, a, opt_f64(inner, "boost", &ctx)?)
}
_ => {
return Err(LuciError::InvalidQuery(
"match query: invalid field value".into(),
));
}
};
Ok(maybe_boost(
ScoringExpression::Match {
field: field.clone(),
query,
analyzer,
},
boost,
))
}
fn parse_match_phrase_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("match_phrase query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("match_phrase query: missing field".into()))?;
let (query, analyzer) = match field_val {
Value::String(s) => (s.clone(), None),
Value::Object(_) => {
let ctx = format!("match_phrase[{field}]");
let inner = validate_keys(field_val, &["query", "analyzer"], &ctx)?;
let q = inner
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'query'")))?
.to_string();
let a = opt_str(inner, "analyzer", &ctx)?.map(String::from);
(q, a)
}
_ => {
return Err(LuciError::InvalidQuery(
"match_phrase: invalid field value".into(),
));
}
};
Ok(ScoringExpression::MatchPhrase {
field: field.clone(),
query,
analyzer,
})
}
fn parse_match_bool_prefix_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("match_bool_prefix must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("match_bool_prefix: missing field".into()))?;
let (query, analyzer) = match field_val {
Value::String(s) => (s.clone(), None),
Value::Object(_) => {
let ctx = format!("match_bool_prefix[{field}]");
let inner = validate_keys(field_val, &["query", "analyzer"], &ctx)?;
let q = inner
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'query'")))?
.to_string();
let a = opt_str(inner, "analyzer", &ctx)?.map(String::from);
(q, a)
}
_ => {
return Err(LuciError::InvalidQuery(
"match_bool_prefix: invalid field value".into(),
));
}
};
Ok(ScoringExpression::MatchBoolPrefix {
field: field.clone(),
query,
analyzer,
})
}
fn parse_bool_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(
node,
&[
"must",
"should",
"must_not",
"filter",
"minimum_should_match",
"boost",
],
"bool",
)?;
let parse_clauses = |key: &str| -> Result<Vec<ScoringExpression>> {
match obj.get(key) {
None => Ok(Vec::new()),
Some(Value::Array(arr)) => arr.iter().map(parse_query_node).collect(),
Some(single) => Ok(vec![parse_query_node(single)?]),
}
};
let boost = opt_f64(obj, "boost", "bool")?;
Ok(maybe_boost(
ScoringExpression::Bool {
must: parse_clauses("must")?,
should: parse_clauses("should")?,
must_not: parse_clauses("must_not")?,
filter: parse_clauses("filter")?,
minimum_should_match: opt_u64(obj, "minimum_should_match", "bool")?.map(|v| v as u32),
},
boost,
))
}
fn parse_dis_max_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(node, &["queries", "tie_breaker", "boost"], "dis_max")?;
let queries = match obj.get("queries") {
Some(Value::Array(arr)) => arr
.iter()
.map(parse_query_node)
.collect::<Result<Vec<_>>>()?,
_ => {
return Err(LuciError::InvalidQuery(
"dis_max: missing 'queries' array".into(),
));
}
};
let tie_breaker = opt_f64(obj, "tie_breaker", "dis_max")?.unwrap_or(0.0) as f32;
let boost = opt_f64(obj, "boost", "dis_max")?;
Ok(maybe_boost(
ScoringExpression::DisMax {
queries,
tie_breaker,
},
boost,
))
}
fn parse_exists_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(node, &["field"], "exists")?;
let field = obj
.get("field")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("exists: missing 'field'".into()))?
.to_string();
Ok(ScoringExpression::Exists { field })
}
fn parse_prefix_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("prefix query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("prefix query: missing field".into()))?;
let (value, boost) = match field_val {
Value::String(s) => (s.clone(), None),
Value::Object(_) => {
let ctx = format!("prefix[{field}]");
let inner = validate_keys(field_val, &["value", "boost"], &ctx)?;
let value = inner
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'value'")))?
.to_string();
(value, opt_f64(inner, "boost", &ctx)?)
}
_ => {
return Err(LuciError::InvalidQuery(
"prefix query: invalid value type".into(),
));
}
};
Ok(maybe_boost(
ScoringExpression::Prefix {
field: field.clone(),
value,
},
boost,
))
}
fn parse_range_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("range query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("range query: missing field".into()))?;
let ctx = format!("range[{field}]");
let range_obj = validate_keys(field_val, &["gte", "gt", "lte", "lt", "boost"], &ctx)?;
let base = ScoringExpression::Range {
field: field.clone(),
gte: opt_f64(range_obj, "gte", &ctx)?,
gt: opt_f64(range_obj, "gt", &ctx)?,
lte: opt_f64(range_obj, "lte", &ctx)?,
lt: opt_f64(range_obj, "lt", &ctx)?,
};
let boost = opt_f64(range_obj, "boost", &ctx)?;
Ok(maybe_boost(base, boost))
}
fn parse_script_score_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(node, &["query", "script"], "script_score")?;
let query = match obj.get("query") {
Some(q) => parse_query_node(q)?,
None => ScoringExpression::MatchAll,
};
let script_val = obj
.get("script")
.ok_or_else(|| LuciError::InvalidQuery("script_score: missing 'script' object".into()))?;
let script_obj = validate_keys(script_val, &["source", "params"], "script_score.script")?;
let source = script_obj
.get("source")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("script_score: missing 'source'".into()))?
.to_string();
let mut params = std::collections::HashMap::new();
if let Some(p) = script_obj.get("params") {
let p = p.as_object().ok_or_else(|| {
LuciError::InvalidQuery("script_score.script.params: must be an object".into())
})?;
for (k, v) in p {
let n = v.as_f64().ok_or_else(|| {
LuciError::InvalidQuery(format!(
"script_score.script.params: \"{k}\" must be a number, got {v}"
))
})?;
params.insert(k.clone(), n);
}
}
Ok(ScoringExpression::ScriptScore {
query: Box::new(query),
script: source,
params,
})
}
fn parse_function_score_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(
node,
&[
"query",
"functions",
"field_value_factor",
"random_score",
"weight",
"score_mode",
"boost_mode",
"boost",
],
"function_score",
)?;
let query = match obj.get("query") {
Some(q) => parse_query_node(q)?,
None => ScoringExpression::MatchAll,
};
let mut functions = Vec::new();
if let Some(Value::Array(funcs)) = obj.get("functions") {
for func_obj in funcs {
if let Some(f) = parse_score_function(func_obj)? {
functions.push(f);
}
}
}
if let Some(fvf) = obj.get("field_value_factor") {
functions.push(parse_field_value_factor(fvf)?);
}
if let Some(rs) = obj.get("random_score") {
functions.push(parse_random_score(rs)?);
}
if let Some(weight) = opt_f64(obj, "weight", "function_score")? {
functions.push(ScoreFunction::Weight(weight as f32));
}
let score_mode = match opt_str(obj, "score_mode", "function_score")? {
Some("multiply") | None => FunctionScoreMode::Multiply,
Some("sum") => FunctionScoreMode::Sum,
Some("avg") => FunctionScoreMode::Avg,
Some("first") => FunctionScoreMode::First,
Some("max") => FunctionScoreMode::Max,
Some("min") => FunctionScoreMode::Min,
Some(other) => {
return Err(LuciError::InvalidQuery(format!(
"function_score: unknown score_mode '{other}'"
)));
}
};
let boost_mode = match opt_str(obj, "boost_mode", "function_score")? {
Some("multiply") | None => FunctionBoostMode::Multiply,
Some("replace") => FunctionBoostMode::Replace,
Some("sum") => FunctionBoostMode::Sum,
Some("avg") => FunctionBoostMode::Avg,
Some("max") => FunctionBoostMode::Max,
Some("min") => FunctionBoostMode::Min,
Some(other) => {
return Err(LuciError::InvalidQuery(format!(
"function_score: unknown boost_mode '{other}'"
)));
}
};
let boost = opt_f64(obj, "boost", "function_score")?;
let base = ScoringExpression::FunctionScore {
query: Box::new(query),
functions,
score_mode,
boost_mode,
};
Ok(maybe_boost(base, boost))
}
fn parse_score_function(node: &Value) -> Result<Option<ScoreFunction>> {
let obj = validate_keys(
node,
&["query", "field_value_factor", "random_score", "weight"],
"function_score.functions[]",
)?;
if let Some(fvf) = obj.get("field_value_factor") {
return Ok(Some(parse_field_value_factor(fvf)?));
}
if let Some(rs) = obj.get("random_score") {
return Ok(Some(parse_random_score(rs)?));
}
if let Some(weight) = opt_f64(obj, "weight", "function_score.functions[]")? {
return Ok(Some(ScoreFunction::Weight(weight as f32)));
}
Ok(None)
}
fn parse_field_value_factor(node: &Value) -> Result<ScoreFunction> {
let obj = validate_keys(
node,
&["field", "factor", "modifier", "missing"],
"field_value_factor",
)?;
let field = obj
.get("field")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("field_value_factor: missing 'field'".into()))?
.to_string();
let factor = opt_f64(obj, "factor", "field_value_factor")?.unwrap_or(1.0) as f32;
let modifier = match opt_str(obj, "modifier", "field_value_factor")? {
Some("log1p") => FieldValueModifier::Log1p,
Some("log2p") => FieldValueModifier::Log2p,
Some("ln1p") => FieldValueModifier::Ln1p,
Some("ln2p") => FieldValueModifier::Ln2p,
Some("sqrt") => FieldValueModifier::Sqrt,
Some("square") => FieldValueModifier::Square,
Some("reciprocal") => FieldValueModifier::Reciprocal,
None | Some("none") => FieldValueModifier::None,
Some(other) => {
return Err(LuciError::InvalidQuery(format!(
"field_value_factor: unknown modifier '{other}'"
)));
}
};
let missing = opt_f64(obj, "missing", "field_value_factor")?.unwrap_or(1.0);
Ok(ScoreFunction::FieldValueFactor {
field,
factor,
modifier,
missing,
})
}
fn parse_random_score(node: &Value) -> Result<ScoreFunction> {
let obj = validate_keys(node, &["seed"], "random_score")?;
let seed = opt_u64(obj, "seed", "random_score")?.unwrap_or(0);
Ok(ScoreFunction::RandomScore { seed })
}
fn parse_boosting_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(
node,
&["positive", "negative", "negative_boost"],
"boosting",
)?;
let positive = obj
.get("positive")
.ok_or_else(|| LuciError::InvalidQuery("boosting: missing 'positive'".into()))?;
let negative = obj
.get("negative")
.ok_or_else(|| LuciError::InvalidQuery("boosting: missing 'negative'".into()))?;
let negative_boost = opt_f64(obj, "negative_boost", "boosting")?.unwrap_or(0.5) as f32;
Ok(ScoringExpression::Boosting {
positive: Box::new(parse_query_node(positive)?),
negative: Box::new(parse_query_node(negative)?),
negative_boost,
})
}
fn parse_fuzzy_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("fuzzy query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("fuzzy query: missing field".into()))?;
let (value, fuzziness, boost) = match field_val {
Value::String(s) => (s.clone(), 2u32, None),
Value::Object(_) => {
let ctx = format!("fuzzy[{field}]");
let inner = validate_keys(field_val, &["value", "fuzziness", "boost"], &ctx)?;
let value = inner
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'value'")))?
.to_string();
let fuzziness = opt_u64(inner, "fuzziness", &ctx)?.unwrap_or(2) as u32;
(value, fuzziness, opt_f64(inner, "boost", &ctx)?)
}
other => (other.to_string(), 2u32, None),
};
Ok(maybe_boost(
ScoringExpression::Fuzzy {
field: field.clone(),
value,
fuzziness,
},
boost,
))
}
fn parse_regexp_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("regexp query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("regexp query: missing field".into()))?;
let (value, boost) = parse_pattern_value(field_val, &format!("regexp[{field}]"))?;
Ok(maybe_boost(
ScoringExpression::Regexp {
field: field.clone(),
value,
},
boost,
))
}
fn parse_wildcard_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("wildcard query must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("wildcard query: missing field".into()))?;
let (value, boost) = parse_pattern_value(field_val, &format!("wildcard[{field}]"))?;
Ok(maybe_boost(
ScoringExpression::Wildcard {
field: field.clone(),
value,
},
boost,
))
}
fn parse_pattern_value(field_val: &Value, ctx: &str) -> Result<(String, Option<f64>)> {
match field_val {
Value::String(s) => Ok((s.clone(), None)),
Value::Object(_) => {
let inner = validate_keys(field_val, &["value", "boost"], ctx)?;
let value = inner
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'value'")))?
.to_string();
Ok((value, opt_f64(inner, "boost", ctx)?))
}
other => Ok((other.to_string(), None)),
}
}
fn parse_multi_match_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(
node,
&["query", "fields", "analyzer", "type", "tie_breaker"],
"multi_match",
)?;
let query = obj
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("multi_match: missing 'query'".into()))?
.to_string();
let fields = obj
.get("fields")
.and_then(|v| v.as_array())
.ok_or_else(|| LuciError::InvalidQuery("multi_match: missing 'fields' array".into()))?
.iter()
.map(|v| {
v.as_str().map(String::from).ok_or_else(|| {
LuciError::InvalidQuery(format!(
"multi_match: fields[] entries must be strings, got {v}"
))
})
})
.collect::<Result<Vec<_>>>()?;
let analyzer = opt_str(obj, "analyzer", "multi_match")?.map(String::from);
let mm_type = opt_str(obj, "type", "multi_match")?.unwrap_or("best_fields");
let default_tie_breaker = match mm_type {
"best_fields" => 0.0,
"most_fields" | "bool_prefix" => 1.0,
other => {
return Err(LuciError::InvalidQuery(format!(
"multi_match: unsupported type '{other}', expected one of \
best_fields, most_fields, bool_prefix"
)));
}
};
let tie_breaker = opt_f64(obj, "tie_breaker", "multi_match")?
.map(|v| v as f32)
.unwrap_or(default_tie_breaker);
Ok(ScoringExpression::MultiMatch {
fields,
query,
analyzer,
tie_breaker,
})
}
fn parse_span_term_query(node: &Value) -> Result<ScoringExpression> {
Ok(ScoringExpression::Span(parse_span_expression_term(node)?))
}
fn parse_span_near_query(node: &Value) -> Result<ScoringExpression> {
Ok(ScoringExpression::Span(parse_span_expression_near(node)?))
}
fn parse_span_not_query(node: &Value) -> Result<ScoringExpression> {
Ok(ScoringExpression::Span(parse_span_expression_not(node)?))
}
fn parse_span_first_query(node: &Value) -> Result<ScoringExpression> {
Ok(ScoringExpression::Span(parse_span_expression_first(node)?))
}
fn parse_span_expression_node(node: &Value) -> Result<SpanExpression> {
let obj = node.as_object().ok_or_else(|| {
LuciError::InvalidQuery("span_first/span_not inner must be a span query object".into())
})?;
if let Some(v) = obj.get("span_term") {
return parse_span_expression_term(v);
}
if let Some(v) = obj.get("span_near") {
return parse_span_expression_near(v);
}
if let Some(v) = obj.get("span_not") {
return parse_span_expression_not(v);
}
if let Some(v) = obj.get("span_first") {
return parse_span_expression_first(v);
}
Err(LuciError::InvalidQuery(
"span_first/span_not inner must be one of: span_term, span_near, span_not, span_first"
.into(),
))
}
fn parse_span_expression_term(node: &Value) -> Result<SpanExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("span_term must be an object".into()))?;
let (field, field_val) = obj
.iter()
.next()
.ok_or_else(|| LuciError::InvalidQuery("span_term: missing field".into()))?;
let value = match field_val {
Value::String(s) => s.clone(),
Value::Object(_) => {
let ctx = format!("span_term[{field}]");
let inner = validate_keys(field_val, &["value"], &ctx)?;
inner
.get("value")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery(format!("{ctx}: missing 'value'")))?
.to_string()
}
other => other.to_string(),
};
Ok(SpanExpression::SpanTerm {
field: field.clone(),
value,
})
}
fn parse_span_expression_near(node: &Value) -> Result<SpanExpression> {
let obj = validate_keys(node, &["clauses", "slop", "in_order"], "span_near")?;
let clauses = obj
.get("clauses")
.and_then(|v| v.as_array())
.ok_or_else(|| LuciError::InvalidQuery("span_near: missing 'clauses'".into()))?;
if clauses.is_empty() {
return Err(LuciError::InvalidQuery("span_near: no clauses".into()));
}
let slop = opt_u64(obj, "slop", "span_near")?
.map(|v| v as u32)
.unwrap_or(0);
let in_order = opt_bool(obj, "in_order", "span_near")?.unwrap_or(true);
let mut field: Option<String> = None;
let mut terms: Vec<String> = Vec::with_capacity(clauses.len());
for clause in clauses {
let inner = clause.get("span_term").ok_or_else(|| {
LuciError::InvalidQuery("span_near.clauses[] must be span_term".into())
})?;
let sub = parse_span_expression_term(inner)?;
let SpanExpression::SpanTerm { field: f, value: v } = sub else {
return Err(LuciError::InvalidQuery(
"span_near.clauses[] must be span_term".into(),
));
};
if let Some(existing) = &field {
if existing != &f {
return Err(LuciError::InvalidQuery(format!(
"span_near: all clauses must be on the same field; got {existing:?} and {f:?}"
)));
}
} else {
field = Some(f);
}
terms.push(v);
}
let field = field.ok_or_else(|| LuciError::InvalidQuery("span_near: no clauses".into()))?;
Ok(SpanExpression::SpanNear {
field,
terms,
slop,
in_order,
})
}
fn parse_span_expression_not(node: &Value) -> Result<SpanExpression> {
let obj = validate_keys(node, &["include", "exclude"], "span_not")?;
let include = obj
.get("include")
.ok_or_else(|| LuciError::InvalidQuery("span_not: missing 'include'".into()))?;
let exclude = obj
.get("exclude")
.ok_or_else(|| LuciError::InvalidQuery("span_not: missing 'exclude'".into()))?;
Ok(SpanExpression::SpanNot {
include: Box::new(parse_span_expression_node(include)?),
exclude: Box::new(parse_span_expression_node(exclude)?),
})
}
fn parse_span_expression_first(node: &Value) -> Result<SpanExpression> {
let obj = validate_keys(node, &["match", "end"], "span_first")?;
let match_query = obj
.get("match")
.ok_or_else(|| LuciError::InvalidQuery("span_first: missing 'match'".into()))?;
let end = obj
.get("end")
.and_then(|v| v.as_u64())
.ok_or_else(|| LuciError::InvalidQuery("span_first: missing 'end'".into()))?
as u32;
Ok(SpanExpression::SpanFirst {
query: Box::new(parse_span_expression_node(match_query)?),
end,
})
}
fn parse_constant_score_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(node, &["filter", "boost"], "constant_score")?;
let filter = obj
.get("filter")
.ok_or_else(|| LuciError::InvalidQuery("constant_score: missing 'filter'".into()))?;
let query = parse_query_node(filter)?;
let boost = opt_f64(obj, "boost", "constant_score")?
.map(|f| f as f32)
.unwrap_or(1.0);
Ok(ScoringExpression::ConstantScore {
query: Box::new(query),
boost,
})
}
fn parse_nested_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(node, &["path", "query", "inner_hits"], "nested")?;
let path = obj
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("nested: missing 'path'".into()))?
.to_string();
let query = obj
.get("query")
.ok_or_else(|| LuciError::InvalidQuery("nested: missing 'query'".into()))?;
let inner_hits = match obj.get("inner_hits") {
Some(ih) => {
let ih_obj = validate_keys(ih, &["name", "size", "from"], "nested.inner_hits")?;
Some(crate::query::ast::InnerHitsConfig {
name: opt_str(ih_obj, "name", "nested.inner_hits")?.map(String::from),
size: opt_u64(ih_obj, "size", "nested.inner_hits")?
.map(|v| v as usize)
.unwrap_or(3),
from: opt_u64(ih_obj, "from", "nested.inner_hits")?
.map(|v| v as usize)
.unwrap_or(0),
})
}
None => None,
};
Ok(ScoringExpression::Nested {
path,
query: Box::new(parse_query_node(query)?),
inner_hits,
})
}
fn parse_geo_distance_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("geo_distance must be an object".into()))?;
let distance = obj
.get("distance")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("geo_distance: missing 'distance'".into()))?
.to_string();
for (key, val) in obj {
if key == "distance" {
continue;
}
let point = crate::spatial::geo::GeoPoint::from_json(val).ok_or_else(|| {
LuciError::InvalidQuery(format!("geo_distance: invalid geo point for field '{key}'"))
})?;
return Ok(ScoringExpression::GeoDistance {
field: key.clone(),
lat: point.lat,
lon: point.lon,
distance,
});
}
Err(LuciError::InvalidQuery(
"geo_distance: missing field".into(),
))
}
fn parse_geo_bbox_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("geo_bounding_box must be an object".into()))?;
for (key, val) in obj {
let bbox = val.as_object().ok_or_else(|| {
LuciError::InvalidQuery("geo_bounding_box: field value must be an object".into())
})?;
let tl = bbox.get("top_left").ok_or_else(|| {
LuciError::InvalidQuery("geo_bounding_box: missing 'top_left'".into())
})?;
let br = bbox.get("bottom_right").ok_or_else(|| {
LuciError::InvalidQuery("geo_bounding_box: missing 'bottom_right'".into())
})?;
let tl_point = crate::spatial::geo::GeoPoint::from_json(tl)
.ok_or_else(|| LuciError::InvalidQuery("invalid top_left".into()))?;
let br_point = crate::spatial::geo::GeoPoint::from_json(br)
.ok_or_else(|| LuciError::InvalidQuery("invalid bottom_right".into()))?;
return Ok(ScoringExpression::GeoBoundingBox {
field: key.clone(),
top_left_lat: tl_point.lat,
top_left_lon: tl_point.lon,
bottom_right_lat: br_point.lat,
bottom_right_lon: br_point.lon,
});
}
Err(LuciError::InvalidQuery(
"geo_bounding_box: missing field".into(),
))
}
fn parse_geo_shape_query(node: &Value) -> Result<ScoringExpression> {
let obj = node
.as_object()
.ok_or_else(|| LuciError::InvalidQuery("geo_shape must be an object".into()))?;
for (key, val) in obj {
let field_obj = val.as_object().ok_or_else(|| {
LuciError::InvalidQuery(format!("geo_shape: field '{key}' must be an object"))
})?;
let shape_val = field_obj
.get("shape")
.ok_or_else(|| LuciError::InvalidQuery("geo_shape: missing 'shape'".into()))?;
let relation_str = opt_str(field_obj, "relation", "geo_shape")?.unwrap_or("intersects");
let relation = match relation_str {
"intersects" | "INTERSECTS" => SpatialRelation::Intersects,
"within" | "WITHIN" => SpatialRelation::Within,
"contains" | "CONTAINS" => SpatialRelation::Contains,
"disjoint" | "DISJOINT" => SpatialRelation::Disjoint,
"touches" | "TOUCHES" => SpatialRelation::Touches,
"crosses" | "CROSSES" => SpatialRelation::Crosses,
"overlaps" | "OVERLAPS" => SpatialRelation::Overlaps,
"equals" | "EQUALS" => SpatialRelation::Equals,
"covers" | "COVERS" => SpatialRelation::Covers,
"coveredby" | "COVEREDBY" => SpatialRelation::CoveredBy,
"contains_properly" | "CONTAINS_PROPERLY" => SpatialRelation::ContainsProperly,
other => {
return Err(LuciError::InvalidQuery(format!(
"geo_shape: unknown relation '{other}'"
)));
}
};
return Ok(ScoringExpression::GeoShape {
field: key.clone(),
shape: GeoShapeValue {
json: shape_val.clone(),
},
relation,
});
}
Err(LuciError::InvalidQuery("geo_shape: missing field".into()))
}
fn parse_knn_query(node: &Value) -> Result<ScoringExpression> {
let obj = validate_keys(
node,
&[
"field",
"query_vector",
"k",
"num_candidates",
"threshold",
"boost",
],
"knn",
)?;
let field = obj
.get("field")
.and_then(|v| v.as_str())
.ok_or_else(|| LuciError::InvalidQuery("knn requires 'field'".into()))?
.to_string();
let raw_vec = obj
.get("query_vector")
.and_then(|v| v.as_array())
.ok_or_else(|| LuciError::InvalidQuery("knn requires 'query_vector'".into()))?;
let query_vector: Vec<f32> = raw_vec
.iter()
.map(|v| {
v.as_f64().map(|f| f as f32).ok_or_else(|| {
LuciError::InvalidQuery("knn query_vector elements must be numbers".into())
})
})
.collect::<Result<Vec<f32>>>()?;
if query_vector.is_empty() {
return Err(LuciError::InvalidQuery(
"knn query_vector must not be empty".into(),
));
}
let k = opt_u64(obj, "k", "knn")?.unwrap_or(10) as usize;
if k == 0 {
return Err(LuciError::InvalidQuery("knn k must be > 0".into()));
}
let num_candidates = opt_u64(obj, "num_candidates", "knn")?
.map(|v| v as usize)
.unwrap_or((k as f64 * 1.5).ceil() as usize);
let threshold = opt_f64(obj, "threshold", "knn")?.map(|v| v as f32);
let boost = opt_f64(obj, "boost", "knn")?;
let base = ScoringExpression::Knn {
field,
query_vector,
k,
num_candidates,
threshold,
};
Ok(maybe_boost(base, boost))
}
fn parse_fusion_query(node: &Value) -> Result<QueryExpression> {
let obj = validate_keys(
node,
&[
"sources",
"method",
"rank_constant",
"rank_window_size",
"weights",
],
"fusion",
)?;
let sources_arr = obj
.get("sources")
.and_then(|v| v.as_array())
.ok_or_else(|| LuciError::InvalidQuery("fusion requires 'sources' array".into()))?;
if sources_arr.len() < 2 {
return Err(LuciError::InvalidQuery(
"fusion requires at least 2 sources".into(),
));
}
let sources: Vec<QueryExpression> = sources_arr
.iter()
.map(parse_query_expression_node)
.collect::<Result<Vec<_>>>()?;
let method = match opt_str(obj, "method", "fusion")? {
Some("rrf") | Some("reciprocal_rank") => FusionMethod::ReciprocalRank,
Some("sum") => FusionMethod::Sum,
Some("arithmetic_mean") | Some("avg") => FusionMethod::ArithmeticMean,
Some("harmonic_mean") => FusionMethod::HarmonicMean,
Some("geometric_mean") => FusionMethod::GeometricMean,
Some(other) => {
return Err(LuciError::InvalidQuery(format!(
"unknown fusion method: '{other}'"
)));
}
None => FusionMethod::ReciprocalRank, };
let rank_constant = opt_f64(obj, "rank_constant", "fusion")?.unwrap_or(60.0) as f32;
let rank_window_size = opt_u64(obj, "rank_window_size", "fusion")?.map(|v| v as usize);
let weights = match obj.get("weights") {
Some(v) => {
let arr = v.as_array().ok_or_else(|| {
LuciError::InvalidQuery("fusion: \"weights\" must be an array of numbers".into())
})?;
let ws = arr
.iter()
.map(|w| {
w.as_f64().map(|f| f as f32).ok_or_else(|| {
LuciError::InvalidQuery(format!(
"fusion: weights[] entries must be numbers, got {w}"
))
})
})
.collect::<Result<Vec<f32>>>()?;
Some(ws)
}
None => None,
};
if let Some(ref ws) = weights {
if ws.len() != sources.len() {
return Err(LuciError::InvalidQuery(format!(
"fusion weights length ({}) must match sources length ({})",
ws.len(),
sources.len()
)));
}
}
Ok(QueryExpression::Ranking(RankingExpression::Fusion {
sources,
method,
rank_constant,
rank_window_size,
weights,
}))
}
pub fn parse_distance_km(s: &str) -> f64 {
let s = s.trim();
if let Some(n) = s.strip_suffix("km") {
n.trim().parse().unwrap_or(0.0)
} else if let Some(n) = s.strip_suffix("mi") {
n.trim().parse::<f64>().unwrap_or(0.0) * 1.60934
} else if let Some(n) = s.strip_suffix('m') {
n.trim().parse::<f64>().unwrap_or(0.0) / 1000.0
} else {
s.parse::<f64>().unwrap_or(0.0) / 1000.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_term_shorthand() {
let q = parse_query(&json!({"term": {"status": "active"}})).unwrap();
assert_eq!(
q,
ScoringExpression::Term {
field: "status".into(),
value: "active".into()
}
);
}
#[test]
fn parse_term_full_form() {
let q = parse_query(&json!({"term": {"status": {"value": "active"}}})).unwrap();
assert_eq!(
q,
ScoringExpression::Term {
field: "status".into(),
value: "active".into()
}
);
}
#[test]
fn parse_term_numeric() {
let q = parse_query(&json!({"term": {"age": 25}})).unwrap();
assert_eq!(
q,
ScoringExpression::Term {
field: "age".into(),
value: "25".into()
}
);
}
#[test]
fn parse_terms() {
let q = parse_query(&json!({"terms": {"status": ["a", "b", "c"]}})).unwrap();
assert_eq!(
q,
ScoringExpression::Terms {
field: "status".into(),
values: vec!["a".into(), "b".into(), "c".into()]
}
);
}
#[test]
fn parse_match_shorthand() {
let q = parse_query(&json!({"match": {"title": "search engine"}})).unwrap();
assert_eq!(
q,
ScoringExpression::Match {
field: "title".into(),
query: "search engine".into(),
analyzer: None
}
);
}
#[test]
fn parse_match_full_form() {
let q = parse_query(&json!({
"match": {"title": {"query": "search", "analyzer": "standard"}}
}))
.unwrap();
assert_eq!(
q,
ScoringExpression::Match {
field: "title".into(),
query: "search".into(),
analyzer: Some("standard".into())
}
);
}
#[test]
fn parse_match_phrase_shorthand() {
let q = parse_query(&json!({"match_phrase": {"body": "quick brown fox"}})).unwrap();
assert_eq!(
q,
ScoringExpression::MatchPhrase {
field: "body".into(),
query: "quick brown fox".into(),
analyzer: None
}
);
}
#[test]
fn parse_match_phrase_full_form() {
let q = parse_query(&json!({
"match_phrase": {"body": {"query": "quick brown"}}
}))
.unwrap();
if let ScoringExpression::MatchPhrase { query, .. } = &q {
assert_eq!(query, "quick brown");
} else {
panic!("expected MatchPhrase");
}
}
#[test]
fn parse_bool_basic() {
let q = parse_query(&json!({
"bool": {
"must": [{"term": {"status": "active"}}],
"filter": [{"exists": {"field": "title"}}]
}
}))
.unwrap();
if let ScoringExpression::Bool {
must,
should,
must_not,
filter,
..
} = &q
{
assert_eq!(must.len(), 1);
assert!(should.is_empty());
assert!(must_not.is_empty());
assert_eq!(filter.len(), 1);
} else {
panic!("expected Bool");
}
}
#[test]
fn parse_bool_all_clauses() {
let q = parse_query(&json!({
"bool": {
"must": [{"match": {"title": "search"}}],
"should": [{"term": {"tag": "hot"}}],
"must_not": [{"term": {"status": "deleted"}}],
"filter": [{"exists": {"field": "body"}}]
}
}))
.unwrap();
if let ScoringExpression::Bool {
must,
should,
must_not,
filter,
..
} = &q
{
assert_eq!(must.len(), 1);
assert_eq!(should.len(), 1);
assert_eq!(must_not.len(), 1);
assert_eq!(filter.len(), 1);
}
}
#[test]
fn parse_bool_single_clause_not_array() {
let q = parse_query(&json!({
"bool": {
"must": {"term": {"status": "active"}}
}
}))
.unwrap();
if let ScoringExpression::Bool { must, .. } = &q {
assert_eq!(must.len(), 1);
}
}
#[test]
fn parse_exists() {
let q = parse_query(&json!({"exists": {"field": "title"}})).unwrap();
assert_eq!(
q,
ScoringExpression::Exists {
field: "title".into()
}
);
}
#[test]
fn parse_prefix_shorthand() {
let q = parse_query(&json!({"prefix": {"title": "sea"}})).unwrap();
assert_eq!(
q,
ScoringExpression::Prefix {
field: "title".into(),
value: "sea".into()
}
);
}
#[test]
fn parse_prefix_full_form() {
let q = parse_query(&json!({"prefix": {"title": {"value": "sea"}}})).unwrap();
assert_eq!(
q,
ScoringExpression::Prefix {
field: "title".into(),
value: "sea".into()
}
);
}
#[test]
fn parse_constant_score() {
let q = parse_query(&json!({
"constant_score": {
"filter": {"term": {"status": "active"}},
"boost": 1.5
}
}))
.unwrap();
if let ScoringExpression::ConstantScore { boost, query } = &q {
assert_eq!(*boost, 1.5);
assert!(matches!(query.as_ref(), ScoringExpression::Term { .. }));
} else {
panic!("expected ConstantScore");
}
}
#[test]
fn parse_match_all() {
let q = parse_query(&json!({"match_all": {}})).unwrap();
assert_eq!(q, ScoringExpression::MatchAll);
}
#[test]
fn parse_match_none() {
let q = parse_query(&json!({"match_none": {}})).unwrap();
assert_eq!(q, ScoringExpression::MatchNone);
}
#[test]
fn parse_with_query_wrapper() {
let q = parse_query(&json!({
"query": {"term": {"status": "active"}}
}))
.unwrap();
assert!(matches!(q, ScoringExpression::Term { .. }));
}
#[test]
fn parse_unknown_query_type() {
let r = parse_query(&json!({"unknown_type": {"field": "val"}}));
assert!(r.is_err());
}
#[test]
fn parse_empty_object() {
let r = parse_query(&json!({}));
assert!(r.is_err());
}
#[test]
fn parse_nested_bool() {
let q = parse_query(&json!({
"bool": {
"must": [{
"bool": {
"should": [
{"term": {"a": "1"}},
{"term": {"b": "2"}}
]
}
}]
}
}))
.unwrap();
if let ScoringExpression::Bool { must, .. } = &q {
assert!(matches!(&must[0], ScoringExpression::Bool { .. }));
}
}
#[test]
fn parse_deeply_nested() {
let q = parse_query(&json!({
"bool": {
"filter": [{
"constant_score": {
"filter": {"term": {"x": "y"}},
"boost": 2.0
}
}]
}
}))
.unwrap();
if let ScoringExpression::Bool { filter, .. } = &q {
assert!(matches!(
&filter[0],
ScoringExpression::ConstantScore { .. }
));
}
}
#[test]
fn parse_knn_query_basic() {
let q = parse_query(&json!({"knn": {
"field": "embedding",
"query_vector": [1.0, 2.0, 3.0],
"k": 5,
"num_candidates": 20
}}))
.unwrap();
assert!(matches!(q, ScoringExpression::Knn { k: 5, .. }));
if let ScoringExpression::Knn {
field,
query_vector,
k,
num_candidates,
threshold,
} = &q
{
assert_eq!(field, "embedding");
assert_eq!(query_vector, &[1.0, 2.0, 3.0]);
assert_eq!(*k, 5);
assert_eq!(*num_candidates, 20);
assert!(threshold.is_none());
}
}
#[test]
fn parse_knn_query_defaults() {
let q = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0]
}}))
.unwrap();
if let ScoringExpression::Knn {
k, num_candidates, ..
} = &q
{
assert_eq!(*k, 10); assert_eq!(*num_candidates, 15); } else {
panic!("expected Knn");
}
}
#[test]
fn parse_knn_query_with_threshold() {
let q = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"threshold": 0.5
}}))
.unwrap();
if let ScoringExpression::Knn { threshold, .. } = &q {
assert_eq!(*threshold, Some(0.5));
} else {
panic!("expected Knn");
}
}
#[test]
fn parse_knn_query_zero_k_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"k": 0
}}));
assert!(result.is_err());
}
#[test]
fn parse_knn_query_empty_vector_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": []
}}));
assert!(result.is_err());
}
#[test]
fn parse_knn_query_non_numeric_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0, "bad", 3.0]
}}));
assert!(result.is_err());
}
#[test]
fn parse_knn_query_missing_field_rejected() {
let result = parse_query(&json!({"knn": {
"query_vector": [1.0]
}}));
assert!(result.is_err());
}
#[test]
fn parse_knn_query_missing_vector_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f"
}}));
assert!(result.is_err());
}
#[test]
fn parse_knn_query_string_k_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"k": "5"
}}));
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("\"k\"") && msg.contains("integer"),
"error must explain the type mismatch: {msg}"
);
}
#[test]
fn parse_knn_query_float_k_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"k": 5.5
}}));
assert!(result.is_err(), "float k must be rejected");
}
#[test]
fn parse_knn_query_string_num_candidates_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"num_candidates": "20"
}}));
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("num_candidates"),
"error must name the option: {msg}"
);
}
#[test]
fn parse_knn_query_string_threshold_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"threshold": "high"
}}));
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("threshold") && msg.contains("number"),
"error must explain the type mismatch: {msg}"
);
}
#[test]
fn parse_knn_query_string_boost_rejected() {
let result = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"boost": "2"
}}));
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("boost"), "error must name the option: {msg}");
}
#[test]
fn parse_knn_query_null_k_uses_default() {
let q = parse_query(&json!({"knn": {
"field": "f",
"query_vector": [1.0],
"k": null
}}))
.unwrap();
if let ScoringExpression::Knn { k, .. } = &q {
assert_eq!(*k, 10);
} else {
panic!("expected Knn");
}
}
#[test]
fn parse_term_string_boost_rejected() {
let err = parse_query(&json!({"term": {"f": {"value": "x", "boost": "2"}}})).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("boost") && msg.contains("number"),
"got: {msg}"
);
}
#[test]
fn parse_term_valid_boost_still_parses() {
parse_query(&json!({"term": {"f": {"value": "x", "boost": 2.0}}})).unwrap();
}
#[test]
fn parse_match_non_string_analyzer_rejected() {
let err = parse_query(&json!({"match": {"f": {"query": "x", "analyzer": 7}}})).unwrap_err();
assert!(format!("{err}").contains("analyzer"), "{err}");
}
#[test]
fn parse_bool_string_minimum_should_match_rejected() {
let err = parse_query(
&json!({"bool": {"should": [{"term": {"f": "a"}}], "minimum_should_match": "1"}}),
)
.unwrap_err();
assert!(format!("{err}").contains("minimum_should_match"), "{err}");
}
#[test]
fn parse_range_string_bound_rejected() {
let err = parse_query(&json!({"range": {"price": {"gte": "10"}}})).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("gte") && msg.contains("number"), "{msg}");
}
#[test]
fn parse_function_score_non_string_score_mode_rejected() {
let err = parse_query(&json!({"function_score": {"score_mode": 5}})).unwrap_err();
assert!(format!("{err}").contains("score_mode"), "{err}");
}
#[test]
fn parse_function_score_unknown_score_mode_rejected() {
let err = parse_query(&json!({"function_score": {"score_mode": "prod"}})).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("score_mode") && msg.contains("prod"), "{msg}");
}
#[test]
fn parse_multi_match_non_string_field_rejected() {
let err = parse_query(&json!({"multi_match": {"query": "x", "fields": ["title", 7]}}))
.unwrap_err();
assert!(format!("{err}").contains("fields"), "{err}");
}
#[test]
fn parse_multi_match_unsupported_type_rejected() {
let err = parse_query(
&json!({"multi_match": {"query": "x", "fields": ["a"], "type": "cross_fields"}}),
)
.unwrap_err();
assert!(format!("{err}").contains("type"), "{err}");
}
#[test]
fn parse_fusion_non_number_weight_rejected() {
let err = parse_query_expression(&json!({"fusion": {
"sources": [{"match": {"f": "a"}}, {"match": {"f": "b"}}],
"weights": ["1.0", "2.0"]
}}))
.unwrap_err();
assert!(format!("{err}").contains("weights"), "{err}");
}
}