fix(isthmus)!: keep the declared column order of an aggregate over grouping sets - #1161
Conversation
51d2276 to
a351938
Compare
a351938 to
6bb070e
Compare
nielspardon
left a comment
There was a problem hiding this comment.
The unconditional dedup in updateGrouping needs a decision before the rest is worth reading: a single grouping set that names a grouping expression twice now converts to fewer Calcite columns than the relation declares, where main converted the same plan correctly. Dropping the isOrdered trigger in isValidCalciteGrouping looks like the cleanest way out, since the emit mapping added here already handles a within-set reorder and the dedup map then has no job — but that reaches past the bug, so it is your call.
…ouping sets Substrait takes the grouping columns of an aggregate to be the distinct grouping expressions in the order they first appear across its grouping sets, while Calcite takes them from a bit set and emits them ordered by field index. Neither direction accounted for that, so a plan whose sets first mention field 1 and then field 0 changed meaning on the way through: a reference to the aggregate's first column reached the column Calcite had put there instead, with its type quietly changing along with it. Both directions now carry the difference in the emit mapping. On the way in, the mapping a relation carries is translated into the order the converted aggregate emits, and a relation that emits directly gets the mapping that puts its columns back in the declared order. On the way out, the mapping presents the aggregate's output in Calcite's order, so a parent converted from the same Calcite plan finds its columns where it left them. Neither adds a relation the plan did not have, so a plan that already agrees with Calcite round-trips unchanged. The pre-aggregate projection now reuses one column for a field grouped on by several sets. Two copies of it would each be missing from a grouping set, and Calcite would make both nullable. Closes substrait-io#1159 BREAKING CHANGE: an aggregate over several grouping sets is now emitted with an emit mapping that presents its output in the order the plan it came from had, and a plan carrying such a mapping is read that way. Consumers that assumed the grouping columns were ordered by field index will see them in the order the grouping sets declare.
The translation from declared to emitted grouping order gave up when a grouping expression was not a field reference into the aggregate's input, on the grounds that anything else is projected below the aggregate in declared order. That holds for what transformToValidCalciteAggregate rewrites, but not for an outer reference: it passes the validator, is left alone, and Calcite projects it itself, after the input's own fields. A plan that groups on one before a field of its input then read the wrong column. Order the columns by where they sit in the aggregate's input instead -- a field reference where its field is, anything else after them all, in declared order, which a stable sort keeps.
…olumn The mapping that keeps the index replaced it with aggregateCalls.size() - 1, an index into the aggregate calls rather than into the aggregate's output, so the column that came back was a copy of a grouping column. The index the relation declares for that column counted every mention of a grouping expression, so a field grouped on by several sets shifted it past the end: an aggregate with such a field and a mapping that keeps the index threw ArrayIndexOutOfBoundsException. Both counts are now over the distinct grouping columns, which is what the record type holds.
Three of the four grouping columns in this fixture are BIGINT, so comparing types cannot show a permutation among them. The mapping is what carries the declared order, so it is asserted directly.
The projection this conversion puts under an aggregate shared one column between every mention of a grouping expression, which is right only where the aggregate's own record type does the same: it dedups the grouping expressions across several grouping sets and not within a lone one, where each mention is a column of its own. A single grouping set naming a field twice declared two columns and converted to one, and under an emit mapping the conversion threw an ArrayIndexOutOfBoundsException. So the transformer shares a column only where the record type does, and the order the fields are grouped in stops sending an aggregate through it: Calcite emits its grouping columns by field index whatever order they were declared in, and the emit mapping added here already carries that difference, so rewriting the input buys nothing. What a rewrite is still needed for is a grouping set naming an expression twice, which Calcite's bit set cannot hold -- including the ascending case that reached Calcite a column short before.
…library The spec's direct output order for an aggregate is the declaration order of the relation's shared grouping-expression list, which each grouping set's expression references index into (spec v0.101.0). The POJO models a per-set expression list and cannot hold that order, so substrait-java reconstructs the shared list as the distinct expressions in the order they first appear across the sets -- the rule these comments describe, which is this library's and not the spec's.
227ae0d to
5f80a57
Compare
nielspardon
left a comment
There was a problem hiding this comment.
Add the BREAKING CHANGE: footer — the ! cuts the release, but the footer text is what fills the ⚠ section of the notes, and without it they ship empty for a change that rewrites the emit mapping in both directions. The rest is three comments the fix outran and one test gap; the type-blind grouping-column count is #1210's, not this PR's.
… index with itself The mapping that keeps the grouping-set index took its index before the GROUP_ID call was appended and the call's index after, so the two were the same integer and the loop between them replaced every match with itself. What is left is the condition that decides whether to append the call at all.
…duces Two comments still described what the branch removed. The grouping-field count says both remap branches read the distinct count, which is what they do now, and applyOutputNames gives the reason names are dropped as the type of the grouping-set index rather than the column order the emit mapping now settles.
Every fixture swapped two columns, and a transposition is its own
inverse, so replacing either permutation with the one that undoes it
left the suite green. The two added here use grouping sets {0, 3} and
{1, 2}, whose mapping is a three-cycle, one per direction.
The output-names test named for a column order the conversion no longer
disagrees on now covers the case that order made possible: with the
grouping-set index mapped away, the names land on the columns the
relation declares. The shape that still drops them is the index itself,
which the relation types i32 and the GROUP_ID call i64.
nielspardon
left a comment
There was a problem hiding this comment.
All three carried findings are fixed and the BREAKING CHANGE: footer is in place; one import nit below. The type-blind grouping-column count at SubstraitRelNodeConverter:368 stays with #1210 rather than growing this PR.
The fixtures added here named RelBuilder, SqlKind, Project and Aggregate inline where the file imports its types, and neither of the first two collides with anything it already imports.
nielspardon
left a comment
There was a problem hiding this comment.
Drop the two (spec v0.101.0) markers: the rule they annotate has read identically since v0.57.0, so the version names what substrait-java pins rather than when the ordering changed.
The shared grouping-expression list and the declaration-order rule arrived in spec substrait-io#706, released in v0.57.0, and the Direct Output Order sentence is byte-identical from there through v0.101.0. Naming the pinned version reads as "changed in v0.101.0" and goes stale on every bump, while the rule it describes does not move.
substrait-java takes the grouping columns of an aggregate to be the distinct grouping expressions in the order they first appear across its grouping sets, while Calcite takes them from a bit set and emits them ordered by field index. (The spec orders them by the relation's shared grouping-expression list, which each set's expression references index into; the POJO models a per-set expression list and cannot hold that order, so first-appearance is this library's reconstruction — spec v0.101.0.) Neither direction accounted for that, so a plan whose sets first mention field 1 and then field 0 changed meaning on the way through: a reference to the aggregate's first column reached the column Calcite had put there instead, and its type changed with it.
Both directions now carry the difference in the emit mapping rather than in the shape of the plan. On the way in, the mapping a relation carries is translated into the order the converted aggregate emits, and a relation that emits directly gets the mapping that puts its columns back in the declared order. On the way out, the mapping presents the aggregate's output in Calcite's order, so a parent converted from the same Calcite plan finds its columns where it left them. Neither direction adds a relation the plan did not have, so a plan that already agrees with Calcite round-trips unchanged — which is what
Substrait2SqlTest.simpleTestGroupingSetschecks, and it is also how I found that fixing only one direction is not enough.The pre-aggregate projection reuses one column for a field grouped on by several sets — two copies of it would each be missing from a grouping set, and Calcite would make both nullable — and only there, because that is where
Aggregate.deriveRecordTypededups: a lone grouping set gives every mention a column of its own.That leaves the order itself no reason to send an aggregate through the projection, since the emit mapping now carries it, and it no longer does. What a rewrite is still needed for is a grouping set naming an expression twice, which Calcite's bit set cannot hold. Measured against
origin/main(934a60e):foo(a i64, b i64, c string)main(2, 0, 2)(2, 0, 2), emit(0, 1, 2)(0, 2, 2)a + 42twice(2, 0)and(2)The
(0, 2, 2)row is wrong onmainas well: its fields are ascending, so the aggregate went to Calcite directly and the bit set ate a column. The visible change from the order no longer triggering a rewrite is where the reordering sits —LogicalProject(c=[$1], a=[$0])overLogicalAggregate(group=[{0, 2}])over the scan, where the projection used to sit under the aggregate.The per-mention count that keeps a repeated grouping expression a column of its own is a convention of this library rather than something the plan carries.
RelProtoConverter.visit(Aggregate)dedupsgrouping_expressionsunconditionally, whereAggregate.deriveRecordTypededups only across several sets, so the one-set(2, 0, 2)above writes two grouping expressions with references[0, 1, 0]: three columns here, two to a consumer reading the proto as the spec's list of grouping expressions in declaration order. A round trip through proto still compares equal, because both directions flatten per-Grouping. That split is #1210.Worth a second look, since the change touches a branch that had no coverage: the GROUP_ID branch of
SubstraitRelVisitor.visit(Aggregate)used a grouping-field count that included a field shared by several sets more than once, which disagrees withAggregate.deriveRecordType. It now uses the distinct count. No query reaches that branch — Calcite foldsGROUP_ID()into a literal wherever it can work out the answer, including over duplicated grouping sets — so the test that covers it builds the Calcite aggregate directly.Two more defects in the grouping-set index column came out of the same math and are fixed here. The mapping that keeps that column replaced its index with
aggregateCalls.size() - 1, which counts aggregate calls rather than output columns, so what came back was a copy of a grouping column. And the index the relation declares for it counted every mention of a grouping expression, so a field grouped on by several sets shifted it past the end: such an aggregate with a mapping that keeps the index threwArrayIndexOutOfBoundsException, onmainas well. Both counts are over the distinct grouping columns now. What the column holds is still Calcite's foldedGROUP_ID()literal, which is #1182.One difference is left alone: the grouping-set index comes back as an i64. The conversion builds Calcite's
GROUP_IDcall as a BIGINT while the spec gives the aggregate an i32 column, and Calcite folds the call to a literal of its own type. That is #1162.Closes #1159
BREAKING CHANGE: an aggregate whose grouping sets do not first mention its fields in ascending order now converts with its grouping columns in the order the relation declares them, in both directions. For the sets
{0, 3}and{1, 2}, converting the relation to Calcite emits fields 0, 3, 1, 2 where it emitted 0, 1, 2, 3, and converting the Calcite aggregate back gives the emit mapping[0, 2, 3, 1]where it gave[0, 1, 2, 3]. An aggregate whose grouping sets already agree with Calcite's field order converts as before.