Skip to main content

nodedb_sql/planner/index_ddl/
create.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Plan a `CREATE [UNIQUE] INDEX` statement parsed by sqlparser.
4
5use crate::types::CreateIndexPlan;
6use sqlparser::ast;
7
8use crate::SqlPlan;
9use crate::error::{Result, SqlError};
10use crate::parser::normalize::{normalize_ident, normalize_object_name_checked};
11
12/// Plan a `CREATE INDEX` statement.
13///
14/// Supports:
15/// - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] [name] ON table (col [COLLATE coll])`
16/// - `COLLATE NOCASE` / `COLLATE CI` / `COLLATE CASE_INSENSITIVE` on the
17///   indexed column → `case_insensitive = true`.
18///
19/// Multi-column indexes, expression indexes, and predicate (`WHERE`) indexes
20/// are rejected with a typed error rather than silently dropped.
21pub fn plan_create_index(ci: &ast::CreateIndex) -> Result<SqlPlan> {
22    let collection =
23        normalize_object_name_checked(&ci.table_name).map_err(|_| SqlError::Parse {
24            detail: "CREATE INDEX: missing or schema-qualified table name".into(),
25        })?;
26
27    let index_name = match ci.name.as_ref() {
28        Some(n) => Some(normalize_object_name_checked(n)?),
29        None => None,
30    };
31
32    if ci.columns.is_empty() {
33        return Err(SqlError::Parse {
34            detail: "CREATE INDEX: at least one column is required".into(),
35        });
36    }
37    if ci.columns.len() > 1 {
38        return Err(SqlError::Unsupported {
39            detail: "CREATE INDEX: multi-column indexes are not supported".into(),
40        });
41    }
42
43    let col = &ci.columns[0];
44    let (field_expr, case_insensitive) = strip_collate(&col.column.expr);
45    let field = match field_expr {
46        ast::Expr::Identifier(ident) => normalize_ident(ident),
47        ast::Expr::CompoundIdentifier(parts) if parts.len() == 1 => normalize_ident(&parts[0]),
48        other => {
49            return Err(SqlError::Unsupported {
50                detail: format!("CREATE INDEX: expression indexes are not supported: {other}"),
51            });
52        }
53    };
54
55    if ci.predicate.is_some() {
56        return Err(SqlError::Unsupported {
57            detail: "CREATE INDEX: partial (WHERE) indexes are not supported".into(),
58        });
59    }
60
61    Ok(SqlPlan::CreateIndex(CreateIndexPlan {
62        index_name,
63        collection,
64        field,
65        unique: ci.unique,
66        if_not_exists: ci.if_not_exists,
67        case_insensitive,
68    }))
69}
70
71/// Strip a `COLLATE <name>` wrapper from `expr` and return the inner
72/// expression together with whether the collation is a recognised
73/// case-insensitive collation.
74fn strip_collate(expr: &ast::Expr) -> (&ast::Expr, bool) {
75    if let ast::Expr::Collate { expr, collation } = expr {
76        let ci = collation
77            .0
78            .iter()
79            .filter_map(|part| match part {
80                ast::ObjectNamePart::Identifier(ident) => Some(ident.value.as_str()),
81                _ => None,
82            })
83            .any(is_case_insensitive_collation);
84        return (expr.as_ref(), ci);
85    }
86    (expr, false)
87}
88
89fn is_case_insensitive_collation(name: &str) -> bool {
90    name.eq_ignore_ascii_case("NOCASE")
91        || name.eq_ignore_ascii_case("CI")
92        || name.eq_ignore_ascii_case("CASE_INSENSITIVE")
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::parser::statement::parse_sql;
99
100    fn plan(sql: &str) -> Result<SqlPlan> {
101        let stmts = parse_sql(sql).expect("parse");
102        let ast::Statement::CreateIndex(ci) = &stmts[0] else {
103            panic!("expected CREATE INDEX");
104        };
105        plan_create_index(ci)
106    }
107
108    #[test]
109    fn basic_index() {
110        let SqlPlan::CreateIndex(CreateIndexPlan {
111            index_name,
112            collection,
113            field,
114            unique,
115            if_not_exists,
116            case_insensitive,
117        }) = plan("CREATE INDEX idx_users_email ON users (email)").unwrap()
118        else {
119            panic!("expected CreateIndex");
120        };
121        assert_eq!(index_name.as_deref(), Some("idx_users_email"));
122        assert_eq!(collection, "users");
123        assert_eq!(field, "email");
124        assert!(!unique);
125        assert!(!if_not_exists);
126        assert!(!case_insensitive);
127    }
128
129    #[test]
130    fn anonymous_index_name() {
131        let SqlPlan::CreateIndex(CreateIndexPlan { index_name, .. }) =
132            plan("CREATE INDEX ON users (email)").unwrap()
133        else {
134            panic!("expected CreateIndex");
135        };
136        assert!(index_name.is_none());
137    }
138
139    #[test]
140    fn unique_and_if_not_exists() {
141        let SqlPlan::CreateIndex(CreateIndexPlan {
142            unique,
143            if_not_exists,
144            ..
145        }) = plan("CREATE UNIQUE INDEX IF NOT EXISTS u ON users (email)").unwrap()
146        else {
147            panic!("expected CreateIndex");
148        };
149        assert!(unique);
150        assert!(if_not_exists);
151    }
152
153    #[test]
154    fn collate_nocase_detected() {
155        for sql in [
156            "CREATE INDEX i ON users (email COLLATE NOCASE)",
157            "CREATE INDEX i ON users (email COLLATE \"NOCASE\")",
158            "CREATE INDEX i ON users (email COLLATE ci)",
159            "CREATE INDEX i ON users (email COLLATE case_insensitive)",
160        ] {
161            let SqlPlan::CreateIndex(CreateIndexPlan {
162                case_insensitive, ..
163            }) = plan(sql).unwrap()
164            else {
165                panic!("expected CreateIndex for {sql}");
166            };
167            assert!(case_insensitive, "expected case_insensitive for: {sql}");
168        }
169    }
170
171    #[test]
172    fn collate_other_not_case_insensitive() {
173        let SqlPlan::CreateIndex(CreateIndexPlan {
174            case_insensitive, ..
175        }) = plan("CREATE INDEX i ON users (email COLLATE \"en_US\")").unwrap()
176        else {
177            panic!("expected CreateIndex");
178        };
179        assert!(!case_insensitive);
180    }
181
182    #[test]
183    fn multi_column_rejected() {
184        let err = plan("CREATE INDEX i ON users (a, b)").unwrap_err();
185        assert!(matches!(err, SqlError::Unsupported { .. }), "{err:?}");
186    }
187
188    #[test]
189    fn partial_index_rejected() {
190        let err = plan("CREATE INDEX i ON users (email) WHERE email IS NOT NULL").unwrap_err();
191        assert!(matches!(err, SqlError::Unsupported { .. }), "{err:?}");
192    }
193
194    #[test]
195    fn expression_index_rejected() {
196        let err = plan("CREATE INDEX i ON users (lower(email))").unwrap_err();
197        assert!(matches!(err, SqlError::Unsupported { .. }), "{err:?}");
198    }
199}