Skip to content

Joining with a dj.U restriction returns duplicate rows and inflates aggr counts: the DISTINCT is dropped #1660

Open

Description

@dimitri-yatsenko

What happens

dj.U("parent_id") & Child lists each parent_id once: its SQL is SELECT DISTINCT parent_id FROM child. When this expression is used inside another query (a join on either side, extend, aggr, or dj.U(...).aggr), the DISTINCT is dropped. The operand is joined as the plain child table, so it contributes one row per child instead of one row per parent. Joins return repeated rows and aggr counts are too high. Nothing warns.

len() of the same join is correct, because it counts distinct primary-key values. So len(q) and len(q.to_dicts()) disagree. The preview shows four rows above (Total: 2).

Restricting by the expression works: Parent & (dj.U("parent_id") & Child) and Parent - (dj.U("parent_id") & Child) return the right parents.

Reproduction

On master (3c912c4), the results are the same on MySQL 8.0 and PostgreSQL 15:

import datajoint as dj

schema = dj.Schema("u_distinct_repro")

@schema
class Parent(dj.Manual):
    definition = """
    parent_id : int32
    ---
    label : varchar(8)
    """

@schema
class Child(dj.Manual):
    definition = """
    -> Parent
    child_id : int32
    ---
    color : varchar(8)
    """

Parent.insert([{"parent_id": 1, "label": "a"}, {"parent_id": 2, "label": "b"}, {"parent_id": 3, "label": "a"}])
Child.insert([
    {"parent_id": 1, "child_id": 1, "color": "red"},
    {"parent_id": 1, "child_id": 2, "color": "blue"},
    {"parent_id": 2, "child_id": 1, "color": "blue"},
    {"parent_id": 2, "child_id": 2, "color": "blue"},
])

has_children = dj.U("parent_id") & Child         # alone: parents 1, 2
colors = dj.U("parent_id", "color") & Child      # alone: (1, blue), (1, red), (2, blue)
Query Expected Actual
Parent * has_children (1, a), (2, b) (1, a), (1, a), (2, b), (2, b)
has_children * Parent (1, a), (2, b) (1, a), (1, a), (2, b), (2, b)
(has_children & "parent_id < 3") * Parent (1, a), (2, b) (1, a), (1, a), (2, b), (2, b)
Parent * colors (1, blue), (1, red), (2, blue) (2, blue) appears twice
Parent.extend(has_children) parents 1, 2, 3 1, 1, 2, 2, 3
has_children.extend(Parent) (1, a), (2, b) each twice
Parent.aggr(colors, n="count(color)") (colors per parent) {1: 2, 2: 1, 3: 0} {1: 2, 2: 2, 3: 0}
Parent.aggr(has_children, n="count(*)", exclude_nonmatching=True) {1: 1, 2: 1} {1: 2, 2: 2}
has_children.aggr(Parent, n="count(label)") {1: 1, 2: 1} {1: 2, 2: 2}
dj.U("color").aggr(colors, n="count(*)") (parents per color) {blue: 2, red: 1} {blue: 3, red: 1}
dj.U().aggr(dj.U("color") & Child, n="count(*)") (number of colors) 2 4
len(Parent * has_children) 2 2 (correct, but to_dicts() returns 4 rows)
Parent & has_children, Parent - has_children parents 1, 2 / parent 3 correct

The SQL for Parent * has_children is a plain join with no DISTINCT:

SELECT `parent_id`,`label` FROM `parent` JOIN `child` USING (`parent_id`)

Cause

dj.U.__and__ (src/datajoint/expression.py:1507) returns a copy of the table expression with _distinct = True (line 1513). Only make_sql reads that flag (line 162), so DISTINCT appears only when the expression is the outermost query or is already wrapped in a subquery.

QueryExpression.join (line 343) decides whether to wrap each operand in a subquery (lines 400–425). The checks are:

  • the operands' source tables share columns other than the join columns
  • the operand is an aggregation or a union
  • a join column is computed
  • the operand already joins several tables

_distinct is not among them. An unwrapped operand is merged in by its tables, joins and conditions (lines 426–436). The result is a new expression with _distinct set to False, so the DISTINCT is lost. extend (line 490) and aggr (Aggregation.create, line 1341) go through the same join(), with the expression on either side.

When the operand is wrapped for another reason, the DISTINCT survives. For example, Child * (dj.U("parent_id") & Child) is correct, because both operands come from child and share child_id and color.

dj.U.aggr (line 1532) does not call join(). It builds the aggregation directly from the group's tables, joins and conditions (lines 1566–1573), and drops _distinct the same way.

__len__ (line 1149) counts distinct primary-key values (lines 1163–1174), which hides the repeated rows.

DataJoint 0.14 has the same code, so this is not new in 2.x.

Expected

An operand that has _distinct set should be wrapped in a subquery, as an aggregation or a union already is. That means wrapping it in join() for either operand, which covers *, extend, and aggr with the expression as the group or as the grouping side. dj.U.aggr needs the same wrapping for its group. Then SELECT DISTINCT … runs before the join or the GROUP BY. With this wrapping tried locally, every case in the table is correct on both backends, and the existing tests for dj.U, joins, aggr and grouping still pass.

#1657 adds the same kind of check for dj.Top at these places in join() and dj.U.aggr. It does not cover _distinct, and this reproduction fails the same way on that branch. The _distinct check belongs next to the _top one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIndicates an unexpected problem or unintended behavior

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions