1use 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#[derive(Debug)]
36pub struct Query<E: EntityKind> {
37 intent: QueryModel<'static, E::Key>,
38}
39
40impl<E: EntityKind> Query<E> {
41 #[must_use]
45 pub const fn new(consistency: MissingRowPolicy) -> Self {
46 Self {
47 intent: QueryModel::new(E::MODEL, consistency),
48 }
49 }
50
51 #[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 #[must_use]
77 pub fn filter(mut self, predicate: Predicate) -> Self {
78 self.intent = self.intent.filter(predicate);
79 self
80 }
81
82 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 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 #[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 #[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 #[must_use]
114 pub fn distinct(mut self) -> Self {
115 self.intent = self.intent.distinct();
116 self
117 }
118
119 #[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 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 #[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 #[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 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 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 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 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 #[must_use]
200 pub fn delete(mut self) -> Self {
201 self.intent = self.intent.delete();
202 self
203 }
204
205 #[must_use]
212 pub fn limit(mut self, limit: u32) -> Self {
213 self.intent = self.intent.limit(limit);
214 self
215 }
216
217 #[must_use]
223 pub fn offset(mut self, offset: u32) -> Self {
224 self.intent = self.intent.offset(offset);
225 self
226 }
227
228 pub fn explain(&self) -> Result<ExplainPlan, QueryError> {
230 let plan = self.planned()?;
231
232 Ok(plan.explain())
233 }
234
235 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 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 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 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 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 let mut lines = vec![descriptor.render_text_tree_verbose()];
289 lines.extend(route_diagnostics);
290
291 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 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 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 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 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 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#[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 #[must_use]
557 pub fn plan_hash_hex(&self) -> String {
558 self.plan.fingerprint().to_string()
559 }
560}
561
562#[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 #[must_use]
588 pub fn plan_hash_hex(&self) -> String {
589 self.plan.fingerprint().to_string()
590 }
591
592 #[must_use]
594 pub(in crate::db) fn projection_spec(&self) -> crate::db::query::plan::expr::ProjectionSpec {
595 self.plan.projection_spec(E::MODEL)
596 }
597
598 #[must_use]
599 pub(in crate::db) fn into_inner(self) -> AccessPlannedQuery<E::Key> {
600 self.plan
601 }
602}
603
604#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::{db::predicate::CoercionSpec, types::Ulid};
612
613 fn strict_compare(field: &str, op: CompareOp, value: Value) -> ExplainPredicate {
614 ExplainPredicate::Compare {
615 field: field.to_string(),
616 op,
617 value,
618 coercion: CoercionSpec::new(CoercionId::Strict),
619 }
620 }
621
622 #[test]
623 fn predicate_pushdown_label_prefix_like_and_equivalent_range_share_label() {
624 let starts_with_predicate = strict_compare(
625 "name",
626 CompareOp::StartsWith,
627 Value::Text("foo".to_string()),
628 );
629 let equivalent_range_predicate = ExplainPredicate::And(vec![
630 strict_compare("name", CompareOp::Gte, Value::Text("foo".to_string())),
631 strict_compare("name", CompareOp::Lt, Value::Text("fop".to_string())),
632 ]);
633 let access = ExplainAccessPath::IndexRange {
634 name: "idx_name",
635 fields: vec!["name"],
636 prefix_len: 0,
637 prefix: Vec::new(),
638 lower: std::ops::Bound::Included(Value::Text("foo".to_string())),
639 upper: std::ops::Bound::Excluded(Value::Text("fop".to_string())),
640 };
641
642 assert_eq!(
643 plan_predicate_pushdown_label(&starts_with_predicate, &access),
644 plan_predicate_pushdown_label(&equivalent_range_predicate, &access),
645 "equivalent prefix-like and bounded-range shapes should report identical pushdown reason labels",
646 );
647 assert_eq!(
648 plan_predicate_pushdown_label(&starts_with_predicate, &access),
649 "applied(index_range)"
650 );
651 }
652
653 #[test]
654 fn predicate_pushdown_label_distinguishes_is_null_and_non_strict_full_scan_fallbacks() {
655 let is_null_predicate = ExplainPredicate::IsNull {
656 field: "group".to_string(),
657 };
658 let non_strict_predicate = ExplainPredicate::Compare {
659 field: "group".to_string(),
660 op: CompareOp::Eq,
661 value: Value::Uint(7),
662 coercion: CoercionSpec::new(CoercionId::NumericWiden),
663 };
664 let access = ExplainAccessPath::FullScan;
665
666 assert_eq!(
667 plan_predicate_pushdown_label(&is_null_predicate, &access),
668 "fallback(is_null_full_scan)"
669 );
670 assert_eq!(
671 plan_predicate_pushdown_label(&non_strict_predicate, &access),
672 "fallback(non_strict_compare_coercion)"
673 );
674 }
675
676 #[test]
677 fn predicate_pushdown_label_reports_none_when_no_predicate_is_present() {
678 let predicate = ExplainPredicate::None;
679 let access = ExplainAccessPath::ByKey {
680 key: Value::Ulid(Ulid::from_u128(7)),
681 };
682
683 assert_eq!(plan_predicate_pushdown_label(&predicate, &access), "none");
684 }
685
686 #[test]
687 fn predicate_pushdown_label_reports_empty_access_contract_for_impossible_shapes() {
688 let predicate = ExplainPredicate::Or(vec![
689 ExplainPredicate::IsNull {
690 field: "id".to_string(),
691 },
692 ExplainPredicate::And(vec![
693 ExplainPredicate::Compare {
694 field: "id".to_string(),
695 op: CompareOp::In,
696 value: Value::List(Vec::new()),
697 coercion: CoercionSpec::new(CoercionId::Strict),
698 },
699 ExplainPredicate::True,
700 ]),
701 ]);
702 let access = ExplainAccessPath::ByKeys { keys: Vec::new() };
703
704 assert_eq!(
705 plan_predicate_pushdown_label(&predicate, &access),
706 "applied(empty_access_contract)"
707 );
708 }
709
710 #[test]
711 fn predicate_pushdown_label_distinguishes_empty_prefix_starts_with_full_scan_fallback() {
712 let empty_prefix_predicate = ExplainPredicate::Compare {
713 field: "label".to_string(),
714 op: CompareOp::StartsWith,
715 value: Value::Text(String::new()),
716 coercion: CoercionSpec::new(CoercionId::Strict),
717 };
718 let non_empty_prefix_predicate = ExplainPredicate::Compare {
719 field: "label".to_string(),
720 op: CompareOp::StartsWith,
721 value: Value::Text("l".to_string()),
722 coercion: CoercionSpec::new(CoercionId::Strict),
723 };
724 let access = ExplainAccessPath::FullScan;
725
726 assert_eq!(
727 plan_predicate_pushdown_label(&empty_prefix_predicate, &access),
728 "fallback(starts_with_empty_prefix)"
729 );
730 assert_eq!(
731 plan_predicate_pushdown_label(&non_empty_prefix_predicate, &access),
732 "fallback(full_scan)"
733 );
734 }
735
736 #[test]
737 fn predicate_pushdown_label_reports_text_operator_full_scan_fallback() {
738 let text_contains = ExplainPredicate::TextContainsCi {
739 field: "label".to_string(),
740 value: Value::Text("needle".to_string()),
741 };
742 let ends_with = ExplainPredicate::Compare {
743 field: "label".to_string(),
744 op: CompareOp::EndsWith,
745 value: Value::Text("fix".to_string()),
746 coercion: CoercionSpec::new(CoercionId::Strict),
747 };
748 let access = ExplainAccessPath::FullScan;
749
750 assert_eq!(
751 plan_predicate_pushdown_label(&text_contains, &access),
752 "fallback(text_operator_full_scan)"
753 );
754 assert_eq!(
755 plan_predicate_pushdown_label(&ends_with, &access),
756 "fallback(text_operator_full_scan)"
757 );
758 }
759
760 #[test]
761 fn predicate_pushdown_label_keeps_collection_contains_on_generic_full_scan_fallback() {
762 let collection_contains = ExplainPredicate::Compare {
763 field: "tags".to_string(),
764 op: CompareOp::Contains,
765 value: Value::Uint(7),
766 coercion: CoercionSpec::new(CoercionId::CollectionElement),
767 };
768 let access = ExplainAccessPath::FullScan;
769
770 assert_eq!(
771 plan_predicate_pushdown_label(&collection_contains, &access),
772 "fallback(non_strict_compare_coercion)"
773 );
774 assert_ne!(
775 plan_predicate_pushdown_label(&collection_contains, &access),
776 "fallback(text_operator_full_scan)"
777 );
778 }
779
780 #[test]
781 fn predicate_pushdown_label_non_strict_ends_with_uses_non_strict_fallback_precedence() {
782 let non_strict_ends_with = ExplainPredicate::Compare {
783 field: "label".to_string(),
784 op: CompareOp::EndsWith,
785 value: Value::Text("fix".to_string()),
786 coercion: CoercionSpec::new(CoercionId::TextCasefold),
787 };
788 let access = ExplainAccessPath::FullScan;
789
790 assert_eq!(
791 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
792 "fallback(non_strict_compare_coercion)"
793 );
794 assert_ne!(
795 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
796 "fallback(text_operator_full_scan)"
797 );
798 }
799}