Skip to main content

qail_core/transpiler/nosql/
qdrant.rs

1use crate::ast::*;
2
3const ORIGINAL_POINT_ID_PAYLOAD_KEY: &str = "_qail_original_point_id";
4
5fn json_string(value: &str) -> String {
6    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
7}
8
9/// Trait for converting QAIL AST to Qdrant vector-search JSON.
10pub trait ToQdrant {
11    /// Convert a QAIL query into a Qdrant search/upsert/delete JSON body.
12    fn to_qdrant_search(&self) -> String;
13}
14
15impl ToQdrant for Qail {
16    fn to_qdrant_search(&self) -> String {
17        let result = match self.action {
18            Action::Get | Action::Search => build_qdrant_search(self),
19            Action::Put | Action::Add | Action::Upsert => build_qdrant_upsert(self),
20            Action::Scroll => build_qdrant_scroll(self),
21            Action::Del => build_qdrant_delete(self),
22            _ => {
23                return format!(
24                    "{{ \"error\": \"Action {:?} not supported for Qdrant\" }}",
25                    self.action
26                );
27            }
28        };
29
30        result.unwrap_or_else(|err| qdrant_error(&err))
31    }
32}
33
34fn qdrant_error(message: &str) -> String {
35    format!("{{ \"error\": {} }}", json_string(message))
36}
37
38fn normalize_qdrant_field(raw: &str) -> &str {
39    raw.trim().trim_matches('"').trim()
40}
41
42fn qdrant_requested_projection_segment<'a>(raw: &'a str, table: &str) -> &'a str {
43    let trimmed = raw.trim();
44    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
45        trimmed
46    } else if let Some(rest) = trimmed
47        .strip_prefix(table)
48        .and_then(|rest| rest.strip_prefix('.'))
49    {
50        rest.trim()
51    } else {
52        trimmed
53    }
54}
55
56fn qdrant_reserved_field_matches(raw: &str, reserved: &str) -> bool {
57    normalize_qdrant_field(raw).eq_ignore_ascii_case(normalize_qdrant_field(reserved))
58}
59
60fn qdrant_projection_is_wildcard(raw: &str, table: &str) -> bool {
61    let trimmed = raw.trim();
62    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
63        return false;
64    }
65    trimmed == "*" || trimmed.strip_prefix(table).is_some_and(|rest| rest == ".*")
66}
67
68fn raw_named_qdrant_field(expr: &Expr) -> Result<&str, String> {
69    let raw = match expr {
70        Expr::Named(name) | Expr::Aliased { name, .. } => name.as_str(),
71        other => {
72            return Err(format!(
73                "Qdrant fields must be named, got expression `{other}`"
74            ));
75        }
76    };
77    let field = normalize_qdrant_field(raw);
78    if field.is_empty() {
79        return Err("Qdrant field name cannot be empty".to_string());
80    }
81    Ok(raw)
82}
83
84fn named_qdrant_field(expr: &Expr) -> Result<&str, String> {
85    Ok(normalize_qdrant_field(raw_named_qdrant_field(expr)?))
86}
87
88fn validate_json_payload_value(value: &serde_json::Value) -> Result<(), String> {
89    match value {
90        serde_json::Value::Object(map) => {
91            for (key, value) in map {
92                if key.trim().is_empty() {
93                    return Err("Qdrant JSON payload object keys cannot be empty".to_string());
94                }
95                validate_json_payload_value(value)?;
96            }
97            Ok(())
98        }
99        serde_json::Value::Array(items) => {
100            for item in items {
101                validate_json_payload_value(item)?;
102            }
103            Ok(())
104        }
105        serde_json::Value::Number(number) => {
106            if let Some(value) = number.as_u64()
107                && value > i64::MAX as u64
108            {
109                return Err(
110                    "Qdrant JSON integer payload values must fit in signed 64-bit range"
111                        .to_string(),
112                );
113            }
114            Ok(())
115        }
116        _ => Ok(()),
117    }
118}
119
120fn point_id_to_json(value: &Value) -> Result<String, String> {
121    match value {
122        Value::Int(n) if *n >= 0 => Ok(n.to_string()),
123        Value::String(s) if !s.trim().is_empty() => Ok(json_string(s)),
124        Value::Uuid(u) => Ok(json_string(&u.to_string())),
125        _ => Err(
126            "Qdrant point id must be a non-negative integer, non-empty string, or UUID".to_string(),
127        ),
128    }
129}
130
131fn point_id_array_to_json(value: &Value) -> Result<String, String> {
132    let Value::Array(values) = value else {
133        return Err("Qdrant id IN filters require an array value".to_string());
134    };
135    if values.is_empty() {
136        return Err("Qdrant id IN filters require at least one id".to_string());
137    }
138    let values = values
139        .iter()
140        .map(point_id_to_json)
141        .map(|result| result.map_err(|err| format!("Qdrant id IN value is invalid: {err}")))
142        .collect::<Result<Vec<_>, _>>()?;
143    Ok(values.join(", "))
144}
145
146fn qdrant_limit(cmd: &Qail) -> Result<usize, String> {
147    let mut limit = None;
148    for cage in &cmd.cages {
149        if let CageKind::Limit(n) = cage.kind {
150            if n == 0 {
151                return Err("Qdrant limit must be greater than zero".to_string());
152            }
153            if limit.replace(n).is_some() {
154                return Err("Duplicate Qdrant LIMIT clauses are not supported".to_string());
155            }
156        }
157    }
158    Ok(limit.unwrap_or(10))
159}
160
161fn qdrant_offset(cmd: &Qail) -> Result<Option<usize>, String> {
162    let mut offset = None;
163    for cage in &cmd.cages {
164        if let CageKind::Offset(n) = cage.kind
165            && offset.replace(n).is_some()
166        {
167            return Err("Duplicate Qdrant OFFSET clauses are not supported".to_string());
168        }
169    }
170    Ok(offset)
171}
172
173fn append_qdrant_projection_options(cmd: &Qail, parts: &mut Vec<String>) -> Result<(), String> {
174    let mut wants_vector = cmd.with_vector;
175    if !cmd.columns.is_empty() {
176        let mut payload_includes = Vec::new();
177        let mut has_wildcard = false;
178        for c in &cmd.columns {
179            let raw_field = raw_named_qdrant_field(c)?;
180            let field =
181                normalize_qdrant_field(qdrant_requested_projection_segment(raw_field, &cmd.table));
182            if qdrant_projection_is_wildcard(raw_field, &cmd.table) {
183                has_wildcard = true;
184                continue;
185            }
186            if qdrant_reserved_field_matches(field, "vector") {
187                wants_vector = true;
188                continue;
189            }
190            if qdrant_reserved_field_matches(field, "id")
191                || qdrant_reserved_field_matches(field, "score")
192            {
193                continue;
194            }
195            payload_includes.push(json_string(field));
196        }
197        if has_wildcard {
198            parts.push("\"with_payload\": true".to_string());
199        } else if payload_includes.is_empty() {
200            parts.push("\"with_payload\": false".to_string());
201        } else {
202            parts.push(format!(
203                "\"with_payload\": {{ \"include\": [{}] }}",
204                payload_includes.join(", ")
205            ));
206        }
207    } else {
208        parts.push("\"with_payload\": true".to_string());
209    }
210    if wants_vector {
211        parts.push("\"with_vector\": true".to_string());
212    }
213    Ok(())
214}
215
216fn build_qdrant_upsert(cmd: &Qail) -> Result<String, String> {
217    // POST /collections/{name}/points?wait=true
218    // Body: { "points": [ { "id": 1, "vector": [...], "payload": {...} } ] }
219
220    // Single point upsert from payload/filter cages.
221    let mut point_id = None;
222    let mut vector = cmd
223        .vector
224        .as_ref()
225        .map(|values| vector_values_to_json(values))
226        .transpose()?;
227    let mut payload_parts = Vec::new();
228    let mut payload_fields = std::collections::HashSet::new();
229
230    for cage in &cmd.cages {
231        match cage.kind {
232            CageKind::Payload => {
233                for cond in &cage.conditions {
234                    if cond.op != Operator::Eq {
235                        return Err(
236                            "Qdrant upsert payload fields require equality values".to_string()
237                        );
238                    }
239                    let name = named_qdrant_field(&cond.left)?;
240                    if qdrant_reserved_field_matches(name, "id") {
241                        if point_id.replace(point_id_to_json(&cond.value)?).is_some() {
242                            return Err(
243                                "Duplicate Qdrant upsert id fields are not supported".to_string()
244                            );
245                        }
246                    } else if qdrant_reserved_field_matches(name, "vector") {
247                        if vector.replace(vector_to_json(&cond.value)?).is_some() {
248                            return Err("Duplicate Qdrant upsert vector fields are not supported"
249                                .to_string());
250                        }
251                    } else if qdrant_reserved_field_matches(name, ORIGINAL_POINT_ID_PAYLOAD_KEY) {
252                        return Err(format!(
253                            "Qdrant upsert payload field `{ORIGINAL_POINT_ID_PAYLOAD_KEY}` is reserved"
254                        ));
255                    } else {
256                        if !payload_fields.insert(name.to_string()) {
257                            return Err(format!(
258                                "Duplicate Qdrant upsert payload field `{name}` is not supported"
259                            ));
260                        }
261                        payload_parts.push(format!(
262                            "{}: {}",
263                            json_string(name),
264                            value_to_json(&cond.value)?
265                        ));
266                    }
267                }
268            }
269            CageKind::Filter => {
270                let can_infer_identity =
271                    matches!(cage.logical_op, LogicalOp::And) || cage.conditions.len() == 1;
272                for cond in &cage.conditions {
273                    let name = named_qdrant_field(&cond.left)?;
274                    if cond.op != Operator::Eq {
275                        return Err(
276                            "Qdrant upsert filter fallbacks require equality values".to_string()
277                        );
278                    }
279                    if qdrant_reserved_field_matches(name, "id") {
280                        if !can_infer_identity {
281                            if point_id.is_none() {
282                                return Err(
283                                    "Qdrant upsert id cannot be inferred from a multi-condition OR filter"
284                                        .to_string(),
285                                );
286                            }
287                            continue;
288                        }
289                        let next = point_id_to_json(&cond.value)?;
290                        if point_id.as_ref().is_some_and(|existing| existing != &next) {
291                            return Err(
292                                "Qdrant upsert filter id conflicts with payload id".to_string()
293                            );
294                        }
295                        point_id = Some(next);
296                    } else if qdrant_reserved_field_matches(name, "vector") {
297                        if !can_infer_identity {
298                            if vector.is_none() {
299                                return Err(
300                                    "Qdrant upsert vector cannot be inferred from a multi-condition OR filter"
301                                        .to_string(),
302                                );
303                            }
304                            continue;
305                        }
306                        let next = vector_to_json(&cond.value)?;
307                        if vector.as_ref().is_some_and(|existing| existing != &next) {
308                            return Err(
309                                "Qdrant upsert filter vector conflicts with payload vector"
310                                    .to_string(),
311                            );
312                        }
313                        vector = Some(next);
314                    } else {
315                        return Err(format!(
316                            "Qdrant upsert filters cannot be encoded as conditional writes: `{name}`"
317                        ));
318                    }
319                }
320            }
321            _ => {}
322        }
323    }
324
325    let point_id =
326        point_id.ok_or_else(|| "Qdrant upsert requires payload/filter field `id`".to_string())?;
327    let vector = vector.ok_or_else(|| {
328        "Qdrant upsert requires payload/filter field `vector` or cmd.vector".to_string()
329    })?;
330
331    let payload_json = if payload_parts.is_empty() {
332        "{}".to_string()
333    } else {
334        format!("{{ {} }}", payload_parts.join(", "))
335    };
336
337    // Construct single point
338    let point = format!(
339        "{{ \"id\": {}, \"vector\": {}, \"payload\": {} }}",
340        point_id, vector, payload_json
341    );
342
343    Ok(format!("{{ \"points\": [{}] }}", point))
344}
345
346fn build_qdrant_delete(cmd: &Qail) -> Result<String, String> {
347    // POST /collections/{name}/points/delete
348    // Body: { "points": [1, 2, 3] } OR { "filter": ... }
349    //
350    // Use the filter selector even for id predicates. A point-id selector cannot
351    // be combined with tenant/policy payload filters, and extracting ids would
352    // silently drop those additional predicates.
353    let filter = build_filter(cmd)?;
354    if filter.is_empty() {
355        return Err("Qdrant delete requires an id or filter condition".to_string());
356    }
357    Ok(format!("{{ \"filter\": {} }}", filter))
358}
359
360fn build_qdrant_search(cmd: &Qail) -> Result<String, String> {
361    // Target endpoint: POST /collections/{collection_name}/points/search
362    // Output: JSON Body
363
364    let mut parts = Vec::new();
365
366    // 1. Vector handling
367    // We look for a condition with the key "vector" or similar, usage: [vector~[0.1, 0.2]]
368    // Any array value with a Fuzzy match (~) is treated as the query vector.
369    let mut vector_json = cmd
370        .vector
371        .as_ref()
372        .map(|values| vector_values_to_json(values))
373        .transpose()?;
374
375    for cage in &cmd.cages {
376        if let CageKind::Filter = cage.kind {
377            for cond in &cage.conditions {
378                if cond.op == Operator::Fuzzy {
379                    let field = named_qdrant_field(&cond.left)?;
380                    if !qdrant_reserved_field_matches(field, "vector") {
381                        return Err(
382                            "Qdrant fuzzy search is only supported on the vector field".to_string()
383                        );
384                    }
385                    if vector_json.is_some() {
386                        return Err("Duplicate Qdrant search vectors are not supported".to_string());
387                    }
388                    // Vector Query found.
389                    // Case 1: [vector~[0.1, 0.2]] -> Explicit Vector (Already handled by Value::Array)
390                    // Case 2: [vector~"cute cat"] -> Semantic Search Intent
391                    let encoded = match &cond.value {
392                        Value::String(s) => {
393                            if s.trim().is_empty() {
394                                return Err(
395                                    "Qdrant semantic vector prompt cannot be empty".to_string()
396                                );
397                            }
398                            // Output Placeholder for Runtime Resolution
399                            // e.g. {{EMBED:cute cat}}
400                            json_string(&format!("{{{{EMBED:{}}}}}", s))
401                        }
402                        _ => vector_to_json(&cond.value)?,
403                    };
404                    vector_json = Some(encoded);
405                }
406            }
407        }
408    }
409
410    let vector_json = vector_json
411        .ok_or_else(|| "Qdrant search requires cmd.vector or a fuzzy vector filter".to_string())?;
412    parts.push(format!("\"vector\": {vector_json}"));
413
414    if let Some(threshold) = cmd.score_threshold {
415        if !threshold.is_finite() {
416            return Err("Qdrant score threshold must be finite".to_string());
417        }
418        parts.push(format!("\"score_threshold\": {threshold}"));
419    }
420
421    if let Some(vector_name) = &cmd.vector_name {
422        if vector_name.trim().is_empty() {
423            return Err("Qdrant vector name cannot be empty".to_string());
424        }
425        return Err(
426            "Qdrant JSON transpiler does not support named vector searches; use the qail-qdrant driver"
427                .to_string(),
428        );
429    }
430
431    // 2. Filters (Hybrid Search)
432    let filter = build_filter(cmd)?;
433    if !filter.is_empty() {
434        parts.push(format!("\"filter\": {}", filter));
435    }
436
437    // 3. Limit
438    let limit = qdrant_limit(cmd)?;
439    parts.push(format!("\"limit\": {}", limit));
440
441    // 4. With Payload (Projections)
442    append_qdrant_projection_options(cmd, &mut parts)?;
443
444    Ok(format!("{{ {} }}", parts.join(", ")))
445}
446
447fn build_qdrant_scroll(cmd: &Qail) -> Result<String, String> {
448    // Target endpoint: POST /collections/{collection_name}/points/scroll
449    // Output: JSON Body
450    if cmd.vector.is_some() {
451        return Err("Qdrant scroll does not accept a search vector".to_string());
452    }
453    if cmd.score_threshold.is_some() {
454        return Err("Qdrant scroll does not accept a score threshold".to_string());
455    }
456    if let Some(vector_name) = &cmd.vector_name {
457        if vector_name.trim().is_empty() {
458            return Err("Qdrant vector name cannot be empty".to_string());
459        }
460        return Err("Qdrant JSON transpiler does not support named vector scroll selectors; use the qail-qdrant driver".to_string());
461    }
462
463    let mut parts = Vec::new();
464    let filter = build_filter(cmd)?;
465    if !filter.is_empty() {
466        parts.push(format!("\"filter\": {}", filter));
467    }
468
469    let limit = qdrant_limit(cmd)?;
470    parts.push(format!("\"limit\": {}", limit));
471
472    if let Some(offset) = qdrant_offset(cmd)? {
473        parts.push(format!("\"offset\": {}", offset));
474    }
475
476    append_qdrant_projection_options(cmd, &mut parts)?;
477
478    Ok(format!("{{ {} }}", parts.join(", ")))
479}
480
481fn build_filter(cmd: &Qail) -> Result<String, String> {
482    // Qdrant Filter structure: { "must": [ { "key": "city", "match": { "value": "London" } } ] }
483    let mut musts = Vec::new();
484    let mut should_groups: Vec<Vec<String>> = Vec::new();
485
486    for cage in &cmd.cages {
487        if let CageKind::Filter = cage.kind {
488            let mut cage_clauses = Vec::new();
489            for cond in &cage.conditions {
490                let col_str = named_qdrant_field(&cond.left)?;
491
492                if qdrant_reserved_field_matches(col_str, "id") {
493                    let clause = match cond.op {
494                        Operator::Eq => {
495                            let ids = point_id_to_json(&cond.value)?;
496                            format!("{{ \"has_id\": [{ids}] }}")
497                        }
498                        Operator::In => {
499                            let ids = point_id_array_to_json(&cond.value)?;
500                            format!("{{ \"has_id\": [{ids}] }}")
501                        }
502                        Operator::Ne => {
503                            let ids = point_id_to_json(&cond.value)?;
504                            negated_qdrant_clause(format!("{{ \"has_id\": [{ids}] }}"))
505                        }
506                        Operator::NotIn => {
507                            let ids = point_id_array_to_json(&cond.value)?;
508                            negated_qdrant_clause(format!("{{ \"has_id\": [{ids}] }}"))
509                        }
510                        _ => {
511                            return Err(
512                                "Qdrant id filters support equality, inequality, IN, or NOT IN against integer, string, or UUID values"
513                                    .to_string(),
514                            );
515                        }
516                    };
517                    cage_clauses.push(clause);
518                    continue;
519                }
520
521                let clause = match cond.op {
522                    Operator::Eq => format!(
523                        "{{ \"key\": {}, \"match\": {{ \"value\": {} }} }}",
524                        json_string(col_str),
525                        filter_match_value_to_json(&cond.value)?
526                    ),
527                    Operator::Ne => negated_qdrant_clause(format!(
528                        "{{ \"key\": {}, \"match\": {{ \"value\": {} }} }}",
529                        json_string(col_str),
530                        filter_match_value_to_json(&cond.value)?
531                    )),
532                    Operator::In => format!(
533                        "{{ \"key\": {}, \"match\": {{ \"any\": [{}] }} }}",
534                        json_string(col_str),
535                        filter_array_values_to_json(&cond.value)?
536                    ),
537                    Operator::NotIn => negated_qdrant_clause(format!(
538                        "{{ \"key\": {}, \"match\": {{ \"any\": [{}] }} }}",
539                        json_string(col_str),
540                        filter_array_values_to_json(&cond.value)?
541                    )),
542                    // Qdrant range: { "key": "price", "range": { "gt": 10.0 } }
543                    Operator::Gt => format!(
544                        "{{ \"key\": {}, \"range\": {{ \"gt\": {} }} }}",
545                        json_string(col_str),
546                        numeric_filter_value(&cond.value)?
547                    ),
548                    Operator::Gte => format!(
549                        "{{ \"key\": {}, \"range\": {{ \"gte\": {} }} }}",
550                        json_string(col_str),
551                        numeric_filter_value(&cond.value)?
552                    ),
553                    Operator::Lt => format!(
554                        "{{ \"key\": {}, \"range\": {{ \"lt\": {} }} }}",
555                        json_string(col_str),
556                        numeric_filter_value(&cond.value)?
557                    ),
558                    Operator::Lte => format!(
559                        "{{ \"key\": {}, \"range\": {{ \"lte\": {} }} }}",
560                        json_string(col_str),
561                        numeric_filter_value(&cond.value)?
562                    ),
563                    Operator::IsNull => {
564                        if !matches!(cond.value, Value::Null | Value::NullUuid) {
565                            return Err("Qdrant IS NULL filters require a null value".to_string());
566                        }
567                        format!("{{ \"is_null\": {{ \"key\": {} }} }}", json_string(col_str))
568                    }
569                    Operator::IsNotNull => {
570                        if !matches!(cond.value, Value::Null | Value::NullUuid) {
571                            return Err(
572                                "Qdrant IS NOT NULL filters require a null value".to_string()
573                            );
574                        }
575                        negated_qdrant_clause(format!(
576                            "{{ \"is_null\": {{ \"key\": {} }} }}",
577                            json_string(col_str)
578                        ))
579                    }
580                    Operator::Fuzzy => {
581                        if qdrant_reserved_field_matches(col_str, "vector") {
582                            continue;
583                        }
584                        return Err(
585                            "Qdrant fuzzy filters are only supported on the vector field"
586                                .to_string(),
587                        );
588                    }
589                    Operator::Contains | Operator::Like => {
590                        let Value::String(value) = &cond.value else {
591                            return Err("Qdrant text filters require a string value".to_string());
592                        };
593                        if value.trim().is_empty() {
594                            return Err("Qdrant text filter value cannot be empty".to_string());
595                        }
596                        format!(
597                            "{{ \"key\": {}, \"match\": {{ \"text\": {} }} }}",
598                            json_string(col_str),
599                            json_string(value)
600                        )
601                    }
602                    Operator::NotLike => {
603                        let Value::String(value) = &cond.value else {
604                            return Err("Qdrant text filters require a string value".to_string());
605                        };
606                        if value.trim().is_empty() {
607                            return Err("Qdrant text filter value cannot be empty".to_string());
608                        }
609                        negated_qdrant_clause(format!(
610                            "{{ \"key\": {}, \"match\": {{ \"text\": {} }} }}",
611                            json_string(col_str),
612                            json_string(value)
613                        ))
614                    }
615                    _ => return Err(format!("unsupported Qdrant filter operator {:?}", cond.op)),
616                };
617                cage_clauses.push(clause);
618            }
619
620            if cage_clauses.is_empty() {
621                continue;
622            }
623
624            match cage.logical_op {
625                LogicalOp::And => musts.extend(cage_clauses),
626                LogicalOp::Or => should_groups.push(cage_clauses),
627            }
628        }
629    }
630
631    for mut group in should_groups {
632        if group.len() == 1 {
633            musts.push(group.remove(0));
634        } else {
635            musts.push(format!("{{ \"should\": [{}] }}", group.join(", ")));
636        }
637    }
638
639    if musts.is_empty() {
640        return Ok(String::new());
641    }
642
643    let mut parts = Vec::new();
644    if !musts.is_empty() {
645        parts.push(format!("\"must\": [{}]", musts.join(", ")));
646    }
647    Ok(format!("{{ {} }}", parts.join(", ")))
648}
649
650fn negated_qdrant_clause(clause: String) -> String {
651    format!("{{ \"must_not\": [{clause}] }}")
652}
653
654fn filter_match_value_to_json(v: &Value) -> Result<String, String> {
655    match v {
656        Value::String(s) => Ok(json_string(s)),
657        Value::Uuid(u) => Ok(json_string(&u.to_string())),
658        Value::Int(n) => Ok(n.to_string()),
659        Value::Bool(b) => Ok(b.to_string()),
660        Value::Null | Value::NullUuid => {
661            Err("Qdrant equality filters cannot match null; use IS NULL".to_string())
662        }
663        other => Err(format!(
664            "Qdrant equality filters support only string, UUID, integer, or bool values, got {other}"
665        )),
666    }
667}
668
669fn filter_array_values_to_json(v: &Value) -> Result<String, String> {
670    let Value::Array(values) = v else {
671        return Err("Qdrant IN filters require an array value".to_string());
672    };
673    if values.is_empty() {
674        return Err("Qdrant IN filters require at least one value".to_string());
675    }
676    if values
677        .iter()
678        .all(|value| matches!(value, Value::String(_) | Value::Uuid(_)))
679    {
680        let values = values
681            .iter()
682            .map(|value| match value {
683                Value::String(value) => Ok(json_string(value)),
684                Value::Uuid(value) => Ok(json_string(&value.to_string())),
685                _ => unreachable!("checked by all()"),
686            })
687            .collect::<Result<Vec<_>, String>>()?;
688        return Ok(values.join(", "));
689    }
690    if values.iter().all(|value| matches!(value, Value::Int(_))) {
691        let values = values
692            .iter()
693            .map(|value| match value {
694                Value::Int(value) => Ok(value.to_string()),
695                _ => unreachable!("checked by all()"),
696            })
697            .collect::<Result<Vec<_>, String>>()?;
698        return Ok(values.join(", "));
699    }
700    Err(
701        "Qdrant IN filters support only a non-empty homogeneous string/UUID or integer array"
702            .to_string(),
703    )
704}
705
706fn value_to_json(v: &Value) -> Result<String, String> {
707    match v {
708        Value::Null | Value::NullUuid => Ok("null".to_string()),
709        Value::String(s) => Ok(json_string(s)),
710        Value::Int(n) => Ok(n.to_string()),
711        Value::Float(n) if n.is_finite() => Ok(n.to_string()),
712        Value::Float(_) => Err("non-finite floats cannot be encoded as Qdrant JSON".to_string()),
713        Value::Bool(b) => Ok(b.to_string()),
714        Value::Uuid(u) => Ok(json_string(&u.to_string())),
715        Value::Timestamp(ts) => Ok(json_string(ts)),
716        Value::Array(arr) => {
717            let elems: Result<Vec<String>, String> = arr.iter().map(value_to_json).collect();
718            Ok(format!("[{}]", elems?.join(", ")))
719        }
720        Value::Vector(values) => {
721            let elems: Result<Vec<String>, String> = values
722                .iter()
723                .map(|value| {
724                    if value.is_finite() {
725                        Ok(value.to_string())
726                    } else {
727                        Err("non-finite vector values cannot be encoded as Qdrant JSON".to_string())
728                    }
729                })
730                .collect();
731            Ok(format!("[{}]", elems?.join(", ")))
732        }
733        Value::Json(json) => {
734            let value = serde_json::from_str::<serde_json::Value>(json)
735                .map_err(|err| format!("invalid JSON value for Qdrant payload: {err}"))?;
736            validate_json_payload_value(&value)?;
737            Ok(value.to_string())
738        }
739        other => Err(format!("unsupported Qdrant JSON value: {other}")),
740    }
741}
742
743fn numeric_filter_value(v: &Value) -> Result<String, String> {
744    match v {
745        Value::Int(n) => Ok(n.to_string()),
746        Value::Float(n) if n.is_finite() => Ok(n.to_string()),
747        Value::Float(_) => Err("Qdrant range filter values must be finite numbers".to_string()),
748        other => Err(format!(
749            "Qdrant range filter values must be numeric, got {other}"
750        )),
751    }
752}
753
754fn vector_to_json(v: &Value) -> Result<String, String> {
755    let elems: Result<Vec<String>, String> = match v {
756        Value::Vector(values) => return vector_values_to_json(values),
757        Value::Array(values) => values
758            .iter()
759            .map(|value| match value {
760                Value::Int(n) => Ok(n.to_string()),
761                Value::Float(n) if n.is_finite() => Ok(n.to_string()),
762                Value::Float(_) => Err("Qdrant vector values must be finite numbers".to_string()),
763                other => Err(format!("Qdrant vector values must be numeric, got {other}")),
764            })
765            .collect(),
766        other => return Err(format!("Qdrant vector must be an array, got {other}")),
767    };
768
769    let elems = elems?;
770    if elems.is_empty() {
771        return Err("Qdrant vector cannot be empty".to_string());
772    }
773    Ok(format!("[{}]", elems.join(", ")))
774}
775
776fn vector_values_to_json(values: &[f32]) -> Result<String, String> {
777    let elems: Result<Vec<String>, String> = values
778        .iter()
779        .map(|value| {
780            if value.is_finite() {
781                Ok(value.to_string())
782            } else {
783                Err("Qdrant vector values must be finite numbers".to_string())
784            }
785        })
786        .collect();
787
788    let elems = elems?;
789    if elems.is_empty() {
790        return Err("Qdrant vector cannot be empty".to_string());
791    }
792    Ok(format!("[{}]", elems.join(", ")))
793}
794
795#[cfg(test)]
796mod tests {
797    use super::ToQdrant;
798    use crate::ast::{Expr, Qail};
799
800    #[test]
801    fn search_projection_requests_table_qualified_vector() {
802        let body = Qail::get("embeddings")
803            .vector(vec![0.1, 0.2])
804            .columns(["embeddings.vector"])
805            .to_qdrant_search();
806        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
807
808        assert_eq!(json["with_vector"], true);
809        assert_eq!(json["with_payload"], false);
810    }
811
812    #[test]
813    fn search_projection_strips_table_qualified_payload_field() {
814        let body = Qail::get("embeddings")
815            .vector(vec![0.1, 0.2])
816            .columns(["embeddings.title"])
817            .to_qdrant_search();
818        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
819
820        assert_eq!(
821            json["with_payload"]["include"],
822            serde_json::json!(["title"])
823        );
824        assert!(json.get("with_vector").is_none());
825    }
826
827    #[test]
828    fn search_projection_preserves_quoted_dotted_payload_field() {
829        let mut cmd = Qail::get("embeddings").vector(vec![0.1, 0.2]);
830        cmd.columns = vec![Expr::Named("\"embeddings.vector\"".to_string())];
831
832        let body = cmd.to_qdrant_search();
833        let json: serde_json::Value = serde_json::from_str(&body).unwrap();
834
835        assert_eq!(
836            json["with_payload"]["include"],
837            serde_json::json!(["embeddings.vector"])
838        );
839        assert!(json.get("with_vector").is_none());
840    }
841}