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> {
385 self.advance()?; match self.peek().clone() {
388 Token::Ident(ref name) if name.eq_ignore_ascii_case("RADIUS") => {
389 self.advance()?; let lat_pos = self.position();
391 let center_lat = self.parse_float()?;
392 if !(-90.0..=90.0).contains(¢er_lat) {
393 return Err(ParseError::value_out_of_range(
394 "lat",
395 "must be in -90.0..=90.0",
396 lat_pos,
397 ));
398 }
399 let lon_pos = self.position();
400 let center_lon = self.parse_float()?;
401 if !(-180.0..=180.0).contains(¢er_lon) {
402 return Err(ParseError::value_out_of_range(
403 "lon",
404 "must be in -180.0..=180.0",
405 lon_pos,
406 ));
407 }
408 let r_pos = self.position();
409 let radius_km = self.parse_float()?;
410 if radius_km.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
411 return Err(ParseError::value_out_of_range(
412 "radius",
413 "must be a positive number",
414 r_pos,
415 ));
416 }
417
418 self.expect(Token::Collection)?;
419 let collection = self.expect_ident()?;
420
421 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
422 let column = self.expect_ident()?;
423
424 let mut limit_param: Option<usize> = None;
425 let limit = if self.consume(&Token::Limit)? {
426 if matches!(self.peek(), Token::Dollar | Token::Question) {
427 limit_param = Some(self.parse_param_slot("LIMIT")?);
428 0
429 } else {
430 self.parse_integer()? as usize
431 }
432 } else {
433 100
434 };
435
436 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialRadius {
437 center_lat,
438 center_lon,
439 radius_km,
440 collection,
441 column,
442 limit,
443 limit_param,
444 }))
445 }
446 Token::Ident(ref name) if name.eq_ignore_ascii_case("BBOX") => {
447 self.advance()?; let p = self.position();
449 let min_lat = self.parse_float()?;
450 if !(-90.0..=90.0).contains(&min_lat) {
451 return Err(ParseError::value_out_of_range(
452 "lat",
453 "must be in -90.0..=90.0",
454 p,
455 ));
456 }
457 let p = self.position();
458 let min_lon = self.parse_float()?;
459 if !(-180.0..=180.0).contains(&min_lon) {
460 return Err(ParseError::value_out_of_range(
461 "lon",
462 "must be in -180.0..=180.0",
463 p,
464 ));
465 }
466 let p = self.position();
467 let max_lat = self.parse_float()?;
468 if !(-90.0..=90.0).contains(&max_lat) {
469 return Err(ParseError::value_out_of_range(
470 "lat",
471 "must be in -90.0..=90.0",
472 p,
473 ));
474 }
475 let p = self.position();
476 let max_lon = self.parse_float()?;
477 if !(-180.0..=180.0).contains(&max_lon) {
478 return Err(ParseError::value_out_of_range(
479 "lon",
480 "must be in -180.0..=180.0",
481 p,
482 ));
483 }
484
485 self.expect(Token::Collection)?;
486 let collection = self.expect_ident()?;
487
488 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
489 let column = self.expect_ident()?;
490
491 let mut limit_param: Option<usize> = None;
492 let limit = if self.consume(&Token::Limit)? {
493 if matches!(self.peek(), Token::Dollar | Token::Question) {
494 limit_param = Some(self.parse_param_slot("LIMIT")?);
495 0
496 } else {
497 self.parse_integer()? as usize
498 }
499 } else {
500 100
501 };
502
503 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
504 min_lat,
505 min_lon,
506 max_lat,
507 max_lon,
508 collection,
509 column,
510 limit,
511 limit_param,
512 }))
513 }
514 Token::Ident(ref name) if name.eq_ignore_ascii_case("NEAREST") => {
515 self.advance()?; let lat_pos = self.position();
517 let lat = self.parse_float()?;
518 if !(-90.0..=90.0).contains(&lat) {
519 return Err(ParseError::value_out_of_range(
520 "lat",
521 "must be in -90.0..=90.0",
522 lat_pos,
523 ));
524 }
525 let lon_pos = self.position();
526 let lon = self.parse_float()?;
527 if !(-180.0..=180.0).contains(&lon) {
528 return Err(ParseError::value_out_of_range(
529 "lon",
530 "must be in -180.0..=180.0",
531 lon_pos,
532 ));
533 }
534
535 self.expect(Token::K)?;
536 let mut k_param: Option<usize> = None;
538 let k = if matches!(self.peek(), Token::Dollar | Token::Question) {
539 k_param = Some(self.parse_param_slot("K")?);
540 0
541 } else {
542 self.parse_positive_integer("K")? as usize
543 };
544
545 self.expect(Token::Collection)?;
546 let collection = self.expect_ident()?;
547
548 let _ = self.consume(&Token::Column)? || self.consume_search_ident("COLUMN")?;
549 let column = self.expect_ident()?;
550
551 Ok(QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
552 lat,
553 lon,
554 k,
555 collection,
556 column,
557 k_param,
558 }))
559 }
560 _ => Err(ParseError::expected(
561 vec!["RADIUS", "BBOX", "NEAREST"],
562 self.peek(),
563 self.position(),
564 )),
565 }
566 }
567
568 fn parse_vector_literal(&mut self) -> Result<Vec<f32>, ParseError> {
570 self.expect(Token::LBracket)?;
571 let mut items = Vec::new();
572 if !self.check(&Token::RBracket) {
573 loop {
574 let val = self.parse_float()? as f32;
575 items.push(val);
576 if !self.consume(&Token::Comma)? {
577 break;
578 }
579 }
580 }
581 self.expect(Token::RBracket)?;
582 Ok(items)
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn parse_query(input: &str) -> QueryExpr {
591 crate::parser::parse(input).unwrap().query
592 }
593
594 fn assert_parse_err(input: &str) {
595 assert!(crate::parser::parse(input).is_err(), "{input}");
596 }
597
598 #[test]
599 fn parses_search_similar_text_vector_and_limit_parameters() {
600 let query = parse_query(
601 "SEARCH SIMILAR TEXT 'semantic query' COLLECTION docs LIMIT 7 MIN_SCORE 0.42 USING openai",
602 );
603 let QueryExpr::SearchCommand(SearchCommand::Similar {
604 vector,
605 text,
606 provider,
607 collection,
608 limit,
609 min_score,
610 vector_param,
611 limit_param,
612 min_score_param,
613 text_param,
614 }) = query
615 else {
616 panic!("Expected SearchCommand::Similar");
617 };
618 assert!(vector.is_empty());
619 assert_eq!(text, Some("semantic query".to_string()));
620 assert_eq!(provider, Some("openai".to_string()));
621 assert_eq!(collection, "docs");
622 assert_eq!(limit, 7);
623 assert!((min_score - 0.42).abs() < 0.01);
624 assert_eq!(vector_param, None);
625 assert_eq!(limit_param, None);
626 assert_eq!(min_score_param, None);
627 assert_eq!(text_param, None);
628
629 let query = parse_query("SEARCH SIMILAR TEXT $1 COLLECTION docs LIMIT $2 MIN_SCORE $3");
630 let QueryExpr::SearchCommand(SearchCommand::Similar {
631 vector,
632 text,
633 limit,
634 min_score,
635 vector_param,
636 limit_param,
637 min_score_param,
638 text_param,
639 ..
640 }) = query
641 else {
642 panic!("Expected parameterized SearchCommand::Similar");
643 };
644 assert!(vector.is_empty());
645 assert_eq!(text, None);
646 assert_eq!(limit, 0);
647 assert!((min_score).abs() < 0.01);
648 assert_eq!(vector_param, None);
649 assert_eq!(limit_param, Some(1));
650 assert_eq!(min_score_param, Some(2));
651 assert_eq!(text_param, Some(0));
652
653 let query = parse_query("SEARCH SIMILAR $1 COLLECTION embeddings");
654 let QueryExpr::SearchCommand(SearchCommand::Similar {
655 vector,
656 vector_param,
657 limit,
658 min_score,
659 ..
660 }) = query
661 else {
662 panic!("Expected vector parameter SearchCommand::Similar");
663 };
664 assert!(vector.is_empty());
665 assert_eq!(vector_param, Some(0));
666 assert_eq!(limit, 10);
667 assert!((min_score).abs() < 0.01);
668 }
669
670 #[test]
671 fn parses_search_text_in_collection_with_question_limit() {
672 let query = parse_query("SEARCH TEXT 'needle' IN docs LIMIT ? FUZZY");
673 let QueryExpr::SearchCommand(SearchCommand::Text {
674 query,
675 collection,
676 limit,
677 fuzzy,
678 limit_param,
679 }) = query
680 else {
681 panic!("Expected SearchCommand::Text");
682 };
683 assert_eq!(query, "needle");
684 assert_eq!(collection, Some("docs".to_string()));
685 assert_eq!(limit, 0);
686 assert!(fuzzy);
687 assert_eq!(limit_param, Some(0));
688 }
689
690 #[test]
691 fn parses_search_hybrid_vector_keyword_k_equals_and_keyword_collection() {
692 let query = parse_query("SEARCH HYBRID VECTOR [1, 2] TEXT 'needle' IN TEXT K = $1");
693 let QueryExpr::SearchCommand(SearchCommand::Hybrid {
694 vector,
695 query,
696 collection,
697 limit,
698 limit_param,
699 }) = query
700 else {
701 panic!("Expected SearchCommand::Hybrid");
702 };
703 assert_eq!(vector, Some(vec![1.0, 2.0]));
704 assert_eq!(query, Some("needle".to_string()));
705 assert_eq!(collection, "text");
706 assert_eq!(limit, 0);
707 assert_eq!(limit_param, Some(0));
708 }
709
710 #[test]
711 fn parses_multimodal_and_index_parameterized_limits() {
712 let query = parse_query("SEARCH MULTIMODAL 'image query' COLLECTION assets LIMIT $1");
713 let QueryExpr::SearchCommand(SearchCommand::Multimodal {
714 query,
715 collection,
716 limit,
717 limit_param,
718 }) = query
719 else {
720 panic!("Expected SearchCommand::Multimodal");
721 };
722 assert_eq!(query, "image query");
723 assert_eq!(collection, Some("assets".to_string()));
724 assert_eq!(limit, 0);
725 assert_eq!(limit_param, Some(0));
726
727 let query = parse_query(
728 "SEARCH INDEX email VALUE 'a@example.test' COLLECTION users LIMIT $1 EXACT",
729 );
730 let QueryExpr::SearchCommand(SearchCommand::Index {
731 index,
732 value,
733 collection,
734 limit,
735 exact,
736 limit_param,
737 }) = query
738 else {
739 panic!("Expected SearchCommand::Index");
740 };
741 assert_eq!(index, "email");
742 assert_eq!(value, "a@example.test");
743 assert_eq!(collection, Some("users".to_string()));
744 assert_eq!(limit, 0);
745 assert!(exact);
746 assert_eq!(limit_param, Some(0));
747 }
748
749 #[test]
750 fn parses_search_context_depth_before_parameterized_limit() {
751 let query =
752 parse_query("SEARCH CONTEXT 'who' FIELD subject COLLECTION docs DEPTH 3 LIMIT $1");
753 let QueryExpr::SearchCommand(SearchCommand::Context {
754 query,
755 field,
756 collection,
757 limit,
758 depth,
759 limit_param,
760 }) = query
761 else {
762 panic!("Expected SearchCommand::Context");
763 };
764 assert_eq!(query, "who");
765 assert_eq!(field, Some("subject".to_string()));
766 assert_eq!(collection, Some("docs".to_string()));
767 assert_eq!(limit, 0);
768 assert_eq!(depth, 3);
769 assert_eq!(limit_param, Some(0));
770 }
771
772 #[test]
773 fn parses_search_spatial_bbox_and_nearest_parameters() {
774 let query =
775 parse_query("SEARCH SPATIAL BBOX -10 -20 10 20 COLLECTION sites COLUMN geog LIMIT $1");
776 let QueryExpr::SearchCommand(SearchCommand::SpatialBbox {
777 min_lat,
778 min_lon,
779 max_lat,
780 max_lon,
781 collection,
782 column,
783 limit,
784 limit_param,
785 }) = query
786 else {
787 panic!("Expected SearchCommand::SpatialBbox");
788 };
789 assert!((min_lat + 10.0).abs() < 0.001);
790 assert!((min_lon + 20.0).abs() < 0.001);
791 assert!((max_lat - 10.0).abs() < 0.001);
792 assert!((max_lon - 20.0).abs() < 0.001);
793 assert_eq!(collection, "sites");
794 assert_eq!(column, "geog");
795 assert_eq!(limit, 0);
796 assert_eq!(limit_param, Some(0));
797
798 let query =
799 parse_query("SEARCH SPATIAL NEAREST 48.85 2.35 K ?2 COLLECTION sites COLUMN geog");
800 let QueryExpr::SearchCommand(SearchCommand::SpatialNearest {
801 lat,
802 lon,
803 k,
804 collection,
805 column,
806 k_param,
807 }) = query
808 else {
809 panic!("Expected SearchCommand::SpatialNearest");
810 };
811 assert!((lat - 48.85).abs() < 0.001);
812 assert!((lon - 2.35).abs() < 0.001);
813 assert_eq!(k, 0);
814 assert_eq!(collection, "sites");
815 assert_eq!(column, "geog");
816 assert_eq!(k_param, Some(1));
817 }
818
819 #[test]
820 fn rejects_invalid_search_command_forms() {
821 for input in [
822 "SEARCH AUDIO 'needle'",
823 "SEARCH HYBRID TEXT 'needle'",
824 "SEARCH SPATIAL WITHIN 0 0 COLLECTION sites COLUMN geog",
825 "SEARCH SPATIAL RADIUS 91 0 1 COLLECTION sites COLUMN geog",
826 "SEARCH SPATIAL RADIUS 45 181 1 COLLECTION sites COLUMN geog",
827 "SEARCH SPATIAL RADIUS 45 90 0 COLLECTION sites COLUMN geog",
828 "SEARCH SPATIAL BBOX -91 0 1 1 COLLECTION sites COLUMN geog",
829 "SEARCH SPATIAL BBOX 0 -181 1 1 COLLECTION sites COLUMN geog",
830 "SEARCH SPATIAL BBOX 0 0 91 1 COLLECTION sites COLUMN geog",
831 "SEARCH SPATIAL BBOX 0 0 1 181 COLLECTION sites COLUMN geog",
832 "SEARCH SPATIAL NEAREST 91 0 K 1 COLLECTION sites COLUMN geog",
833 "SEARCH SPATIAL NEAREST 0 181 K 1 COLLECTION sites COLUMN geog",
834 "SEARCH SPATIAL NEAREST 0 0 K 0 COLLECTION sites COLUMN geog",
835 ] {
836 assert_parse_err(input);
837 }
838 }
839}