Skip to content

INTPYTHON-792 Add support for window functions#549

Merged
timgraham merged 1 commit into
mongodb:mainfrom
timgraham:window-funcs
Jun 30, 2026
Merged

INTPYTHON-792 Add support for window functions#549
timgraham merged 1 commit into
mongodb:mainfrom
timgraham:window-funcs

Conversation

@timgraham

@timgraham timgraham commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Claude did the heavy lifting. I did a little refactoring, made some cosmetic edits, and removed unneeded code.

I added additional test coverage in Django's test suite: django/django#21515

@timgraham timgraham force-pushed the window-funcs branch 6 times, most recently from 8bd6c2e to cedcbbd Compare June 8, 2026 18:32
@timgraham timgraham marked this pull request as ready for review June 8, 2026 20:13
@timgraham timgraham requested a review from a team as a code owner June 8, 2026 20:13
@timgraham timgraham requested a review from NoahStapp June 8, 2026 20:13
# Window stages must precede the wrapping pipeline so the $group
# can reference the window function outputs.
if subquery.window_pipeline:
subquery.aggregation_pipeline.extend(subquery.window_pipeline)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the comment, "window stages must precede the wrapping pipeline", but here we add the window stages to the end of the pipeline?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapping pipeline is added in the line below the added code:
subquery.aggregation_pipeline.extend(wrapping_result_pipeline).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A subquery with __in will cause a misordering here:

  top_per_dept = (
      Employee.objects.annotate(r=Window(Rank(), order_by=F("salary").desc()))
      .filter(r=1)
      .values("name")
  )

result = list(
    Employee.objects.filter(name__in=Subquery(top_per_dept))
    .values_list("name", flat=True)
)

# causes this DatabaseError
pymongo.errors.OperationFailure: Executor error during aggregate command on namespace: test_djangotests_windowbugs.window_bugs__employee :: caused by :: $in requires an array as a second argument, found: missing, full error: {'ok': 0.0, 'errmsg': 'Executor error during aggregate command on namespace: test_djangotests_windowbugs.window_bugs__employee :: caused by :: $in requires an array as a second argument, found: missing', 'code': 40081, 'codeName': 'Location40081'}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_window_qualify_filter_in_subquery.

Comment thread django_mongodb_backend/compiler.py Outdated
exprs = order_by_list.get_source_expressions()
sort_doc = {}
pre_add_fields = {}
sort_idx = itertools.count(start=1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two windows with different order_by expressions in the same partition will both have the same field names and cause a collision. For example:

qs = Employee.objects.annotate(
            a=Window(
                Sum("salary"),
                partition_by="department",
                order_by=(F("age") + F("salary")).asc(),
            ),
            b=Window(
                Sum("salary"),
                partition_by="department",
                order_by=(F("salary") + F("hire_date")).asc(),
            ),
        ).order_by("name")

Both will use __wsort1.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_multiple_windows_different_complex_order_by in django/django#21515.



def aggregate(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
operator = self.function.lower()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Window(StdDev), Window(Variance), and Window(Count)` all produce invalid MQL:

Window(StdDev) -> {alias: {"$stddev_samp/stddev_pop": ... # not valid MQL operators
Window(StdDev) -> {alias: {"$var_samp/var_pop": ... # not valid MQL operators
Window(Count) -> {alias: {"$count": ... # must have a value of {}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_window_count, test_window_stddev, test_window_variance.

def aggregate(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
operator = self.function.lower()
exprs = self.get_source_expressions()
mql = exprs[0].as_mql(compiler, connection, as_expr=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Window(Sum('salary', filter=Q(department='ENG'))) drops the filter here since only expr[0] is read.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_aggregate_filter, test_stddev_filter, test_variance_filter.

return output, add_fields


def ntile(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQL ntile puts extras in the first buckets, here we put extras in the last buckets. Is that difference significant and if so do should we document or fix it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Added test_ntile_uneven_distribution.

mql = expr.as_mql(compiler, connection, as_expr=True)
nth = nth_expr.value
push_alias = f"__wtemp{next(idx)}"
output = {push_alias: {"$push": mql, "window": {"documents": ["unbounded", "current"]}}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded window means any user-passed Window.frame is ignored here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_nthvalue_full_partition_frame.

# Window stages must precede the wrapping pipeline so the $group
# can reference the window function outputs.
if subquery.window_pipeline:
subquery.aggregation_pipeline.extend(subquery.window_pipeline)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A subquery with __in will cause a misordering here:

  top_per_dept = (
      Employee.objects.annotate(r=Window(Rank(), order_by=F("salary").desc()))
      .filter(r=1)
      .values("name")
  )

result = list(
    Employee.objects.filter(name__in=Subquery(top_per_dept))
    .values_list("name", flat=True)
)

# causes this DatabaseError
pymongo.errors.OperationFailure: Executor error during aggregate command on namespace: test_djangotests_windowbugs.window_bugs__employee :: caused by :: $in requires an array as a second argument, found: missing, full error: {'ok': 0.0, 'errmsg': 'Executor error during aggregate command on namespace: test_djangotests_windowbugs.window_bugs__employee :: caused by :: $in requires an array as a second argument, found: missing', 'code': 40081, 'codeName': 'Location40081'}

Comment thread django_mongodb_backend/compiler.py Outdated
else:
field_name = f"__wsort{next(sort_idx)}"
pre_add_fields[field_name] = expr.as_mql(self, self.connection, as_expr=True)
sort_doc[field_name] = direction

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nulls_first and nulls_last aren't respected here:

qs = (
                Employee.objects.annotate(
                    r=Window(Rank(), order_by=F("hire_date").asc(nulls_last=True))
                )
                .filter(name="nullhire")
                .values_list("r", flat=True)
            )
# nulls are first

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_window_order_by_nulls_last and test_window_order_by_nulls_first.

Comment thread django_mongodb_backend/compiler.py Outdated
# the default.
sort_doc = group_info["sort"]
if not sort_doc and any(
isinstance(v, dict) and "$documentNumber" in v

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback doesn't catch other operators that also require a sortBy field:

list(Employee.objects.annotate(r=Window(Rank()))))

# Produces this error
pymongo.errors.OperationFailure: $rank must be specified with a top level sortBy expression with exactly one element, full error: {'ok': 0.0, 'errmsg': '$rank must be specified with a top level sortBy expression with exactly one element', 'code': 5371602, 'codeName': 'Location5371602'}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_rank_no_order_by, test_dense_rank_no_order_by.

Comment thread django_mongodb_backend/compiler.py
Comment thread django_mongodb_backend/compiler.py
Comment thread django_mongodb_backend/compiler.py
@timgraham timgraham requested a review from NoahStapp June 29, 2026 23:31
@timgraham timgraham merged commit fa8988f into mongodb:main Jun 30, 2026
21 checks passed
@timgraham timgraham deleted the window-funcs branch June 30, 2026 16:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants