Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions src/datajoint/expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,10 @@ def __len__(self):
Number of elements in the result set.
"""
result = self.make_subquery() if self._top else copy.copy(self)
if not result.primary_key:
# An empty primary key (e.g. ``dj.U() & table``) admits at most one row:
# count it by checking whether it exists.
return int(bool(result))
has_left_join = any(is_left for is_left, _ in result._joins)

# Build COUNT query - PostgreSQL requires different syntax for multi-column DISTINCT
Expand Down Expand Up @@ -1355,41 +1359,38 @@ def where_clause(self):
def make_sql(self, fields=None):
adapter = self.connection.adapter
fields = self.heading.as_sql(fields or self.heading.names, adapter=adapter)
assert self._grouping_attributes or not self.restriction
distinct = set(self.heading.names) == set(self.primary_key)
# Without grouping attributes (``dj.U().aggr(...)``), the whole input is one group.
group_by = (
" GROUP BY {}".format(", ".join(adapter.quote_identifier(col) for col in self._grouping_attributes))
if self._grouping_attributes
else ""
)
conditions = ")AND(".join(str(s) for s in self.restriction)

# PostgreSQL doesn't allow column aliases in HAVING clause (SQL standard).
# For PostgreSQL with restrictions, wrap aggregation in subquery and use WHERE.
use_subquery_for_having = adapter.backend == "postgresql" and self.restriction and self._grouping_attributes

if use_subquery_for_having:
if adapter.backend == "postgresql" and self.restriction:
# Generate inner query without HAVING
inner_sql = "SELECT {distinct}{fields} FROM {from_}{where}{group_by}".format(
distinct="DISTINCT " if distinct else "",
fields=fields,
from_=self.from_clause(),
where=self.where_clause(),
group_by=" GROUP BY {}".format(", ".join(adapter.quote_identifier(col) for col in self._grouping_attributes)),
group_by=group_by,
)
# Wrap in subquery with WHERE for the HAVING conditions
subquery_alias = adapter.quote_identifier(f"_aggr{next(self._subquery_alias_count)}")
outer_where = " WHERE (%s)" % ")AND(".join(self.restriction)
return f"SELECT * FROM ({inner_sql}) AS {subquery_alias}{outer_where}{self.sorting_clauses()}"
return f"SELECT * FROM ({inner_sql}) AS {subquery_alias} WHERE ({conditions}){self.sorting_clauses()}"
else:
# MySQL path: use HAVING directly
return "SELECT {distinct}{fields} FROM {from_}{where}{group_by}{sorting}".format(
# MySQL path: use HAVING directly, which MySQL also accepts without GROUP BY
return "SELECT {distinct}{fields} FROM {from_}{where}{group_by}{having}{sorting}".format(
distinct="DISTINCT " if distinct else "",
fields=fields,
from_=self.from_clause(),
where=self.where_clause(),
group_by=(
""
if not self.primary_key
else (
" GROUP BY {}".format(", ".join(adapter.quote_identifier(col) for col in self._grouping_attributes))
+ ("" if not self.restriction else " HAVING (%s)" % ")AND(".join(self.restriction))
)
),
group_by=group_by,
having=f" HAVING ({conditions})" if self.restriction else "",
sorting=self.sorting_clauses(),
)

Expand Down
118 changes: 118 additions & 0 deletions tests/integration/test_empty_primary_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
Queries whose primary key is empty: ``dj.U() & table`` and ``dj.U().aggr(...)``.

An expression with an empty primary key holds at most one row. ``len()`` must
count that row (#1581), and a restriction on a ``dj.U()`` aggregation must
filter it (#1580). Both run on MySQL and PostgreSQL.
"""

import time

import pytest

import datajoint as dj


@pytest.fixture(scope="function")
def schema_by_backend(connection_by_backend, db_creds_by_backend):
"""Create a fresh schema per test, parameterized across backends."""
backend = db_creds_by_backend["backend"]
test_id = str(int(time.time() * 1000))[-8:]
schema_name = f"djtest_emptypk_{backend}_{test_id}"[:64]

if connection_by_backend.is_connected:
try:
connection_by_backend.query(
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
)
except Exception:
pass

schema = dj.Schema(schema_name, connection=connection_by_backend)
yield schema

if connection_by_backend.is_connected:
try:
connection_by_backend.query(
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
)
except Exception:
pass


@pytest.fixture
def File(schema_by_backend):
@schema_by_backend
class File(dj.Manual):
definition = """
file_id : int32
---
kind : varchar(16)
"""

return File


def test_len_of_universal_restriction(File):
"""``dj.U() & table`` has one row when the table is non-empty, none otherwise."""
assert len(dj.U() & File) == 0
File.insert([(1, "data"), (2, "data"), (3, "meta")])
assert len(dj.U() & File) == 1
assert len(dj.U() & (File & "kind = 'meta'")) == 1
assert len(dj.U() & (File & "kind = 'none'")) == 0


def test_len_of_universal_aggregation_subquery(File):
"""``len()`` of a subquery over a ``dj.U()`` aggregation, restricted or not."""
File.insert([(1, "data"), (2, "data"), (3, "meta")])
total = dj.U().aggr(File, n="count(*)").make_subquery()
assert len(total) == 1
assert len(total & "n = 3") == 1
assert len(total & "n = 4") == 0


def test_restrict_universal_aggregation(File):
"""A restriction on the aggregated value of a ``dj.U()`` aggregation filters its row."""
File.insert([(1, "data"), (2, "data"), (3, "meta")])
total = dj.U().aggr(File, n="count(*)")

assert (total & "n = 3").to_dicts() == [{"n": 3}]
assert (total & {"n": 3}).to_dicts() == [{"n": 3}]
assert (total - "n = 4").to_dicts() == [{"n": 3}]
assert (total & "n = 4").to_dicts() == []
assert (total & {"n": 4}).to_dicts() == []
assert (total - "n = 3").to_dicts() == []
assert (total & False).to_dicts() == []

assert len(total & "n = 3") == 1
assert len(total & "n = 4") == 0
assert total & "n = 3"
assert not total & "n = 4"


def test_restrict_universal_aggregation_of_restricted_group(File):
"""Conditions on the group (WHERE) and on the aggregate (HAVING) combine."""
File.insert([(1, "data"), (2, "data"), (3, "meta")])
data_count = dj.U().aggr(File & "kind = 'data'", n="count(*)")
assert (data_count & "n = 2").to_dicts() == [{"n": 2}]
assert (data_count & "n = 3").to_dicts() == []


def test_restrict_by_restricted_universal_aggregation(File):
"""A restricted ``dj.U()`` aggregation shares no attributes with ``File``:
it keeps every row when non-empty and none when empty."""
File.insert([(1, "data"), (2, "data"), (3, "meta")])
total = dj.U().aggr(File, n="count(*)")
assert len(File & (total & "n = 3")) == 3
assert len(File & (total & "n = 4")) == 0
assert len(File - (total & "n = 4")) == 3


def test_restrict_grouped_aggregation(File):
"""Restricting an aggregation with grouping attributes still filters by group."""
File.insert([(1, "data"), (2, "data"), (3, "meta")])
per_kind = dj.U("kind").aggr(File, n="count(*)")
assert (per_kind & "n > 1").to_dicts() == [{"kind": "data", "n": 2}]
assert len(per_kind & "n > 1") == 1
assert (per_kind & {"kind": "meta"}).to_dicts() == [{"kind": "meta", "n": 1}]
assert (per_kind & False).to_dicts() == []