nodedb_sql/planner/index_ddl/
create.rs1use 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
12pub 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
71fn 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}