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
9pub trait ToQdrant {
11 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 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 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 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 let mut parts = Vec::new();
365
366 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 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 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 let filter = build_filter(cmd)?;
433 if !filter.is_empty() {
434 parts.push(format!("\"filter\": {}", filter));
435 }
436
437 let limit = qdrant_limit(cmd)?;
439 parts.push(format!("\"limit\": {}", limit));
440
441 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 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 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 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}