Skip to main content

query/promql/planner/
matching_filters.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Propagation of label matchers between the operands of a PromQL binary expression.
16
17use promql_parser::label::{MatchOp, Matcher, Matchers};
18use promql_parser::parser::{
19    AggregateExpr, BinaryExpr, Expr, LabelModifier, VectorMatchCardinality, token,
20};
21
22/// [`super::PromPlanner::matchers_to_expr`] emits no filter for this, so copying it would only
23/// force a re-plan.
24const MATCH_ALL_REGEX: &str = "^(?:.*)$";
25
26/// Rollup functions emitting at most one output series per input series, carrying the labels of
27/// the matrix selector they wrap. `absent_over_time` is excluded: it synthesizes a series from
28/// the matchers when its input has none.
29const LABEL_PRESERVING_RANGE_FUNCTIONS: [&str; 20] = [
30    "avg_over_time",
31    "changes",
32    "count_over_time",
33    "delta",
34    "deriv",
35    "double_exponential_smoothing",
36    "idelta",
37    "increase",
38    "irate",
39    "last_over_time",
40    "max_over_time",
41    "min_over_time",
42    "predict_linear",
43    "present_over_time",
44    "quantile_over_time",
45    "rate",
46    "resets",
47    "stddev_over_time",
48    "stdvar_over_time",
49    "sum_over_time",
50];
51
52/// Copies label matchers from one operand of `binary` to the other, so both scans discard
53/// non-joining series instead of the join.
54///
55/// Arithmetic operands are inner-joined on their matching labels with plain column
56/// equality, so a matcher on a matching label already holds for every surviving pair. Copying it
57/// to the other operand can only drop rows that had no partner, whatever the matcher kind, and
58/// `NULL` pairs only with `NULL`. Nor can it split a match group, whose series agree on every
59/// matching label: a propagated matcher removes whole groups, so the cardinality check still
60/// sees every group that takes part in the matching. A duplicate in a group without a partner
61/// goes unreported as a result, where Prometheus fails the query on it.
62/// Grouped matching is conservatively restricted to a provably unique one-side operand.
63///
64/// `left_tags` and `right_tags` are the tag columns of the planned operands: a matcher may just
65/// as well constrain a value field, which the parsed expression does not distinguish from a
66/// label. Matchers can cross an aggregate only on labels that partition its input.
67/// `left_field_labels` and `right_field_labels` exclude value fields promoted to grouping
68/// labels: filtering them before sample selection could replace the latest sample with an
69/// older one (#9242).
70///
71/// Whether a matcher reaches a scan is decided per receiving operand ([`preserves_filter`]), so
72/// propagation is one-way when only one of the two can take the filter.
73pub(super) fn propagate(
74    binary: &BinaryExpr,
75    left_tags: &[String],
76    right_tags: &[String],
77    left_field_labels: &[String],
78    right_field_labels: &[String],
79) -> Option<BinaryExpr> {
80    match try_propagate(
81        binary,
82        left_tags,
83        right_tags,
84        left_field_labels,
85        right_field_labels,
86    ) {
87        Ok(rewritten) => Some(rewritten),
88        Err(reason) => {
89            common_telemetry::debug!("Matching filter not propagated ({reason}): {binary}");
90            None
91        }
92    }
93}
94
95/// `Err` carries why the rewrite does not apply, for [`propagate`] to log.
96fn try_propagate(
97    binary: &BinaryExpr,
98    left_tags: &[String],
99    right_tags: &[String],
100    left_field_labels: &[String],
101    right_field_labels: &[String],
102) -> Result<BinaryExpr, &'static str> {
103    if !matches!(
104        binary.op.id(),
105        token::T_ADD | token::T_SUB | token::T_MUL | token::T_DIV | token::T_MOD | token::T_POW
106    ) {
107        return Err("operator is not arithmetic");
108    }
109    if let Some(modifier) = &binary.modifier
110        && (modifier.fill_values.lhs.is_some() || modifier.fill_values.rhs.is_some())
111    {
112        return Err("operand carries a fill modifier");
113    }
114    let matching = binary
115        .modifier
116        .as_ref()
117        .and_then(|modifier| modifier.matching.as_ref());
118
119    let is_matching_label = |name: &String| {
120        // PromQL reserves the `__` prefix; none of those names is a join key.
121        !name.starts_with("__")
122            && left_tags.contains(name)
123            && right_tags.contains(name)
124            // A grouping label that is a value field of the operand's input is not a series tag.
125            && !left_field_labels.contains(name)
126            && !right_field_labels.contains(name)
127            && match matching {
128                None => true,
129                Some(LabelModifier::Include(on)) => on.labels.contains(name),
130                Some(LabelModifier::Exclude(ignoring)) => !ignoring.labels.contains(name),
131            }
132    };
133    if let Some(modifier) = &binary.modifier {
134        let unique_on_matching_labels = |expr: &Expr, tags: &[String]| {
135            has_unique_aggregate_output(expr) && tags.iter().all(&is_matching_label)
136        };
137        let supported_cardinality = match &modifier.card {
138            VectorMatchCardinality::OneToOne => true,
139            VectorMatchCardinality::ManyToOne(_) => {
140                unique_on_matching_labels(&binary.rhs, right_tags)
141            }
142            VectorMatchCardinality::OneToMany(_) => {
143                unique_on_matching_labels(&binary.lhs, left_tags)
144            }
145            VectorMatchCardinality::ManyToMany => false,
146        };
147        if !supported_cardinality {
148            return Err("one-side uniqueness is not proven");
149        }
150    }
151    let mut rewritten = binary.clone();
152    let left = selector_matchers(&mut rewritten.lhs).ok_or("left operand is not a selector")?;
153    let right = selector_matchers(&mut rewritten.rhs).ok_or("right operand is not a selector")?;
154    if !left.or_matchers.is_empty() || !right.or_matchers.is_empty() {
155        return Err("selector has an or matcher group");
156    }
157
158    let constraints = left
159        .matchers
160        .iter()
161        .chain(&right.matchers)
162        .filter(|matcher| is_matching_label(&matcher.name) && !matches_every_value(matcher))
163        .cloned()
164        .collect::<Vec<_>>();
165
166    let mut changed = false;
167    for matcher in constraints {
168        for (target, operand) in [
169            (&mut left.matchers, &binary.lhs),
170            (&mut right.matchers, &binary.rhs),
171        ] {
172            if !target.contains(&matcher) && preserves_filter(operand, &matcher.name) {
173                target.push(matcher.clone());
174                changed = true;
175            }
176        }
177    }
178    if !changed {
179        return Err("operands already carry the same matching-label matchers");
180    }
181    Ok(rewritten)
182}
183
184fn matches_every_value(matcher: &Matcher) -> bool {
185    matches!(&matcher.op, MatchOp::Re(re) if re.as_str() == MATCH_ALL_REGEX)
186}
187
188/// Aggregations partitioning their input by the grouping labels, so that dropping input series
189/// by a grouping label drops exactly the matching output series and leaves the remaining
190/// aggregated values untouched.
191///
192/// Ranking aggregates instead retain input labels. `count_values` synthesizes a label.
193fn partitions_by_grouping_labels(aggregate: &AggregateExpr) -> bool {
194    matches!(
195        aggregate.op.id(),
196        token::T_SUM
197            | token::T_AVG
198            | token::T_COUNT
199            | token::T_MIN
200            | token::T_MAX
201            | token::T_GROUP
202            | token::T_STDDEV
203            | token::T_STDVAR
204            | token::T_QUANTILE
205    )
206}
207
208/// Ranking operators supported by the planner; limitk and limit_ratio are not implemented.
209fn ranks_by_grouping_labels(aggregate: &AggregateExpr) -> bool {
210    matches!(aggregate.op.id(), token::T_TOPK | token::T_BOTTOMK)
211}
212
213/// Whether a partitioning aggregate proves the operand emits at most one series per combination
214/// of its output labels; ranking narrows its input, so it inherits the proof. Uniqueness per
215/// *match signature* additionally requires every output label to be a matching label, which the
216/// caller checks.
217fn has_unique_aggregate_output(expr: &Expr) -> bool {
218    match expr {
219        Expr::Paren(paren) => has_unique_aggregate_output(&paren.expr),
220        Expr::Aggregate(aggregate) if partitions_by_grouping_labels(aggregate) => true,
221        Expr::Aggregate(aggregate) if ranks_by_grouping_labels(aggregate) => {
222            has_unique_aggregate_output(&aggregate.expr)
223        }
224        _ => false,
225    }
226}
227
228fn vector_operand_is_lhs(binary: &BinaryExpr) -> Option<bool> {
229    if !matches!(
230        binary.op.id(),
231        token::T_ADD | token::T_SUB | token::T_MUL | token::T_DIV | token::T_MOD | token::T_POW
232    ) || binary.modifier.is_some()
233    {
234        return None;
235    }
236    if matches!(binary.lhs.as_ref(), Expr::NumberLiteral(_)) {
237        Some(false)
238    } else if matches!(binary.rhs.as_ref(), Expr::NumberLiteral(_)) {
239        Some(true)
240    } else {
241        None
242    }
243}
244
245/// Whether adding a matcher on `label` to the scan [`selector_matchers`] finds leaves the
246/// operand's output equal to the subset of its unfiltered output that satisfies the matcher.
247/// A filter may cross an aggregate only when it removes whole groups: in particular, a label
248/// retained by topk is not necessarily one of its partitioning labels.
249fn preserves_filter(expr: &Expr, label: &str) -> bool {
250    match expr {
251        Expr::VectorSelector(_) => true,
252        Expr::Paren(paren) => preserves_filter(&paren.expr, label),
253        Expr::Aggregate(aggregate) => {
254            let partition_label = match &aggregate.modifier {
255                Some(LabelModifier::Include(labels)) => labels.labels.iter().any(|x| x == label),
256                Some(LabelModifier::Exclude(labels)) => labels.labels.iter().all(|x| x != label),
257                None => false,
258            };
259            partition_label && preserves_filter(&aggregate.expr, label)
260        }
261        Expr::Binary(binary) => match vector_operand_is_lhs(binary) {
262            Some(true) => preserves_filter(&binary.lhs, label),
263            Some(false) => preserves_filter(&binary.rhs, label),
264            None => false,
265        },
266        // A rollup emits at most one output series per input series and carries its labels.
267        Expr::Call(call) if LABEL_PRESERVING_RANGE_FUNCTIONS.contains(&call.func.name) => true,
268        _ => false,
269    }
270}
271
272/// Finds the scanned selector through label-preserving operations. Aggregate partitioning
273/// is checked separately for each propagated label.
274fn selector_matchers(expr: &mut Expr) -> Option<&mut Matchers> {
275    match expr {
276        Expr::VectorSelector(selector) => Some(&mut selector.matchers),
277        Expr::Paren(paren) => selector_matchers(&mut paren.expr),
278        Expr::Aggregate(aggregate)
279            if partitions_by_grouping_labels(aggregate) || ranks_by_grouping_labels(aggregate) =>
280        {
281            selector_matchers(&mut aggregate.expr)
282        }
283        Expr::Binary(binary) => match vector_operand_is_lhs(binary) {
284            Some(true) => selector_matchers(&mut binary.lhs),
285            Some(false) => selector_matchers(&mut binary.rhs),
286            None => None,
287        },
288        Expr::Call(call) if LABEL_PRESERVING_RANGE_FUNCTIONS.contains(&call.func.name) => {
289            // Every other argument is a scalar, so position does not matter.
290            let matrix = single_matrix_argument(&call.args.args)?;
291            match call.args.args[matrix].as_mut() {
292                Expr::MatrixSelector(selector) => Some(&mut selector.vs.matchers),
293                _ => None,
294            }
295        }
296        // Label rewriting and subqueries need separate proofs.
297        _ => None,
298    }
299}
300
301fn single_matrix_argument(args: &[Box<Expr>]) -> Option<usize> {
302    let mut found = None;
303    for (index, arg) in args.iter().enumerate() {
304        if matches!(arg.as_ref(), Expr::MatrixSelector(_)) {
305            if found.is_some() {
306                return None;
307            }
308            found = Some(index);
309        }
310    }
311    found
312}
313
314#[cfg(test)]
315mod tests {
316    use promql_parser::parser::parse;
317
318    use super::*;
319
320    fn rewrite_with(query: &str, left_tags: &[&str], right_tags: &[&str]) -> Option<Expr> {
321        rewrite_labels_with(query, left_tags, &[], right_tags, &[])
322    }
323
324    /// [`rewrite_with`] with the grouping labels of each operand that the planner found to be
325    /// value fields of its input rather than tags (the metric's tags are `["host", "zone"]`).
326    fn rewrite_labels_with(
327        query: &str,
328        left_tags: &[&str],
329        left_field_labels: &[&str],
330        right_tags: &[&str],
331        right_field_labels: &[&str],
332    ) -> Option<Expr> {
333        let Expr::Binary(binary) = parse(query).unwrap() else {
334            panic!("expected binary")
335        };
336        let owned = |tags: &[&str]| tags.iter().map(|tag| tag.to_string()).collect::<Vec<_>>();
337        propagate(
338            &binary,
339            &owned(left_tags),
340            &owned(right_tags),
341            &owned(left_field_labels),
342            &owned(right_field_labels),
343        )
344        .map(Expr::Binary)
345    }
346
347    fn rewrite(query: &str) -> Option<Expr> {
348        rewrite_with(query, &["host", "zone"], &["host", "zone"])
349    }
350
351    #[track_caller]
352    fn assert_rewrite(query: &str, expected: &str) {
353        assert_eq!(rewrite(query).unwrap(), parse(expected).unwrap(), "{query}");
354        // Re-running the rewrite must be a no-op, including one-way propagation.
355        assert!(rewrite(expected).is_none(), "{expected}");
356    }
357
358    #[test]
359    fn propagates_only_explicit_matching_labels() {
360        assert_rewrite(
361            r#"a / on(host) b{host="x",zone="y"}"#,
362            r#"a{host="x"} / on(host) b{host="x",zone="y"}"#,
363        );
364    }
365
366    #[test]
367    fn propagates_labels_not_named_in_ignoring() {
368        assert_rewrite(
369            r#"a{zone="y"} / ignoring(zone) b{host="x"}"#,
370            r#"a{zone="y",host="x"} / ignoring(zone) b{host="x"}"#,
371        );
372    }
373
374    #[test]
375    fn propagates_default_matching_without_metric_name() {
376        assert_rewrite(
377            r#"a / {host="x",__name__="b"}"#,
378            r#"a{host="x"} / {host="x",__name__="b"}"#,
379        );
380        assert!(rewrite(r#"a / {__name__="b"}"#).is_none());
381    }
382
383    #[test]
384    fn propagates_only_names_that_are_tags_on_both_sides() {
385        // `value` is a field column of both metrics, even when named in `on(...)`.
386        for query in [r#"a / b{value="2"}"#, r#"a / on(host,value) b{value="2"}"#] {
387            assert!(rewrite(query).is_none(), "{query}");
388        }
389        assert_rewrite(
390            r#"a / b{host="x",value="2"}"#,
391            r#"a{host="x"} / b{host="x",value="2"}"#,
392        );
393        // `zone` is a tag of the right metric only.
394        assert!(rewrite_with(r#"a / b{zone="y"}"#, &["host"], &["host", "zone"]).is_none());
395    }
396
397    #[test]
398    fn propagates_matchers_the_join_enforces_anyway() {
399        for (query, expected) in [
400            (
401                r#"a / b{host=~"x.*"}"#,
402                r#"a{host=~"x.*"} / b{host=~"x.*"}"#,
403            ),
404            (r#"a / b{host!="x"}"#, r#"a{host!="x"} / b{host!="x"}"#),
405            (
406                r#"a / b{host!~"x.*"}"#,
407                r#"a{host!~"x.*"} / b{host!~"x.*"}"#,
408            ),
409            (r#"a / b{host=""}"#, r#"a{host=""} / b{host=""}"#),
410        ] {
411            assert_rewrite(query, expected);
412        }
413        assert!(rewrite(r#"a / b{host=~".*"}"#).is_none());
414    }
415
416    #[test]
417    fn propagates_through_partitioning_aggregations() {
418        assert_rewrite(
419            r#"sum by(host) (a) / on(host) max by(host) (b{host="x"})"#,
420            r#"sum by(host) (a{host="x"}) / on(host) max by(host) (b{host="x"})"#,
421        );
422        assert_rewrite(
423            r#"avg without(zone) (rate(a[5m])) / b{host="x"}"#,
424            r#"avg without(zone) (rate(a{host="x"}[5m])) / b{host="x"}"#,
425        );
426    }
427
428    #[test]
429    fn does_not_propagate_grouping_labels_that_are_value_fields() {
430        // `status` is a value field of both metrics, though `count by(status)` reports it as a
431        // grouping label of the aggregate; its value varies between the samples of one series.
432        assert!(
433            rewrite_labels_with(
434                r#"count by(status) (a) / on(status) count by(status) (b{status="ready"})"#,
435                &["status"],
436                &["status"],
437                &["status"],
438                &["status"],
439            )
440            .is_none()
441        );
442        // `host` is a tag, `status` is a value field: only the tag may propagate.
443        assert!(
444            rewrite_labels_with(
445                r#"sum by(host, status) (a) / on(status) sum by(host, status) (b{status="ready"})"#,
446                &["host", "status"],
447                &["status"],
448                &["host", "status"],
449                &["status"],
450            )
451            .is_none()
452        );
453    }
454
455    #[test]
456    fn propagates_grouping_labels_of_aggregated_operands() {
457        assert_rewrite(
458            r#"count by(host) (a) / on(host) count by(host) (b{host="x"})"#,
459            r#"count by(host) (a{host="x"}) / on(host) count by(host) (b{host="x"})"#,
460        );
461        assert_rewrite(
462            r#"sum by(host) (rate(a[5m])) / on(host) sum by(host) (b{host="x"})"#,
463            r#"sum by(host) (rate(a{host="x"}[5m])) / on(host) sum by(host) (b{host="x"})"#,
464        );
465    }
466
467    #[test]
468    fn keeps_matchers_outside_the_grouping_labels_on_their_own_operand() {
469        assert_rewrite(
470            r#"sum by(host) (a) / on(host) sum by(host) (b{host="x",status="ready"})"#,
471            r#"sum by(host) (a{host="x"}) / on(host) sum by(host) (b{host="x",status="ready"})"#,
472        );
473        // `without(host)` leaves `host` out of the grouping labels, so nothing may cross it.
474        assert!(rewrite(r#"avg without(host) (a) / on(host) b{host="x"}"#).is_none());
475        assert_rewrite(
476            r#"avg without(zone) (a) / on(host) b{host="x"}"#,
477            r#"avg without(zone) (a{host="x"}) / on(host) b{host="x"}"#,
478        );
479    }
480
481    #[test]
482    fn leaves_selecting_aggregations_alone() {
483        for query in [
484            r#"topk(3, a) / on(host) b{host="x"}"#,
485            r#"bottomk(3, a) / on(host) b{host="x"}"#,
486            r#"count_values("v", a) / on(host) b{host="x"}"#,
487        ] {
488            assert!(rewrite(query).is_none(), "{query}");
489        }
490    }
491
492    #[test]
493    fn finds_the_matrix_argument_of_multi_argument_rollups() {
494        assert_rewrite(
495            r#"quantile_over_time(0.9, a[5m]) / on(host) b{host="x"}"#,
496            r#"quantile_over_time(0.9, a{host="x"}[5m]) / on(host) b{host="x"}"#,
497        );
498        assert_rewrite(
499            r#"predict_linear(a[5m], 60) / on(host) b{host="x"}"#,
500            r#"predict_linear(a{host="x"}[5m], 60) / on(host) b{host="x"}"#,
501        );
502        assert_rewrite(
503            r#"double_exponential_smoothing(a[5m], 0.5, 0.5) / on(host) b{host="x"}"#,
504            r#"double_exponential_smoothing(a{host="x"}[5m], 0.5, 0.5) / on(host) b{host="x"}"#,
505        );
506    }
507
508    #[test]
509    fn preserves_windows_offsets_and_conflicting_matchers() {
510        assert_eq!(
511            rewrite(
512                r#"rate(a{host="x"}[5m] offset 1h) / on(host) count_over_time(b{host="y"}[1m])"#
513            )
514            .unwrap(),
515            parse(
516                r#"rate(a{host="x",host="y"}[5m] offset 1h) / on(host) count_over_time(b{host="y",host="x"}[1m])"#
517            )
518            .unwrap()
519        );
520    }
521
522    #[test]
523    fn propagates_through_scalar_arithmetic_and_ranking() {
524        assert_rewrite(
525            r#"topk(1, a{host="x"}) / on(host) b"#,
526            r#"topk(1, a{host="x"}) / on(host) b{host="x"}"#,
527        );
528        assert_rewrite(
529            r#"a / on(host) bottomk(1, b{host="x"})"#,
530            r#"a{host="x"} / on(host) bottomk(1, b{host="x"})"#,
531        );
532        assert_rewrite(
533            r#"(8 * rate(a{host="x"}[5m])) / on(host) topk by(host)(1, b)"#,
534            r#"(8 * rate(a{host="x"}[5m])) / on(host) topk by(host)(1, b{host="x"})"#,
535        );
536        assert_rewrite(
537            r#"bottomk without(zone)(2, a / 8) / on(host) b{host="x"}"#,
538            r#"bottomk without(zone)(2, a{host="x"} / 8) / on(host) b{host="x"}"#,
539        );
540    }
541
542    #[test]
543    fn propagates_grouped_matching_only_with_a_unique_one_side() {
544        let cases = [
545            (
546                r#"(8 * rate(a{host="x"}[5m])) / on(host) group_left topk by(host)(1, max by(host)(b))"#,
547                r#"(8 * rate(a{host="x"}[5m])) / on(host) group_left topk by(host)(1, max by(host)(b{host="x"}))"#,
548                vec!["host", "zone"],
549                vec!["host"],
550            ),
551            (
552                r#"sum by(host)(a) / on(host) group_right b{host!="x"}"#,
553                r#"sum by(host)(a{host!="x"}) / on(host) group_right b{host!="x"}"#,
554                vec!["host"],
555                vec!["host", "zone"],
556            ),
557            (
558                r#"a{host=""} / on(host) group_left max by(host)(b)"#,
559                r#"a{host=""} / on(host) group_left max by(host)(b{host=""})"#,
560                vec!["host", "zone"],
561                vec!["host"],
562            ),
563        ];
564        for (query, expected, left_tags, right_tags) in cases {
565            assert_eq!(
566                rewrite_with(query, &left_tags, &right_tags).unwrap(),
567                parse(expected).unwrap(),
568                "{query}"
569            );
570            assert!(rewrite_with(expected, &left_tags, &right_tags).is_none());
571        }
572    }
573
574    #[test]
575    fn preserves_ranking_candidates_and_unproven_cardinality() {
576        for query in [
577            r#"a{host="x"} / on(host) group_left max by(host,zone)(b)"#,
578            r#"a{host="x"} / on(host) group_left topk by(host)(1, b)"#,
579            r#"a{host="x"} / on(host) group_left topk(1, max by(host)(b))"#,
580            r#"topk by(zone)(1, a) / on(host) b{host="x"}"#,
581            r#"topk by(host)(1, topk(1, a)) / on(host) b{host="x"}"#,
582            r#"topk by(host)(1, sum by(zone)(a)) / on(host) b{host="x"}"#,
583            r#"(a + vector(8)) / on(host) b{host="x"}"#,
584        ] {
585            assert!(rewrite(query).is_none(), "{query}");
586        }
587        // Even with a unique one-side aggregate, global topk must see every host.
588        assert!(
589            rewrite_with(
590                r#"a{host="x"} / on(host) group_left topk(1, max by(host)(b))"#,
591                &["host", "zone"],
592                &["host"]
593            )
594            .is_none()
595        );
596    }
597
598    #[test]
599    fn leaves_unproven_semantics_unchanged() {
600        for query in [
601            r#"a or on(host) b{host="x"}"#,
602            r#"a / on(host) group_left b{host="x"}"#,
603            r#"sum by(host,zone)(a) / on(host) group_right b{host="x"}"#,
604            r#"label_replace(a,"host","x","zone",".*") / on(host) b{host="x"}"#,
605            r#"a > on(host) b{host="x"}"#,
606            r#"a / on(host) absent_over_time(b{host="x"}[5m])"#,
607            r#"a / on(host) (b{host="x"} + b)"#,
608        ] {
609            assert!(rewrite(query).is_none(), "{query}");
610        }
611    }
612}