Skip to main content

radixdb_executor/mutation/
partial_index.rs

1// Copyright 2026 RadixDB Contributors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8
9//! SQL binding for storage-owned partial-index predicates.
10
11use 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}