use crate::ast::*;
const ORIGINAL_POINT_ID_PAYLOAD_KEY: &str = "_qail_original_point_id";
fn json_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
pub trait ToQdrant {
fn to_qdrant_search(&self) -> String;
}
impl ToQdrant for Qail {
fn to_qdrant_search(&self) -> String {
let result = match self.action {
Action::Get | Action::Search => build_qdrant_search(self),
Action::Put | Action::Add | Action::Upsert => build_qdrant_upsert(self),
Action::Scroll => build_qdrant_scroll(self),
Action::Del => build_qdrant_delete(self),
_ => {
return format!(
"{{ \"error\": \"Action {:?} not supported for Qdrant\" }}",
self.action
);
}
};
result.unwrap_or_else(|err| qdrant_error(&err))
}
}
fn qdrant_error(message: &str) -> String {
format!("{{ \"error\": {} }}", json_string(message))
}
fn normalize_qdrant_field(raw: &str) -> &str {
raw.trim().trim_matches('"').trim()
}
fn qdrant_requested_projection_segment<'a>(raw: &'a str, table: &str) -> &'a str {
let trimmed = raw.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
trimmed
} else if let Some(rest) = trimmed
.strip_prefix(table)
.and_then(|rest| rest.strip_prefix('.'))
{
rest.trim()
} else {
trimmed
}
}
fn qdrant_reserved_field_matches(raw: &str, reserved: &str) -> bool {
normalize_qdrant_field(raw).eq_ignore_ascii_case(normalize_qdrant_field(reserved))
}
fn qdrant_projection_is_wildcard(raw: &str, table: &str) -> bool {
let trimmed = raw.trim();
if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
return false;
}
trimmed == "*" || trimmed.strip_prefix(table).is_some_and(|rest| rest == ".*")
}
fn raw_named_qdrant_field(expr: &Expr) -> Result<&str, String> {
let raw = match expr {
Expr::Named(name) | Expr::Aliased { name, .. } => name.as_str(),
other => {
return Err(format!(
"Qdrant fields must be named, got expression `{other}`"
));
}
};
let field = normalize_qdrant_field(raw);
if field.is_empty() {
return Err("Qdrant field name cannot be empty".to_string());
}
Ok(raw)
}
fn named_qdrant_field(expr: &Expr) -> Result<&str, String> {
Ok(normalize_qdrant_field(raw_named_qdrant_field(expr)?))
}
fn validate_json_payload_value(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(map) => {
for (key, value) in map {
if key.trim().is_empty() {
return Err("Qdrant JSON payload object keys cannot be empty".to_string());
}
validate_json_payload_value(value)?;
}
Ok(())
}
serde_json::Value::Array(items) => {
for item in items {
validate_json_payload_value(item)?;
}
Ok(())
}
serde_json::Value::Number(number) => {
if let Some(value) = number.as_u64()
&& value > i64::MAX as u64
{
return Err(
"Qdrant JSON integer payload values must fit in signed 64-bit range"
.to_string(),
);
}
Ok(())
}
_ => Ok(()),
}
}
fn point_id_to_json(value: &Value) -> Result<String, String> {
match value {
Value::Int(n) if *n >= 0 => Ok(n.to_string()),
Value::String(s) if !s.trim().is_empty() => Ok(json_string(s)),
Value::Uuid(u) => Ok(json_string(&u.to_string())),
_ => Err(
"Qdrant point id must be a non-negative integer, non-empty string, or UUID".to_string(),
),
}
}
fn point_id_array_to_json(value: &Value) -> Result<String, String> {
let Value::Array(values) = value else {
return Err("Qdrant id IN filters require an array value".to_string());
};
if values.is_empty() {
return Err("Qdrant id IN filters require at least one id".to_string());
}
let values = values
.iter()
.map(point_id_to_json)
.map(|result| result.map_err(|err| format!("Qdrant id IN value is invalid: {err}")))
.collect::<Result<Vec<_>, _>>()?;
Ok(values.join(", "))
}
fn qdrant_limit(cmd: &Qail) -> Result<usize, String> {
let mut limit = None;
for cage in &cmd.cages {
if let CageKind::Limit(n) = cage.kind {
if n == 0 {
return Err("Qdrant limit must be greater than zero".to_string());
}
if limit.replace(n).is_some() {
return Err("Duplicate Qdrant LIMIT clauses are not supported".to_string());
}
}
}
Ok(limit.unwrap_or(10))
}
fn qdrant_offset(cmd: &Qail) -> Result<Option<usize>, String> {
let mut offset = None;
for cage in &cmd.cages {
if let CageKind::Offset(n) = cage.kind
&& offset.replace(n).is_some()
{
return Err("Duplicate Qdrant OFFSET clauses are not supported".to_string());
}
}
Ok(offset)
}
fn append_qdrant_projection_options(cmd: &Qail, parts: &mut Vec<String>) -> Result<(), String> {
let mut wants_vector = cmd.with_vector;
if !cmd.columns.is_empty() {
let mut payload_includes = Vec::new();
let mut has_wildcard = false;
for c in &cmd.columns {
let raw_field = raw_named_qdrant_field(c)?;
let field =
normalize_qdrant_field(qdrant_requested_projection_segment(raw_field, &cmd.table));
if qdrant_projection_is_wildcard(raw_field, &cmd.table) {
has_wildcard = true;
continue;
}
if qdrant_reserved_field_matches(field, "vector") {
wants_vector = true;
continue;
}
if qdrant_reserved_field_matches(field, "id")
|| qdrant_reserved_field_matches(field, "score")
{
continue;
}
payload_includes.push(json_string(field));
}
if has_wildcard {
parts.push("\"with_payload\": true".to_string());
} else if payload_includes.is_empty() {
parts.push("\"with_payload\": false".to_string());
} else {
parts.push(format!(
"\"with_payload\": {{ \"include\": [{}] }}",
payload_includes.join(", ")
));
}
} else {
parts.push("\"with_payload\": true".to_string());
}
if wants_vector {
parts.push("\"with_vector\": true".to_string());
}
Ok(())
}
fn build_qdrant_upsert(cmd: &Qail) -> Result<String, String> {
let mut point_id = None;
let mut vector = cmd
.vector
.as_ref()
.map(|values| vector_values_to_json(values))
.transpose()?;
let mut payload_parts = Vec::new();
let mut payload_fields = std::collections::HashSet::new();
for cage in &cmd.cages {
match cage.kind {
CageKind::Payload => {
for cond in &cage.conditions {
if cond.op != Operator::Eq {
return Err(
"Qdrant upsert payload fields require equality values".to_string()
);
}
let name = named_qdrant_field(&cond.left)?;
if qdrant_reserved_field_matches(name, "id") {
if point_id.replace(point_id_to_json(&cond.value)?).is_some() {
return Err(
"Duplicate Qdrant upsert id fields are not supported".to_string()
);
}
} else if qdrant_reserved_field_matches(name, "vector") {
if vector.replace(vector_to_json(&cond.value)?).is_some() {
return Err("Duplicate Qdrant upsert vector fields are not supported"
.to_string());
}
} else if qdrant_reserved_field_matches(name, ORIGINAL_POINT_ID_PAYLOAD_KEY) {
return Err(format!(
"Qdrant upsert payload field `{ORIGINAL_POINT_ID_PAYLOAD_KEY}` is reserved"
));
} else {
if !payload_fields.insert(name.to_string()) {
return Err(format!(
"Duplicate Qdrant upsert payload field `{name}` is not supported"
));
}
payload_parts.push(format!(
"{}: {}",
json_string(name),
value_to_json(&cond.value)?
));
}
}
}
CageKind::Filter => {
let can_infer_identity =
matches!(cage.logical_op, LogicalOp::And) || cage.conditions.len() == 1;
for cond in &cage.conditions {
let name = named_qdrant_field(&cond.left)?;
if cond.op != Operator::Eq {
return Err(
"Qdrant upsert filter fallbacks require equality values".to_string()
);
}
if qdrant_reserved_field_matches(name, "id") {
if !can_infer_identity {
if point_id.is_none() {
return Err(
"Qdrant upsert id cannot be inferred from a multi-condition OR filter"
.to_string(),
);
}
continue;
}
let next = point_id_to_json(&cond.value)?;
if point_id.as_ref().is_some_and(|existing| existing != &next) {
return Err(
"Qdrant upsert filter id conflicts with payload id".to_string()
);
}
point_id = Some(next);
} else if qdrant_reserved_field_matches(name, "vector") {
if !can_infer_identity {
if vector.is_none() {
return Err(
"Qdrant upsert vector cannot be inferred from a multi-condition OR filter"
.to_string(),
);
}
continue;
}
let next = vector_to_json(&cond.value)?;
if vector.as_ref().is_some_and(|existing| existing != &next) {
return Err(
"Qdrant upsert filter vector conflicts with payload vector"
.to_string(),
);
}
vector = Some(next);
} else {
return Err(format!(
"Qdrant upsert filters cannot be encoded as conditional writes: `{name}`"
));
}
}
}
_ => {}
}
}
let point_id =
point_id.ok_or_else(|| "Qdrant upsert requires payload/filter field `id`".to_string())?;
let vector = vector.ok_or_else(|| {
"Qdrant upsert requires payload/filter field `vector` or cmd.vector".to_string()
})?;
let payload_json = if payload_parts.is_empty() {
"{}".to_string()
} else {
format!("{{ {} }}", payload_parts.join(", "))
};
let point = format!(
"{{ \"id\": {}, \"vector\": {}, \"payload\": {} }}",
point_id, vector, payload_json
);
Ok(format!("{{ \"points\": [{}] }}", point))
}
fn build_qdrant_delete(cmd: &Qail) -> Result<String, String> {
let filter = build_filter(cmd)?;
if filter.is_empty() {
return Err("Qdrant delete requires an id or filter condition".to_string());
}
Ok(format!("{{ \"filter\": {} }}", filter))
}
fn build_qdrant_search(cmd: &Qail) -> Result<String, String> {
let mut parts = Vec::new();
let mut vector_json = cmd
.vector
.as_ref()
.map(|values| vector_values_to_json(values))
.transpose()?;
for cage in &cmd.cages {
if let CageKind::Filter = cage.kind {
for cond in &cage.conditions {
if cond.op == Operator::Fuzzy {
let field = named_qdrant_field(&cond.left)?;
if !qdrant_reserved_field_matches(field, "vector") {
return Err(
"Qdrant fuzzy search is only supported on the vector field".to_string()
);
}
if vector_json.is_some() {
return Err("Duplicate Qdrant search vectors are not supported".to_string());
}
let encoded = match &cond.value {
Value::String(s) => {
if s.trim().is_empty() {
return Err(
"Qdrant semantic vector prompt cannot be empty".to_string()
);
}
json_string(&format!("{{{{EMBED:{}}}}}", s))
}
_ => vector_to_json(&cond.value)?,
};
vector_json = Some(encoded);
}
}
}
}
let vector_json = vector_json
.ok_or_else(|| "Qdrant search requires cmd.vector or a fuzzy vector filter".to_string())?;
parts.push(format!("\"vector\": {vector_json}"));
if let Some(threshold) = cmd.score_threshold {
if !threshold.is_finite() {
return Err("Qdrant score threshold must be finite".to_string());
}
parts.push(format!("\"score_threshold\": {threshold}"));
}
if let Some(vector_name) = &cmd.vector_name {
if vector_name.trim().is_empty() {
return Err("Qdrant vector name cannot be empty".to_string());
}
return Err(
"Qdrant JSON transpiler does not support named vector searches; use the qail-qdrant driver"
.to_string(),
);
}
let filter = build_filter(cmd)?;
if !filter.is_empty() {
parts.push(format!("\"filter\": {}", filter));
}
let limit = qdrant_limit(cmd)?;
parts.push(format!("\"limit\": {}", limit));
append_qdrant_projection_options(cmd, &mut parts)?;
Ok(format!("{{ {} }}", parts.join(", ")))
}
fn build_qdrant_scroll(cmd: &Qail) -> Result<String, String> {
if cmd.vector.is_some() {
return Err("Qdrant scroll does not accept a search vector".to_string());
}
if cmd.score_threshold.is_some() {
return Err("Qdrant scroll does not accept a score threshold".to_string());
}
if let Some(vector_name) = &cmd.vector_name {
if vector_name.trim().is_empty() {
return Err("Qdrant vector name cannot be empty".to_string());
}
return Err("Qdrant JSON transpiler does not support named vector scroll selectors; use the qail-qdrant driver".to_string());
}
let mut parts = Vec::new();
let filter = build_filter(cmd)?;
if !filter.is_empty() {
parts.push(format!("\"filter\": {}", filter));
}
let limit = qdrant_limit(cmd)?;
parts.push(format!("\"limit\": {}", limit));
if let Some(offset) = qdrant_offset(cmd)? {
parts.push(format!("\"offset\": {}", offset));
}
append_qdrant_projection_options(cmd, &mut parts)?;
Ok(format!("{{ {} }}", parts.join(", ")))
}
fn build_filter(cmd: &Qail) -> Result<String, String> {
let mut musts = Vec::new();
let mut should_groups: Vec<Vec<String>> = Vec::new();
for cage in &cmd.cages {
if let CageKind::Filter = cage.kind {
let mut cage_clauses = Vec::new();
for cond in &cage.conditions {
let col_str = named_qdrant_field(&cond.left)?;
if qdrant_reserved_field_matches(col_str, "id") {
let clause = match cond.op {
Operator::Eq => {
let ids = point_id_to_json(&cond.value)?;
format!("{{ \"has_id\": [{ids}] }}")
}
Operator::In => {
let ids = point_id_array_to_json(&cond.value)?;
format!("{{ \"has_id\": [{ids}] }}")
}
Operator::Ne => {
let ids = point_id_to_json(&cond.value)?;
negated_qdrant_clause(format!("{{ \"has_id\": [{ids}] }}"))
}
Operator::NotIn => {
let ids = point_id_array_to_json(&cond.value)?;
negated_qdrant_clause(format!("{{ \"has_id\": [{ids}] }}"))
}
_ => {
return Err(
"Qdrant id filters support equality, inequality, IN, or NOT IN against integer, string, or UUID values"
.to_string(),
);
}
};
cage_clauses.push(clause);
continue;
}
let clause = match cond.op {
Operator::Eq => format!(
"{{ \"key\": {}, \"match\": {{ \"value\": {} }} }}",
json_string(col_str),
filter_match_value_to_json(&cond.value)?
),
Operator::Ne => negated_qdrant_clause(format!(
"{{ \"key\": {}, \"match\": {{ \"value\": {} }} }}",
json_string(col_str),
filter_match_value_to_json(&cond.value)?
)),
Operator::In => format!(
"{{ \"key\": {}, \"match\": {{ \"any\": [{}] }} }}",
json_string(col_str),
filter_array_values_to_json(&cond.value)?
),
Operator::NotIn => negated_qdrant_clause(format!(
"{{ \"key\": {}, \"match\": {{ \"any\": [{}] }} }}",
json_string(col_str),
filter_array_values_to_json(&cond.value)?
)),
Operator::Gt => format!(
"{{ \"key\": {}, \"range\": {{ \"gt\": {} }} }}",
json_string(col_str),
numeric_filter_value(&cond.value)?
),
Operator::Gte => format!(
"{{ \"key\": {}, \"range\": {{ \"gte\": {} }} }}",
json_string(col_str),
numeric_filter_value(&cond.value)?
),
Operator::Lt => format!(
"{{ \"key\": {}, \"range\": {{ \"lt\": {} }} }}",
json_string(col_str),
numeric_filter_value(&cond.value)?
),
Operator::Lte => format!(
"{{ \"key\": {}, \"range\": {{ \"lte\": {} }} }}",
json_string(col_str),
numeric_filter_value(&cond.value)?
),
Operator::IsNull => {
if !matches!(cond.value, Value::Null | Value::NullUuid) {
return Err("Qdrant IS NULL filters require a null value".to_string());
}
format!("{{ \"is_null\": {{ \"key\": {} }} }}", json_string(col_str))
}
Operator::IsNotNull => {
if !matches!(cond.value, Value::Null | Value::NullUuid) {
return Err(
"Qdrant IS NOT NULL filters require a null value".to_string()
);
}
negated_qdrant_clause(format!(
"{{ \"is_null\": {{ \"key\": {} }} }}",
json_string(col_str)
))
}
Operator::Fuzzy => {
if qdrant_reserved_field_matches(col_str, "vector") {
continue;
}
return Err(
"Qdrant fuzzy filters are only supported on the vector field"
.to_string(),
);
}
Operator::Contains | Operator::Like => {
let Value::String(value) = &cond.value else {
return Err("Qdrant text filters require a string value".to_string());
};
if value.trim().is_empty() {
return Err("Qdrant text filter value cannot be empty".to_string());
}
format!(
"{{ \"key\": {}, \"match\": {{ \"text\": {} }} }}",
json_string(col_str),
json_string(value)
)
}
Operator::NotLike => {
let Value::String(value) = &cond.value else {
return Err("Qdrant text filters require a string value".to_string());
};
if value.trim().is_empty() {
return Err("Qdrant text filter value cannot be empty".to_string());
}
negated_qdrant_clause(format!(
"{{ \"key\": {}, \"match\": {{ \"text\": {} }} }}",
json_string(col_str),
json_string(value)
))
}
_ => return Err(format!("unsupported Qdrant filter operator {:?}", cond.op)),
};
cage_clauses.push(clause);
}
if cage_clauses.is_empty() {
continue;
}
match cage.logical_op {
LogicalOp::And => musts.extend(cage_clauses),
LogicalOp::Or => should_groups.push(cage_clauses),
}
}
}
for mut group in should_groups {
if group.len() == 1 {
musts.push(group.remove(0));
} else {
musts.push(format!("{{ \"should\": [{}] }}", group.join(", ")));
}
}
if musts.is_empty() {
return Ok(String::new());
}
let mut parts = Vec::new();
if !musts.is_empty() {
parts.push(format!("\"must\": [{}]", musts.join(", ")));
}
Ok(format!("{{ {} }}", parts.join(", ")))
}
fn negated_qdrant_clause(clause: String) -> String {
format!("{{ \"must_not\": [{clause}] }}")
}
fn filter_match_value_to_json(v: &Value) -> Result<String, String> {
match v {
Value::String(s) => Ok(json_string(s)),
Value::Uuid(u) => Ok(json_string(&u.to_string())),
Value::Int(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
Value::Null | Value::NullUuid => {
Err("Qdrant equality filters cannot match null; use IS NULL".to_string())
}
other => Err(format!(
"Qdrant equality filters support only string, UUID, integer, or bool values, got {other}"
)),
}
}
fn filter_array_values_to_json(v: &Value) -> Result<String, String> {
let Value::Array(values) = v else {
return Err("Qdrant IN filters require an array value".to_string());
};
if values.is_empty() {
return Err("Qdrant IN filters require at least one value".to_string());
}
if values
.iter()
.all(|value| matches!(value, Value::String(_) | Value::Uuid(_)))
{
let values = values
.iter()
.map(|value| match value {
Value::String(value) => Ok(json_string(value)),
Value::Uuid(value) => Ok(json_string(&value.to_string())),
_ => unreachable!("checked by all()"),
})
.collect::<Result<Vec<_>, String>>()?;
return Ok(values.join(", "));
}
if values.iter().all(|value| matches!(value, Value::Int(_))) {
let values = values
.iter()
.map(|value| match value {
Value::Int(value) => Ok(value.to_string()),
_ => unreachable!("checked by all()"),
})
.collect::<Result<Vec<_>, String>>()?;
return Ok(values.join(", "));
}
Err(
"Qdrant IN filters support only a non-empty homogeneous string/UUID or integer array"
.to_string(),
)
}
fn value_to_json(v: &Value) -> Result<String, String> {
match v {
Value::Null | Value::NullUuid => Ok("null".to_string()),
Value::String(s) => Ok(json_string(s)),
Value::Int(n) => Ok(n.to_string()),
Value::Float(n) if n.is_finite() => Ok(n.to_string()),
Value::Float(_) => Err("non-finite floats cannot be encoded as Qdrant JSON".to_string()),
Value::Bool(b) => Ok(b.to_string()),
Value::Uuid(u) => Ok(json_string(&u.to_string())),
Value::Timestamp(ts) => Ok(json_string(ts)),
Value::Array(arr) => {
let elems: Result<Vec<String>, String> = arr.iter().map(value_to_json).collect();
Ok(format!("[{}]", elems?.join(", ")))
}
Value::Vector(values) => {
let elems: Result<Vec<String>, String> = values
.iter()
.map(|value| {
if value.is_finite() {
Ok(value.to_string())
} else {
Err("non-finite vector values cannot be encoded as Qdrant JSON".to_string())
}
})
.collect();
Ok(format!("[{}]", elems?.join(", ")))
}
Value::Json(json) => {
let value = serde_json::from_str::<serde_json::Value>(json)
.map_err(|err| format!("invalid JSON value for Qdrant payload: {err}"))?;
validate_json_payload_value(&value)?;
Ok(value.to_string())
}
other => Err(format!("unsupported Qdrant JSON value: {other}")),
}
}
fn numeric_filter_value(v: &Value) -> Result<String, String> {
match v {
Value::Int(n) => Ok(n.to_string()),
Value::Float(n) if n.is_finite() => Ok(n.to_string()),
Value::Float(_) => Err("Qdrant range filter values must be finite numbers".to_string()),
other => Err(format!(
"Qdrant range filter values must be numeric, got {other}"
)),
}
}
fn vector_to_json(v: &Value) -> Result<String, String> {
let elems: Result<Vec<String>, String> = match v {
Value::Vector(values) => return vector_values_to_json(values),
Value::Array(values) => values
.iter()
.map(|value| match value {
Value::Int(n) => Ok(n.to_string()),
Value::Float(n) if n.is_finite() => Ok(n.to_string()),
Value::Float(_) => Err("Qdrant vector values must be finite numbers".to_string()),
other => Err(format!("Qdrant vector values must be numeric, got {other}")),
})
.collect(),
other => return Err(format!("Qdrant vector must be an array, got {other}")),
};
let elems = elems?;
if elems.is_empty() {
return Err("Qdrant vector cannot be empty".to_string());
}
Ok(format!("[{}]", elems.join(", ")))
}
fn vector_values_to_json(values: &[f32]) -> Result<String, String> {
let elems: Result<Vec<String>, String> = values
.iter()
.map(|value| {
if value.is_finite() {
Ok(value.to_string())
} else {
Err("Qdrant vector values must be finite numbers".to_string())
}
})
.collect();
let elems = elems?;
if elems.is_empty() {
return Err("Qdrant vector cannot be empty".to_string());
}
Ok(format!("[{}]", elems.join(", ")))
}
#[cfg(test)]
mod tests {
use super::ToQdrant;
use crate::ast::{Expr, Qail};
#[test]
fn search_projection_requests_table_qualified_vector() {
let body = Qail::get("embeddings")
.vector(vec![0.1, 0.2])
.columns(["embeddings.vector"])
.to_qdrant_search();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["with_vector"], true);
assert_eq!(json["with_payload"], false);
}
#[test]
fn search_projection_strips_table_qualified_payload_field() {
let body = Qail::get("embeddings")
.vector(vec![0.1, 0.2])
.columns(["embeddings.title"])
.to_qdrant_search();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
json["with_payload"]["include"],
serde_json::json!(["title"])
);
assert!(json.get("with_vector").is_none());
}
#[test]
fn search_projection_preserves_quoted_dotted_payload_field() {
let mut cmd = Qail::get("embeddings").vector(vec![0.1, 0.2]);
cmd.columns = vec![Expr::Named("\"embeddings.vector\"".to_string())];
let body = cmd.to_qdrant_search();
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
json["with_payload"]["include"],
serde_json::json!(["embeddings.vector"])
);
assert!(json.get("with_vector").is_none());
}
}