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, model::QueryModel},
17 plan::{AccessPlannedQuery, LoadSpec, QueryMode},
18 },
19 },
20 traits::{EntityKind, EntityValue, SingletonEntity},
21 value::Value,
22};
23use core::marker::PhantomData;
24
25#[derive(Debug)]
37pub struct Query<E: EntityKind> {
38 intent: QueryModel<'static, E::Key>,
39}
40
41impl<E: EntityKind> Query<E> {
42 #[must_use]
46 pub const fn new(consistency: MissingRowPolicy) -> Self {
47 Self {
48 intent: QueryModel::new(E::MODEL, consistency),
49 }
50 }
51
52 #[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 #[must_use]
78 pub fn filter(mut self, predicate: Predicate) -> Self {
79 self.intent = self.intent.filter(predicate);
80 self
81 }
82
83 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 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 #[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 #[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 #[must_use]
115 pub fn distinct(mut self) -> Self {
116 self.intent = self.intent.distinct();
117 self
118 }
119
120 #[cfg(feature = "sql")]
122 #[must_use]
123 pub(in crate::db) fn select_fields<I, S>(mut self, fields: I) -> Self
124 where
125 I: IntoIterator<Item = S>,
126 S: Into<String>,
127 {
128 self.intent = self.intent.select_fields(fields);
129 self
130 }
131
132 pub fn group_by(self, field: impl AsRef<str>) -> Result<Self, QueryError> {
134 let Self { intent } = self;
135 let intent = intent.push_group_field(field.as_ref())?;
136
137 Ok(Self { intent })
138 }
139
140 #[must_use]
142 pub fn aggregate(mut self, aggregate: AggregateExpr) -> Self {
143 self.intent = self.intent.push_group_aggregate(aggregate);
144 self
145 }
146
147 #[must_use]
149 pub fn grouped_limits(mut self, max_groups: u64, max_group_bytes: u64) -> Self {
150 self.intent = self.intent.grouped_limits(max_groups, max_group_bytes);
151 self
152 }
153
154 pub fn having_group(
156 self,
157 field: impl AsRef<str>,
158 op: CompareOp,
159 value: Value,
160 ) -> Result<Self, QueryError> {
161 let field = field.as_ref().to_owned();
162 let Self { intent } = self;
163 let intent = intent.push_having_group_clause(&field, op, value)?;
164
165 Ok(Self { intent })
166 }
167
168 pub fn having_aggregate(
170 self,
171 aggregate_index: usize,
172 op: CompareOp,
173 value: Value,
174 ) -> Result<Self, QueryError> {
175 let Self { intent } = self;
176 let intent = intent.push_having_aggregate_clause(aggregate_index, op, value)?;
177
178 Ok(Self { intent })
179 }
180
181 pub(crate) fn by_id(self, id: E::Key) -> Self {
183 let Self { intent } = self;
184 Self {
185 intent: intent.by_id(id),
186 }
187 }
188
189 pub(crate) fn by_ids<I>(self, ids: I) -> Self
191 where
192 I: IntoIterator<Item = E::Key>,
193 {
194 let Self { intent } = self;
195 Self {
196 intent: intent.by_ids(ids),
197 }
198 }
199
200 #[must_use]
202 pub fn delete(mut self) -> Self {
203 self.intent = self.intent.delete();
204 self
205 }
206
207 #[must_use]
214 pub fn limit(mut self, limit: u32) -> Self {
215 self.intent = self.intent.limit(limit);
216 self
217 }
218
219 #[must_use]
225 pub fn offset(mut self, offset: u32) -> Self {
226 self.intent = self.intent.offset(offset);
227 self
228 }
229
230 pub fn explain(&self) -> Result<ExplainPlan, QueryError> {
232 let plan = self.planned()?;
233
234 Ok(plan.explain())
235 }
236
237 pub fn plan_hash_hex(&self) -> Result<String, QueryError> {
242 let plan = self.build_plan()?;
243
244 Ok(plan.fingerprint().to_string())
245 }
246
247 pub fn explain_execution(&self) -> Result<ExplainExecutionNodeDescriptor, QueryError>
249 where
250 E: EntityValue,
251 {
252 let executable = self.plan()?.into_executable();
253
254 executable
255 .explain_load_execution_node_descriptor()
256 .map_err(QueryError::execute)
257 }
258
259 pub fn explain_execution_text(&self) -> Result<String, QueryError>
261 where
262 E: EntityValue,
263 {
264 Ok(self.explain_execution()?.render_text_tree())
265 }
266
267 pub fn explain_execution_json(&self) -> Result<String, QueryError>
269 where
270 E: EntityValue,
271 {
272 Ok(self.explain_execution()?.render_json_canonical())
273 }
274
275 pub fn explain_execution_verbose(&self) -> Result<String, QueryError>
277 where
278 E: EntityValue,
279 {
280 let executable = self.plan()?.into_executable();
281 let descriptor = executable
282 .explain_load_execution_node_descriptor()
283 .map_err(QueryError::execute)?;
284 let route_diagnostics = executable
285 .explain_load_execution_verbose_diagnostics()
286 .map_err(QueryError::execute)?;
287 let explain = self.explain()?;
288
289 let mut lines = vec![descriptor.render_text_tree_verbose()];
291 lines.extend(route_diagnostics);
292
293 lines.push(format!(
295 "diagnostic.descriptor.has_top_n_seek={}",
296 contains_execution_node_type(&descriptor, ExplainExecutionNodeType::TopNSeek)
297 ));
298 lines.push(format!(
299 "diagnostic.descriptor.has_index_range_limit_pushdown={}",
300 contains_execution_node_type(
301 &descriptor,
302 ExplainExecutionNodeType::IndexRangeLimitPushdown,
303 )
304 ));
305 lines.push(format!(
306 "diagnostic.descriptor.has_index_predicate_prefilter={}",
307 contains_execution_node_type(
308 &descriptor,
309 ExplainExecutionNodeType::IndexPredicatePrefilter,
310 )
311 ));
312 lines.push(format!(
313 "diagnostic.descriptor.has_residual_predicate_filter={}",
314 contains_execution_node_type(
315 &descriptor,
316 ExplainExecutionNodeType::ResidualPredicateFilter,
317 )
318 ));
319
320 lines.push(format!("diagnostic.plan.mode={:?}", explain.mode()));
322 lines.push(format!(
323 "diagnostic.plan.order_pushdown={}",
324 plan_order_pushdown_label(explain.order_pushdown())
325 ));
326 lines.push(format!(
327 "diagnostic.plan.predicate_pushdown={}",
328 plan_predicate_pushdown_label(explain.predicate(), explain.access())
329 ));
330 lines.push(format!("diagnostic.plan.distinct={}", explain.distinct()));
331 lines.push(format!("diagnostic.plan.page={:?}", explain.page()));
332 lines.push(format!(
333 "diagnostic.plan.consistency={:?}",
334 explain.consistency()
335 ));
336
337 Ok(lines.join("\n"))
338 }
339
340 pub fn planned(&self) -> Result<PlannedQuery<E>, QueryError> {
342 let plan = self.build_plan()?;
343 let _projection = plan.projection_spec(E::MODEL);
344
345 Ok(PlannedQuery::<E>::new(plan))
346 }
347
348 pub fn plan(&self) -> Result<CompiledQuery<E>, QueryError> {
352 let plan = self.build_plan()?;
353 let _projection = plan.projection_spec(E::MODEL);
354
355 Ok(CompiledQuery::<E>::new(plan))
356 }
357
358 fn build_plan(&self) -> Result<AccessPlannedQuery, QueryError> {
360 self.intent.build_plan_model()
361 }
362}
363
364fn contains_execution_node_type(
365 descriptor: &ExplainExecutionNodeDescriptor,
366 target: ExplainExecutionNodeType,
367) -> bool {
368 descriptor.node_type() == target
369 || descriptor
370 .children()
371 .iter()
372 .any(|child| contains_execution_node_type(child, target))
373}
374
375fn plan_order_pushdown_label(order_pushdown: &ExplainOrderPushdown) -> String {
376 match order_pushdown {
377 ExplainOrderPushdown::MissingModelContext => "missing_model_context".to_string(),
378 ExplainOrderPushdown::EligibleSecondaryIndex { index, prefix_len } => {
379 format!("eligible(index={index},prefix_len={prefix_len})",)
380 }
381 ExplainOrderPushdown::Rejected(reason) => format!("rejected({reason:?})"),
382 }
383}
384
385fn plan_predicate_pushdown_label(
386 predicate: &ExplainPredicate,
387 access: &ExplainAccessPath,
388) -> String {
389 let access_label = match access {
390 ExplainAccessPath::ByKey { .. } => "by_key",
391 ExplainAccessPath::ByKeys { keys } if keys.is_empty() => "empty_access_contract",
392 ExplainAccessPath::ByKeys { .. } => "by_keys",
393 ExplainAccessPath::KeyRange { .. } => "key_range",
394 ExplainAccessPath::IndexPrefix { .. } => "index_prefix",
395 ExplainAccessPath::IndexMultiLookup { .. } => "index_multi_lookup",
396 ExplainAccessPath::IndexRange { .. } => "index_range",
397 ExplainAccessPath::FullScan => "full_scan",
398 ExplainAccessPath::Union(_) => "union",
399 ExplainAccessPath::Intersection(_) => "intersection",
400 };
401 if matches!(predicate, ExplainPredicate::None) {
402 return "none".to_string();
403 }
404 if matches!(access, ExplainAccessPath::FullScan) {
405 if explain_predicate_contains_non_strict_compare(predicate) {
406 return "fallback(non_strict_compare_coercion)".to_string();
407 }
408 if explain_predicate_contains_empty_prefix_starts_with(predicate) {
409 return "fallback(starts_with_empty_prefix)".to_string();
410 }
411 if explain_predicate_contains_is_null(predicate) {
412 return "fallback(is_null_full_scan)".to_string();
413 }
414 if explain_predicate_contains_text_scan_operator(predicate) {
415 return "fallback(text_operator_full_scan)".to_string();
416 }
417
418 return format!("fallback({access_label})");
419 }
420
421 format!("applied({access_label})")
422}
423
424fn explain_predicate_contains_non_strict_compare(predicate: &ExplainPredicate) -> bool {
425 match predicate {
426 ExplainPredicate::Compare { coercion, .. } => coercion.id != CoercionId::Strict,
427 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
428 .iter()
429 .any(explain_predicate_contains_non_strict_compare),
430 ExplainPredicate::Not(inner) => explain_predicate_contains_non_strict_compare(inner),
431 ExplainPredicate::None
432 | ExplainPredicate::True
433 | ExplainPredicate::False
434 | ExplainPredicate::IsNull { .. }
435 | ExplainPredicate::IsNotNull { .. }
436 | ExplainPredicate::IsMissing { .. }
437 | ExplainPredicate::IsEmpty { .. }
438 | ExplainPredicate::IsNotEmpty { .. }
439 | ExplainPredicate::TextContains { .. }
440 | ExplainPredicate::TextContainsCi { .. } => false,
441 }
442}
443
444fn explain_predicate_contains_is_null(predicate: &ExplainPredicate) -> bool {
445 match predicate {
446 ExplainPredicate::IsNull { .. } => true,
447 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => {
448 children.iter().any(explain_predicate_contains_is_null)
449 }
450 ExplainPredicate::Not(inner) => explain_predicate_contains_is_null(inner),
451 ExplainPredicate::None
452 | ExplainPredicate::True
453 | ExplainPredicate::False
454 | ExplainPredicate::Compare { .. }
455 | ExplainPredicate::IsNotNull { .. }
456 | ExplainPredicate::IsMissing { .. }
457 | ExplainPredicate::IsEmpty { .. }
458 | ExplainPredicate::IsNotEmpty { .. }
459 | ExplainPredicate::TextContains { .. }
460 | ExplainPredicate::TextContainsCi { .. } => false,
461 }
462}
463
464fn explain_predicate_contains_empty_prefix_starts_with(predicate: &ExplainPredicate) -> bool {
465 match predicate {
466 ExplainPredicate::Compare {
467 op: CompareOp::StartsWith,
468 value: Value::Text(prefix),
469 ..
470 } => prefix.is_empty(),
471 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
472 .iter()
473 .any(explain_predicate_contains_empty_prefix_starts_with),
474 ExplainPredicate::Not(inner) => explain_predicate_contains_empty_prefix_starts_with(inner),
475 ExplainPredicate::None
476 | ExplainPredicate::True
477 | ExplainPredicate::False
478 | ExplainPredicate::Compare { .. }
479 | ExplainPredicate::IsNull { .. }
480 | ExplainPredicate::IsNotNull { .. }
481 | ExplainPredicate::IsMissing { .. }
482 | ExplainPredicate::IsEmpty { .. }
483 | ExplainPredicate::IsNotEmpty { .. }
484 | ExplainPredicate::TextContains { .. }
485 | ExplainPredicate::TextContainsCi { .. } => false,
486 }
487}
488
489fn explain_predicate_contains_text_scan_operator(predicate: &ExplainPredicate) -> bool {
490 match predicate {
491 ExplainPredicate::Compare {
492 op: CompareOp::EndsWith,
493 ..
494 }
495 | ExplainPredicate::TextContains { .. }
496 | ExplainPredicate::TextContainsCi { .. } => true,
497 ExplainPredicate::And(children) | ExplainPredicate::Or(children) => children
498 .iter()
499 .any(explain_predicate_contains_text_scan_operator),
500 ExplainPredicate::Not(inner) => explain_predicate_contains_text_scan_operator(inner),
501 ExplainPredicate::Compare { .. }
502 | ExplainPredicate::None
503 | ExplainPredicate::True
504 | ExplainPredicate::False
505 | ExplainPredicate::IsNull { .. }
506 | ExplainPredicate::IsNotNull { .. }
507 | ExplainPredicate::IsMissing { .. }
508 | ExplainPredicate::IsEmpty { .. }
509 | ExplainPredicate::IsNotEmpty { .. } => false,
510 }
511}
512
513impl<E> Query<E>
514where
515 E: EntityKind + SingletonEntity,
516 E::Key: Default,
517{
518 pub(crate) fn only(self) -> Self {
520 let Self { intent } = self;
521
522 Self {
523 intent: intent.only(E::Key::default()),
524 }
525 }
526}
527
528#[derive(Debug)]
536pub struct PlannedQuery<E: EntityKind> {
537 plan: AccessPlannedQuery,
538 _marker: PhantomData<E>,
539}
540
541impl<E: EntityKind> PlannedQuery<E> {
542 #[must_use]
543 pub(in crate::db) const fn new(plan: AccessPlannedQuery) -> Self {
544 Self {
545 plan,
546 _marker: PhantomData,
547 }
548 }
549
550 #[must_use]
551 pub fn explain(&self) -> ExplainPlan {
552 self.plan.explain_with_model(E::MODEL)
553 }
554
555 #[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,
573 _marker: PhantomData<E>,
574}
575
576impl<E: EntityKind> CompiledQuery<E> {
577 #[must_use]
578 pub(in crate::db) const fn new(plan: AccessPlannedQuery) -> Self {
579 Self {
580 plan,
581 _marker: PhantomData,
582 }
583 }
584
585 #[must_use]
586 pub fn explain(&self) -> ExplainPlan {
587 self.plan.explain_with_model(E::MODEL)
588 }
589
590 #[must_use]
592 pub fn plan_hash_hex(&self) -> String {
593 self.plan.fingerprint().to_string()
594 }
595
596 #[must_use]
598 #[cfg(feature = "sql")]
599 pub(in crate::db) fn projection_spec(&self) -> crate::db::query::plan::expr::ProjectionSpec {
600 self.plan.projection_spec(E::MODEL)
601 }
602
603 #[must_use]
604 pub(in crate::db) fn into_inner(self) -> AccessPlannedQuery {
605 self.plan
606 }
607}
608
609#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::{db::predicate::CoercionSpec, types::Ulid};
617
618 fn strict_compare(field: &str, op: CompareOp, value: Value) -> ExplainPredicate {
619 ExplainPredicate::Compare {
620 field: field.to_string(),
621 op,
622 value,
623 coercion: CoercionSpec::new(CoercionId::Strict),
624 }
625 }
626
627 #[test]
628 fn predicate_pushdown_label_prefix_like_and_equivalent_range_share_label() {
629 let starts_with_predicate = strict_compare(
630 "name",
631 CompareOp::StartsWith,
632 Value::Text("foo".to_string()),
633 );
634 let equivalent_range_predicate = ExplainPredicate::And(vec![
635 strict_compare("name", CompareOp::Gte, Value::Text("foo".to_string())),
636 strict_compare("name", CompareOp::Lt, Value::Text("fop".to_string())),
637 ]);
638 let access = ExplainAccessPath::IndexRange {
639 name: "idx_name",
640 fields: vec!["name"],
641 prefix_len: 0,
642 prefix: Vec::new(),
643 lower: std::ops::Bound::Included(Value::Text("foo".to_string())),
644 upper: std::ops::Bound::Excluded(Value::Text("fop".to_string())),
645 };
646
647 assert_eq!(
648 plan_predicate_pushdown_label(&starts_with_predicate, &access),
649 plan_predicate_pushdown_label(&equivalent_range_predicate, &access),
650 "equivalent prefix-like and bounded-range shapes should report identical pushdown reason labels",
651 );
652 assert_eq!(
653 plan_predicate_pushdown_label(&starts_with_predicate, &access),
654 "applied(index_range)"
655 );
656 }
657
658 #[test]
659 fn predicate_pushdown_label_distinguishes_is_null_and_non_strict_full_scan_fallbacks() {
660 let is_null_predicate = ExplainPredicate::IsNull {
661 field: "group".to_string(),
662 };
663 let non_strict_predicate = ExplainPredicate::Compare {
664 field: "group".to_string(),
665 op: CompareOp::Eq,
666 value: Value::Uint(7),
667 coercion: CoercionSpec::new(CoercionId::NumericWiden),
668 };
669 let access = ExplainAccessPath::FullScan;
670
671 assert_eq!(
672 plan_predicate_pushdown_label(&is_null_predicate, &access),
673 "fallback(is_null_full_scan)"
674 );
675 assert_eq!(
676 plan_predicate_pushdown_label(&non_strict_predicate, &access),
677 "fallback(non_strict_compare_coercion)"
678 );
679 }
680
681 #[test]
682 fn predicate_pushdown_label_reports_none_when_no_predicate_is_present() {
683 let predicate = ExplainPredicate::None;
684 let access = ExplainAccessPath::ByKey {
685 key: Value::Ulid(Ulid::from_u128(7)),
686 };
687
688 assert_eq!(plan_predicate_pushdown_label(&predicate, &access), "none");
689 }
690
691 #[test]
692 fn predicate_pushdown_label_reports_empty_access_contract_for_impossible_shapes() {
693 let predicate = ExplainPredicate::Or(vec![
694 ExplainPredicate::IsNull {
695 field: "id".to_string(),
696 },
697 ExplainPredicate::And(vec![
698 ExplainPredicate::Compare {
699 field: "id".to_string(),
700 op: CompareOp::In,
701 value: Value::List(Vec::new()),
702 coercion: CoercionSpec::new(CoercionId::Strict),
703 },
704 ExplainPredicate::True,
705 ]),
706 ]);
707 let access = ExplainAccessPath::ByKeys { keys: Vec::new() };
708
709 assert_eq!(
710 plan_predicate_pushdown_label(&predicate, &access),
711 "applied(empty_access_contract)"
712 );
713 }
714
715 #[test]
716 fn predicate_pushdown_label_distinguishes_empty_prefix_starts_with_full_scan_fallback() {
717 let empty_prefix_predicate = ExplainPredicate::Compare {
718 field: "label".to_string(),
719 op: CompareOp::StartsWith,
720 value: Value::Text(String::new()),
721 coercion: CoercionSpec::new(CoercionId::Strict),
722 };
723 let non_empty_prefix_predicate = ExplainPredicate::Compare {
724 field: "label".to_string(),
725 op: CompareOp::StartsWith,
726 value: Value::Text("l".to_string()),
727 coercion: CoercionSpec::new(CoercionId::Strict),
728 };
729 let access = ExplainAccessPath::FullScan;
730
731 assert_eq!(
732 plan_predicate_pushdown_label(&empty_prefix_predicate, &access),
733 "fallback(starts_with_empty_prefix)"
734 );
735 assert_eq!(
736 plan_predicate_pushdown_label(&non_empty_prefix_predicate, &access),
737 "fallback(full_scan)"
738 );
739 }
740
741 #[test]
742 fn predicate_pushdown_label_reports_text_operator_full_scan_fallback() {
743 let text_contains = ExplainPredicate::TextContainsCi {
744 field: "label".to_string(),
745 value: Value::Text("needle".to_string()),
746 };
747 let ends_with = ExplainPredicate::Compare {
748 field: "label".to_string(),
749 op: CompareOp::EndsWith,
750 value: Value::Text("fix".to_string()),
751 coercion: CoercionSpec::new(CoercionId::Strict),
752 };
753 let access = ExplainAccessPath::FullScan;
754
755 assert_eq!(
756 plan_predicate_pushdown_label(&text_contains, &access),
757 "fallback(text_operator_full_scan)"
758 );
759 assert_eq!(
760 plan_predicate_pushdown_label(&ends_with, &access),
761 "fallback(text_operator_full_scan)"
762 );
763 }
764
765 #[test]
766 fn predicate_pushdown_label_keeps_collection_contains_on_generic_full_scan_fallback() {
767 let collection_contains = ExplainPredicate::Compare {
768 field: "tags".to_string(),
769 op: CompareOp::Contains,
770 value: Value::Uint(7),
771 coercion: CoercionSpec::new(CoercionId::CollectionElement),
772 };
773 let access = ExplainAccessPath::FullScan;
774
775 assert_eq!(
776 plan_predicate_pushdown_label(&collection_contains, &access),
777 "fallback(non_strict_compare_coercion)"
778 );
779 assert_ne!(
780 plan_predicate_pushdown_label(&collection_contains, &access),
781 "fallback(text_operator_full_scan)"
782 );
783 }
784
785 #[test]
786 fn predicate_pushdown_label_non_strict_ends_with_uses_non_strict_fallback_precedence() {
787 let non_strict_ends_with = ExplainPredicate::Compare {
788 field: "label".to_string(),
789 op: CompareOp::EndsWith,
790 value: Value::Text("fix".to_string()),
791 coercion: CoercionSpec::new(CoercionId::TextCasefold),
792 };
793 let access = ExplainAccessPath::FullScan;
794
795 assert_eq!(
796 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
797 "fallback(non_strict_compare_coercion)"
798 );
799 assert_ne!(
800 plan_predicate_pushdown_label(&non_strict_ends_with, &access),
801 "fallback(text_operator_full_scan)"
802 );
803 }
804}