Skip to content

UNINDEXED_FILTER_PATH

Code UNINDEXED_FILTER_PATH
Category persistence
Level warning

What it flags

An aggregate field that a repository query filters on but that no declared index covers. Such a query is a full table scan on a relational backend: the cost is invisible on small development data and grows with the production table.

protean check reads two halves and joins them:

  • the declared-index half — the indexes declared on the aggregate, from the IR, and
  • the filter-path half — the fields each repository query names, read from method bodies through the behavioral-analysis substrate.

It reports one finding per uncovered field per call-site: the same field filtered in two methods yields two findings, each pointing at its own query.

A query is any of the repository read surfaces — filter, get, find, find_by, exclude — recognised on a receiver that statically resolves to a registered repository or aggregate class (or is self-rooted, self._dao, inside a repository). The finding is attributed to the aggregate (that is where the Index() fix and the natural suppression site live), names the field, and points at the call-site as <element>.<method>, line N — the enclosing element and source line, stable across machines so it does not pollute the content-checksummed IR (the absolute source path is deliberately left out).

An operator lookup such as age__gte, status__in, or email__contains filters its base field: the rule strips the __<lookup> suffix and evaluates the base (age, status, email), so an operator query on an unindexed column is flagged and one on an indexed column is not.

When a field is covered

A filtered field is not flagged when:

  • it is the aggregate's identifier — a relational backend indexes the primary key;
  • it carries a single-column unique=True constraint — a unique constraint is a real index on a relational backend; or
  • it is the leading column of a declared Index.

Only the leading column of a composite index is covered. A filter on a non-leading column alone (for example Index("channel", "region") with a filter on region) is flagged, because that index cannot serve it.

Scope and limits

The rule is deterministic and conservative: where it cannot resolve a query to a specific aggregate, it skips rather than guesses, so an unresolved join is a silent miss, never a false positive. It deliberately does not cover:

  • Dynamic filters. filter(**kwargs) names no field, so it is skipped.
  • Unresolvable receivers. A .filter(...) on a receiver static analysis cannot tie to a registered class is skipped — a plain local variable or parameter, or the idiomatic current_domain.repository_for(Order).filter(...) whose receiver is a call result. Only a receiver that resolves to a repository or aggregate class (OrderRepository.filter(...), Order.filter(...)) or a self-rooted repository query joins today; instance and repository_for(...) receivers wait on dataflow.
  • Non-scalar names. A filter naming a value-object attribute, an association, or a name that is not a declared scalar field of the aggregate is left alone (the same scalar-field scope as the sibling persistence rules).
  • Abstract aggregates. A non-instantiable base emits no table, so a filter path joined to one is not flagged.

Why it matters

A query that filters on an unindexed column forces the database to scan the whole table. On a development database of a few hundred rows this is instantaneous and invisible; in production, against millions of rows, the same query degrades linearly with table size. Because the mismatch never shows up in tests, it typically surfaces first as a production incident. This rule detects it mechanically, at build time, from the code as written.

Example

Flagged — a repository filters on a field the aggregate does not index:

from protean import Domain, Index
from protean.core.repository import BaseRepository
from protean.fields import Identifier, String

domain = Domain(name="app")

@domain.aggregate(indexes=[Index("status")])
class Order:
    order_id = Identifier(identifier=True)
    status = String(max_length=20)
    reference = String(max_length=40)   # not indexed

@domain.repository(part_of=Order)
class OrderRepository:
    def by_reference(self, reference):
        # filters `reference`, which no index covers -> UNINDEXED_FILTER_PATH
        return self._dao.filter(reference=reference)

    def open(self):
        # filters `status`, the leading column of Index("status") -> no finding
        return self._dao.filter(status="open")

Compliant — the filtered field is indexed:

@domain.aggregate(indexes=[Index("status"), Index("reference")])
class Order:
    order_id = Identifier(identifier=True)
    status = String(max_length=20)
    reference = String(max_length=40)   # now indexed -> no finding

How to fix

  • Add an index led by the field to the aggregate (indexes=[Index("reference")], or a composite index whose leading column is this field), or
  • suppress the check when the scan is acceptable — a small lookup table, or a one-off admin/reporting query where the cost does not matter.

Suppressing

Suppress the rule for a single aggregate with suppress_checks (it resolves against the aggregate FQN the finding is attributed to):

@domain.aggregate(
    indexes=[Index("status")],
    suppress_checks=["UNINDEXED_FILTER_PATH"],
)
class Order:
    order_id = Identifier(identifier=True)
    status = String(max_length=20)
    reference = String(max_length=40)

Or grandfather a fixed number of existing findings while adopting the rule, via the [lint] table:

[lint.suppressions]
UNINDEXED_FILTER_PATH = 12   # allow the first 12 findings, fail on the 13th