radixdb_executor/mutation/
partial_index.rs1use radixdb_core::{Error, Result, Schema};
12use radixdb_sql::ast::{
13 Expression as AstExpression, Identifier, InfixOperator, PrefixOperator, Statement,
14};
15use radixdb_storage::index::PartialIndexPredicate;
16
17pub fn bind_from_sql(canonical_sql: &str, schema: &Schema) -> Result<PartialIndexPredicate> {
18 let sql = format!(
19 "SELECT * FROM __partial_index_predicate WHERE {}",
20 canonical_sql
21 );
22 let mut statements = radixdb_sql::parse_sql(&sql).map_err(|err| {
23 Error::invalid_argument(format!(
24 "cannot parse partial index predicate '{}': {}",
25 canonical_sql, err
26 ))
27 })?;
28
29 if statements.len() != 1 {
30 return Err(Error::invalid_argument(format!(
31 "partial index predicate '{}' produced {} parser statements",
32 canonical_sql,
33 statements.len()
34 )));
35 }
36
37 let statement = statements.remove(0);
38 let Statement::Select(select) = statement else {
39 return Err(Error::invalid_argument(format!(
40 "partial index predicate '{}' did not parse as SELECT predicate",
41 canonical_sql
42 )));
43 };
44 let Some(where_clause) = select.where_clause else {
45 return Err(Error::invalid_argument(format!(
46 "partial index predicate '{}' parsed without WHERE clause",
47 canonical_sql
48 )));
49 };
50 bind_from_ast(&where_clause, schema)
51}
52
53pub fn bind_from_ast(
54 where_clause: &AstExpression,
55 schema: &Schema,
56) -> Result<PartialIndexPredicate> {
57 let mut referenced_column_names = Vec::new();
58 validate_ast(where_clause, schema, &mut referenced_column_names)?;
59 referenced_column_names.sort_by_key(|name| name.to_lowercase());
60 referenced_column_names.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
61
62 let storage_expr = crate::expr_converter::convert_ast_to_storage_expr(where_clause)
63 .ok_or_else(|| {
64 Error::invalid_argument(format!(
65 "unsupported partial index predicate: {}",
66 where_clause
67 ))
68 })?;
69 PartialIndexPredicate::new(
70 where_clause.to_string(),
71 referenced_column_names,
72 storage_expr,
73 schema,
74 )
75}
76
77fn validate_ast(
78 expr: &AstExpression,
79 schema: &Schema,
80 referenced_column_names: &mut Vec<String>,
81) -> Result<()> {
82 match expr {
83 AstExpression::Identifier(identifier) => {
84 record_column_reference(identifier, schema, referenced_column_names)
85 }
86 AstExpression::QualifiedIdentifier(qualified) => Err(Error::invalid_argument(format!(
87 "qualified column reference '{}' is not supported in partial index predicates",
88 qualified
89 ))),
90 AstExpression::IntegerLiteral(_)
91 | AstExpression::FloatLiteral(_)
92 | AstExpression::StringLiteral(_)
93 | AstExpression::BooleanLiteral(_)
94 | AstExpression::NullLiteral(_) => Ok(()),
95 AstExpression::Prefix(prefix) => match prefix.op_type {
96 PrefixOperator::Not => validate_ast(&prefix.right, schema, referenced_column_names),
97 _ => Err(Error::invalid_argument(format!(
98 "unsupported partial index predicate prefix operator '{}'",
99 prefix.operator
100 ))),
101 },
102 AstExpression::Infix(infix) => match infix.op_type {
103 InfixOperator::And
104 | InfixOperator::Or
105 | InfixOperator::Equal
106 | InfixOperator::NotEqual
107 | InfixOperator::LessThan
108 | InfixOperator::LessEqual
109 | InfixOperator::GreaterThan
110 | InfixOperator::GreaterEqual => {
111 validate_ast(&infix.left, schema, referenced_column_names)?;
112 validate_ast(&infix.right, schema, referenced_column_names)
113 }
114 InfixOperator::Is | InfixOperator::IsNot => {
115 validate_ast(&infix.left, schema, referenced_column_names)?;
116 if matches!(
117 &*infix.right,
118 AstExpression::NullLiteral(_) | AstExpression::BooleanLiteral(_)
119 ) {
120 Ok(())
121 } else {
122 Err(Error::invalid_argument(format!(
123 "unsupported partial index predicate IS operand '{}'",
124 infix.right
125 )))
126 }
127 }
128 _ => Err(Error::invalid_argument(format!(
129 "unsupported partial index predicate operator '{}'",
130 infix.operator
131 ))),
132 },
133 AstExpression::In(in_expr) => {
134 validate_ast(&in_expr.left, schema, referenced_column_names)?;
135 match &*in_expr.right {
136 AstExpression::ExpressionList(list) => list
137 .expressions
138 .iter()
139 .try_for_each(|expr| validate_literal(expr, schema, referenced_column_names)),
140 AstExpression::List(list) => list
141 .elements
142 .iter()
143 .try_for_each(|expr| validate_literal(expr, schema, referenced_column_names)),
144 _ => Err(Error::invalid_argument(format!(
145 "partial index IN predicates require a literal value list: {}",
146 in_expr
147 ))),
148 }
149 }
150 AstExpression::Between(between) => {
151 validate_ast(&between.expr, schema, referenced_column_names)?;
152 validate_literal(&between.lower, schema, referenced_column_names)?;
153 validate_literal(&between.upper, schema, referenced_column_names)
154 }
155 AstExpression::Like(like) => {
156 validate_ast(&like.left, schema, referenced_column_names)?;
157 validate_literal(&like.pattern, schema, referenced_column_names)?;
158 if like.escape.is_some() {
159 return Err(Error::invalid_argument(
160 "partial index LIKE ESCAPE is unsupported by the durable runtime predicate",
161 ));
162 }
163 Ok(())
164 }
165 AstExpression::ExpressionList(list) => list
166 .expressions
167 .iter()
168 .try_for_each(|expr| validate_literal(expr, schema, referenced_column_names)),
169 AstExpression::List(list) => list
170 .elements
171 .iter()
172 .try_for_each(|expr| validate_literal(expr, schema, referenced_column_names)),
173 _ => Err(Error::invalid_argument(format!(
174 "unsupported partial index predicate expression: {}",
175 expr
176 ))),
177 }
178}
179
180fn validate_literal(
181 expr: &AstExpression,
182 schema: &Schema,
183 referenced_column_names: &mut Vec<String>,
184) -> Result<()> {
185 match expr {
186 AstExpression::IntegerLiteral(_)
187 | AstExpression::FloatLiteral(_)
188 | AstExpression::StringLiteral(_)
189 | AstExpression::BooleanLiteral(_)
190 | AstExpression::NullLiteral(_) => Ok(()),
191 _ => validate_ast(expr, schema, referenced_column_names),
192 }
193}
194
195fn record_column_reference(
196 identifier: &Identifier,
197 schema: &Schema,
198 referenced_column_names: &mut Vec<String>,
199) -> Result<()> {
200 let column_name = identifier.value.to_string();
201 if !schema
202 .column_index_map()
203 .contains_key(identifier.value_lower.as_str())
204 {
205 return Err(Error::ColumnNotFound(column_name));
206 }
207 referenced_column_names.push(column_name);
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use radixdb_core::{DataType, SchemaBuilder};
215
216 fn schema() -> Schema {
217 SchemaBuilder::new("users")
218 .add_primary_key("id", DataType::Integer)
219 .add("email", DataType::Text)
220 .add_nullable("__raf_deleted_at", DataType::Timestamp)
221 .build()
222 }
223
224 #[test]
225 fn rejects_like_escape_without_runtime_support() {
226 let error = bind_from_sql("email LIKE 'owner!_%' ESCAPE '!'", &schema())
227 .expect_err("durable predicate must not discard ESCAPE semantics");
228 assert!(error.to_string().contains("ESCAPE"));
229 }
230
231 #[test]
232 fn binds_canonical_sql_to_storage_predicate() {
233 let predicate = bind_from_sql("__raf_deleted_at IS NULL", &schema()).unwrap();
234 assert_eq!(
235 predicate.referenced_column_names(),
236 &["__raf_deleted_at".to_string()]
237 );
238 assert_eq!(predicate.referenced_column_ids(), &[2]);
239 }
240}