Skip to main content

reddb_rql/parser/
kv.rs

1//! Parser for KV commands: `KV PUT key = value [EXPIRE n unit] [IF NOT EXISTS]`,
2//! `KV GET key`, `KV DELETE key`, `KV INCR key [BY n] [EXPIRE dur]`,
3//! `KV CAS key EXPECT <val|NULL> SET <val> [EXPIRE dur]`.
4//!
5//! Syntax summary:
6//! ```text
7//! KV PUT  <key> = <value> [EXPIRE <n> [unit]] [IF NOT EXISTS]
8//! KV PUT  <key> = <value> [EXPIRE <n> [unit]] [TAGS [tag, ...]]
9//! KV GET  <key>
10//! KV DELETE <key>
11//! INVALIDATE TAGS [tag, ...] FROM <collection>
12//! KV INCR <key> [BY <n>] [EXPIRE <n> [unit]]
13//! KV DECR <key> [BY <n>] [EXPIRE <n> [unit]]   -- sugar for INCR BY -n
14//! KV CAS  <key> EXPECT <value|NULL> SET <value> [EXPIRE <n> [unit]]
15//! ```
16//!
17//! Key forms:
18//! - Bare:   `name`          → collection = "kv_default", key = "name"
19//! - Dotted: `sessions.abc`  → collection = "sessions", key = "abc"
20//! - Quoted: `'a:b'` or `sessions.'a:b'` for keys with special characters
21
22use super::error::ParseError;
23use super::Parser;
24use crate::ast::{KvCommand, QueryExpr};
25use crate::lexer::Token;
26use reddb_types::catalog::CollectionModel;
27
28/// Default collection used when a bare (non-dotted) key is specified.
29pub const KV_DEFAULT_COLLECTION: &str = "kv_default";
30
31impl<'a> Parser<'a> {
32    /// Parse `KV <verb> …` (called after the leading `KV` token is consumed).
33    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    /// Parse `VAULT <verb> …` (called before consuming the leading identifier).
39    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    /// Parse `UNSEAL VAULT <collection.key>`.
231    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    /// Parse top-level `ROTATE/HISTORY/DELETE/PURGE VAULT <collection.key>`.
256    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        // Expect `=`
324        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 NOT EXISTS
347                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    /// Parse `INVALIDATE TAGS [tag, ...] FROM collection`.
379    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    /// Parse a key that may be bare (`name`) or dotted (`collection.key`).
405    /// Keys with punctuation must be quoted as a string literal.
406    /// Returns `(collection, key)`.
407    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    /// Parse `INCR/DECR key [BY n] [EXPIRE dur]`. `sign` is +1 or -1.
576    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    /// Parse `KV CAS key EXPECT <val|NULL> SET <val> [EXPIRE dur]`.
661    fn parse_kv_cas(&mut self, model: CollectionModel) -> Result<QueryExpr, ParseError> {
662        let (collection, key) = self.parse_kv_key(model)?;
663
664        // EXPECT <value | NULL>
665        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        // SET <value>
680        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        // Optional EXPIRE
690        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    /// Duration unit multiplier to milliseconds, defaulting to seconds.
708    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}