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