Skip to content

Empty-result breakdown

When a multi-condition query returns zero rows, it's often not obvious why. For a single condition (doi = "x") it's clear — nothing matches. But for genre = 1 and rating = 5 and published_date = "1900-01-01" the user wants to know where in the query the data runs out: which condition (or combination of conditions) collapses the result to nothing.

The empty-result breakdown answers that question. It walks the validated query tree, runs one count() per sub-expression against the base queryset, and points at the node where the count drops to zero.

It works for an arbitrary boolean structure (and / or / parentheses), not just a flat and chain.

In the admin

The breakdown is wired into DjangoQLSearchMixin and needs no configuration. When a valid DjangoQL search returns no rows, the changelist shows a warning that renders the query as a condition tree with per-node counts and highlights the node where the data runs out, for example:

genre = 1                         2
and rating = 5                    2
and published_date = "1900-01-01" 0   ← no data from here on

It is lazy: the extra count() queries run only when the result set is actually empty, so a normal (non-empty) search never pays for them.

You can tune or disable it per ModelAdmin:

from djangoql.admin import DjangoQLSearchMixin
from django.contrib import admin

from .models import Book


@admin.register(Book)
class BookAdmin(DjangoQLSearchMixin, admin.ModelAdmin):
    # Turn the breakdown off entirely (no extra queries on empty results):
    djangoql_explain_empty = True
    # Cost guard: max AST nodes counted before the breakdown is truncated to
    # the top-level conditions (see "Cost guard" below).
    djangoql_explain_empty_max_nodes = 50

Queryset API

The same logic is available as a standalone helper so you can render the breakdown yourself (outside the admin, in an API, etc.):

from djangoql.breakdown import explain_empty

tree = explain_empty(
    Book.objects.all(),
    'genre = 1 and rating = 5',
)

explain_empty returns None unless there is an active search and the overall result is empty — the breakdown only applies to the zero-rows case. Otherwise it returns a tree of plain dicts:

{
    'text': '(genre = 1) and (rating = 5)',
    'count': 0,
    'role': 'killer_and',
    'children': [
        {'text': 'genre = 1', 'count': 2, 'role': 'leaf', 'children': []},
        {'text': 'rating = 5', 'count': 2, 'role': 'leaf', 'children': []},
    ],
}

Each node carries:

  • text — a readable label reconstructed from the query tree.
  • count — rows matching that sub-expression on its own, against the base queryset.
  • role — what the node represents (see below).
  • children — child nodes (empty for leaves).

If a derived/aggregate field (e.g. book__count) is referenced, its annotations are applied before counting, so the helper works with ExtrasSchema too:

from djangoql.extras import ExtrasSchema

tree = explain_empty(
    User.objects.all(),
    'book__count > 100 and is_staff = True',
    schema=ExtrasSchema,
)

Node roles

role Meaning
leaf A single comparison (Name <op> value).
and An AND node whose count is non-zero.
or An OR node whose count is non-zero.
killer_and An AND whose count is zero — this is where the data runs out.
dead_or_branch A branch of an OR that matches zero rows on its own.

A killer_and is the key signal: each side may individually match rows, yet their intersection is empty. A dead_or_branch flags the parts of an OR that never contribute, even when the OR as a whole still matches something.

Cost guard

The breakdown costs one count() per evaluated node. Typical queries are small, but a pathological query could be large, so the walk is bounded by a max node budget (max_nodes, default 50). If the query exceeds the budget, only the top-level conditions are counted and the returned tree is marked truncated=True (and the admin warning says so) — there is no silent cap.

tree = explain_empty(qs, very_large_search, max_nodes=20)
if tree and tree.get('truncated'):
    ...  # only the top-level conditions were evaluated

Labels

Node labels are reconstructed from the parsed query tree rather than sliced from the original search string (the parser does not expose per-node source spans). The rendering is stable and unambiguous; it may differ cosmetically from what you typed (e.g. normalized spacing, explicit parentheses around grouped nodes).