Skip to main content

reddb_rql/parser/
index_ddl.rs

1//! DDL Parser for CREATE INDEX and DROP INDEX
2
3use super::error::ParseError;
4use super::Parser;
5use crate::ast::{CreateIndexQuery, DropIndexQuery, IndexMethod, QueryExpr, DEFAULT_H3_RESOLUTION};
6use crate::lexer::Token;
7
8impl<'a> Parser<'a> {
9    /// Parse: CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col1, ...) [USING method]
10    ///
11    /// Called after `Token::Create` has been consumed and we've peeked `Token::Index`
12    /// or `Token::Unique`.
13    pub fn parse_create_index_query(&mut self) -> Result<QueryExpr, ParseError> {
14        // CREATE has already been consumed by the dispatcher
15
16        let unique = self.consume(&Token::Unique)?;
17
18        self.expect(Token::Index)?;
19
20        let if_not_exists = self.match_if_not_exists()?;
21
22        let name = self.expect_ident()?;
23
24        self.expect(Token::On)?;
25
26        let table = self.expect_ident()?;
27
28        // Parse column list: (col1, col2, ...)
29        self.expect(Token::LParen)?;
30        let mut columns = Vec::new();
31        loop {
32            columns.push(self.parse_index_column_path()?);
33            if !self.consume(&Token::Comma)? {
34                break;
35            }
36        }
37        self.expect(Token::RParen)?;
38
39        // Parse optional USING method
40        let method = if self.consume(&Token::Using)? {
41            self.parse_index_method()?
42        } else {
43            IndexMethod::BTree // default
44        };
45
46        Ok(QueryExpr::CreateIndex(CreateIndexQuery {
47            name,
48            table,
49            columns,
50            method,
51            unique,
52            if_not_exists,
53        }))
54    }
55
56    /// Parse: DROP INDEX [IF EXISTS] name ON table
57    ///
58    /// Called after `Token::Drop` has been consumed and we've peeked `Token::Index`.
59    pub fn parse_drop_index_query(&mut self) -> Result<QueryExpr, ParseError> {
60        // DROP has already been consumed by the dispatcher
61
62        self.expect(Token::Index)?;
63
64        let if_exists = self.match_if_exists()?;
65
66        let name = self.expect_ident()?;
67
68        self.expect(Token::On)?;
69
70        let table = self.expect_ident()?;
71
72        Ok(QueryExpr::DropIndex(DropIndexQuery {
73            name,
74            table,
75            if_exists,
76        }))
77    }
78
79    /// Parse index method identifier: HASH | BTREE | BITMAP | SPATIAL | H3.
80    /// `HASH` is also a reserved keyword token, so we match both the
81    /// keyword form and the ident form — otherwise `USING HASH`
82    /// fails with "Unexpected token: HASH" even though the parser
83    /// lists HASH as an expected option.
84    ///
85    /// `H3` additionally accepts an optional parenthesised resolution
86    /// (`USING H3 (12)`); when omitted it defaults to
87    /// [`DEFAULT_H3_RESOLUTION`]. The column list is already consumed
88    /// before `USING`, so the trailing `(...)` is unambiguously the
89    /// resolution argument.
90    fn parse_index_method(&mut self) -> Result<IndexMethod, ParseError> {
91        let peeked = self.peek().clone();
92        if matches!(peeked, Token::Hash) {
93            self.advance()?;
94            return Ok(IndexMethod::Hash);
95        }
96        match peeked {
97            Token::Ident(ref name) => {
98                let upper = name.to_ascii_uppercase();
99                if upper == "H3" {
100                    self.advance()?;
101                    let resolution = self.parse_h3_resolution_opt()?;
102                    return Ok(IndexMethod::H3 { resolution });
103                }
104                let method = match upper.as_str() {
105                    "HASH" => IndexMethod::Hash,
106                    "BTREE" => IndexMethod::BTree,
107                    "BITMAP" => IndexMethod::Bitmap,
108                    "RTREE" => {
109                        return Err(ParseError::new(
110                            "USING RTREE was removed: the in-RAM R-tree indexed nothing and served no queries. Use USING H3 — same SEARCH SPATIAL surface, disk-resident, maintained on every write. Example: CREATE INDEX idx_loc ON events (gpsLocation) USING H3",
111                            self.position(),
112                        ));
113                    }
114                    "SPATIAL" => IndexMethod::Spatial,
115                    _ => {
116                        return Err(ParseError::new(
117                            format!(
118                                "unknown index method '{}', expected HASH, BTREE, BITMAP, SPATIAL, or H3",
119                                name
120                            ),
121                            self.position(),
122                        ));
123                    }
124                };
125                self.advance()?;
126                Ok(method)
127            }
128            other => Err(ParseError::expected(
129                vec!["HASH", "BTREE", "BITMAP", "SPATIAL", "H3"],
130                &other,
131                self.position(),
132            )),
133        }
134    }
135
136    /// Parse the optional `(resolution)` argument that follows `USING H3`.
137    /// Resolution must be an integer in H3's valid `0..=15` range; absence
138    /// of the parenthesised argument yields [`DEFAULT_H3_RESOLUTION`].
139    fn parse_h3_resolution_opt(&mut self) -> Result<u8, ParseError> {
140        if !self.consume(&Token::LParen)? {
141            return Ok(DEFAULT_H3_RESOLUTION);
142        }
143        let resolution = match self.peek().clone() {
144            Token::Integer(n) if (0..=15).contains(&n) => {
145                self.advance()?;
146                n as u8
147            }
148            other => {
149                return Err(ParseError::new(
150                    format!("H3 resolution must be an integer 0..=15, got {other:?}"),
151                    self.position(),
152                ));
153            }
154        };
155        self.expect(Token::RParen)?;
156        Ok(resolution)
157    }
158
159    fn parse_index_column_path(&mut self) -> Result<String, ParseError> {
160        let mut column = self.expect_ident_or_keyword()?;
161        while self.consume(&Token::Dot)? {
162            column.push('.');
163            column.push_str(&self.expect_ident_or_keyword()?);
164        }
165        Ok(column)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn parser(input: &str) -> Parser<'_> {
174        Parser::new(input).unwrap_or_else(|err| panic!("failed to lex {input:?}: {err:?}"))
175    }
176
177    fn parse_create_index(input: &str) -> CreateIndexQuery {
178        let mut parser = parser(input);
179        parser.expect(Token::Create).expect("CREATE");
180        let QueryExpr::CreateIndex(query) = parser
181            .parse_create_index_query()
182            .unwrap_or_else(|err| panic!("failed to parse {input:?}: {err:?}"))
183        else {
184            panic!("Expected CreateIndexQuery");
185        };
186        assert!(
187            matches!(parser.peek(), Token::Eof),
188            "CREATE INDEX parse did not consume all input: {input:?}"
189        );
190        query
191    }
192
193    fn parse_drop_index(input: &str) -> DropIndexQuery {
194        let mut parser = parser(input);
195        parser.expect(Token::Drop).expect("DROP");
196        let QueryExpr::DropIndex(query) = parser
197            .parse_drop_index_query()
198            .unwrap_or_else(|err| panic!("failed to parse {input:?}: {err:?}"))
199        else {
200            panic!("Expected DropIndexQuery");
201        };
202        assert!(
203            matches!(parser.peek(), Token::Eof),
204            "DROP INDEX parse did not consume all input: {input:?}"
205        );
206        query
207    }
208
209    #[test]
210    fn parse_create_index_defaults_and_options() {
211        let query = parse_create_index("CREATE INDEX IF NOT EXISTS idx ON users (email, type)");
212        assert_eq!(query.name, "idx");
213        assert_eq!(query.table, "users");
214        assert_eq!(query.columns, vec!["email", "type"]);
215        assert_eq!(query.method, IndexMethod::BTree);
216        assert!(query.if_not_exists);
217        assert!(!query.unique);
218
219        let query = parse_create_index("CREATE UNIQUE INDEX idx_orders ON orders (id) USING HASH");
220        assert!(query.unique);
221        assert_eq!(query.method, IndexMethod::Hash);
222    }
223
224    #[test]
225    fn parse_create_index_on_document_path() {
226        let query = parse_create_index("CREATE INDEX idx_docs_tier ON docs (body.service.tier)");
227        assert_eq!(query.name, "idx_docs_tier");
228        assert_eq!(query.table, "docs");
229        assert_eq!(query.columns, vec!["body.service.tier"]);
230        assert_eq!(query.method, IndexMethod::BTree);
231    }
232
233    #[test]
234    fn parse_index_method_accepts_all_ident_variants() {
235        for (method, expected) in [
236            ("BTREE", IndexMethod::BTree),
237            ("BITMAP", IndexMethod::Bitmap),
238            ("hash", IndexMethod::Hash),
239        ] {
240            let query = parse_create_index(&format!(
241                "CREATE INDEX idx_{method} ON users (email) USING {method}"
242            ));
243            assert_eq!(query.method, expected);
244        }
245    }
246
247    #[test]
248    fn parse_create_index_using_rtree_reports_didactic_removal() {
249        let mut p = parser("CREATE INDEX idx_loc ON events (gpsLocation) USING RTREE");
250        p.expect(Token::Create).expect("CREATE");
251        let err = p.parse_create_index_query().unwrap_err();
252        let msg = err.to_string();
253        assert!(msg.contains("USING RTREE was removed"), "{msg}");
254        assert!(msg.contains("Use USING H3"), "{msg}");
255        assert!(
256            msg.contains("CREATE INDEX idx_loc ON events (gpsLocation) USING H3"),
257            "{msg}"
258        );
259    }
260
261    #[test]
262    fn parse_create_index_using_h3_defaults_resolution() {
263        let query = parse_create_index("CREATE INDEX idx_loc ON places (loc) USING H3");
264        assert_eq!(query.name, "idx_loc");
265        assert_eq!(query.table, "places");
266        assert_eq!(query.columns, vec!["loc"]);
267        assert_eq!(
268            query.method,
269            IndexMethod::H3 {
270                resolution: DEFAULT_H3_RESOLUTION
271            }
272        );
273    }
274
275    #[test]
276    fn parse_create_index_using_h3_explicit_resolution() {
277        let query = parse_create_index("CREATE INDEX idx_loc ON places (loc) USING H3 (12)");
278        assert_eq!(query.method, IndexMethod::H3 { resolution: 12 });
279
280        // Lowercase method ident is accepted too.
281        let query = parse_create_index("CREATE INDEX idx_loc ON places (loc) USING h3 (0)");
282        assert_eq!(query.method, IndexMethod::H3 { resolution: 0 });
283    }
284
285    #[test]
286    fn parse_create_index_using_h3_rejects_out_of_range_resolution() {
287        let mut p = parser("CREATE INDEX idx ON places (loc) USING H3 (16)");
288        p.expect(Token::Create).expect("CREATE");
289        let err = p.parse_create_index_query().unwrap_err();
290        assert!(
291            err.to_string().contains("H3 resolution must be an integer"),
292            "{err}"
293        );
294    }
295
296    #[test]
297    fn parse_drop_index_body_covers_if_exists() {
298        let query = parse_drop_index("DROP INDEX IF EXISTS idx_email ON users");
299        assert_eq!(query.name, "idx_email");
300        assert_eq!(query.table, "users");
301        assert!(query.if_exists);
302
303        let query = parse_drop_index("DROP INDEX idx_email ON users");
304        assert!(!query.if_exists);
305    }
306
307    #[test]
308    fn parse_index_method_reports_unknown_ident_and_non_ident_errors() {
309        let mut p = parser("CREATE INDEX idx ON users (email) USING GIN");
310        p.expect(Token::Create).expect("CREATE");
311        let err = p.parse_create_index_query().unwrap_err();
312        assert!(
313            err.to_string().contains("unknown index method 'GIN'"),
314            "{err}"
315        );
316
317        let mut p = parser("CREATE INDEX idx ON users (email) USING 42");
318        p.expect(Token::Create).expect("CREATE");
319        let err = p.parse_create_index_query().unwrap_err();
320        assert!(
321            err.to_string()
322                .contains("expected: HASH, BTREE, BITMAP, SPATIAL"),
323            "{err}"
324        );
325    }
326
327    #[test]
328    fn parse_create_index_using_spatial_is_generic_spatial_method() {
329        // `USING SPATIAL` is the generic spatial request; the engine maps
330        // it to the default spatial backend (H3 as of #1578). The parser
331        // only needs to surface the generic `Spatial` method here.
332        let query = parse_create_index("CREATE INDEX gix ON places (loc) USING SPATIAL");
333        assert_eq!(query.method, IndexMethod::Spatial);
334        // Lowercase ident is accepted too.
335        let query = parse_create_index("CREATE INDEX gix ON places (loc) USING spatial");
336        assert_eq!(query.method, IndexMethod::Spatial);
337    }
338}