Skip to main content

uqa_execution/
projected_predicate.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Scalar predicates compiled against positional storage projections.
8
9use uqa_core::Value;
10use uqa_sql::ast::BinaryOp;
11use uqa_sql::expr::IntegerWidth;
12use uqa_sql::{SQLError, SQLParam};
13
14use crate::{RowSchema, ScalarExpr};
15
16mod compile;
17mod evaluate;
18
19/// A predicate whose column lookups are resolved once to projection slots.
20/// Unsupported expressions return `None` and continue through the canonical
21/// map-backed evaluator.
22pub struct ProjectedPredicate {
23    expression: ProjectedExpr,
24}
25
26pub(super) enum ProjectedIntPredicate {
27    Comparison {
28        field: usize,
29        op: BinaryOp,
30        literal: i64,
31        field_on_left: bool,
32    },
33    Between {
34        field: usize,
35        low: i64,
36        high: i64,
37    },
38}
39
40pub(super) enum ProjectedExpr {
41    Field(usize),
42    Literal(Value),
43    Binary {
44        op: BinaryOp,
45        lhs: Box<Self>,
46        rhs: Box<Self>,
47        integer_width: Option<IntegerWidth>,
48    },
49    UnaryMinus(Box<Self>),
50    IntFieldComparison {
51        field: usize,
52        op: BinaryOp,
53        literal: i64,
54        field_on_left: bool,
55    },
56    Not(Box<Self>),
57    And(Vec<Self>),
58    IntFieldConjunction(Vec<ProjectedIntPredicate>),
59    Or(Vec<Self>),
60    IsNull {
61        expression: Box<Self>,
62        negated: bool,
63    },
64    Between {
65        expression: Box<Self>,
66        low: Box<Self>,
67        high: Box<Self>,
68    },
69    IntFieldBetween {
70        field: usize,
71        low: i64,
72        high: i64,
73    },
74    InList {
75        expression: Box<Self>,
76        list: Vec<Self>,
77        negated: bool,
78    },
79    Like {
80        expression: Box<Self>,
81        pattern: uqa_sql::expr::CompiledLikePattern,
82    },
83    Cast {
84        expression: Box<Self>,
85        ty: String,
86    },
87}
88
89impl ProjectedPredicate {
90    pub fn compile(
91        expression: &ScalarExpr,
92        fields: &[String],
93        params: &[SQLParam],
94    ) -> Result<Option<Self>, SQLError> {
95        Self::compile_with_schema(expression, &RowSchema::new(fields.to_vec()), params)
96    }
97
98    /// Compile against structured SQL identities instead of interpreting punctuation in public labels as qualification metadata.
99    pub fn compile_with_schema(
100        expression: &ScalarExpr,
101        schema: &RowSchema,
102        params: &[SQLParam],
103    ) -> Result<Option<Self>, SQLError> {
104        match compile::compile(expression, schema, params) {
105            Ok(expression) => Ok(expression.map(|expression| Self { expression })),
106            Err(SQLError::Unsupported(_)) => Ok(None),
107            Err(error) => Err(error),
108        }
109    }
110
111    #[inline]
112    pub fn keep(&self, values: &[&Value]) -> Result<bool, SQLError> {
113        evaluate::keep(&self.expression, values)
114    }
115
116    /// Evaluate against backend-owned values through a positional projection without constructing an intermediate reference array. A `usize::MAX` projection slot reads as SQL NULL.
117    #[inline]
118    pub fn keep_indexed(&self, values: &[Value], projection: &[usize]) -> Result<bool, SQLError> {
119        evaluate::keep_indexed(&self.expression, values, projection)
120    }
121
122    /// Evaluate directly against a composite physical row. Column names and
123    /// qualifiers were resolved to logical positions during compilation, so
124    /// the hot path neither builds a named row nor allocates a reference list.
125    #[inline]
126    pub fn keep_row(&self, row: &crate::PhysicalRowView<'_>) -> Result<bool, SQLError> {
127        evaluate::keep_row(&self.expression, row)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::{eval_scalar, ScalarEvalContext};
135    use uqa_sql::expr::truthy;
136
137    #[test]
138    fn positional_predicate_preserves_null_and_short_circuit_semantics() {
139        let expression = ScalarExpr::And(vec![
140            ScalarExpr::Between {
141                expr: Box::new(ScalarExpr::Column("x".into())),
142                low: Box::new(ScalarExpr::Literal(Value::Int(2))),
143                high: Box::new(ScalarExpr::Literal(Value::Int(4))),
144            },
145            ScalarExpr::IsNull {
146                expr: Box::new(ScalarExpr::Column("y".into())),
147                negated: false,
148            },
149        ]);
150        let predicate = ProjectedPredicate::compile(&expression, &["x".into(), "y".into()], &[])
151            .unwrap()
152            .unwrap();
153        assert!(predicate.keep(&[&Value::Int(3), &Value::Null]).unwrap());
154        assert!(!predicate.keep(&[&Value::Int(5), &Value::Null]).unwrap());
155        assert!(!predicate.keep(&[&Value::Null, &Value::Null]).unwrap());
156
157        let stored = [Value::Null, Value::Int(3)];
158        assert!(predicate.keep_indexed(&stored, &[1, 0]).unwrap());
159        assert!(!predicate.keep_indexed(&stored, &[usize::MAX, 0]).unwrap());
160    }
161
162    #[test]
163    fn projected_q1_and_q6_predicates_match_the_canonical_evaluator() {
164        let fields = vec!["discount".into(), "quantity".into(), "ship_day".into()];
165        let q6 = ScalarExpr::And(vec![
166            ScalarExpr::Between {
167                expr: Box::new(ScalarExpr::Column("ship_day".into())),
168                low: Box::new(ScalarExpr::Param(1)),
169                high: Box::new(ScalarExpr::Literal(Value::Int(2_190))),
170            },
171            ScalarExpr::Between {
172                expr: Box::new(ScalarExpr::Column("discount".into())),
173                low: Box::new(ScalarExpr::Literal(Value::Int(2))),
174                high: Box::new(ScalarExpr::Literal(Value::Int(8))),
175            },
176            ScalarExpr::Binary {
177                op: BinaryOp::Greater,
178                lhs: Box::new(ScalarExpr::Literal(Value::Int(40))),
179                rhs: Box::new(ScalarExpr::Column("quantity".into())),
180            },
181        ]);
182        let params = vec![SQLParam::Scalar(Value::Int(365))];
183        let predicate = ProjectedPredicate::compile(&q6, &fields, &params)
184            .unwrap()
185            .unwrap();
186        assert!(matches!(
187            &predicate.expression,
188            ProjectedExpr::IntFieldConjunction(items) if items.len() == 3
189        ));
190        let discounts = [Value::Null, Value::Int(1), Value::Int(2), Value::Int(8)];
191        let quantities = [
192            Value::Null,
193            Value::Int(39),
194            Value::Int(40),
195            Value::Float(39.5),
196        ];
197        let ship_days = [
198            Value::Null,
199            Value::Int(364),
200            Value::Int(365),
201            Value::Int(2_190),
202            Value::Int(2_191),
203        ];
204        for discount in &discounts {
205            for quantity in &quantities {
206                for ship_day in &ship_days {
207                    assert_projected_parity(
208                        &q6,
209                        &predicate,
210                        &fields,
211                        &[discount.clone(), quantity.clone(), ship_day.clone()],
212                        &params,
213                    );
214                }
215            }
216        }
217
218        let q1 = ScalarExpr::Binary {
219            op: BinaryOp::LessEqual,
220            lhs: Box::new(ScalarExpr::Column("ship_day".into())),
221            rhs: Box::new(ScalarExpr::Literal(Value::Int(2_449))),
222        };
223        let predicate = ProjectedPredicate::compile(&q1, &fields, &[])
224            .unwrap()
225            .unwrap();
226        for ship_day in [Value::Null, Value::Int(2_449), Value::Int(2_450)] {
227            assert_projected_parity(
228                &q1,
229                &predicate,
230                &fields,
231                &[Value::Int(0), Value::Int(0), ship_day],
232                &[],
233            );
234        }
235    }
236
237    #[test]
238    fn projected_integer_comparisons_match_every_canonical_operator_and_operand_order() {
239        let fields = vec!["value".into()];
240        for op in [
241            BinaryOp::Equal,
242            BinaryOp::NotEqual,
243            BinaryOp::Less,
244            BinaryOp::LessEqual,
245            BinaryOp::Greater,
246            BinaryOp::GreaterEqual,
247        ] {
248            for field_on_left in [true, false] {
249                let field = ScalarExpr::Column("value".into());
250                let literal = ScalarExpr::Literal(Value::Int(7));
251                let expression = ScalarExpr::Binary {
252                    op,
253                    lhs: Box::new(if field_on_left {
254                        field.clone()
255                    } else {
256                        literal.clone()
257                    }),
258                    rhs: Box::new(if field_on_left {
259                        literal.clone()
260                    } else {
261                        field.clone()
262                    }),
263                };
264                let predicate = ProjectedPredicate::compile(&expression, &fields, &[])
265                    .unwrap()
266                    .unwrap();
267                for value in [
268                    Value::Null,
269                    Value::Int(6),
270                    Value::Int(7),
271                    Value::Int(8),
272                    Value::Float(7.0),
273                ] {
274                    assert_projected_parity(&expression, &predicate, &fields, &[value], &[]);
275                }
276            }
277        }
278    }
279
280    #[test]
281    fn projected_like_predicates_match_the_canonical_evaluator() {
282        for (name, pattern) in [
283            ("like", "%"),
284            ("like", "%green%"),
285            ("like", "%special%requests%"),
286            ("like", "a_c"),
287            ("ilike", "%GREEN%"),
288        ] {
289            let expression = ScalarExpr::Func {
290                name: name.into(),
291                binding: None,
292                args: vec![
293                    ScalarExpr::Column("text".into()),
294                    ScalarExpr::Literal(Value::Str(pattern.into())),
295                ],
296                distinct: false,
297                order_by: Vec::new(),
298                filter: None,
299            };
300            let fields = vec!["text".into()];
301            let predicate = ProjectedPredicate::compile(&expression, &fields, &[])
302                .unwrap()
303                .unwrap();
304            for value in [
305                Value::Str("forest green part".into()),
306                Value::FixedChar("GREEN   ".into()),
307                Value::Str("a-c".into()),
308                Value::Str("special pending requests".into()),
309                Value::Null,
310            ] {
311                assert_projected_parity(&expression, &predicate, &fields, &[value], &[]);
312            }
313        }
314    }
315
316    #[test]
317    fn qualified_like_runs_directly_on_a_composite_physical_row() {
318        let expression = ScalarExpr::Not(Box::new(ScalarExpr::Func {
319            name: "like".into(),
320            binding: None,
321            args: vec![
322                ScalarExpr::qualified_column("o", "comment"),
323                ScalarExpr::Literal(Value::Str("%special%requests%".into())),
324            ],
325            distinct: false,
326            order_by: Vec::new(),
327            filter: None,
328        }));
329        let left_schema =
330            crate::RowSchema::with_qualified_types("c", vec!["id".into()], vec![None]);
331        let right_schema =
332            crate::RowSchema::with_qualified_types("o", vec!["comment".into()], vec![None]);
333        let schema = crate::RowSchema::join(&left_schema, &right_schema, std::iter::empty());
334        let predicate = ProjectedPredicate::compile_with_schema(&expression, &schema, &[])
335            .unwrap()
336            .unwrap();
337
338        let accepted = crate::PhysicalRow::concat(
339            &crate::PhysicalRow::from_values(vec![Value::Int(1)]),
340            &crate::PhysicalRow::from_values(vec![Value::Str("ordinary order".into())]),
341        );
342        let rejected = crate::PhysicalRow::concat(
343            &crate::PhysicalRow::from_values(vec![Value::Int(1)]),
344            &crate::PhysicalRow::from_values(vec![Value::Str("special pending requests".into())]),
345        );
346        assert!(predicate.keep_row(&schema.view(&accepted)).unwrap());
347        assert!(!predicate.keep_row(&schema.view(&rejected)).unwrap());
348    }
349
350    #[test]
351    fn projected_predicate_folds_typed_literals_once() {
352        let expression = ScalarExpr::Binary {
353            op: BinaryOp::Less,
354            lhs: Box::new(ScalarExpr::Column("day".into())),
355            rhs: Box::new(ScalarExpr::Cast {
356                expr: Box::new(ScalarExpr::Literal(Value::Str("1995-03-15".into()))),
357                ty: "date".into(),
358            }),
359        };
360        let predicate = ProjectedPredicate::compile(&expression, &["day".into()], &[])
361            .unwrap()
362            .unwrap();
363
364        let ProjectedExpr::Binary { rhs, .. } = &predicate.expression else {
365            panic!("expected a compiled comparison");
366        };
367        assert!(matches!(
368            rhs.as_ref(),
369            ProjectedExpr::Literal(Value::Temporal(_))
370        ));
371    }
372
373    #[test]
374    fn projected_predicate_preserves_unary_minus_integer_width() {
375        let expression = ScalarExpr::Binary {
376            op: BinaryOp::Equal,
377            lhs: Box::new(ScalarExpr::UnaryMinus(Box::new(ScalarExpr::Cast {
378                expr: Box::new(ScalarExpr::Column("x".into())),
379                ty: "smallint".into(),
380            }))),
381            rhs: Box::new(ScalarExpr::Literal(Value::Int(-1))),
382        };
383        let predicate = ProjectedPredicate::compile(&expression, &["x".into()], &[])
384            .unwrap()
385            .unwrap();
386
387        assert!(predicate.keep(&[&Value::Int(1)]).unwrap());
388        let error = predicate
389            .keep(&[&Value::Int(i64::from(i16::MIN))])
390            .expect_err("negating smallint minimum must overflow");
391        assert_eq!(error.sqlstate(), Some("22003"));
392    }
393
394    fn assert_projected_parity(
395        expression: &ScalarExpr,
396        predicate: &ProjectedPredicate,
397        fields: &[String],
398        values: &[Value],
399        params: &[SQLParam],
400    ) {
401        let row = fields
402            .iter()
403            .cloned()
404            .zip(values.iter().cloned())
405            .collect::<uqa_sql::ResultRow>();
406        let expected = eval_scalar(expression, &ScalarEvalContext::new(Some(&row), params))
407            .map(|value| truthy(&value));
408        let references = values.iter().collect::<Vec<_>>();
409        let actual = predicate.keep(&references);
410        match (expected, actual) {
411            (Ok(expected), Ok(actual)) => assert_eq!(actual, expected, "row: {row:?}"),
412            (Err(expected), Err(actual)) => assert_eq!(actual.to_string(), expected.to_string()),
413            (expected, actual) => panic!("projected result {actual:?} != canonical {expected:?}"),
414        }
415    }
416}