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