Skip to main content

icydb_core/db/query/intent/
query.rs

1//! Module: query::intent::query
2//! Responsibility: typed query-intent construction and planner handoff for entity queries.
3//! Does not own: runtime execution semantics or access-path execution behavior.
4//! Boundary: exposes query APIs and emits planner-owned compiled query contracts.
5
6use crate::{
7    db::{
8        predicate::{CoercionId, CompareOp, MissingRowPolicy, Predicate},
9        query::{
10            builder::aggregate::AggregateExpr,
11            explain::{
12                ExplainAccessPath, ExplainExecutionNodeDescriptor, ExplainExecutionNodeType,
13                ExplainOrderPushdown, ExplainPlan, ExplainPredicate,
14            },
15            expr::{FilterExpr, SortExpr},
16            intent::{QueryError, model::QueryModel},
17            plan::{AccessPlannedQuery, LoadSpec, QueryMode},
18        },
19    },
20    traits::{EntityKind, EntityValue, SingletonEntity},
21    value::Value,
22};
23use core::marker::PhantomData;
24
25///
26/// Query
27///
28/// Typed, declarative query intent for a specific entity type.
29///
30/// This intent is:
31/// - schema-agnostic at construction
32/// - normalized and validated only during planning
33/// - free of access-path decisions
34///
35
36#[derive(Debug)]
37pub struct Query<E: EntityKind> {
38    intent: QueryModel<'static, E::Key>,
39}
40
41impl<E: EntityKind> Query<E> {
42    /// Create a new intent with an explicit missing-row policy.
43    /// Ignore favors idempotency and may mask index/data divergence on deletes.
44    /// Use Error to surface missing rows during scan/delete execution.
45    #[must_use]
46    pub const fn new(consistency: MissingRowPolicy) -> Self {
47        Self {
48            intent: QueryModel::new(E::MODEL, consistency),
49        }
50    }
51
52    /// Return the intent mode (load vs delete).
53    #[must_use]
54    pub const fn mode(&self) -> QueryMode {
55        self.intent.mode()
56    }
57
58    #[must_use]
59    pub(crate) fn has_explicit_order(&self) -> bool {
60        self.intent.has_explicit_order()
61    }
62
63    #[must_use]
64    pub(crate) const fn has_grouping(&self) -> bool {
65        self.intent.has_grouping()
66    }
67
68    #[must_use]
69    pub(crate) const fn load_spec(&self) -> Option<LoadSpec> {
70        match self.intent.mode() {
71            QueryMode::Load(spec) => Some(spec),
72            QueryMode::Delete(_) => None,
73        }
74    }
75
76    /// Add a predicate, implicitly AND-ing with any existing predicate.
77    #[must_use]
78    pub fn filter(mut self, predicate: Predicate) -> Self {
79        self.intent = self.intent.filter(predicate);
80        self
81    }
82
83    /// Apply a dynamic filter expression.
84    pub fn filter_expr(self, expr: FilterExpr) -> Result<Self, QueryError> {
85        let Self { intent } = self;
86        let intent = intent.filter_expr(expr)?;
87
88        Ok(Self { intent })
89    }
90
91    /// Apply a dynamic sort expression.
92    pub fn sort_expr(self, expr: SortExpr) -> Result<Self, QueryError> {
93        let Self { intent } = self;
94        let intent = intent.sort_expr(expr)?;
95
96        Ok(Self { intent })
97    }
98
99    /// Append an ascending sort key.
100    #[must_use]
101    pub fn order_by(mut self, field: impl AsRef<str>) -> Self {
102        self.intent = self.intent.order_by(field);
103        self
104    }
105
106    /// Append a descending sort key.
107    #[must_use]
108    pub fn order_by_desc(mut self, field: impl AsRef<str>) -> Self {
109        self.intent = self.intent.order_by_desc(field);
110        self
111    }
112
113    /// Enable DISTINCT semantics for this query.
114    #[must_use]
115    pub fn distinct(mut self) -> Self {
116        self.intent = self.intent.distinct();
117        self
118    }
119
120    /// Override scalar projection selection with one explicit field list.
121    #[cfg(feature = "sql")]
122    #[must_use]
123    pub(in crate::db) fn select_fields<I, S>(mut self, fields: I) -> Self
124    where
125        I: IntoIterator<Item = S>,
126        S: Into<String>,
127    {
128        self.intent = self.intent.select_fields(fields);
129        self
130    }
131
132    /// Add one GROUP BY field.
133    pub fn group_by(self, field: impl AsRef<str>) -> Result<Self, QueryError> {
134        let Self { intent } = self;
135        let intent = intent.push_group_field(field.as_ref())?;
136
137        Ok(Self { intent })
138    }
139
140    /// Add one aggregate terminal via composable aggregate expression.
141    #[must_use]
142    pub fn aggregate(mut self, aggregate: AggregateExpr) -> Self {
143        self.intent = self.intent.push_group_aggregate(aggregate);
144        self
145    }
146
147    /// Override grouped hard limits for grouped execution budget enforcement.
148    #[must_use]
149    pub fn grouped_limits(mut self, max_groups: u64, max_group_bytes: u64) -> Self {
150        self.intent = self.intent.grouped_limits(max_groups, max_group_bytes);
151        self
152    }
153
154    /// Add one grouped HAVING compare clause over one grouped key field.
155    pub fn having_group(
156        self,
157        field: impl AsRef<str>,
158        op: CompareOp,
159        value: Value,
160    ) -> Result<Self, QueryError> {
161        let field = field.as_ref().to_owned();
162        let Self { intent } = self;
163        let intent = intent.push_having_group_clause(&field, op, value)?;
164
165        Ok(Self { intent })
166    }
167
168    /// Add one grouped HAVING compare clause over one grouped aggregate output.
169    pub fn having_aggregate(
170        self,
171        aggregate_index: usize,
172        op: CompareOp,
173        value: Value,
174    ) -> Result<Self, QueryError> {
175        let Self { intent } = self;
176        let intent = intent.push_having_aggregate_clause(aggregate_index, op, value)?;
177
178        Ok(Self { intent })
179    }
180
181    /// Set the access path to a single primary key lookup.
182    pub(crate) fn by_id(self, id: E::Key) -> Self {
183        let Self { intent } = self;
184        Self {
185            intent: intent.by_id(id),
186        }
187    }
188
189    /// Set the access path to a primary key batch lookup.
190    pub(crate) fn by_ids<I>(self, ids: I) -> Self
191    where
192        I: IntoIterator<Item = E::Key>,
193    {
194        let Self { intent } = self;
195        Self {
196            intent: intent.by_ids(ids),
197        }
198    }
199
200    /// Mark this intent as a delete query.
201    #[must_use]
202    pub fn delete(mut self) -> Self {
203        self.intent = self.intent.delete();
204        self
205    }
206
207    /// Apply a limit to the current mode.
208    ///
209    /// Load limits bound result size; delete limits bound mutation size.
210    /// For scalar load queries, any use of `limit` or `offset` requires an
211    /// explicit `order_by(...)` so pagination is deterministic.
212    /// GROUP BY queries use canonical grouped-key order by default.
213    #[must_use]
214    pub fn limit(mut self, limit: u32) -> Self {
215        self.intent = self.intent.limit(limit);
216        self
217    }
218
219    /// Apply an offset to a load intent.
220    ///
221    /// Scalar pagination requires an explicit `order_by(...)`.
222    /// GROUP BY queries use canonical grouped-key order by default.
223    /// Delete intents reject `offset(...)` during planning.
224    #[must_use]
225    pub fn offset(mut self, offset: u32) -> Self {
226        self.intent = self.intent.offset(offset);
227        self
228    }
229
230    /// Explain this intent without executing it.
231    pub fn explain(&self) -> Result<ExplainPlan, QueryError> {
232        let plan = self.planned()?;
233
234        Ok(plan.explain())
235    }
236
237    /// Return a stable plan hash for this intent.
238    ///
239    /// The hash is derived from canonical planner contracts and is suitable
240    /// for diagnostics, explain diffing, and cache key construction.
241    pub fn plan_hash_hex(&self) -> Result<String, QueryError> {
242        let plan = self.build_plan()?;
243
244        Ok(plan.fingerprint().to_string())
245    }
246
247    /// Explain executor-selected scalar load execution shape without running it.
248    pub fn explain_execution(&self) -> Result<ExplainExecutionNodeDescriptor, QueryError>
249    where
250        E: EntityValue,
251    {
252        let executable = self.plan()?.into_executable();
253
254        executable
255            .explain_load_execution_node_descriptor()
256            .map_err(QueryError::execute)
257    }
258
259    /// Explain executor-selected scalar load execution shape as deterministic text.
260    pub fn explain_execution_text(&self) -> Result<String, QueryError>
261    where
262        E: EntityValue,
263    {
264        Ok(self.explain_execution()?.render_text_tree())
265    }
266
267    /// Explain executor-selected scalar load execution shape as canonical JSON.
268    pub fn explain_execution_json(&self) -> Result<String, QueryError>
269    where
270        E: EntityValue,
271    {
272        Ok(self.explain_execution()?.render_json_canonical())
273    }
274
275    /// Explain executor-selected scalar load execution shape with route diagnostics.
276    pub fn explain_execution_verbose(&self) -> Result<String, QueryError>
277    where
278        E: EntityValue,
279    {
280        let executable = self.plan()?.into_executable();
281        let descriptor = executable
282            .explain_load_execution_node_descriptor()
283            .map_err(QueryError::execute)?;
284        let route_diagnostics = executable
285            .explain_load_execution_verbose_diagnostics()
286            .map_err(QueryError::execute)?;
287        let explain = self.explain()?;
288
289        // Phase 1: render descriptor tree with node-local metadata.
290        let mut lines = vec![descriptor.render_text_tree_verbose()];
291        lines.extend(route_diagnostics);
292
293        // Phase 2: add descriptor-stage summaries for key execution operators.
294        lines.push(format!(
295            "diagnostic.descriptor.has_top_n_seek={}",
296            contains_execution_node_type(&descriptor, ExplainExecutionNodeType::TopNSeek)
297        ));
298        lines.push(format!(
299            "diagnostic.descriptor.has_index_range_limit_pushdown={}",
300            contains_execution_node_type(
301                &descriptor,
302                ExplainExecutionNodeType::IndexRangeLimitPushdown,
303            )
304        ));
305        lines.push(format!(
306            "diagnostic.descriptor.has_index_predicate_prefilter={}",
307            contains_execution_node_type(
308                &descriptor,
309                ExplainExecutionNodeType::IndexPredicatePrefilter,
310            )
311        ));
312        lines.push(format!(
313            "diagnostic.descriptor.has_residual_predicate_filter={}",
314            contains_execution_node_type(
315                &descriptor,
316                ExplainExecutionNodeType::ResidualPredicateFilter,
317            )
318        ));
319
320        // Phase 3: append logical-plan diagnostics relevant to verbose explain.
321        lines.push(format!("diagnostic.plan.mode={:?}", explain.mode()));
322        lines.push(format!(
323            "diagnostic.plan.order_pushdown={}",
324            plan_order_pushdown_label(explain.order_pushdown())
325        ));
326        lines.push(format!(
327            "diagnostic.plan.predicate_pushdown={}",
328            plan_predicate_pushdown_label(explain.predicate(), explain.access())
329        ));
330        lines.push(format!("diagnostic.plan.distinct={}", explain.distinct()));
331        lines.push(format!("diagnostic.plan.page={:?}", explain.page()));
332        lines.push(format!(
333            "diagnostic.plan.consistency={:?}",
334            explain.consistency()
335        ));
336
337        Ok(lines.join("\n"))
338    }
339
340    /// Plan this intent into a neutral planned query contract.
341    pub fn planned(&self) -> Result<PlannedQuery<E>, QueryError> {
342        let plan = self.build_plan()?;
343        let _projection = plan.projection_spec(E::MODEL);
344
345        Ok(PlannedQuery::<E>::new(plan))
346    }
347
348    /// Compile this intent into query-owned handoff state.
349    ///
350    /// This boundary intentionally does not expose executor runtime shape.
351    pub fn plan(&self) -> Result<CompiledQuery<E>, QueryError> {
352        let plan = self.build_plan()?;
353        let _projection = plan.projection_spec(E::MODEL);
354
355        Ok(CompiledQuery::<E>::new(plan))
356    }
357
358    // Build a logical plan for the current intent.
359    fn build_plan(&self) -> Result<AccessPlannedQuery, QueryError> {
360        self.intent.build_plan_model()
361    }
362}
363
364fn contains_execution_node_type(
365    descriptor: &ExplainExecutionNodeDescriptor,
366    target: ExplainExecutionNodeType,
367) -> bool {
368    descriptor.node_type() == target
369        || descriptor
370            .children()
371            .iter()
372            .any(|child| contains_execution_node_type(child, target))
373}
374
375fn plan_order_pushdown_label(order_pushdown: &ExplainOrderPushdown) -> String {
376    match order_pushdown {
377        ExplainOrderPushdown::MissingModelContext => "missing_model_context".to_string(),
378        ExplainOrderPushdown::EligibleSecondaryIndex { index, prefix_len } => {
379            format!("eligible(index={index},prefix_len={prefix_len})",)
380        }
381        ExplainOrderPushdown::Rejected(reason) => format!("rejected({reason:?})"),
382    }
383}
384
385fn plan_predicate_pushdown_label(
386    predicate: &ExplainPredicate,
387    access: &ExplainAccessPath,
388) -> String {
389    let access_label = match access {
390        ExplainAccessPath::ByKey { .. } => "by_key",
391        ExplainAccessPath::ByKeys { keys } if keys.is_empty() => "empty_access_contract",
392        ExplainAccessPath::ByKeys { .. } => "by_keys",
393        ExplainAccessPath::KeyRange { .. } => "key_range",
394        ExplainAccessPath::IndexPrefix { .. } => "index_prefix",
395        ExplainAccessPath::IndexMultiLookup { .. } => "index_multi_lookup",
396        ExplainAccessPath::IndexRange { .. } => "index_range",
397        ExplainAccessPath::FullScan => "full_scan",
398        ExplainAccessPath::Union(_) => "union",
399        ExplainAccessPath::Intersection(_) => "intersection",
400    };
401    if matches!(predicate, ExplainPredicate::None) {
402        return "none".to_string();
403    }
404    if matches!(access, ExplainAccessPath::FullScan) {
405        if explain_predicate_contains_non_strict_compare(predicate) {
406            return "fallback(non_strict_compare_coercion)".to_string();
407        }
408        if explain_predicate_contains_empty_prefix_starts_with(predicate) {
409            return "fallback(starts_with_empty_prefix)".to_string();
410        }
411        if explain_predicate_contains_is_null(predicate) {
412            return "fallback(is_null_full_scan)".to_string();
413        }
414        if explain_predicate_contains_text_scan_operator(predicate) {
415            return "fallback(text_operator_full_scan)".to_string();
416        }
417
418        return format!("fallback({access_label})");
419    }
420
421    format!("applied({access_label})")
422}
423
424fn explain_predicate_contains_non_strict_compare(predicate: &ExplainPredicate) -> bool {
425    match predicate {
426        ExplainPredicate::Compare { coercion, .. } => coercion.id != CoercionId::Strict,
427        ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
428            .iter()
429            .any(explain_predicate_contains_non_strict_compare),
430        ExplainPredicate::Not(inner) => explain_predicate_contains_non_strict_compare(inner),
431        ExplainPredicate::None
432        | ExplainPredicate::True
433        | ExplainPredicate::False
434        | ExplainPredicate::IsNull { .. }
435        | ExplainPredicate::IsNotNull { .. }
436        | ExplainPredicate::IsMissing { .. }
437        | ExplainPredicate::IsEmpty { .. }
438        | ExplainPredicate::IsNotEmpty { .. }
439        | ExplainPredicate::TextContains { .. }
440        | ExplainPredicate::TextContainsCi { .. } => false,
441    }
442}
443
444fn explain_predicate_contains_is_null(predicate: &ExplainPredicate) -> bool {
445    match predicate {
446        ExplainPredicate::IsNull { .. } => true,
447        ExplainPredicate::And(children) | ExplainPredicate::Or(children) => {
448            children.iter().any(explain_predicate_contains_is_null)
449        }
450        ExplainPredicate::Not(inner) => explain_predicate_contains_is_null(inner),
451        ExplainPredicate::None
452        | ExplainPredicate::True
453        | ExplainPredicate::False
454        | ExplainPredicate::Compare { .. }
455        | ExplainPredicate::IsNotNull { .. }
456        | ExplainPredicate::IsMissing { .. }
457        | ExplainPredicate::IsEmpty { .. }
458        | ExplainPredicate::IsNotEmpty { .. }
459        | ExplainPredicate::TextContains { .. }
460        | ExplainPredicate::TextContainsCi { .. } => false,
461    }
462}
463
464fn explain_predicate_contains_empty_prefix_starts_with(predicate: &ExplainPredicate) -> bool {
465    match predicate {
466        ExplainPredicate::Compare {
467            op: CompareOp::StartsWith,
468            value: Value::Text(prefix),
469            ..
470        } => prefix.is_empty(),
471        ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
472            .iter()
473            .any(explain_predicate_contains_empty_prefix_starts_with),
474        ExplainPredicate::Not(inner) => explain_predicate_contains_empty_prefix_starts_with(inner),
475        ExplainPredicate::None
476        | ExplainPredicate::True
477        | ExplainPredicate::False
478        | ExplainPredicate::Compare { .. }
479        | ExplainPredicate::IsNull { .. }
480        | ExplainPredicate::IsNotNull { .. }
481        | ExplainPredicate::IsMissing { .. }
482        | ExplainPredicate::IsEmpty { .. }
483        | ExplainPredicate::IsNotEmpty { .. }
484        | ExplainPredicate::TextContains { .. }
485        | ExplainPredicate::TextContainsCi { .. } => false,
486    }
487}
488
489fn explain_predicate_contains_text_scan_operator(predicate: &ExplainPredicate) -> bool {
490    match predicate {
491        ExplainPredicate::Compare {
492            op: CompareOp::EndsWith,
493            ..
494        }
495        | ExplainPredicate::TextContains { .. }
496        | ExplainPredicate::TextContainsCi { .. } => true,
497        ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
498            .iter()
499            .any(explain_predicate_contains_text_scan_operator),
500        ExplainPredicate::Not(inner) => explain_predicate_contains_text_scan_operator(inner),
501        ExplainPredicate::Compare { .. }
502        | ExplainPredicate::None
503        | ExplainPredicate::True
504        | ExplainPredicate::False
505        | ExplainPredicate::IsNull { .. }
506        | ExplainPredicate::IsNotNull { .. }
507        | ExplainPredicate::IsMissing { .. }
508        | ExplainPredicate::IsEmpty { .. }
509        | ExplainPredicate::IsNotEmpty { .. } => false,
510    }
511}
512
513impl<E> Query<E>
514where
515    E: EntityKind + SingletonEntity,
516    E::Key: Default,
517{
518    /// Set the access path to the singleton primary key.
519    pub(crate) fn only(self) -> Self {
520        let Self { intent } = self;
521
522        Self {
523            intent: intent.only(E::Key::default()),
524        }
525    }
526}
527
528///
529/// PlannedQuery
530///
531/// Neutral query-owned planned contract produced by query planning.
532/// Stores logical + access shape without executor compilation state.
533///
534
535#[derive(Debug)]
536pub struct PlannedQuery<E: EntityKind> {
537    plan: AccessPlannedQuery,
538    _marker: PhantomData<E>,
539}
540
541impl<E: EntityKind> PlannedQuery<E> {
542    #[must_use]
543    pub(in crate::db) const fn new(plan: AccessPlannedQuery) -> Self {
544        Self {
545            plan,
546            _marker: PhantomData,
547        }
548    }
549
550    #[must_use]
551    pub fn explain(&self) -> ExplainPlan {
552        self.plan.explain_with_model(E::MODEL)
553    }
554
555    /// Return the stable plan hash for this planned query.
556    #[must_use]
557    pub fn plan_hash_hex(&self) -> String {
558        self.plan.fingerprint().to_string()
559    }
560}
561
562///
563/// CompiledQuery
564///
565/// Query-owned compiled handoff produced by `Query::plan()`.
566/// This type intentionally carries only logical/access query semantics.
567/// Executor runtime shape is derived explicitly at the executor boundary.
568///
569
570#[derive(Clone, Debug)]
571pub struct CompiledQuery<E: EntityKind> {
572    plan: AccessPlannedQuery,
573    _marker: PhantomData<E>,
574}
575
576impl<E: EntityKind> CompiledQuery<E> {
577    #[must_use]
578    pub(in crate::db) const fn new(plan: AccessPlannedQuery) -> Self {
579        Self {
580            plan,
581            _marker: PhantomData,
582        }
583    }
584
585    #[must_use]
586    pub fn explain(&self) -> ExplainPlan {
587        self.plan.explain_with_model(E::MODEL)
588    }
589
590    /// Return the stable plan hash for this compiled query.
591    #[must_use]
592    pub fn plan_hash_hex(&self) -> String {
593        self.plan.fingerprint().to_string()
594    }
595
596    /// Borrow planner-lowered projection semantics for this compiled query.
597    #[must_use]
598    #[cfg(feature = "sql")]
599    pub(in crate::db) fn projection_spec(&self) -> crate::db::query::plan::expr::ProjectionSpec {
600        self.plan.projection_spec(E::MODEL)
601    }
602
603    #[must_use]
604    pub(in crate::db) fn into_inner(self) -> AccessPlannedQuery {
605        self.plan
606    }
607}
608
609///
610/// TESTS
611///
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616    use crate::{db::predicate::CoercionSpec, types::Ulid};
617
618    fn strict_compare(field: &str, op: CompareOp, value: Value) -> ExplainPredicate {
619        ExplainPredicate::Compare {
620            field: field.to_string(),
621            op,
622            value,
623            coercion: CoercionSpec::new(CoercionId::Strict),
624        }
625    }
626
627    #[test]
628    fn predicate_pushdown_label_prefix_like_and_equivalent_range_share_label() {
629        let starts_with_predicate = strict_compare(
630            "name",
631            CompareOp::StartsWith,
632            Value::Text("foo".to_string()),
633        );
634        let equivalent_range_predicate = ExplainPredicate::And(vec![
635            strict_compare("name", CompareOp::Gte, Value::Text("foo".to_string())),
636            strict_compare("name", CompareOp::Lt, Value::Text("fop".to_string())),
637        ]);
638        let access = ExplainAccessPath::IndexRange {
639            name: "idx_name",
640            fields: vec!["name"],
641            prefix_len: 0,
642            prefix: Vec::new(),
643            lower: std::ops::Bound::Included(Value::Text("foo".to_string())),
644            upper: std::ops::Bound::Excluded(Value::Text("fop".to_string())),
645        };
646
647        assert_eq!(
648            plan_predicate_pushdown_label(&starts_with_predicate, &access),
649            plan_predicate_pushdown_label(&equivalent_range_predicate, &access),
650            "equivalent prefix-like and bounded-range shapes should report identical pushdown reason labels",
651        );
652        assert_eq!(
653            plan_predicate_pushdown_label(&starts_with_predicate, &access),
654            "applied(index_range)"
655        );
656    }
657
658    #[test]
659    fn predicate_pushdown_label_distinguishes_is_null_and_non_strict_full_scan_fallbacks() {
660        let is_null_predicate = ExplainPredicate::IsNull {
661            field: "group".to_string(),
662        };
663        let non_strict_predicate = ExplainPredicate::Compare {
664            field: "group".to_string(),
665            op: CompareOp::Eq,
666            value: Value::Uint(7),
667            coercion: CoercionSpec::new(CoercionId::NumericWiden),
668        };
669        let access = ExplainAccessPath::FullScan;
670
671        assert_eq!(
672            plan_predicate_pushdown_label(&is_null_predicate, &access),
673            "fallback(is_null_full_scan)"
674        );
675        assert_eq!(
676            plan_predicate_pushdown_label(&non_strict_predicate, &access),
677            "fallback(non_strict_compare_coercion)"
678        );
679    }
680
681    #[test]
682    fn predicate_pushdown_label_reports_none_when_no_predicate_is_present() {
683        let predicate = ExplainPredicate::None;
684        let access = ExplainAccessPath::ByKey {
685            key: Value::Ulid(Ulid::from_u128(7)),
686        };
687
688        assert_eq!(plan_predicate_pushdown_label(&predicate, &access), "none");
689    }
690
691    #[test]
692    fn predicate_pushdown_label_reports_empty_access_contract_for_impossible_shapes() {
693        let predicate = ExplainPredicate::Or(vec![
694            ExplainPredicate::IsNull {
695                field: "id".to_string(),
696            },
697            ExplainPredicate::And(vec![
698                ExplainPredicate::Compare {
699                    field: "id".to_string(),
700                    op: CompareOp::In,
701                    value: Value::List(Vec::new()),
702                    coercion: CoercionSpec::new(CoercionId::Strict),
703                },
704                ExplainPredicate::True,
705            ]),
706        ]);
707        let access = ExplainAccessPath::ByKeys { keys: Vec::new() };
708
709        assert_eq!(
710            plan_predicate_pushdown_label(&predicate, &access),
711            "applied(empty_access_contract)"
712        );
713    }
714
715    #[test]
716    fn predicate_pushdown_label_distinguishes_empty_prefix_starts_with_full_scan_fallback() {
717        let empty_prefix_predicate = ExplainPredicate::Compare {
718            field: "label".to_string(),
719            op: CompareOp::StartsWith,
720            value: Value::Text(String::new()),
721            coercion: CoercionSpec::new(CoercionId::Strict),
722        };
723        let non_empty_prefix_predicate = ExplainPredicate::Compare {
724            field: "label".to_string(),
725            op: CompareOp::StartsWith,
726            value: Value::Text("l".to_string()),
727            coercion: CoercionSpec::new(CoercionId::Strict),
728        };
729        let access = ExplainAccessPath::FullScan;
730
731        assert_eq!(
732            plan_predicate_pushdown_label(&empty_prefix_predicate, &access),
733            "fallback(starts_with_empty_prefix)"
734        );
735        assert_eq!(
736            plan_predicate_pushdown_label(&non_empty_prefix_predicate, &access),
737            "fallback(full_scan)"
738        );
739    }
740
741    #[test]
742    fn predicate_pushdown_label_reports_text_operator_full_scan_fallback() {
743        let text_contains = ExplainPredicate::TextContainsCi {
744            field: "label".to_string(),
745            value: Value::Text("needle".to_string()),
746        };
747        let ends_with = ExplainPredicate::Compare {
748            field: "label".to_string(),
749            op: CompareOp::EndsWith,
750            value: Value::Text("fix".to_string()),
751            coercion: CoercionSpec::new(CoercionId::Strict),
752        };
753        let access = ExplainAccessPath::FullScan;
754
755        assert_eq!(
756            plan_predicate_pushdown_label(&text_contains, &access),
757            "fallback(text_operator_full_scan)"
758        );
759        assert_eq!(
760            plan_predicate_pushdown_label(&ends_with, &access),
761            "fallback(text_operator_full_scan)"
762        );
763    }
764
765    #[test]
766    fn predicate_pushdown_label_keeps_collection_contains_on_generic_full_scan_fallback() {
767        let collection_contains = ExplainPredicate::Compare {
768            field: "tags".to_string(),
769            op: CompareOp::Contains,
770            value: Value::Uint(7),
771            coercion: CoercionSpec::new(CoercionId::CollectionElement),
772        };
773        let access = ExplainAccessPath::FullScan;
774
775        assert_eq!(
776            plan_predicate_pushdown_label(&collection_contains, &access),
777            "fallback(non_strict_compare_coercion)"
778        );
779        assert_ne!(
780            plan_predicate_pushdown_label(&collection_contains, &access),
781            "fallback(text_operator_full_scan)"
782        );
783    }
784
785    #[test]
786    fn predicate_pushdown_label_non_strict_ends_with_uses_non_strict_fallback_precedence() {
787        let non_strict_ends_with = ExplainPredicate::Compare {
788            field: "label".to_string(),
789            op: CompareOp::EndsWith,
790            value: Value::Text("fix".to_string()),
791            coercion: CoercionSpec::new(CoercionId::TextCasefold),
792        };
793        let access = ExplainAccessPath::FullScan;
794
795        assert_eq!(
796            plan_predicate_pushdown_label(&non_strict_ends_with, &access),
797            "fallback(non_strict_compare_coercion)"
798        );
799        assert_ne!(
800            plan_predicate_pushdown_label(&non_strict_ends_with, &access),
801            "fallback(text_operator_full_scan)"
802        );
803    }
804}