qail_core/transpiler/nosql/
qdrant.rs

1use crate::ast::*;
2
3pub trait ToQdrant {
4    fn to_qdrant_search(&self) -> String;
5}
6
7impl ToQdrant for Qail {
8    fn to_qdrant_search(&self) -> String {
9        match self.action {
10            Action::Get => build_qdrant_search(self),
11            Action::Put | Action::Add => build_qdrant_upsert(self),
12            Action::Del => build_qdrant_delete(self),
13            _ => format!(
14                "{{ \"error\": \"Action {:?} not supported for Qdrant\" }}",
15                self.action
16            ),
17        }
18    }
19}
20
21fn build_qdrant_upsert(cmd: &Qail) -> String {
22    // POST /collections/{name}/points?wait=true
23    // Body: { "points": [ { "id": 1, "vector": [...], "payload": {...} } ] }
24    // let mut points = Vec::new(); // Unused
25
26    // Single point upsert from payload/filter cages.
27    let mut point_id = "0".to_string(); // Default ID?
28    let mut vector = "[0.0]".to_string();
29    let mut payload_parts = Vec::new();
30
31    for cage in &cmd.cages {
32        match cage.kind {
33            CageKind::Payload | CageKind::Filter => {
34                for cond in &cage.conditions {
35                    if let Expr::Named(name) = &cond.left {
36                        if name == "id" {
37                            point_id = value_to_json(&cond.value);
38                        } else if name == "vector" {
39                            vector = value_to_json(&cond.value);
40                        } else {
41                            payload_parts.push(format!(
42                                "\"{}\": {}",
43                                name,
44                                value_to_json(&cond.value)
45                            ));
46                        }
47                    }
48                }
49            }
50            _ => {}
51        }
52    }
53
54    let payload_json = if payload_parts.is_empty() {
55        "{}".to_string()
56    } else {
57        format!("{{ {} }}", payload_parts.join(", "))
58    };
59
60    // Construct single point
61    let point = format!(
62        "{{ \"id\": {}, \"vector\": {}, \"payload\": {} }}",
63        point_id, vector, payload_json
64    );
65
66    format!("{{ \"points\": [{}] }}", point)
67}
68
69fn build_qdrant_delete(cmd: &Qail) -> String {
70    // POST /collections/{name}/points/delete
71    // Body: { "points": [1, 2, 3] } OR { "filter": ... }
72
73    // If ID specified, delete by ID. Else delete by filter.
74    let mut ids = Vec::new();
75
76    for cage in &cmd.cages {
77        if let CageKind::Filter = cage.kind {
78            for cond in &cage.conditions {
79                if let Expr::Named(name) = &cond.left
80                    && name == "id"
81                {
82                    ids.push(value_to_json(&cond.value));
83                }
84            }
85        }
86    }
87
88    if !ids.is_empty() {
89        format!("{{ \"points\": [{}] }}", ids.join(", "))
90    } else {
91        // Delete by filter
92        let filter = build_filter(cmd);
93        format!("{{ \"filter\": {} }}", filter)
94    }
95}
96
97fn build_qdrant_search(cmd: &Qail) -> String {
98    // Target endpoint: POST /collections/{collection_name}/points/search
99    // Output: JSON Body
100
101    let mut parts = Vec::new();
102
103    // 1. Vector handling
104    // We look for a condition with the key "vector" or similar, usage: [vector~[0.1, 0.2]]
105    // Any array value with a Fuzzy match (~) is treated as the query vector.
106    let mut vector_found = false;
107
108    for cage in &cmd.cages {
109        if let CageKind::Filter = cage.kind {
110            for cond in &cage.conditions {
111                if cond.op == Operator::Fuzzy {
112                    // Vector Query found.
113                    // Case 1: [vector~[0.1, 0.2]] -> Explicit Vector (Already handled by Value::Array)
114                    // Case 2: [vector~"cute cat"] -> Semantic Search Intent
115                    match &cond.value {
116                        Value::String(s) => {
117                            // Output Placeholder for Runtime Resolution
118                            // e.g. {{EMBED:cute cat}}
119                            parts.push(format!("\"vector\": \"{{{{EMBED:{}}}}}\"", s));
120                        }
121                        _ => {
122                            parts.push(format!("\"vector\": {}", value_to_json(&cond.value)));
123                        }
124                    }
125                    vector_found = true;
126                    break;
127                }
128            }
129        }
130        if vector_found {
131            break;
132        }
133    }
134
135    if !vector_found {
136        // Actually, Qdrant supports Scroll API separate from Search.
137        parts.push("\"vector\": [0.0]".to_string()); // Dummy vector or error? Let's use dummy to show intent.
138    }
139
140    // 2. Filters (Hybrid Search)
141    let filter = build_filter(cmd);
142    if !filter.is_empty() {
143        parts.push(format!("\"filter\": {}", filter));
144    }
145
146    // 3. Limit
147    let mut limit = 10;
148    if let Some(l) = get_cage_val(cmd, CageKind::Limit(0)) {
149        limit = l;
150    }
151    parts.push(format!("\"limit\": {}", limit));
152
153    // 4. With Payload (Projections)
154    if !cmd.columns.is_empty() {
155        let mut incl = Vec::new();
156        for c in &cmd.columns {
157            if let Expr::Named(n) = c {
158                incl.push(format!("\"{}\"", n));
159            }
160        }
161        parts.push(format!(
162            "\"with_payload\": {{ \"include\": [{}] }}",
163            incl.join(", ")
164        ));
165    } else {
166        parts.push("\"with_payload\": true".to_string());
167    }
168
169    format!("{{ {} }}", parts.join(", "))
170}
171
172fn build_filter(cmd: &Qail) -> String {
173    // Qdrant Filter structure: { "must": [ { "key": "city", "match": { "value": "London" } } ] }
174    let mut musts = Vec::new();
175
176    for cage in &cmd.cages {
177        if let CageKind::Filter = cage.kind {
178            for cond in &cage.conditions {
179                // Skip the vector query itself
180                if cond.op == Operator::Fuzzy {
181                    continue;
182                }
183
184                let val = value_to_json(&cond.value);
185                let col_str = match &cond.left {
186                    Expr::Named(name) => name.clone(),
187                    expr => expr.to_string(),
188                };
189
190                let clause = match cond.op {
191                    Operator::Eq => format!(
192                        "{{ \"key\": \"{}\", \"match\": {{ \"value\": {} }} }}",
193                        col_str, val
194                    ),
195                    // Qdrant range: { "key": "price", "range": { "gt": 10.0 } }
196                    Operator::Gt => format!(
197                        "{{ \"key\": \"{}\", \"range\": {{ \"gt\": {} }} }}",
198                        col_str, val
199                    ),
200                    Operator::Gte => format!(
201                        "{{ \"key\": \"{}\", \"range\": {{ \"gte\": {} }} }}",
202                        col_str, val
203                    ),
204                    Operator::Lt => format!(
205                        "{{ \"key\": \"{}\", \"range\": {{ \"lt\": {} }} }}",
206                        col_str, val
207                    ),
208                    Operator::Lte => format!(
209                        "{{ \"key\": \"{}\", \"range\": {{ \"lte\": {} }} }}",
210                        col_str, val
211                    ),
212                    Operator::Ne => format!(
213                        "{{ \"must_not\": [{{ \"key\": \"{}\", \"match\": {{ \"value\": {} }} }}] }}",
214                        col_str, val
215                    ), // This needs wrapping?
216                    _ => format!(
217                        "{{ \"key\": \"{}\", \"match\": {{ \"value\": {} }} }}",
218                        col_str, val
219                    ),
220                };
221                musts.push(clause);
222            }
223        }
224    }
225
226    if musts.is_empty() {
227        return String::new();
228    }
229
230    format!("{{ \"must\": [{}] }}", musts.join(", "))
231}
232
233fn get_cage_val(cmd: &Qail, kind_example: CageKind) -> Option<usize> {
234    for cage in &cmd.cages {
235        if let (CageKind::Limit(n), CageKind::Limit(_)) = (&cage.kind, &kind_example) {
236            return Some(*n);
237        }
238    }
239    None
240}
241
242fn value_to_json(v: &Value) -> String {
243    match v {
244        Value::String(s) => format!("\"{}\"", s),
245        Value::Int(n) => n.to_string(),
246        Value::Float(n) => n.to_string(),
247        Value::Bool(b) => b.to_string(),
248        Value::Array(arr) => {
249            let elems: Vec<String> = arr
250                .iter()
251                .map(|e| match e {
252                    Value::Int(i) => i.to_string(),
253                    Value::Float(f) => f.to_string(),
254                    _ => "0.0".to_string(),
255                })
256                .collect();
257            format!("[{}]", elems.join(", "))
258        }
259        _ => "null".to_string(),
260    }
261}