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