1use super::error::ParseError;
23use super::Parser;
24use crate::ast::{KvCommand, QueryExpr};
25use crate::lexer::Token;
26use reddb_types::catalog::CollectionModel;
27
28pub const KV_DEFAULT_COLLECTION: &str = "kv_default";
30
31impl<'a> Parser<'a> {
32 pub fn parse_kv_command(&mut self) -> Result<QueryExpr, ParseError> {
34 self.expect(Token::Kv)?;
35 self.parse_keyed_command_body(CollectionModel::Kv)
36 }
37
38 pub fn parse_vault_command(&mut self) -> Result<QueryExpr, ParseError> {
40 if !self.consume_ident_ci("VAULT")? {
41 return Err(ParseError::expected(
42 vec!["VAULT"],
43 self.peek(),
44 self.position(),
45 ));
46 }
47 self.parse_keyed_command_body(CollectionModel::Vault)
48 }
49
50 fn parse_keyed_command_body(
51 &mut self,
52 model: CollectionModel,
53 ) -> Result<QueryExpr, ParseError> {
54 match self.peek().clone() {
55 Token::Ident(ref name) if name.eq_ignore_ascii_case("PUT") => {
56 self.advance()?;
57 self.parse_kv_put(model)
58 }
59 Token::Ident(ref name) if name.eq_ignore_ascii_case("GET") => {
60 self.advance()?;
61 let (collection, key) = self.parse_kv_key(model)?;
62 Ok(QueryExpr::KvCommand(KvCommand::Get {
63 model,
64 collection,
65 key,
66 }))
67 }
68 Token::Ident(ref name) if name.eq_ignore_ascii_case("UNSEAL") => {
69 self.advance()?;
70 if model != CollectionModel::Vault {
71 return Err(ParseError::expected(
72 vec!["PUT", "GET", "DELETE", "INCR", "DECR", "CAS"],
73 self.peek(),
74 self.position(),
75 ));
76 }
77 let (collection, key) = self.parse_kv_key(model)?;
78 let version = self.parse_optional_vault_version()?;
79 Ok(QueryExpr::KvCommand(KvCommand::Unseal {
80 collection,
81 key,
82 version,
83 }))
84 }
85 Token::Ident(ref name) if name.eq_ignore_ascii_case("ROTATE") => {
86 self.advance()?;
87 if model != CollectionModel::Vault {
88 return Err(ParseError::expected(
89 vec!["PUT", "GET", "DELETE", "INCR", "DECR", "CAS"],
90 self.peek(),
91 self.position(),
92 ));
93 }
94 self.parse_vault_rotate_body()
95 }
96 Token::Ident(ref name) if name.eq_ignore_ascii_case("HISTORY") => {
97 self.advance()?;
98 if model != CollectionModel::Vault {
99 return Err(ParseError::expected(
100 vec!["PUT", "GET", "DELETE", "INCR", "DECR", "CAS"],
101 self.peek(),
102 self.position(),
103 ));
104 }
105 let (collection, key) = self.parse_kv_key(model)?;
106 Ok(QueryExpr::KvCommand(KvCommand::History { collection, key }))
107 }
108 Token::Purge => {
109 self.advance()?;
110 if model != CollectionModel::Vault {
111 return Err(ParseError::expected(
112 vec!["PUT", "GET", "DELETE", "INCR", "DECR", "CAS"],
113 self.peek(),
114 self.position(),
115 ));
116 }
117 let (collection, key) = self.parse_kv_key(model)?;
118 Ok(QueryExpr::KvCommand(KvCommand::Purge { collection, key }))
119 }
120 Token::Ident(ref name) if name.eq_ignore_ascii_case("PURGE") => {
121 self.advance()?;
122 if model != CollectionModel::Vault {
123 return Err(ParseError::expected(
124 vec!["PUT", "GET", "DELETE", "INCR", "DECR", "CAS"],
125 self.peek(),
126 self.position(),
127 ));
128 }
129 let (collection, key) = self.parse_kv_key(model)?;
130 Ok(QueryExpr::KvCommand(KvCommand::Purge { collection, key }))
131 }
132 Token::List => {
133 self.advance()?;
134 self.parse_keyed_list(model)
135 }
136 Token::Ident(ref name) if name.eq_ignore_ascii_case("LIST") => {
137 self.advance()?;
138 self.parse_keyed_list(model)
139 }
140 Token::Ident(ref name) if name.eq_ignore_ascii_case("WATCH") => {
141 self.advance()?;
142 self.parse_kv_watch(model)
143 }
144 Token::Delete => {
145 self.advance()?;
146 let (collection, key) = self.parse_kv_key(model)?;
147 Ok(QueryExpr::KvCommand(KvCommand::Delete {
148 model,
149 collection,
150 key,
151 }))
152 }
153 Token::Ident(ref name) if name.eq_ignore_ascii_case("DELETE") => {
154 self.advance()?;
155 let (collection, key) = self.parse_kv_key(model)?;
156 Ok(QueryExpr::KvCommand(KvCommand::Delete {
157 model,
158 collection,
159 key,
160 }))
161 }
162 Token::Ident(ref name) if name.eq_ignore_ascii_case("INCR") => {
163 self.advance()?;
164 self.parse_kv_incr(model, 1)
165 }
166 Token::Ident(ref name) if name.eq_ignore_ascii_case("DECR") => {
167 self.advance()?;
168 self.parse_kv_incr(model, -1)
169 }
170 Token::Ident(ref name) if name.eq_ignore_ascii_case("CAS") => {
171 self.advance()?;
172 self.parse_kv_cas(model)
173 }
174 Token::Ident(ref name) if name.eq_ignore_ascii_case("INVALIDATE") => {
175 self.advance()?;
176 self.parse_kv_invalidate_tags_after_invalidate()
177 }
178 _ => Err(ParseError::expected(
179 if model == CollectionModel::Vault {
180 vec![
181 "PUT", "GET", "UNSEAL", "ROTATE", "HISTORY", "LIST", "WATCH", "DELETE",
182 "PURGE", "INCR", "DECR", "CAS",
183 ]
184 } else {
185 vec![
186 "PUT",
187 "GET",
188 "LIST",
189 "WATCH",
190 "DELETE",
191 "INCR",
192 "DECR",
193 "CAS",
194 "INVALIDATE",
195 ]
196 },
197 self.peek(),
198 self.position(),
199 )),
200 }
201 }
202
203 pub(crate) fn parse_vault_list_after_list(&mut self) -> Result<QueryExpr, ParseError> {
204 if !self.consume_ident_ci("VAULT")? {
205 return Err(ParseError::expected(
206 vec!["VAULT"],
207 self.peek(),
208 self.position(),
209 ));
210 }
211 self.parse_keyed_list(CollectionModel::Vault)
212 }
213
214 pub(crate) fn parse_kv_list_after_list(&mut self) -> Result<QueryExpr, ParseError> {
215 self.expect(Token::Kv)?;
216 self.parse_keyed_list(CollectionModel::Kv)
217 }
218
219 pub(crate) fn parse_vault_watch_after_watch(&mut self) -> Result<QueryExpr, ParseError> {
220 if !self.consume_ident_ci("VAULT")? {
221 return Err(ParseError::expected(
222 vec!["VAULT"],
223 self.peek(),
224 self.position(),
225 ));
226 }
227 self.parse_kv_watch(CollectionModel::Vault)
228 }
229
230 pub fn parse_unseal_vault_command(&mut self) -> Result<QueryExpr, ParseError> {
232 if !self.consume_ident_ci("UNSEAL")? {
233 return Err(ParseError::expected(
234 vec!["UNSEAL"],
235 self.peek(),
236 self.position(),
237 ));
238 }
239 if !self.consume_ident_ci("VAULT")? {
240 return Err(ParseError::expected(
241 vec!["VAULT"],
242 self.peek(),
243 self.position(),
244 ));
245 }
246 let (collection, key) = self.parse_kv_key(CollectionModel::Vault)?;
247 let version = self.parse_optional_vault_version()?;
248 Ok(QueryExpr::KvCommand(KvCommand::Unseal {
249 collection,
250 key,
251 version,
252 }))
253 }
254
255 pub fn parse_vault_lifecycle_command(&mut self) -> Result<QueryExpr, ParseError> {
257 let operation = if matches!(self.peek(), Token::Purge) {
258 self.advance()?;
259 "PURGE".to_string()
260 } else {
261 self.expect_ident_or_keyword()?.to_ascii_uppercase()
262 };
263 if !self.consume_ident_ci("VAULT")? {
264 return Err(ParseError::expected(
265 vec!["VAULT"],
266 self.peek(),
267 self.position(),
268 ));
269 }
270 match operation.as_str() {
271 "ROTATE" => self.parse_vault_rotate_body(),
272 "HISTORY" => {
273 let (collection, key) = self.parse_kv_key(CollectionModel::Vault)?;
274 Ok(QueryExpr::KvCommand(KvCommand::History { collection, key }))
275 }
276 "DELETE" => {
277 let (collection, key) = self.parse_kv_key(CollectionModel::Vault)?;
278 Ok(QueryExpr::KvCommand(KvCommand::Delete {
279 model: CollectionModel::Vault,
280 collection,
281 key,
282 }))
283 }
284 "PURGE" => {
285 let (collection, key) = self.parse_kv_key(CollectionModel::Vault)?;
286 Ok(QueryExpr::KvCommand(KvCommand::Purge { collection, key }))
287 }
288 _ => Err(ParseError::expected(
289 vec!["ROTATE", "HISTORY", "DELETE", "PURGE"],
290 self.peek(),
291 self.position(),
292 )),
293 }
294 }
295
296 fn parse_vault_rotate_body(&mut self) -> Result<QueryExpr, ParseError> {
297 let (collection, key) = self.parse_kv_key(CollectionModel::Vault)?;
298 self.expect(Token::Eq)?;
299 let value = self.parse_value()?;
300 let tags = if self.consume_ident_ci("TAGS")? {
301 self.parse_kv_tag_list()?
302 } else {
303 Vec::new()
304 };
305 Ok(QueryExpr::KvCommand(KvCommand::Rotate {
306 collection,
307 key,
308 value,
309 tags,
310 }))
311 }
312
313 fn parse_optional_vault_version(&mut self) -> Result<Option<i64>, ParseError> {
314 if self.consume_ident_ci("VERSION")? {
315 return Ok(Some(self.parse_float()?.round() as i64));
316 }
317 Ok(None)
318 }
319
320 fn parse_kv_put(&mut self, model: CollectionModel) -> Result<QueryExpr, ParseError> {
321 let (collection, key) = self.parse_kv_key(model)?;
322
323 if !self.consume(&Token::Eq)? {
325 return Err(ParseError::expected(
326 vec!["="],
327 self.peek(),
328 self.position(),
329 ));
330 }
331
332 let value = self.parse_value()?;
333
334 let mut ttl_ms: Option<u64> = None;
335 let mut tags: Vec<String> = Vec::new();
336 let mut if_not_exists = false;
337
338 loop {
339 if self.consume_ident_ci("EXPIRE")? {
340 let n = self.parse_float()?;
341 let unit = self.parse_kv_duration_unit()?;
342 ttl_ms = Some((n * unit) as u64);
343 } else if self.consume_ident_ci("TAGS")? {
344 tags = self.parse_kv_tag_list()?;
345 } else if self.consume(&Token::If)? {
346 if !self.consume(&Token::Not)? && !self.consume_ident_ci("NOT")? {
348 return Err(ParseError::expected(
349 vec!["NOT"],
350 self.peek(),
351 self.position(),
352 ));
353 }
354 if !self.consume(&Token::Exists)? && !self.consume_ident_ci("EXISTS")? {
355 return Err(ParseError::expected(
356 vec!["EXISTS"],
357 self.peek(),
358 self.position(),
359 ));
360 }
361 if_not_exists = true;
362 } else {
363 break;
364 }
365 }
366
367 Ok(QueryExpr::KvCommand(KvCommand::Put {
368 model,
369 collection,
370 key,
371 value,
372 ttl_ms,
373 tags,
374 if_not_exists,
375 }))
376 }
377
378 pub(crate) fn parse_kv_invalidate_tags_after_invalidate(
380 &mut self,
381 ) -> Result<QueryExpr, ParseError> {
382 if !self.consume_ident_ci("TAGS")? {
383 return Err(ParseError::expected(
384 vec!["TAGS"],
385 self.peek(),
386 self.position(),
387 ));
388 }
389 let tags = self.parse_kv_tag_list()?;
390 if !self.consume(&Token::From)? && !self.consume_ident_ci("FROM")? {
391 return Err(ParseError::expected(
392 vec!["FROM"],
393 self.peek(),
394 self.position(),
395 ));
396 }
397 let collection = self.parse_keyed_collection_name()?;
398 Ok(QueryExpr::KvCommand(KvCommand::InvalidateTags {
399 collection,
400 tags,
401 }))
402 }
403
404 pub(crate) fn parse_kv_key(
408 &mut self,
409 model: CollectionModel,
410 ) -> Result<(String, String), ParseError> {
411 let first = self.parse_kv_key_part()?;
412 if self.consume(&Token::Colon)? {
413 let second = self.parse_kv_key_part()?;
414 return Err(self.unquoted_kv_special_key_error(format!("'{first}:{second}'")));
415 }
416
417 if !self.consume(&Token::Dot)? {
418 return Ok((KV_DEFAULT_COLLECTION.to_string(), first));
419 }
420
421 let mut segments = vec![first, self.parse_kv_key_part()?];
422 while self.consume(&Token::Dot)? {
423 segments.push(self.parse_kv_key_part()?);
424 }
425 if self.consume(&Token::Colon)? {
426 let next = self.parse_kv_key_part()?;
427 let mut key = segments[1..].join(".");
428 key.push(':');
429 key.push_str(&next);
430 return Err(self.unquoted_kv_special_key_error(format!("{}.'{}'", segments[0], key)));
431 }
432
433 if model == CollectionModel::Vault {
434 let lower_segments: Vec<String> = segments
435 .iter()
436 .map(|segment| segment.to_ascii_lowercase())
437 .collect();
438 if lower_segments.len() >= 3
439 && lower_segments[0] == "red"
440 && lower_segments[1] == "vault"
441 {
442 return Ok(("red.vault".to_string(), lower_segments[2..].join(".")));
443 }
444 if lower_segments.len() >= 3
445 && lower_segments[0] == "red"
446 && (lower_segments[1] == "secret" || lower_segments[1] == "secrets")
447 {
448 return Ok(("red.vault".to_string(), lower_segments[2..].join(".")));
449 }
450 if lower_segments.len() >= 2 && lower_segments[0] == "secret" {
451 return Ok(("red.vault".to_string(), lower_segments[1..].join(".")));
452 }
453 }
454
455 Ok((segments.remove(0), segments.join(".")))
456 }
457
458 fn unquoted_kv_special_key_error(&self, suggestion: String) -> ParseError {
459 ParseError::new(
460 format!("KV keys containing ':' must be quoted as string literals; use {suggestion}"),
461 self.position(),
462 )
463 }
464
465 fn parse_kv_key_part(&mut self) -> Result<String, ParseError> {
466 match self.peek().clone() {
467 Token::String(value) => {
468 self.advance()?;
469 Ok(value)
470 }
471 Token::Ident(_) => self.expect_ident(),
472 _ => self.expect_ident_or_keyword(),
473 }
474 }
475
476 fn parse_keyed_list(&mut self, model: CollectionModel) -> Result<QueryExpr, ParseError> {
477 let collection = self.expect_ident_or_keyword()?;
478 let mut prefix = None;
479 let mut limit = None;
480 let mut offset = 0usize;
481 let mut as_json = false;
482 loop {
483 if self.consume_ident_ci("PREFIX")? {
484 prefix = Some(self.parse_kv_key_part()?);
485 } else if self.consume(&Token::Limit)? || self.consume_ident_ci("LIMIT")? {
486 limit = Some(self.parse_float()?.round().max(0.0) as usize);
487 } else if self.consume(&Token::Offset)? || self.consume_ident_ci("OFFSET")? {
488 offset = self.parse_float()?.round().max(0.0) as usize;
489 } else if self.consume(&Token::As)? || self.consume(&Token::Format)? {
490 if !self.consume(&Token::Json)? {
491 return Err(ParseError::expected(
492 vec!["JSON"],
493 self.peek(),
494 self.position(),
495 ));
496 }
497 as_json = true;
498 } else {
499 break;
500 }
501 }
502 Ok(QueryExpr::KvCommand(KvCommand::List {
503 model,
504 collection,
505 prefix,
506 limit,
507 offset,
508 as_json,
509 }))
510 }
511
512 pub(crate) fn parse_kv_watch(
513 &mut self,
514 model: CollectionModel,
515 ) -> Result<QueryExpr, ParseError> {
516 let first = self.expect_ident()?;
517 let (collection, key, prefix) = if model != CollectionModel::Kv {
518 let mut collection = first;
519 if self.consume(&Token::Dot)? {
520 let next = self.expect_ident_or_keyword()?;
521 collection = format!("{collection}.{next}");
522 }
523 if self.consume_ident_ci("PREFIX")? {
524 (collection, self.expect_ident_or_keyword()?, true)
525 } else {
526 (collection, self.expect_ident_or_keyword()?, false)
527 }
528 } else if self.consume(&Token::Dot)? {
529 if self.consume(&Token::Star)? {
530 (KV_DEFAULT_COLLECTION.to_string(), first, true)
531 } else {
532 let key = self.expect_ident_or_keyword()?;
533 if self.consume(&Token::Dot)? {
534 self.expect(Token::Star)?;
535 (first, key, true)
536 } else {
537 (first, key, false)
538 }
539 }
540 } else {
541 (KV_DEFAULT_COLLECTION.to_string(), first, false)
542 };
543
544 let from_lsn = if self.consume(&Token::From)? || self.consume_ident_ci("FROM")? {
545 if !self.consume_ident_ci("LSN")? {
546 return Err(ParseError::expected(
547 vec!["LSN"],
548 self.peek(),
549 self.position(),
550 ));
551 }
552 Some(self.parse_float()?.round() as u64)
553 } else {
554 None
555 };
556
557 Ok(QueryExpr::KvCommand(KvCommand::Watch {
558 model,
559 collection,
560 key,
561 prefix,
562 from_lsn,
563 }))
564 }
565
566 fn parse_keyed_collection_name(&mut self) -> Result<String, ParseError> {
567 let mut collection = self.expect_ident_or_keyword()?;
568 if self.consume(&Token::Dot)? {
569 let next = self.expect_ident_or_keyword()?;
570 collection = format!("{collection}.{next}");
571 }
572 Ok(collection)
573 }
574
575 fn parse_kv_incr(
577 &mut self,
578 model: CollectionModel,
579 sign: i64,
580 ) -> Result<QueryExpr, ParseError> {
581 let (collection, key) = self.parse_kv_key(model)?;
582 let mut by: i64 = sign;
583 let mut ttl_ms: Option<u64> = None;
584
585 loop {
586 if self.consume(&Token::By)? || self.consume_ident_ci("BY")? {
587 let n = self.parse_float()?;
588 by = sign * (n.round() as i64).max(1);
589 } else if self.consume_ident_ci("EXPIRE")? {
590 let n = self.parse_float()?;
591 let unit = self.parse_kv_duration_unit()?;
592 ttl_ms = Some((n * unit) as u64);
593 } else {
594 break;
595 }
596 }
597
598 Ok(QueryExpr::KvCommand(KvCommand::Incr {
599 model,
600 collection,
601 key,
602 by,
603 ttl_ms,
604 }))
605 }
606
607 pub(crate) fn parse_kv_tag_list(&mut self) -> Result<Vec<String>, ParseError> {
608 self.expect(Token::LBracket)?;
609 let mut tags = Vec::new();
610 while !self.check(&Token::RBracket) {
611 let tag = self.parse_kv_tag()?;
612 if !tag.is_empty() {
613 tags.push(tag);
614 }
615 if !self.consume(&Token::Comma)? {
616 break;
617 }
618 }
619 self.expect(Token::RBracket)?;
620 Ok(tags)
621 }
622
623 fn parse_kv_tag(&mut self) -> Result<String, ParseError> {
624 let mut tag = String::new();
625 loop {
626 match self.peek().clone() {
627 Token::Comma | Token::RBracket | Token::Eof => break,
628 Token::Ident(part) | Token::String(part) => {
629 self.advance()?;
630 tag.push_str(&part);
631 }
632 Token::Integer(n) => {
633 self.advance()?;
634 tag.push_str(&n.to_string());
635 }
636 Token::Float(n) => {
637 self.advance()?;
638 tag.push_str(&n.to_string());
639 }
640 Token::Colon => {
641 self.advance()?;
642 tag.push(':');
643 }
644 Token::Dot => {
645 self.advance()?;
646 tag.push('.');
647 }
648 Token::Dash => {
649 self.advance()?;
650 tag.push('-');
651 }
652 other => {
653 return Err(ParseError::expected(vec!["tag"], &other, self.position()));
654 }
655 }
656 }
657 Ok(tag)
658 }
659
660 fn parse_kv_cas(&mut self, model: CollectionModel) -> Result<QueryExpr, ParseError> {
662 let (collection, key) = self.parse_kv_key(model)?;
663
664 if !self.consume_ident_ci("EXPECT")? {
666 return Err(ParseError::expected(
667 vec!["EXPECT"],
668 self.peek(),
669 self.position(),
670 ));
671 }
672 let expected = if matches!(self.peek(), Token::Null) {
673 self.advance()?;
674 None
675 } else {
676 Some(self.parse_value()?)
677 };
678
679 if !self.consume(&Token::Set)? && !self.consume_ident_ci("SET")? {
681 return Err(ParseError::expected(
682 vec!["SET"],
683 self.peek(),
684 self.position(),
685 ));
686 }
687 let new_value = self.parse_value()?;
688
689 let mut ttl_ms: Option<u64> = None;
691 if self.consume_ident_ci("EXPIRE")? {
692 let n = self.parse_float()?;
693 let unit = self.parse_kv_duration_unit()?;
694 ttl_ms = Some((n * unit) as u64);
695 }
696
697 Ok(QueryExpr::KvCommand(KvCommand::Cas {
698 model,
699 collection,
700 key,
701 expected,
702 new_value,
703 ttl_ms,
704 }))
705 }
706
707 fn parse_kv_duration_unit(&mut self) -> Result<f64, ParseError> {
709 let mult = match self.peek().clone() {
710 Token::Min => 60_000.0,
711 Token::Ident(ref unit) => match unit.to_ascii_lowercase().as_str() {
712 "ms" => 1.0,
713 "s" | "sec" | "secs" => 1_000.0,
714 "m" | "min" | "mins" => 60_000.0,
715 "h" | "hr" | "hrs" => 3_600_000.0,
716 "d" | "day" | "days" => 86_400_000.0,
717 _ => return Ok(1_000.0),
718 },
719 _ => return Ok(1_000.0),
720 };
721 self.advance()?;
722 Ok(mult)
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use reddb_types::types::Value;
730
731 fn parser(input: &str) -> Parser<'_> {
732 Parser::new(input).unwrap_or_else(|err| panic!("failed to lex {input:?}: {err:?}"))
733 }
734
735 #[test]
736 fn kv_key_helper_handles_multisegment_and_vault_aliases() {
737 let mut p = parser("settings.feature.flag");
738 let (collection, key) = p.parse_kv_key(CollectionModel::Kv).unwrap();
739 assert_eq!(collection, "settings");
740 assert_eq!(key, "feature.flag");
741
742 let mut p = parser("red.vault.prod.api_key");
743 let (collection, key) = p.parse_kv_key(CollectionModel::Vault).unwrap();
744 assert_eq!(collection, "red.vault");
745 assert_eq!(key, "prod.api_key");
746
747 let mut p = parser("red.secret.prod.api_key");
748 let (collection, key) = p.parse_kv_key(CollectionModel::Vault).unwrap();
749 assert_eq!(collection, "red.vault");
750 assert_eq!(key, "prod.api_key");
751
752 let mut p = parser("red.secrets.prod.api_key");
753 let (collection, key) = p.parse_kv_key(CollectionModel::Vault).unwrap();
754 assert_eq!(collection, "red.vault");
755 assert_eq!(key, "prod.api_key");
756
757 let mut p = parser("secret.prod.api_key");
758 let (collection, key) = p.parse_kv_key(CollectionModel::Vault).unwrap();
759 assert_eq!(collection, "red.vault");
760 assert_eq!(key, "prod.api_key");
761
762 let mut p = parser("settings.feature:flag");
763 let err = p
764 .parse_kv_key(CollectionModel::Kv)
765 .expect_err("unquoted colon in nested key should fail");
766 assert!(err.to_string().contains("settings.'feature:flag'"));
767 }
768
769 #[test]
770 fn keyed_list_watch_tags_and_duration_helpers_cover_edges() {
771 let mut p = parser("items PREFIX tenant LIMIT -2 OFFSET -3");
772 let QueryExpr::KvCommand(KvCommand::List {
773 model,
774 collection,
775 prefix,
776 limit,
777 offset,
778 as_json,
779 }) = p.parse_keyed_list(CollectionModel::Kv).unwrap()
780 else {
781 panic!("expected kv list");
782 };
783 assert_eq!(model, CollectionModel::Kv);
784 assert_eq!(collection, "items");
785 assert_eq!(prefix.as_deref(), Some("tenant"));
786 assert_eq!(limit, Some(0));
787 assert_eq!(offset, 0);
788 assert!(!as_json);
789
790 let mut p = parser("items PREFIX tenant FORMAT JSON");
791 let QueryExpr::KvCommand(KvCommand::List { as_json, .. }) =
792 p.parse_keyed_list(CollectionModel::Kv).unwrap()
793 else {
794 panic!("expected kv list");
795 };
796 assert!(as_json);
797
798 let mut p = parser("secrets.env PREFIX api FROM LSN 12");
799 let QueryExpr::KvCommand(KvCommand::Watch {
800 model,
801 collection,
802 key,
803 prefix,
804 from_lsn,
805 }) = p.parse_kv_watch(CollectionModel::Vault).unwrap()
806 else {
807 panic!("expected vault watch");
808 };
809 assert_eq!(model, CollectionModel::Vault);
810 assert_eq!(collection, "secrets.env");
811 assert_eq!(key, "api");
812 assert!(prefix);
813 assert_eq!(from_lsn, Some(12));
814
815 let mut p = parser("[org:7, region.us-east-1, 1.5]");
816 assert_eq!(
817 p.parse_kv_tag_list().unwrap(),
818 vec![
819 "org:7".to_string(),
820 "region.us-east-1".to_string(),
821 "1.5".to_string()
822 ]
823 );
824
825 for (unit, expected) in [
826 ("ms", 1.0),
827 ("secs", 1_000.0),
828 ("mins", 60_000.0),
829 ("hrs", 3_600_000.0),
830 ("days", 86_400_000.0),
831 ("fortnight", 1_000.0),
832 ("", 1_000.0),
833 ] {
834 let mut p = parser(unit);
835 assert_eq!(p.parse_kv_duration_unit().unwrap(), expected, "{unit}");
836 }
837 }
838
839 #[test]
840 fn kv_command_error_paths_are_structured() {
841 for sql in [
842 "KV PUT a = 1 IF EXISTS",
843 "KV PUT a = 1 IF NOT",
844 "INVALIDATE [tag] FROM c",
845 "INVALIDATE TAGS [tag] c",
846 "KV CAS key SET 1",
847 "KV CAS key EXPECT NULL VALUE 1",
848 "KV WATCH key FROM 7",
849 ] {
850 assert!(parser(sql).parse_frontend_statement().is_err(), "{sql}");
851 }
852 assert!(crate::sql::parse_frontend("VAULT UNSEAL secret.key FROM 7").is_err());
853 }
854
855 #[test]
856 fn kv_cas_and_vault_lifecycle_cover_remaining_shapes() {
857 let QueryExpr::KvCommand(KvCommand::Cas {
858 model,
859 collection,
860 key,
861 expected,
862 new_value,
863 ttl_ms,
864 }) = parser("KV CAS settings.feature EXPECT NULL SET 'on' EXPIRE 2 min")
865 .parse_frontend_statement()
866 .unwrap()
867 .into_query_expr()
868 else {
869 panic!("expected kv cas");
870 };
871 assert_eq!(model, CollectionModel::Kv);
872 assert_eq!(collection, "settings");
873 assert_eq!(key, "feature");
874 assert_eq!(expected, None);
875 assert_eq!(new_value, Value::text("on"));
876 assert_eq!(ttl_ms, Some(120_000));
877
878 assert!(matches!(
879 parser("DELETE VAULT secrets.api_key")
880 .parse_frontend_statement()
881 .unwrap()
882 .into_query_expr(),
883 QueryExpr::KvCommand(KvCommand::Delete {
884 model: CollectionModel::Vault,
885 collection,
886 key,
887 }) if collection == "secrets" && key == "api_key"
888 ));
889 assert!(matches!(
890 parser("VAULT PURGE secrets.api_key")
891 .parse_frontend_statement()
892 .unwrap()
893 .into_query_expr(),
894 QueryExpr::KvCommand(KvCommand::Purge { collection, key })
895 if collection == "secrets" && key == "api_key"
896 ));
897 assert!(matches!(
898 parser("VAULT ROTATE secrets.api_key = 'v2' TAGS [scope:prod]")
899 .parse_frontend_statement()
900 .unwrap()
901 .into_query_expr(),
902 QueryExpr::KvCommand(KvCommand::Rotate {
903 collection,
904 key,
905 tags,
906 ..
907 }) if collection == "secrets"
908 && key == "api_key"
909 && tags == vec!["scope:prod".to_string()]
910 ));
911 }
912
913 #[test]
914 fn vault_body_and_kv_error_variants_cover_remaining_dispatch() {
915 assert!(parser("NOPE GET key").parse_vault_command().is_err());
916
917 for sql in [
918 "KV UNSEAL secret.key",
919 "KV ROTATE secret.key = 'v2'",
920 "KV HISTORY secret.key",
921 "KV PURGE secret.key",
922 ] {
923 assert!(parser(sql).parse_frontend_statement().is_err(), "{sql}");
924 }
925
926 assert!(matches!(
927 parser("VAULT UNSEAL secret.api_key VERSION 2")
928 .parse_frontend_statement()
929 .unwrap()
930 .into_query_expr(),
931 QueryExpr::KvCommand(KvCommand::Unseal {
932 collection,
933 key,
934 version: Some(2),
935 }) if collection == "red.vault" && key == "api_key"
936 ));
937 assert!(matches!(
938 parser("VAULT HISTORY secret.api_key")
939 .parse_frontend_statement()
940 .unwrap()
941 .into_query_expr(),
942 QueryExpr::KvCommand(KvCommand::History { collection, key })
943 if collection == "red.vault" && key == "api_key"
944 ));
945 assert!(matches!(
946 parser("PURGE VAULT secret.api_key")
947 .parse_frontend_statement()
948 .unwrap()
949 .into_query_expr(),
950 QueryExpr::KvCommand(KvCommand::Purge { collection, key })
951 if collection == "red.vault" && key == "api_key"
952 ));
953
954 let mut p = parser("settings:feature");
955 assert!(p.parse_kv_key(CollectionModel::Kv).is_err());
956
957 assert!(matches!(
958 parser("WATCH user.*")
959 .parse_frontend_statement()
960 .unwrap()
961 .into_query_expr(),
962 QueryExpr::KvCommand(KvCommand::Watch {
963 model: CollectionModel::Kv,
964 collection,
965 key,
966 prefix: true,
967 from_lsn: None,
968 }) if collection == KV_DEFAULT_COLLECTION && key == "user"
969 ));
970
971 let mut p = parser("[, scope:prod]");
972 assert_eq!(
973 p.parse_kv_tag_list().unwrap(),
974 vec!["scope:prod".to_string()]
975 );
976 }
977}