ulysses-you commented on code in PR #58884:
URL: https://github.com/apache/spark/pull/58884#discussion_r4056826681
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -6455,14 +6458,413 @@ class KeyGroupedPartitioningSuite
assert(limitModes(reportedPlan) == Seq(Final, Partial),
s"expected both limit nodes, got
${limitModes(reportedPlan)}:\n$reportedPlan")
assert(collectFirst(reportedPlan) {
- case w: WindowGroupLimitExec if w.mode == Partial => unwrap(w.child)
+ case w: WindowGroupLimitExec if w.mode == Partial =>
unwrapWrappers(w.child)
}.exists(_.isInstanceOf[BatchScanExec]),
s"expected the partial node to read the scan, with no sort of its
own:\n$reportedPlan")
- assert(unwrap(finalChild(reportedPlan)).isInstanceOf[SortExec],
+ assert(unwrapWrappers(finalChild(reportedPlan)).isInstanceOf[SortExec],
s"expected the sort above the grouping to be the one
left:\n$reportedPlan")
}
}
+ test("SPARK-59564: combine adjacent aggregates across a
GroupPartitionsExec") {
+ // (1, 'aa') is stored in two splits, so the table's reported
KeyedPartitioning is not grouped
+ // and EnsureRequirements coalesces the two splits with a
GroupPartitionsExec to satisfy the
+ // final aggregate's clustered distribution. The partial and final
aggregates are therefore not
+ // adjacent, and the rule has to look through the grouping to reach the
pair.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(1, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', 30.0, cast('2020-01-01' as timestamp))")
+
+ // Grouping on the partition keys themselves keeps the partial aggregate
from projecting any of
+ // them away, which is what lets the grouping be re-parented onto the scan
it was reading.
+ val query = s"SELECT id, name, count(*) FROM testcat.ns.$items GROUP BY
id, name"
+ val expected = Seq(Row(1L, "aa", 2L), Row(2L, "bb", 1L))
+
+ def aggregates(plan: SparkPlan): Seq[BaseAggregateExec] =
+ collect(plan) { case agg: BaseAggregateExec => agg }
+
+ // The same pair planned as object-hash aggregates, whose answer is
order-insensitive so the two
+ // plans can be compared.
+ val objectHashQuery =
+ s"SELECT id, name, sort_array(collect_set(price)) FROM testcat.ns.$items
GROUP BY id, name"
+
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ val objectHashExpected = withSQLConf(
+ SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false") {
+ val plan = sql(query).queryExecution.executedPlan
+ val aggs = aggregates(plan)
+ assert(aggs.size == 2, s"expected the pair to be planned
separately:\n$plan")
+ assert(collectAllGroupPartitions(plan).nonEmpty,
+ s"the grouping is what makes the pair non-adjacent:\n$plan")
+ sql(objectHashQuery).collect()
+ }
+
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = aggregates(plan)
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ // The grouping stays, and reads the scan the partial aggregate was
reading: the combine
+ // only drops the partial aggregate, which was the grouping's child.
+ val grouping = collectAllGroupPartitions(plan)
+ assert(grouping.size == 1, s"expected the grouping to stay, got
${grouping.size}:\n$plan")
+ assert(unwrapWrappers(grouping.head.child).isInstanceOf[BatchScanExec],
+ s"expected the grouping to read the scan:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+
+ val objectHash = sql(objectHashQuery)
+ checkAnswer(objectHash, objectHashExpected)
+ val objectHashPlan = objectHash.queryExecution.executedPlan
+ val objectHashAggs = aggregates(objectHashPlan)
+ assert(objectHashAggs.size == 1 &&
+ objectHashAggs.head.isInstanceOf[ObjectHashAggregateExec] &&
+ objectHashAggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected one combined object hash aggregate in complete
mode:\n$objectHashPlan")
+
assert(unwrapWrappers(collectAllGroupPartitions(objectHashPlan).head.child)
+ .isInstanceOf[BatchScanExec],
+ s"expected the grouping to read the scan:\n$objectHashPlan")
+ }
+
+ // AQE runs the rule from its stage-preparation rules, on a plan whose
grouping was inserted
+ // by the stage-preparation `EnsureRequirements` rather than by the
initial planning pass, and
+ // re-runs `EnsureRequirements` over the folded plan.
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = aggregates(plan)
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ val grouping = collectAllGroupPartitions(plan)
+ assert(grouping.size == 1, s"expected the grouping to stay, got
${grouping.size}:\n$plan")
+ assert(unwrapWrappers(grouping.head.child).isInstanceOf[BatchScanExec],
+ s"expected the grouping to read the scan:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59564: combine across a grouping the partial aggregate
narrowed") {
+ // (1, 'aa') and (2, 'aa') are two splits sharing `name`. Grouping by
`name` alone makes the
+ // partial aggregate project `id` away, collapsing the scan's KP([id,
name]) to
+ // KP([name], isCollapsed = true), and the grouping coalesces the two
splits on that narrowed
+ // key. That key sits at position 0 of the narrowing but at position 1 of
the scan's, so
+ // re-parenting the grouping has to translate the positions it projects:
keeping them would have
+ // it group by `id`, where the two 'aa' rows land in different partitions
and the final
+ // aggregate returns a row per partition, a wrong answer rather than a
slower plan.
+ //
+ // `max(id)` is what makes the shape: it keeps `id` in the scan's output,
so the scan reports
+ // the full KP([id, name]) and the narrowing happens at the partial
aggregate, the node the fold
+ // takes away. The narrowed key being collapsed is what
`allowKeysSubsetOfPartitionKeys` is
+ // needed for, the same way the SPARK-46367 test does.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(3, 'cc', 30.0, cast('2020-01-01' as timestamp))")
+
+ val query = s"SELECT name, max(id), count(*) FROM testcat.ns.$items GROUP
BY name"
+ val expected = Seq(Row("aa", 2L, 2L), Row("cc", 3L, 1L))
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false")
{
+ val df = sql(query)
+ checkAnswer(df, expected)
+
assert(collectAllGroupPartitions(df.queryExecution.executedPlan).nonEmpty,
+ s"expected the grouping the pair is separated
by:\n${df.queryExecution.executedPlan}")
+ }
+
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ // The grouping stays, reading the scan with `name` translated to the
position it holds
+ // there, which is what keeps the coalescing the same one it did
before.
+ val grouping = collectAllGroupPartitions(plan)
+ assert(grouping.size == 1, s"expected the grouping to stay, got
${grouping.size}:\n$plan")
+ assert(grouping.head.joinKeyPositions == Some(Seq(1)),
+ s"expected `name` translated to position 1 of the scan:\n$plan")
+ assert(unwrapWrappers(grouping.head.child).isInstanceOf[BatchScanExec],
+ s"expected the grouping to read the scan:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59564: combine where the scan reports the narrowed partitioning
itself") {
+ // Nothing references `id`, so the pruning takes it out of the scan's
output and the scan
+ // reports the keyed partitioning projected down to `name` on its own: the
scan narrows, rather
+ // than the partial aggregate under it. The grouping was therefore planned
against that same
+ // space, and re-parenting it changes no position, unlike the case above.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(3, 'cc', 30.0, cast('2020-01-01' as timestamp))")
+
+ val query = s"SELECT name, count(*) FROM testcat.ns.$items GROUP BY name"
+ val expected = Seq(Row("aa", 2L), Row("cc", 1L))
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false")
{
+ val plan = sql(query).queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 2, s"expected the pair to be planned
separately:\n$plan")
+ // The scan reports `name` alone, and its keyed partitioning with it.
+ val scan = collect(plan) { case s: BatchScanExec => s }.head
+ assert(scan.output.map(_.name) == Seq("name"),
+ s"expected `id` to be pruned out of the scan:\n$plan")
+ scan.outputPartitioning match {
+ case kp: KeyedPartitioning =>
+ assert(kp.expressions == scan.output,
+ s"expected the scan to report the narrowed keyed
partitioning:\n$plan")
+ case other => fail(s"expected a keyed partitioning, got
$other:\n$plan")
+ }
+ assert(collectAllGroupPartitions(plan).nonEmpty,
+ s"expected the grouping the pair is separated by:\n$plan")
+ }
+
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ val grouping = collectAllGroupPartitions(plan)
+ assert(grouping.size == 1, s"expected the grouping to stay, got
${grouping.size}:\n$plan")
+ // Nothing to translate: the positions index the space the scan
reports already.
+ assert(grouping.head.joinKeyPositions.isEmpty,
+ s"expected the grouping to keep the positions it was planned
with:\n$plan")
+ assert(unwrapWrappers(grouping.head.child).isInstanceOf[BatchScanExec],
+ s"expected the grouping to read the scan:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59564: the fold projects what the planner projects without a
partial aggregate") {
+ // With `bypassPartialAggregation`, the planner runs one `Complete`
aggregate and has
+ // `EnsureRequirements` satisfy its distribution, which for a grouping on
part of the partition
+ // keys gives a `GroupPartitionsExec` over the scan. That plan is what the
fold has to reproduce
+ // when the partial aggregation is planned and then taken away, so the
positions come from the
+ // planner rather than from this rule's own arithmetic.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(3, 'cc', 30.0, cast('2020-01-01' as timestamp))")
+
+ val query = s"SELECT name, max(id), count(*) FROM testcat.ns.$items GROUP
BY name"
+ val expected = Seq(Row("aa", 2L, 2L), Row("cc", 3L, 1L))
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ def projections(plan: SparkPlan): Seq[Option[Seq[Int]]] =
+ collectAllGroupPartitions(plan).map(_.joinKeyPositions)
+
+ // Plan and collect inside each block: `executedPlan` is lazy, so asking
for it outside would
+ // plan under the default config and the toggle would do nothing.
+ val planned = withSQLConf(SQLConf.BYPASS_PARTIAL_AGGREGATION.key ->
"true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 1 && aggs.head.aggregateExpressions.forall(_.mode
== Complete),
+ s"expected the planner to run one complete aggregate:\n$plan")
+ assert(projections(plan).nonEmpty,
+ s"expected the planner to satisfy the distribution with a
grouping:\n$plan")
+ projections(plan)
+ }
+
+ val folded =
withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ // The combined answer is asserted by the narrowed-grouping test,
which runs the same query.
+ // This one is about the plan the fold leaves, so that a wrong
projection fails here rather
+ // than being caught by the answer.
+ val plan = sql(query).queryExecution.executedPlan
+ assert(collect(plan) { case agg: BaseAggregateExec => agg }.size == 1,
+ s"expected one combined aggregate:\n$plan")
+ projections(plan)
+ }
+
+ assert(folded == planned,
+ s"expected the fold to project what the planner does without a
partial: $planned")
+ }
+ }
+
+ test("SPARK-59564: combine adjacent sort aggregates across a
GroupPartitionsExec") {
+ // `max` over a string column cannot be planned as a hash aggregate, so
the pair is a pair of
+ // SortAggregateExecs and each of them needs its input ordered by the
grouping keys. The sort
+ // below the partial aggregate only gave the partial aggregate that
ordering, and the sort the
+ // grouping forces above it orders the rows the combined aggregate reads
by the same keys, so
+ // the sort below goes with the partial aggregate.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(1, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', 30.0, cast('2020-01-01' as timestamp))")
+
+ val query =
+ s"SELECT id, name, max(cast(price as string)) FROM testcat.ns.$items
GROUP BY id, name"
+ val expected = Seq(Row(1L, "aa", "20.0"), Row(2L, "bb", "30.0"))
+
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false")
{
+ val plan = sql(query).queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 2 &&
aggs.forall(_.isInstanceOf[SortAggregateExec]),
+ s"expected a pair of sort aggregates:\n$plan")
+ assert(collectAllGroupPartitions(plan).nonEmpty,
+ s"the grouping is what makes the pair non-adjacent:\n$plan")
+ // Two sorts, one for the partial aggregate and one the final one
reads.
+ assert(collect(plan) { case sort: SortExec => sort }.size == 2,
+ s"expected two sorts feeding the pair:\n$plan")
+ }
+
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ val sorts = collect(plan) { case sort: SortExec => sort }
+ assert(sorts.size == 1 && !sorts.head.global,
+ s"expected the sort above the grouping to be the one left:\n$plan")
+
assert(unwrapWrappers(sorts.head.child).isInstanceOf[GroupPartitionsExec],
+ s"expected the sort to read the grouping:\n$plan")
+ assert(collectAllGroupPartitions(plan).size == 1,
+ s"expected the grouping to stay:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59564: combine adjacent sort aggregates across a narrowed
grouping") {
+ // `max(cast(id as string))` cannot be planned as a hash aggregate, and
keeps `id` in the scan's
+ // output, so the grouping is handed the full keyed partitioning and the
positions it projects
+ // have to be translated onto `name`. The sort feeding the partial
aggregate goes with it, as in
+ // the sort test above.
+ val partitions = Array(identity("id"), identity("name"))
+ createTable(items, itemsColumns, partitions)
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 10.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'aa', 20.0, cast('2020-01-01' as timestamp)), " +
+ "(3, 'cc', 30.0, cast('2020-01-01' as timestamp))")
+
+ val query = s"SELECT name, max(cast(id as string)) FROM testcat.ns.$items
GROUP BY name"
+ val expected = Seq(Row("aa", "2"), Row("cc", "3"))
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->
"true") {
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "false")
{
+ val plan = sql(query).queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 2 &&
aggs.forall(_.isInstanceOf[SortAggregateExec]),
+ s"expected a pair of sort aggregates:\n$plan")
+ assert(collectAllGroupPartitions(plan).nonEmpty,
+ s"expected the grouping the pair is separated by:\n$plan")
+ // Two sorts, one for the partial aggregate and one the final one
reads.
+ assert(collect(plan) { case sort: SortExec => sort }.size == 2,
+ s"expected two sorts feeding the pair:\n$plan")
+ }
+
+ withSQLConf(SQLConf.COMBINE_ADJACENT_AGGREGATION_ENABLED.key -> "true") {
+ val df = sql(query)
+ checkAnswer(df, expected)
+ val plan = df.queryExecution.executedPlan
+ val aggs = collect(plan) { case agg: BaseAggregateExec => agg }
+ assert(aggs.size == 1, s"expected one combined aggregate, got
${aggs.size}:\n$plan")
+ assert(aggs.head.aggregateExpressions.forall(_.mode == Complete),
+ s"expected the combined aggregate to be complete:\n$plan")
+ val grouping = collectAllGroupPartitions(plan)
+ assert(grouping.size == 1, s"expected the grouping to stay, got
${grouping.size}:\n$plan")
+ assert(grouping.head.joinKeyPositions == Some(Seq(1)),
+ s"expected `name` translated to position 1 of the scan:\n$plan")
+ // The one sort left is the one the grouping forced above itself.
+ val sorts = collect(plan) { case sort: SortExec => sort }
+ assert(sorts.size == 1 && !sorts.head.global,
+ s"expected one local sort:\n$plan")
+
assert(unwrapWrappers(sorts.head.child).isInstanceOf[GroupPartitionsExec],
+ s"expected the sort to read the grouping:\n$plan")
+ assert(ValidateRequirements.validate(plan), s"the combined plan has to
hold up:\n$plan")
+ }
+ }
+ }
+
+ test("SPARK-59564: keep the pair where the source already orders the
aggregate's input") {
Review Comment:
Added the arm in aafc7cb9306, on the sort-aggregate test: with
`partitionKeyOrdering.enabled` the
scan's derived ordering satisfies the partial aggregate, so it holds no sort
of its own and the bail
keeps the pair. It asserts what the declared-ordering test does: 2
aggregates, one local sort reading
the grouping, the partial aggregate on the scan. Dropping the bail now fails
that arm as well, so the
derived shape no longer goes unheld under that mutation.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -551,6 +551,43 @@ case class GroupPartitionsExec(
}
}
+ /**
+ * This node reading `newChild`, with the key positions it projects moved to
where the expressions
+ * they name sit there, or `None` when it may not be re-parented onto it. It
is named for what it
+ * answers rather than for the node it returns, which is the node it is
called on.
+ *
+ * `EnsureRequirements` computed `joinKeyPositions` against the child this
node was planned for,
+ * whose partitioning is that child's projected down to the positions the
operator above keeps, so
+ * the positions name a key space `newChild` need not share. The projected
expressions are the
+ * planned child's own (`KeyedPartitioning.project` builds them that way),
which makes moving them
Review Comment:
Reworked in aafc7cb9306. The comment now credits
`AliasAwareOutputExpression.projectKeyedPartitionings` for keeping the
projected expressions the
child's own, and names the condition it rests on: a partial aggregate has no
aliases, its
`resultExpressions` being `groupingAttributes ++ bufferAttributes`, so each
position's alternative
stays the child's expression rather than the one `project` built and the
projection replaced.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]