Repository navigation
fix: aggr, extend and union keep unmatched rows when the right operand is restricted - #1655
Conversation
…hed rows aggr (exclude_nonmatching=False), extend, and union of operands with secondary attributes use a left join. A restriction on the right operand, including dj.Top, went into the outer WHERE, where it removed the left rows with no match. It is now applied inside a subquery for the right operand. An unrestricted right operand is joined directly, and inner joins are unchanged. Fixes #1650.
MilagrosMarin
left a comment
There was a problem hiding this comment.
Thanks @dimitri-yatsenko, this is a clean, well-scoped fix.
✅ Wrapping the right operand in a subquery only when it's restricted (or carries a dj.Top) puts the restriction where it belongs and leaves unrestricted and inner joins unchanged.
✅ Checking other._top separately is needed: join() never carries _top onto the result, so without that check a Top-only group would be silently dropped. The dj.Top test covers it.
✅ The tests cover the restriction forms well, and the controls (inner aggr, dj.U.aggr, *, unrestricted extend) pin down what must not change.
✅ The docs already state that aggr keeps every row of the grouping table by default (reference/operators), so this brings the code in line with them. Agreed that it needs a release note.
Two small follow-ups, neither blocking:
dj.Topon the left operand. From reading the code,join()keepsself.restrictionbut notself._top, and doesn't wrapselfin a subquery when it has one. So(Parent & dj.Top(1)).aggr(Child, ...)and.extend(...)look like they drop theToptoo. That's older than this PR, but it might be worth widening the "Not changed here" note (or the follow-up issue) from "inner join" to "either operand of any join".- Possible typo (older code): in
join(),need_subquery2checksisinstance(self, Union). Should that beother?
Approving. Let me know if you'd like me to open an issue for the Top follow-up.
|
Thanks @MilagrosMarin. On the two follow-ups:
GitHub Actions has no run for this branch, because no workflows ran in this repository between Oct 31 and Nov 9. So before merging I merged it onto current |
Fixes #1650.
The bug
A.aggr(B & cond, ...)with the defaultexclude_nonmatching=Falseis documented to keep every row ofA. It builds aLEFT JOIN, but the restriction onBlanded in the outerWHERE, after the join. Rows ofAwith no match haveNULLinB's columns, fail the condition, and drop out, so the result behaved likeexclude_nonmatching=True:The cause is in
QueryExpression.join, so every left join had it:aggrwith the defaultexclude_nonmatching=Falseextend:Child.extend(Parent & cond)dropped children whose parent failed the condition, instead of keeping them withNULLs.Unionof operands that both have secondary attributes, which left-joins the second operand:Parent + (ParentInfo & cond)dropped parents.The fix
In
join(), when the join is a left join and the right operand is restricted, the right operand is wrapped in a subquery, so its restriction applies before the join:dj.Topcounts as a restriction, since it is applied with&. It is stored in_toprather than inrestriction, so the condition checks both.WHERE, includingexclude_nonmatching=Trueanddj.U(...).aggr(...).Behavior change
Queries of the forms above now return the left rows they used to drop: zero counts, or
NULLfor other aggregates and forextend/union attributes. This matches the documented behavior. Code that relied on the filtering should passexclude_nonmatching=Trueor use an inner join (*). The v2.3.7 release notes should say this.Tests
tests/integration/test_aggr_left_join.pyruns on both MySQL and PostgreSQL (38 tests):dj.Top, and a restricted group followed by.proj(). Also covers a restriction on the grouping side, and a restriction on the computed count (& "n = 0", which goes throughHAVING, or the subquery on PostgreSQL).extendand union with a restricted operand.extendoperandaggr,dj.U.aggrand*keep the restriction inWHEREaggralready wrapped in a subquery and which also getsNULLformaxWithout the fix, 13 tests fail on each backend, which is every test that exercises the bug, and the 6 controls pass. With it, all 38 pass. The full suite passes locally: 1272 passed, 14 skipped.
Not changed here
An inner join also drops a
dj.Topon either operand:Parent * (Child & dj.Top(1))returns every matching row. That bug predates this PR and is not covered by #1650, so it is left for a separate fix.