1use super::error::ParseError;
4use super::Parser;
5use crate::ast::{QueryExpr, SearchCommand};
6use crate::lexer::Token;
7use reddb_types::types::Value;
8
9impl<'a> Parser<'a> {
10 pub fn parse_search_command(&mut self) -> Result<QueryExpr, ParseError> {
12 self.expect(Token::Search)?;
13 match self.peek().clone() {
14 Token::Similar => self.parse_search_similar(),
15 Token::Text => self.parse_search_text(),
16 Token::Hybrid => self.parse_search_hybrid(),
17 Token::Index => self.parse_search_index(),
18 Token::Ident(name) if name.eq_ignore_ascii_case("MULTIMODAL") => {
19 self.parse_search_multimodal()
20 }
21 Token::Ident(name) if name.eq_ignore_ascii_case("CONTEXT") => {
22 self.parse_search_context()
23 }
24 Token::Ident(name) if name.eq_ignore_ascii_case("SPATIAL") => {
25 self.parse_search_spatial()
26 }
27 _ => Err(ParseError::expected(
28 vec![
29 "SIMILAR",
30 "TEXT",
31 "HYBRID",
32 "MULTIMODAL",
33 "INDEX",
34 "CONTEXT",
35 "SPATIAL",
36 ],
37 self.peek(),
38 self.position(),
39 )),
40 }
41 }
42
43 fn parse_search_similar(&mut self) -> Result<QueryExpr, ParseError> {
45 self.advance()?; let mut vector_param: Option<usize> = None;
49 let mut text_param: Option<usize> = None;
50 let (vector, text) = if self.consume(&Token::Text)? {
51 if matches!(self.peek(), Token::Dollar | Token::Question) {
53 text_param = Some(self.parse_param_slot("SEARCH SIMILAR TEXT")?);
54 (Vec::new(), None)
55 } else {
56 let query_text = self.parse_string()?;
57 (Vec::new(), Some(query_text))
58 }
59 } else if matches!(self.peek(), Token::Dollar | Token::Question) {
60 vector_param = Some(self.parse_param_slot("SEARCH SIMILAR vector")?);
62 (Vec::new(), None)
63 } else {
64 (self.parse_vector_literal()?, None)
66 };
67
68 self.expect(Token::Collection)?;
70 let collection = self.expect_ident()?;
71
72 let mut limit_param: Option<usize> = None;
74 let limit = if self.consume(&Token::Limit)? {
75 if matches!(self.peek(), Token::Dollar | Token::Question) {
76 limit_param = Some(self.parse_param_slot("LIMIT")?);
77 0
78 } else {
79 self.parse_integer()? as usize
80 }
81 } else {
82 10
83 };
84
85 let mut min_score_param: Option<usize> = None;
87 let min_score = if self.consume(&Token::MinScore)? {
88 if matches!(self.peek(), Token::Dollar | Token::Question) {
89 min_score_param = Some(self.parse_param_slot("MIN_SCORE")?);
90 0.0
91 } else {
92 self.parse_float()? as f32
93 }
94 } else {
95 0.0
96 };
97
98 let provider = if self.consume(&Token::Using)? {
103 Some(self.expect_ident()?)
104 } else {
105 None
106 };
107
108 Ok(QueryExpr::SearchCommand(SearchCommand::Similar {
109 vector,
110 text,
111 provider,
112 collection,
113 limit,
114 min_score,
115 vector_param,
116 limit_param,
117 min_score_param,
118 text_param,
119 }))
120 }
121
122 fn parse_search_text(&mut self) -> Result<QueryExpr, ParseError> {
124 self.advance()?; let query = self.parse_string()?;
127
128 let collection = if self.consume(&Token::Collection)? || self.consume(&Token::In)? {
130 Some(self.expect_ident()?)
131 } else {
132 None
133 };
134
135 let mut limit_param: Option<usize> = None;
137 let limit = if self.consume(&Token::Limit)? {
138 if matches!(self.peek(), Token::Dollar | Token::Question) {
139 limit_param = Some(self.parse_param_slot("LIMIT")?);
140 0
141 } else {
142 self.parse_integer()? as usize
143 }
144 } else {
145 10
146 };
147
148 let fuzzy = self.consume(&Token::Fuzzy)?;
150
151 Ok(QueryExpr::SearchCommand(SearchCommand::Text {
152 query,
153 collection,
154 limit,
155 fuzzy,
156 limit_param,
157 }))
158 }
159
160 fn parse_search_hybrid(&mut self) -> Result<QueryExpr, ParseError> {
162 self.advance()?; let mut vector = None;
165 let mut query = None;
166
167 loop {
168 if self.consume(&Token::Similar)? || self.consume(&Token::Vector)? {
169 vector = Some(self.parse_vector_literal()?);
170 } else if self.consume(&Token::Text)? {
171 query = Some(self.parse_string()?);
172 } else {
173 break;
174 }
175 }
176
177 if vector.is_none() && query.is_none() {
179 return Err(ParseError::new(
180 "SEARCH HYBRID requires at least SIMILAR or TEXT".to_string(),
181 self.position(),
182 ));
183 }
184
185 if !(self.consume(&Token::Collection)? || self.consume(&Token::In)?) {
190 return Err(ParseError::expected(
191 vec!["COLLECTION", "IN"],
192 self.peek(),
193 self.position(),
194 ));
195 }
196 let collection = self.expect_collection_name()?;
197
198 let mut limit_param: Option<usize> = None;
200 let limit = if self.consume(&Token::Limit)? || self.consume(&Token::K)? {
201 let _ = self.consume(&Token::Eq)?;
202 if matches!(self.peek(), Token::Dollar | Token::Question) {
203 limit_param = Some(self.parse_param_slot("LIMIT")?);
204 0
205 } else {
206 self.parse_integer()? as usize
207 }
208 } else {
209 10
210 };
211
212 Ok(QueryExpr::SearchCommand(SearchCommand::Hybrid {
213 vector,
214 query,
215 collection,
216 limit,
217 limit_param,
218 }))
219 }
220
221 fn parse_search_multimodal(&mut self) -> Result<QueryExpr, ParseError> {
223 self.advance()?; let query = self.parse_string()?;
226
227 let collection = if self.consume(&Token::Collection)? {
228 Some(self.expect_ident()?)
229 } else {
230 None
231 };
232
233 let mut limit_param: Option<usize> = None;
235 let limit = if self.consume(&Token::Limit)? {
236 if matches!(self.peek(), Token::Dollar | Token::Question) {
237 limit_param = Some(self.parse_param_slot("LIMIT")?);
238 0
239 } else {
240 self.parse_integer()? as usize
241 }
242 } else {
243 25
244 };
245
246 Ok(QueryExpr::SearchCommand(SearchCommand::Multimodal {
247 query,
248 collection,
249 limit,
250 limit_param,
251 }))
252 }
253
254 fn parse_search_index(&mut self) -> Result<QueryExpr, ParseError> {
256 self.advance()?; let index = self.expect_ident()?;
259 self.expect_search_ident("VALUE")?;
260 let value = self.parse_string()?;
261
262 let collection = if self.consume(&Token::Collection)? {
263 Some(self.expect_ident()?)
264 } else {
265 None
266 };
267
268 let mut limit_param: Option<usize> = None;
270 let limit = if self.consume(&Token::Limit)? {
271 if matches!(self.peek(), Token::Dollar | Token::Question) {
272 limit_param = Some(self.parse_param_slot("LIMIT")?);
273 0
274 } else {
275 self.parse_integer()? as usize
276 }
277 } else {
278 25
279 };
280
281 let fuzzy = self.consume(&Token::Fuzzy)? || self.consume_search_ident("FUZZY")?;
282 if !fuzzy {
283 let _ = self.consume_search_ident("EXACT")?;
284 }
285 let exact = !fuzzy;
286
287 Ok(QueryExpr::SearchCommand(SearchCommand::Index {
288 index,
289 value,
290 collection,
291 limit,
292 exact,
293 limit_param,
294 }))
295 }
296
297 fn expect_collection_name(&mut self) -> Result<String, ParseError> {
302 let was_ident = matches!(self.peek(), Token::Ident(_));
303 let raw = self.expect_ident_or_keyword()?;
304 Ok(if was_ident {
305 raw
306 } else {
307 raw.to_ascii_lowercase()
308 })
309 }
310
311 fn expect_search_ident(&mut self, expected: &str) -> Result<(), ParseError> {
312 if self.consume_search_ident(expected)? {
313 Ok(())
314 } else {
315 Err(ParseError::expected(
316 vec![expected],
317 self.peek(),
318 self.position(),
319 ))
320 }
321 }
322
323 fn consume_search_ident(&mut self, expected: &str) -> Result<bool, ParseError> {
324 match self.peek().clone() {
325 Token::Ident(name) if name.eq_ignore_ascii_case(expected) => {
326 self.advance()?;
327 Ok(true)
328 }
329 _ => Ok(false),
330 }
331 }
332
333 fn parse_search_context(&mut self) -> Result<QueryExpr, ParseError> {
335 self.advance()?; let query = self.parse_string()?;
338
339 let field = if self.consume_search_ident("FIELD")? {
340 Some(self.expect_ident()?)
341 } else {
342 None
343 };
344
345 let collection = if self.consume(&Token::Collection)? {
346 Some(self.expect_ident()?)
347 } else {
348 None
349 };
350
351 let mut limit = 25usize;
353 let mut depth = 1usize;
354 let mut limit_param: Option<usize> = None;
355 for _ in 0..2 {
356 if self.consume(&Token::Limit)? {
357 if matches!(self.peek(), Token::Dollar | Token::Question) {
358 limit_param = Some(self.parse_param_slot("LIMIT")?);
359 limit = 0;
360 } else {
361 limit = self.parse_integer()? as usize;
362 }
363 } else if self.consume(&Token::Depth)? {
364 depth = self.parse_integer()? as usize;
365 }
366 }
367
368 Ok(QueryExpr::SearchCommand(SearchCommand::Context {
369 query,
370 field,
371 collection,
372 limit,
373 depth,
374 limit_param,
375 }))
376 }
377
378 fn parse_search_spatial(&mut self) -> Result<QueryExpr, ParseError> {
386 self.advance()?; match self.peek().clone() {
389 Token::Ident(ref name) if name.eq_ignore_ascii_case("RADIUS") => {
390 self.advance()?; let lat_pos = self.position();
392 let center_lat = self.parse_float()?;
393 if !(-90.0..=90.0).contains(¢er_lat) {
394 return Err(ParseError::value_out_of_range(
395 "lat",
396 "must be in -90.0..=90.0",
397 lat_pos,
398 ));
399 }
400 let lon_pos = self.position();
401 let center_lon = self.parse_float()?;
402 if !(-180.0..=180.0).contains(¢er_lon) {
403 return Err(ParseError::value_out_of_range(
404 "lon",
405 "must be in -180.0..=180.0",
406 lon_pos,
407 ));
408 }
409 let r_pos = self.position();
410 let radius_km = self.parse_float()?;
411 if radius_km.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
412 return Err(ParseError::value_out_of_range(
413 "radius",
414 "must be a positive number",
415 r_pos,
416 ));
417 }
418
419 self.expect(Token::Collection)?;
420 let collection = self.expect_ident()?;
421
422 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
423 let column = self.parse_search_spatial_column()?;
424
425 let mut limit_param: Option<usize> = None;
426 let limit = if self.consume(&Token::Limit)? {
427 if matches!(self.peek(), Token::Dollar | Token::Question) {
428 limit_param = Some(self.parse_param_slot("LIMIT")?);
429 0
430 } else {
431 self.parse_integer()? as usize
432 }
433 } else {
434 100
435 };
436
437 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
438 center_lat,
439 center_lon,
440 radius_km,
441 collection,
442 column,
443 limit,
444 limit_param,
445 }))
446 }
447 Token::Ident(ref name) if name.eq_ignore_ascii_case("BBOX") => {
448 self.advance()?; let p = self.position();
450 let min_lat = self.parse_float()?;
451 if !(-90.0..=90.0).contains(&min_lat) {
452 return Err(ParseError::value_out_of_range(
453 "lat",
454 "must be in -90.0..=90.0",
455 p,
456 ));
457 }
458 let p = self.position();
459 let min_lon = self.parse_float()?;
460 if !(-180.0..=180.0).contains(&min_lon) {
461 return Err(ParseError::value_out_of_range(
462 "lon",
463 "must be in -180.0..=180.0",
464 p,
465 ));
466 }
467 let p = self.position();
468 let max_lat = self.parse_float()?;
469 if !(-90.0..=90.0).contains(&max_lat) {
470 return Err(ParseError::value_out_of_range(
471 "lat",
472 "must be in -90.0..=90.0",
473 p,
474 ));
475 }
476 let p = self.position();
477 let max_lon = self.parse_float()?;
478 if !(-180.0..=180.0).contains(&max_lon) {
479 return Err(ParseError::value_out_of_range(
480 "lon",
481 "must be in -180.0..=180.0",
482 p,
483 ));
484 }
485
486 self.expect(Token::Collection)?;
487 let collection = self.expect_ident()?;
488
489 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
490 let column = self.parse_search_spatial_column()?;
491
492 let mut limit_param: Option<usize> = None;
493 let limit = if self.consume(&Token::Limit)? {
494 if matches!(self.peek(), Token::Dollar | Token::Question) {
495 limit_param = Some(self.parse_param_slot("LIMIT")?);
496 0
497 } else {
498 self.parse_integer()? as usize
499 }
500 } else {
501 100
502 };
503
504 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
505 min_lat,
506 min_lon,
507 max_lat,
508 max_lon,
509 collection,
510 column,
511 limit,
512 limit_param,
513 }))
514 }
515 Token::Ident(ref name) if name.eq_ignore_ascii_case("WITHIN") => {
516 self.advance()?; self.expect_search_ident("POLYGON")?;
518 let vertices = self.parse_search_spatial_polygon_vertices()?;
519 validate_search_spatial_polygon(&vertices, self.position())?;
520
521 self.expect(Token::Collection)?;
522 let collection = self.expect_ident()?;
523
524 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
525 let column = self.parse_search_spatial_column()?;
526
527 let mut limit_param: Option<usize> = None;
528 let limit = if self.consume(&Token::Limit)? {
529 if matches!(self.peek(), Token::Dollar | Token::Question) {
530 limit_param = Some(self.parse_param_slot("LIMIT")?);
531 0
532 } else {
533 self.parse_integer()? as usize
534 }
535 } else {
536 100
537 };
538
539 Ok(QueryExpr::SearchCommand(
540 SearchCommand::SpatialWithinPolygon {
541 vertices,
542 collection,
543 column,
544 limit,
545 limit_param,
546 },
547 ))
548 }
549 Token::Ident(ref name) if name.eq_ignore_ascii_case("NEAREST") => {
550 self.advance()?; let lat_pos = self.position();
552 let lat = self.parse_float()?;
553 if !(-90.0..=90.0).contains(&lat) {
554 return Err(ParseError::value_out_of_range(
555 "lat",
556 "must be in -90.0..=90.0",
557 lat_pos,
558 ));
559 }
560 let lon_pos = self.position();
561 let lon = self.parse_float()?;
562 if !(-180.0..=180.0).contains(&lon) {
563 return Err(ParseError::value_out_of_range(
564 "lon",
565 "must be in -180.0..=180.0",
566 lon_pos,
567 ));
568 }
569
570 self.expect(Token::K)?;
571 let mut k_param: Option<usize> = None;
573 let k = if matches!(self.peek(), Token::Dollar | Token::Question) {
574 k_param = Some(self.parse_param_slot("K")?);
575 0
576 } else {
577 self.parse_positive_integer("K")? as usize
578 };
579
580 self.expect(Token::Collection)?;
581 let collection = self.expect_ident()?;
582
583 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
584 let column = self.parse_search_spatial_column()?;
585
586 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
587 lat,
588 lon,
589 k,
590 collection,
591 column,
592 k_param,
593 }))
594 }
595 _ => Err(ParseError::expected(
596 vec!["RADIUS", "BBOX", "WITHIN", "NEAREST"],
597 self.peek(),
598 self.position(),
599 )),
600 }
601 }
602
603 fn parse_search_spatial_polygon_vertices(&mut self) -> Result<Vec<(f64, f64)>, ParseError> {
604 self.expect(Token::LParen)?;
605 let mut vertices = Vec::new();
606 loop {
607 self.expect(Token::LParen)?;
608 let lat = self.parse_float()?;
609 let lon = self.parse_float()?;
610 self.expect(Token::RParen)?;
611 vertices.push((lat, lon));
612 if !self.consume(&Token::Comma)? {
613 break;
614 }
615 }
616 self.expect(Token::RParen)?;
617 Ok(vertices)
618 }
619
620 fn parse_search_spatial_column(&mut self) -> Result<String, ParseError> {
621 let mut segments = vec![self.expect_spatial_column_segment()?];
622 while self.consume(&Token::Dot)? {
623 segments.push(self.expect_spatial_column_segment()?);
624 }
625 Ok(segments.join("."))
626 }
627
628 pub(crate) fn expect_spatial_column_segment(&mut self) -> Result<String, ParseError> {
640 if let Token::Ident(name) = &self.current.token {
641 let name = name.clone();
642 self.advance()?;
643 return Ok(name);
644 }
645 let start = self.current.start.offset as usize;
646 let end = self.current.end.offset as usize;
647 let raw = self.lexer.source().get(start..end).unwrap_or("");
648 let word_shaped = !raw.is_empty()
649 && raw.chars().all(|c| c.is_alphanumeric() || c == '_')
650 && !raw.chars().next().is_some_and(|c| c.is_ascii_digit());
651 if word_shaped {
652 let name = raw.to_string();
653 self.advance()?;
654 return Ok(name);
655 }
656 Err(ParseError::new(
657 format!("expected column name, found {}", self.current.token),
658 self.position(),
659 ))
660 }
661
662 fn parse_vector_literal(&mut self) -> Result<Vec<f32>, ParseError> {
664 self.expect(Token::LBracket)?;
665 let mut items = Vec::new();
666 if !self.check(&Token::RBracket) {
667 loop {
668 let val = self.parse_float()? as f32;
669 items.push(val);
670 if !self.consume(&Token::Comma)? {
671 break;
672 }
673 }
674 }
675 self.expect(Token::RBracket)?;
676 Ok(items)
677 }
678}
679
680fn validate_search_spatial_polygon(
681 vertices: &[(f64, f64)],
682 pos: crate::lexer::Position,
683) -> Result<(), ParseError> {
684 validate_polygon_vertices(vertices, "SEARCH SPATIAL WITHIN POLYGON", pos)
685}
686
687pub(crate) fn validate_polygon_vertices(
695 vertices: &[(f64, f64)],
696 surface: &str,
697 pos: crate::lexer::Position,
698) -> Result<(), ParseError> {
699 if vertices.len() < 3 {
700 return Err(ParseError::new(
701 format!("{surface} requires at least 3 vertices"),
702 pos,
703 ));
704 }
705 for (lat, lon) in vertices {
706 if !lat.is_finite() || !(-90.0..=90.0).contains(lat) {
707 return Err(ParseError::value_out_of_range(
708 "lat",
709 "must be in -90.0..=90.0",
710 pos,
711 ));
712 }
713 if !lon.is_finite() || !(-180.0..=180.0).contains(lon) {
714 return Err(ParseError::value_out_of_range(
715 "lon",
716 "must be in -180.0..=180.0",
717 pos,
718 ));
719 }
720 }
721 let (min_lon, max_lon) = vertices.iter().map(|(_, lon)| *lon).fold(
722 (f64::INFINITY, f64::NEG_INFINITY),
723 |(min_lon, max_lon), lon| (min_lon.min(lon), max_lon.max(lon)),
724 );
725 if max_lon - min_lon > 180.0 {
726 return Err(ParseError::new(
727 format!("{surface} does not support polygons crossing the antimeridian"),
728 pos,
729 ));
730 }
731 Ok(())
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 fn parse_query(input: &str) -> QueryExpr {
739 crate::parser::parse(input).unwrap().query
740 }
741
742 fn assert_parse_err(input: &str) {
743 assert!(crate::parser::parse(input).is_err(), "{input}");
744 }
745
746 #[test]
747 fn parses_search_similar_text_vector_and_limit_parameters() {
748 let query = parse_query(
749 "SEARCH SIMILAR TEXT 'semantic query' COLLECTION docs LIMIT 7 MIN_SCORE 0.42 USING openai",
750 );
751 let QueryExpr::SearchCommand(SearchCommand::Similar {
752 vector,
753 text,
754 provider,
755 collection,
756 limit,
757 min_score,
758 vector_param,
759 limit_param,
760 min_score_param,
761 text_param,
762 }) = query
763 else {
764 panic!("Expected SearchCommand::Similar");
765 };
766 assert!(vector.is_empty());
767 assert_eq!(text, Some("semantic query".to_string()));
768 assert_eq!(provider, Some("openai".to_string()));
769 assert_eq!(collection, "docs");
770 assert_eq!(limit, 7);
771 assert!((min_score - 0.42).abs() < 0.01);
772 assert_eq!(vector_param, None);
773 assert_eq!(limit_param, None);
774 assert_eq!(min_score_param, None);
775 assert_eq!(text_param, None);
776
777 let query = parse_query("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2 MIN_SCORE $3");
778 let QueryExpr::SearchCommand(SearchCommand::Similar {
779 vector,
780 text,
781 limit,
782 min_score,
783 vector_param,
784 limit_param,
785 min_score_param,
786 text_param,
787 ..
788 }) = query
789 else {
790 panic!("Expected parameterized SearchCommand::Similar");
791 };
792 assert!(vector.is_empty());
793 assert_eq!(text, None);
794 assert_eq!(limit, 0);
795 assert!((min_score).abs() < 0.01);
796 assert_eq!(vector_param, None);
797 assert_eq!(limit_param, Some(1));
798 assert_eq!(min_score_param, Some(2));
799 assert_eq!(text_param, Some(0));
800
801 let query = parse_query("SEARCH SIMILAR $1 COLLECTION embeddings");
802 let QueryExpr::SearchCommand(SearchCommand::Similar {
803 vector,
804 vector_param,
805 limit,
806 min_score,
807 ..
808 }) = query
809 else {
810 panic!("Expected vector parameter SearchCommand::Similar");
811 };
812 assert!(vector.is_empty());
813 assert_eq!(vector_param, Some(0));
814 assert_eq!(limit, 10);
815 assert!((min_score).abs() < 0.01);
816 }
817
818 #[test]
819 fn parses_search_text_in_collection_with_question_limit() {
820 let query = parse_query("SEARCH TEXT 'needle' IN docs LIMIT ? FUZZY");
821 let QueryExpr::SearchCommand(SearchCommand::Text {
822 query,
823 collection,
824 limit,
825 fuzzy,
826 limit_param,
827 }) = query
828 else {
829 panic!("Expected SearchCommand::Text");
830 };
831 assert_eq!(query, "needle");
832 assert_eq!(collection, Some("docs".to_string()));
833 assert_eq!(limit, 0);
834 assert!(fuzzy);
835 assert_eq!(limit_param, Some(0));
836 }
837
838 #[test]
839 fn parses_search_hybrid_vector_keyword_k_equals_and_keyword_collection() {
840 let query = parse_query("SEARCH HYBRID VECTOR [1, 2] TEXT 'needle' IN TEXT K = $1");
841 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
842 vector,
843 query,
844 collection,
845 limit,
846 limit_param,
847 }) = query
848 else {
849 panic!("Expected SearchCommand::Hybrid");
850 };
851 assert_eq!(vector, Some(vec![1.0, 2.0]));
852 assert_eq!(query, Some("needle".to_string()));
853 assert_eq!(collection, "text");
854 assert_eq!(limit, 0);
855 assert_eq!(limit_param, Some(0));
856 }
857
858 #[test]
859 fn parses_multimodal_and_index_parameterized_limits() {
860 let query = parse_query("SEARCH MULTIMODAL 'image query' COLLECTION assets LIMIT $1");
861 let QueryExpr::SearchCommand(SearchCommand::Multimodal {
862 query,
863 collection,
864 limit,
865 limit_param,
866 }) = query
867 else {
868 panic!("Expected SearchCommand::Multimodal");
869 };
870 assert_eq!(query, "image query");
871 assert_eq!(collection, Some("assets".to_string()));
872 assert_eq!(limit, 0);
873 assert_eq!(limit_param, Some(0));
874
875 let query = parse_query(
876 "SEARCH INDEX email VALUE 'a@example.test' COLLECTION users LIMIT $1 EXACT",
877 );
878 let QueryExpr::SearchCommand(SearchCommand::Index {
879 index,
880 value,
881 collection,
882 limit,
883 exact,
884 limit_param,
885 }) = query
886 else {
887 panic!("Expected SearchCommand::Index");
888 };
889 assert_eq!(index, "email");
890 assert_eq!(value, "a@example.test");
891 assert_eq!(collection, Some("users".to_string()));
892 assert_eq!(limit, 0);
893 assert!(exact);
894 assert_eq!(limit_param, Some(0));
895 }
896
897 #[test]
898 fn parses_search_context_depth_before_parameterized_limit() {
899 let query =
900 parse_query("SEARCH CONTEXT 'who' FIELD subject COLLECTION docs DEPTH 3 LIMIT $1");
901 let QueryExpr::SearchCommand(SearchCommand::Context {
902 query,
903 field,
904 collection,
905 limit,
906 depth,
907 limit_param,
908 }) = query
909 else {
910 panic!("Expected SearchCommand::Context");
911 };
912 assert_eq!(query, "who");
913 assert_eq!(field, Some("subject".to_string()));
914 assert_eq!(collection, Some("docs".to_string()));
915 assert_eq!(limit, 0);
916 assert_eq!(depth, 3);
917 assert_eq!(limit_param, Some(0));
918 }
919
920 #[test]
921 fn parses_search_spatial_bbox_and_nearest_parameters() {
922 let query =
923 parse_query("SEARCH SPATIAL BBOX -10 -20 10 20 COLLECTION sites COLUMN geog LIMIT $1");
924 let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
925 min_lat,
926 min_lon,
927 max_lat,
928 max_lon,
929 collection,
930 column,
931 limit,
932 limit_param,
933 }) = query
934 else {
935 panic!("Expected SearchCommand::SpatialBbox");
936 };
937 assert!((min_lat + 10.0).abs() < 0.001);
938 assert!((min_lon + 20.0).abs() < 0.001);
939 assert!((max_lat - 10.0).abs() < 0.001);
940 assert!((max_lon - 20.0).abs() < 0.001);
941 assert_eq!(collection, "sites");
942 assert_eq!(column, "geog");
943 assert_eq!(limit, 0);
944 assert_eq!(limit_param, Some(0));
945
946 let query =
947 parse_query("SEARCH SPATIAL NEAREST 48.85 2.35 K ?2 COLLECTION sites COLUMN geog");
948 let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
949 lat,
950 lon,
951 k,
952 collection,
953 column,
954 k_param,
955 }) = query
956 else {
957 panic!("Expected SearchCommand::SpatialNearest");
958 };
959 assert!((lat - 48.85).abs() < 0.001);
960 assert!((lon - 2.35).abs() < 0.001);
961 assert_eq!(k, 0);
962 assert_eq!(collection, "sites");
963 assert_eq!(column, "geog");
964 assert_eq!(k_param, Some(1));
965 }
966
967 #[test]
968 fn rejects_invalid_search_command_forms() {
969 for input in [
970 "SEARCH AUDIO 'needle'",
971 "SEARCH HYBRID TEXT 'needle'",
972 "SEARCH SPATIAL WITHIN 0 0 COLLECTION sites COLUMN geog",
973 "SEARCH SPATIAL RADIUS 91 0 1 COLLECTION sites COLUMN geog",
974 "SEARCH SPATIAL RADIUS 45 181 1 COLLECTION sites COLUMN geog",
975 "SEARCH SPATIAL RADIUS 45 90 0 COLLECTION sites COLUMN geog",
976 "SEARCH SPATIAL BBOX -91 0 1 1 COLLECTION sites COLUMN geog",
977 "SEARCH SPATIAL BBOX 0 -181 1 1 COLLECTION sites COLUMN geog",
978 "SEARCH SPATIAL BBOX 0 0 91 1 COLLECTION sites COLUMN geog",
979 "SEARCH SPATIAL BBOX 0 0 1 181 COLLECTION sites COLUMN geog",
980 "SEARCH SPATIAL NEAREST 91 0 K 1 COLLECTION sites COLUMN geog",
981 "SEARCH SPATIAL NEAREST 0 181 K 1 COLLECTION sites COLUMN geog",
982 "SEARCH SPATIAL NEAREST 0 0 K 0 COLLECTION sites COLUMN geog",
983 ] {
984 assert_parse_err(input);
985 }
986 }
987}