Skip to main content

icydb_core/db/predicate/
model.rs

1//! Module: predicate::model
2//! Responsibility: public predicate AST and construction helpers.
3//! Does not own: schema validation or runtime slot resolution.
4//! Boundary: user/query-facing predicate model.
5
6use crate::{
7    db::{
8        QueryError,
9        predicate::coercion::{CoercionId, CoercionSpec},
10        sql::lowering::{
11            PreparedSqlPredicateTemplateShape, sql_expr_prepared_predicate_template_shape,
12        },
13    },
14    value::Value,
15};
16use std::ops::{BitAnd, BitOr};
17use thiserror::Error as ThisError;
18
19#[cfg_attr(doc, doc = "Predicate")]
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub enum Predicate {
22    True,
23    False,
24    And(Vec<Self>),
25    Or(Vec<Self>),
26    Not(Box<Self>),
27    Compare(ComparePredicate),
28    CompareFields(CompareFieldsPredicate),
29    IsNull { field: String },
30    IsNotNull { field: String },
31    IsMissing { field: String },
32    IsEmpty { field: String },
33    IsNotEmpty { field: String },
34    TextContains { field: String, value: Value },
35    TextContainsCi { field: String, value: Value },
36}
37
38///
39/// PreparedSqlScalarCompareSlotTemplate
40///
41/// Frozen compare slot metadata for one symbolic scalar prepared predicate.
42/// The template preserves the planner-owned compare field/operator/coercion
43/// shape while deferring only the compared literal value to one binding slot.
44///
45
46#[derive(Clone, Debug)]
47pub(in crate::db) struct PreparedSqlScalarCompareSlotTemplate {
48    field: String,
49    op: CompareOp,
50    coercion: CoercionId,
51    slot_index: usize,
52}
53
54///
55/// PreparedSqlScalarPredicateTemplate
56///
57/// Predicate-owned symbolic scalar prepared template tree.
58/// This keeps prepared scalar predicate structure under the predicate owner
59/// while the session layer supplies only the lowering-owned SQL shape facts.
60///
61
62#[derive(Clone, Debug)]
63pub(in crate::db) enum PreparedSqlScalarPredicateTemplate {
64    Compare(PreparedSqlScalarCompareSlotTemplate),
65    And(Vec<Self>),
66    Or(Vec<Self>),
67    Not(Box<Self>),
68}
69
70impl Predicate {
71    /// Build an `And` predicate from child predicates.
72    #[must_use]
73    pub const fn and(preds: Vec<Self>) -> Self {
74        Self::And(preds)
75    }
76
77    /// Build an `Or` predicate from child predicates.
78    #[must_use]
79    pub const fn or(preds: Vec<Self>) -> Self {
80        Self::Or(preds)
81    }
82
83    /// Negate one predicate.
84    #[must_use]
85    #[expect(clippy::should_implement_trait)]
86    pub fn not(pred: Self) -> Self {
87        Self::Not(Box::new(pred))
88    }
89
90    /// Compare `field == value`.
91    #[must_use]
92    pub fn eq(field: String, value: Value) -> Self {
93        Self::Compare(ComparePredicate::eq(field, value))
94    }
95
96    /// Compare `field != value`.
97    #[must_use]
98    pub fn ne(field: String, value: Value) -> Self {
99        Self::Compare(ComparePredicate::ne(field, value))
100    }
101
102    /// Compare `field < value`.
103    #[must_use]
104    pub fn lt(field: String, value: Value) -> Self {
105        Self::Compare(ComparePredicate::lt(field, value))
106    }
107
108    /// Compare `field <= value`.
109    #[must_use]
110    pub fn lte(field: String, value: Value) -> Self {
111        Self::Compare(ComparePredicate::lte(field, value))
112    }
113
114    /// Compare `field > value`.
115    #[must_use]
116    pub fn gt(field: String, value: Value) -> Self {
117        Self::Compare(ComparePredicate::gt(field, value))
118    }
119
120    /// Compare `field >= value`.
121    #[must_use]
122    pub fn gte(field: String, value: Value) -> Self {
123        Self::Compare(ComparePredicate::gte(field, value))
124    }
125
126    /// Compare `left_field == right_field`.
127    #[must_use]
128    pub fn eq_fields(left_field: String, right_field: String) -> Self {
129        Self::CompareFields(CompareFieldsPredicate::eq(left_field, right_field))
130    }
131
132    /// Compare `left_field != right_field`.
133    #[must_use]
134    pub fn ne_fields(left_field: String, right_field: String) -> Self {
135        Self::CompareFields(CompareFieldsPredicate::ne(left_field, right_field))
136    }
137
138    /// Compare `left_field < right_field`.
139    #[must_use]
140    pub fn lt_fields(left_field: String, right_field: String) -> Self {
141        Self::CompareFields(CompareFieldsPredicate::with_coercion(
142            left_field,
143            CompareOp::Lt,
144            right_field,
145            CoercionId::NumericWiden,
146        ))
147    }
148
149    /// Compare `left_field <= right_field`.
150    #[must_use]
151    pub fn lte_fields(left_field: String, right_field: String) -> Self {
152        Self::CompareFields(CompareFieldsPredicate::with_coercion(
153            left_field,
154            CompareOp::Lte,
155            right_field,
156            CoercionId::NumericWiden,
157        ))
158    }
159
160    /// Compare `left_field > right_field`.
161    #[must_use]
162    pub fn gt_fields(left_field: String, right_field: String) -> Self {
163        Self::CompareFields(CompareFieldsPredicate::with_coercion(
164            left_field,
165            CompareOp::Gt,
166            right_field,
167            CoercionId::NumericWiden,
168        ))
169    }
170
171    /// Compare `left_field >= right_field`.
172    #[must_use]
173    pub fn gte_fields(left_field: String, right_field: String) -> Self {
174        Self::CompareFields(CompareFieldsPredicate::with_coercion(
175            left_field,
176            CompareOp::Gte,
177            right_field,
178            CoercionId::NumericWiden,
179        ))
180    }
181
182    /// Compare `field IN values`.
183    #[must_use]
184    pub fn in_(field: String, values: Vec<Value>) -> Self {
185        Self::Compare(ComparePredicate::in_(field, values))
186    }
187
188    /// Compare `field NOT IN values`.
189    #[must_use]
190    pub fn not_in(field: String, values: Vec<Value>) -> Self {
191        Self::Compare(ComparePredicate::not_in(field, values))
192    }
193
194    /// Compare `field IS NOT NULL`.
195    #[must_use]
196    pub const fn is_not_null(field: String) -> Self {
197        Self::IsNotNull { field }
198    }
199
200    /// Compare `field BETWEEN lower AND upper`.
201    #[must_use]
202    pub fn between(field: String, lower: Value, upper: Value) -> Self {
203        Self::And(vec![
204            Self::gte(field.clone(), lower),
205            Self::lte(field, upper),
206        ])
207    }
208
209    /// Compare `field NOT BETWEEN lower AND upper`.
210    #[must_use]
211    pub fn not_between(field: String, lower: Value, upper: Value) -> Self {
212        Self::Or(vec![Self::lt(field.clone(), lower), Self::gt(field, upper)])
213    }
214
215    /// Return whether this predicate still carries any literal equal to one of
216    /// the supplied runtime candidate values.
217    #[must_use]
218    pub(in crate::db) fn contains_any_runtime_values(&self, candidates: &[Value]) -> bool {
219        match self {
220            Self::True
221            | Self::False
222            | Self::CompareFields(_)
223            | Self::IsNull { .. }
224            | Self::IsNotNull { .. }
225            | Self::IsMissing { .. }
226            | Self::IsEmpty { .. }
227            | Self::IsNotEmpty { .. } => false,
228            Self::Compare(compare) => candidates.contains(compare.value()),
229            Self::And(children) | Self::Or(children) => children
230                .iter()
231                .any(|child| child.contains_any_runtime_values(candidates)),
232            Self::Not(child) => child.contains_any_runtime_values(candidates),
233            Self::TextContains { value, .. } | Self::TextContainsCi { value, .. } => {
234                candidates.contains(value)
235            }
236        }
237    }
238
239    /// Rebind any template sentinel literals in this predicate to their
240    /// runtime bound values without changing predicate structure.
241    #[must_use]
242    pub(in crate::db) fn bind_template_values(self, replacements: &[(Value, Value)]) -> Self {
243        match self {
244            Self::True => Self::True,
245            Self::False => Self::False,
246            Self::And(children) => Self::And(
247                children
248                    .into_iter()
249                    .map(|child| child.bind_template_values(replacements))
250                    .collect(),
251            ),
252            Self::Or(children) => Self::Or(
253                children
254                    .into_iter()
255                    .map(|child| child.bind_template_values(replacements))
256                    .collect(),
257            ),
258            Self::Not(child) => Self::Not(Box::new(child.bind_template_values(replacements))),
259            Self::Compare(compare) => Self::Compare(ComparePredicate::with_coercion(
260                compare.field,
261                compare.op,
262                bind_template_value(compare.value, replacements),
263                compare.coercion.id,
264            )),
265            Self::CompareFields(compare) => Self::CompareFields(compare),
266            Self::IsNull { field } => Self::IsNull { field },
267            Self::IsNotNull { field } => Self::IsNotNull { field },
268            Self::IsMissing { field } => Self::IsMissing { field },
269            Self::IsEmpty { field } => Self::IsEmpty { field },
270            Self::IsNotEmpty { field } => Self::IsNotEmpty { field },
271            Self::TextContains { field, value } => Self::TextContains {
272                field,
273                value: bind_template_value(value, replacements),
274            },
275            Self::TextContainsCi { field, value } => Self::TextContainsCi {
276                field,
277                value: bind_template_value(value, replacements),
278            },
279        }
280    }
281
282    /// Build one symbolic scalar prepared predicate template from one
283    /// lowering-owned SQL predicate shape plus this predicate tree.
284    #[must_use]
285    pub(in crate::db) fn build_prepared_template(
286        &self,
287        shape: PreparedSqlPredicateTemplateShape<'_>,
288    ) -> Option<PreparedSqlScalarPredicateTemplate> {
289        match (shape, self) {
290            (PreparedSqlPredicateTemplateShape::And { left, right }, Self::And(children))
291                if children.len() == 2 =>
292            {
293                Self::build_prepared_binary_children_template(
294                    left,
295                    right,
296                    &children[0],
297                    &children[1],
298                    PreparedSqlScalarPredicateTemplate::And,
299                )
300            }
301            (PreparedSqlPredicateTemplateShape::Or { left, right }, Self::Or(children))
302                if children.len() == 2 =>
303            {
304                Self::build_prepared_binary_children_template(
305                    left,
306                    right,
307                    &children[0],
308                    &children[1],
309                    PreparedSqlScalarPredicateTemplate::Or,
310                )
311            }
312            (PreparedSqlPredicateTemplateShape::Not { expr }, Self::Not(child)) => {
313                Some(PreparedSqlScalarPredicateTemplate::Not(Box::new(
314                    child.build_prepared_template(sql_expr_prepared_predicate_template_shape(
315                        expr,
316                    )?)?,
317                )))
318            }
319            (
320                PreparedSqlPredicateTemplateShape::CompareWithParamRhs { slot_index },
321                Self::Compare(compare),
322            ) => Some(PreparedSqlScalarPredicateTemplate::Compare(
323                PreparedSqlScalarCompareSlotTemplate {
324                    field: compare.field.clone(),
325                    op: compare.op,
326                    coercion: compare.coercion.id,
327                    slot_index,
328                },
329            )),
330            _ => None,
331        }
332    }
333
334    fn build_prepared_binary_children_template(
335        left_sql: &crate::db::sql::parser::SqlExpr,
336        right_sql: &crate::db::sql::parser::SqlExpr,
337        first_child: &Self,
338        second_child: &Self,
339        ctor: fn(Vec<PreparedSqlScalarPredicateTemplate>) -> PreparedSqlScalarPredicateTemplate,
340    ) -> Option<PreparedSqlScalarPredicateTemplate> {
341        if let (Some(left), Some(right)) = (
342            first_child
343                .build_prepared_template(sql_expr_prepared_predicate_template_shape(left_sql)?),
344            second_child
345                .build_prepared_template(sql_expr_prepared_predicate_template_shape(right_sql)?),
346        ) {
347            return Some(ctor(vec![left, right]));
348        }
349
350        let (Some(left), Some(right)) = (
351            second_child
352                .build_prepared_template(sql_expr_prepared_predicate_template_shape(left_sql)?),
353            first_child
354                .build_prepared_template(sql_expr_prepared_predicate_template_shape(right_sql)?),
355        ) else {
356            return None;
357        };
358
359        Some(ctor(vec![left, right]))
360    }
361}
362
363impl PreparedSqlScalarPredicateTemplate {
364    /// Instantiate one symbolic scalar predicate template with runtime
365    /// bindings and rebuild the planner-owned predicate tree.
366    pub(in crate::db) fn instantiate(&self, bindings: &[Value]) -> Result<Predicate, QueryError> {
367        match self {
368            Self::Compare(compare) => {
369                let binding = bindings
370                    .get(compare.slot_index)
371                    .ok_or_else(|| {
372                        QueryError::unsupported_query(format!(
373                            "missing prepared SQL binding at index={}",
374                            compare.slot_index,
375                        ))
376                    })?
377                    .clone();
378
379                Ok(Predicate::Compare(ComparePredicate::with_coercion(
380                    compare.field.clone(),
381                    compare.op,
382                    binding,
383                    compare.coercion,
384                )))
385            }
386            Self::And(children) => Ok(Predicate::And(
387                children
388                    .iter()
389                    .map(|child| child.instantiate(bindings))
390                    .collect::<Result<Vec<_>, _>>()?,
391            )),
392            Self::Or(children) => Ok(Predicate::Or(
393                children
394                    .iter()
395                    .map(|child| child.instantiate(bindings))
396                    .collect::<Result<Vec<_>, _>>()?,
397            )),
398            Self::Not(child) => Ok(Predicate::Not(Box::new(child.instantiate(bindings)?))),
399        }
400    }
401}
402
403fn bind_template_value(value: Value, replacements: &[(Value, Value)]) -> Value {
404    replacements
405        .iter()
406        .find(|(template, _)| *template == value)
407        .map_or(value, |(_, bound)| bound.clone())
408}
409
410impl BitAnd for Predicate {
411    type Output = Self;
412
413    fn bitand(self, rhs: Self) -> Self::Output {
414        Self::And(vec![self, rhs])
415    }
416}
417
418impl BitAnd for &Predicate {
419    type Output = Predicate;
420
421    fn bitand(self, rhs: Self) -> Self::Output {
422        Predicate::And(vec![self.clone(), rhs.clone()])
423    }
424}
425
426impl BitOr for Predicate {
427    type Output = Self;
428
429    fn bitor(self, rhs: Self) -> Self::Output {
430        Self::Or(vec![self, rhs])
431    }
432}
433
434impl BitOr for &Predicate {
435    type Output = Predicate;
436
437    fn bitor(self, rhs: Self) -> Self::Output {
438        Predicate::Or(vec![self.clone(), rhs.clone()])
439    }
440}
441
442#[cfg_attr(doc, doc = "CompareOp")]
443#[derive(Clone, Copy, Debug, Eq, PartialEq)]
444#[repr(u8)]
445pub enum CompareOp {
446    Eq = 0x01,
447    Ne = 0x02,
448    Lt = 0x03,
449    Lte = 0x04,
450    Gt = 0x05,
451    Gte = 0x06,
452    In = 0x07,
453    NotIn = 0x08,
454    Contains = 0x09,
455    StartsWith = 0x0a,
456    EndsWith = 0x0b,
457}
458
459impl CompareOp {
460    /// Return the stable wire tag for this compare operator.
461    #[must_use]
462    pub const fn tag(self) -> u8 {
463        self as u8
464    }
465
466    /// Return the operator that preserves semantics when the two operands are swapped.
467    #[must_use]
468    pub const fn flipped(self) -> Self {
469        match self {
470            Self::Eq => Self::Eq,
471            Self::Ne => Self::Ne,
472            Self::Lt => Self::Gt,
473            Self::Lte => Self::Gte,
474            Self::Gt => Self::Lt,
475            Self::Gte => Self::Lte,
476            Self::In => Self::In,
477            Self::NotIn => Self::NotIn,
478            Self::Contains => Self::Contains,
479            Self::StartsWith => Self::StartsWith,
480            Self::EndsWith => Self::EndsWith,
481        }
482    }
483}
484
485#[cfg_attr(doc, doc = "ComparePredicate")]
486#[derive(Clone, Debug, Eq, PartialEq)]
487pub struct ComparePredicate {
488    pub(crate) field: String,
489    pub(crate) op: CompareOp,
490    pub(crate) value: Value,
491    pub(crate) coercion: CoercionSpec,
492}
493
494impl ComparePredicate {
495    fn new(field: String, op: CompareOp, value: Value) -> Self {
496        Self {
497            field,
498            op,
499            value,
500            coercion: CoercionSpec::default(),
501        }
502    }
503
504    /// Construct a comparison predicate with an explicit coercion policy.
505    #[must_use]
506    pub fn with_coercion(
507        field: impl Into<String>,
508        op: CompareOp,
509        value: Value,
510        coercion: CoercionId,
511    ) -> Self {
512        Self {
513            field: field.into(),
514            op,
515            value,
516            coercion: CoercionSpec::new(coercion),
517        }
518    }
519
520    /// Build `Eq` comparison.
521    #[must_use]
522    pub fn eq(field: String, value: Value) -> Self {
523        Self::new(field, CompareOp::Eq, value)
524    }
525
526    /// Build `Ne` comparison.
527    #[must_use]
528    pub fn ne(field: String, value: Value) -> Self {
529        Self::new(field, CompareOp::Ne, value)
530    }
531
532    /// Build `Lt` comparison.
533    #[must_use]
534    pub fn lt(field: String, value: Value) -> Self {
535        Self::new(field, CompareOp::Lt, value)
536    }
537
538    /// Build `Lte` comparison.
539    #[must_use]
540    pub fn lte(field: String, value: Value) -> Self {
541        Self::new(field, CompareOp::Lte, value)
542    }
543
544    /// Build `Gt` comparison.
545    #[must_use]
546    pub fn gt(field: String, value: Value) -> Self {
547        Self::new(field, CompareOp::Gt, value)
548    }
549
550    /// Build `Gte` comparison.
551    #[must_use]
552    pub fn gte(field: String, value: Value) -> Self {
553        Self::new(field, CompareOp::Gte, value)
554    }
555
556    /// Build `In` comparison.
557    #[must_use]
558    pub fn in_(field: String, values: Vec<Value>) -> Self {
559        Self::new(field, CompareOp::In, Value::List(values))
560    }
561
562    /// Build `NotIn` comparison.
563    #[must_use]
564    pub fn not_in(field: String, values: Vec<Value>) -> Self {
565        Self::new(field, CompareOp::NotIn, Value::List(values))
566    }
567
568    /// Borrow the compared field name.
569    #[must_use]
570    pub fn field(&self) -> &str {
571        &self.field
572    }
573
574    /// Return the compare operator.
575    #[must_use]
576    pub const fn op(&self) -> CompareOp {
577        self.op
578    }
579
580    /// Borrow the compared literal value.
581    #[must_use]
582    pub const fn value(&self) -> &Value {
583        &self.value
584    }
585
586    /// Borrow the comparison coercion policy.
587    #[must_use]
588    pub const fn coercion(&self) -> &CoercionSpec {
589        &self.coercion
590    }
591}
592
593///
594/// CompareFieldsPredicate
595///
596/// Canonical predicate-owned field-to-field comparison leaf.
597/// This keeps bounded compare expressions on the predicate authority seam
598/// instead of routing them through projection-expression ownership.
599///
600
601#[derive(Clone, Debug, Eq, PartialEq)]
602pub struct CompareFieldsPredicate {
603    pub(crate) left_field: String,
604    pub(crate) op: CompareOp,
605    pub(crate) right_field: String,
606    pub(crate) coercion: CoercionSpec,
607}
608
609impl CompareFieldsPredicate {
610    fn canonicalize_symmetric_fields(
611        op: CompareOp,
612        left_field: String,
613        right_field: String,
614    ) -> (String, String) {
615        if matches!(op, CompareOp::Eq | CompareOp::Ne) && left_field < right_field {
616            (right_field, left_field)
617        } else {
618            (left_field, right_field)
619        }
620    }
621
622    fn new(left_field: String, op: CompareOp, right_field: String) -> Self {
623        let (left_field, right_field) =
624            Self::canonicalize_symmetric_fields(op, left_field, right_field);
625
626        Self {
627            left_field,
628            op,
629            right_field,
630            coercion: CoercionSpec::default(),
631        }
632    }
633
634    /// Construct a field-to-field comparison predicate with an explicit
635    /// coercion policy.
636    #[must_use]
637    pub fn with_coercion(
638        left_field: impl Into<String>,
639        op: CompareOp,
640        right_field: impl Into<String>,
641        coercion: CoercionId,
642    ) -> Self {
643        let (left_field, right_field) =
644            Self::canonicalize_symmetric_fields(op, left_field.into(), right_field.into());
645
646        Self {
647            left_field,
648            op,
649            right_field,
650            coercion: CoercionSpec::new(coercion),
651        }
652    }
653
654    /// Build `Eq` field-to-field comparison.
655    #[must_use]
656    pub fn eq(left_field: String, right_field: String) -> Self {
657        Self::new(left_field, CompareOp::Eq, right_field)
658    }
659
660    /// Build `Ne` field-to-field comparison.
661    #[must_use]
662    pub fn ne(left_field: String, right_field: String) -> Self {
663        Self::new(left_field, CompareOp::Ne, right_field)
664    }
665
666    /// Build `Lt` field-to-field comparison.
667    #[must_use]
668    pub fn lt(left_field: String, right_field: String) -> Self {
669        Self::new(left_field, CompareOp::Lt, right_field)
670    }
671
672    /// Build `Lte` field-to-field comparison.
673    #[must_use]
674    pub fn lte(left_field: String, right_field: String) -> Self {
675        Self::new(left_field, CompareOp::Lte, right_field)
676    }
677
678    /// Build `Gt` field-to-field comparison.
679    #[must_use]
680    pub fn gt(left_field: String, right_field: String) -> Self {
681        Self::new(left_field, CompareOp::Gt, right_field)
682    }
683
684    /// Build `Gte` field-to-field comparison.
685    #[must_use]
686    pub fn gte(left_field: String, right_field: String) -> Self {
687        Self::new(left_field, CompareOp::Gte, right_field)
688    }
689
690    /// Borrow the left compared field name.
691    #[must_use]
692    pub fn left_field(&self) -> &str {
693        &self.left_field
694    }
695
696    /// Return the compare operator.
697    #[must_use]
698    pub const fn op(&self) -> CompareOp {
699        self.op
700    }
701
702    /// Borrow the right compared field name.
703    #[must_use]
704    pub fn right_field(&self) -> &str {
705        &self.right_field
706    }
707
708    /// Borrow the comparison coercion policy.
709    #[must_use]
710    pub const fn coercion(&self) -> &CoercionSpec {
711        &self.coercion
712    }
713}
714
715#[cfg_attr(
716    doc,
717    doc = "UnsupportedQueryFeature\n\nPolicy-level query features intentionally rejected by the engine."
718)]
719#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
720pub enum UnsupportedQueryFeature {
721    #[error("map field '{field}' is not queryable; use scalar/indexed fields or list entries")]
722    MapPredicate { field: String },
723}
724
725///
726/// TESTS
727///
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use crate::db::sql::lowering::PreparedSqlPredicateTemplateShape;
733
734    #[test]
735    fn bind_template_values_rebinds_nested_literal_leaves_without_changing_shape() {
736        let template = Predicate::And(vec![
737            Predicate::Compare(ComparePredicate::with_coercion(
738                "age",
739                CompareOp::Gt,
740                Value::Uint(999),
741                CoercionId::NumericWiden,
742            )),
743            Predicate::Not(Box::new(Predicate::TextContains {
744                field: "name".to_string(),
745                value: Value::Text("__template__".to_string()),
746            })),
747            Predicate::CompareFields(CompareFieldsPredicate::eq(
748                "strength".to_string(),
749                "dexterity".to_string(),
750            )),
751        ]);
752
753        let rebound = template.bind_template_values(&[
754            (Value::Uint(999), Value::Uint(21)),
755            (
756                Value::Text("__template__".to_string()),
757                Value::Text("Ada".to_string()),
758            ),
759        ]);
760
761        assert_eq!(
762            rebound,
763            Predicate::And(vec![
764                Predicate::Compare(ComparePredicate::with_coercion(
765                    "age",
766                    CompareOp::Gt,
767                    Value::Uint(21),
768                    CoercionId::NumericWiden,
769                )),
770                Predicate::Not(Box::new(Predicate::TextContains {
771                    field: "name".to_string(),
772                    value: Value::Text("Ada".to_string()),
773                })),
774                Predicate::CompareFields(CompareFieldsPredicate::eq(
775                    "strength".to_string(),
776                    "dexterity".to_string(),
777                )),
778            ]),
779            "predicate-owned template rebinding should replace only literal leaves and keep the predicate structure intact",
780        );
781    }
782
783    #[test]
784    fn build_prepared_template_rebinds_compare_slot_owned_literal_leaves() {
785        let predicate = Predicate::Compare(ComparePredicate::with_coercion(
786            "age",
787            CompareOp::Gt,
788            Value::Uint(999),
789            CoercionId::NumericWiden,
790        ));
791
792        let template = predicate
793            .build_prepared_template(PreparedSqlPredicateTemplateShape::CompareWithParamRhs {
794                slot_index: 0,
795            })
796            .expect("prepared predicate template should build");
797        let rebound = template
798            .instantiate(&[Value::Uint(21)])
799            .expect("prepared predicate template should instantiate");
800
801        assert_eq!(
802            rebound,
803            Predicate::Compare(ComparePredicate::with_coercion(
804                "age",
805                CompareOp::Gt,
806                Value::Uint(21),
807                CoercionId::NumericWiden,
808            )),
809            "predicate-owned prepared templates should preserve compare structure and rebind only the slot-owned literal",
810        );
811    }
812}