Skip to main content

A type-aware, matcher-routed search backend for Django REST Framework that intelligently routes queries to the correct field.

Project description

drf-typed-search

A type-aware, matcher-routed search backend for Django REST Framework.

Instead of blindly searching every configured field with LIKE, dynamic_search inspects the shape of the incoming query and routes it to the single, correct, index-friendly field. A 10-digit code hits an indexed exact lookup on national_code; a phone number goes to phone_number; an integer goes to the primary key; free-text falls back to a DRF-style multi-field search.

class UserViewSet(ModelViewSet):
    filter_backends = [DynamicSearchBackend]

    search_fields_config = [
        {"field": "national_code", "join": "user"},
        {"field": "phone_number", "join": "user"},
        {"field": "id"},
        {"field": "full_name", "join": "user", "lookup": "icontains"},
    ]
  • Zero business logic in the package. All regexes/matchers live in your settings.
  • Extensible by configuration, not by modifying source (Open/Closed).
  • Strongly typed, py.typed, MyPy-strict.
  • Fast: compiled regexes, cached settings & matcher objects, minimal SQL, no needless queryset cloning.

Installation

pip install drf-typed-search

Add the app so configuration is validated at startup:

INSTALLED_APPS = [
    # ...
    "rest_framework",
    "dynamic_search",
]

Requirements: Python ≥ 3.9, Django ≥ 3.2, DRF ≥ 3.12.


Configuration

Matchers are declared globally in settings. Each matcher is either a regex (string or compiled pattern) or a callable (str) -> bool, plus the lookup to use when it wins routing.

import re

UUID_REGEX = re.compile(
    r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)

DYNAMIC_SEARCH = {
    "MATCHERS": {
        "national_code": {"pattern": r"^\d{10}$", "lookup": "exact"},
        "phone_number":  {"pattern": r"^09\d{9}$", "lookup": "exact"},
        "uuid":          {"pattern": UUID_REGEX, "lookup": "exact"},
        "id":            {"pattern": lambda v: v.isdigit(), "lookup": "exact"},
    },
    "DEFAULT_TEXT_LOOKUP": "icontains",   # used for the free-text fallback
    "SEARCH_PARAM": "search",             # ?search=...
    "EMPTY_ON_NO_MATCH": True,            # return none() when nothing matches
}

Regexes are matched with fullmatch semantics — the whole value must match, so a matcher never fires on a substring.

Performance & the routing cost

Regexes are compiled once at startup and the whole matcher registry is cached, so a request only runs pattern.fullmatch() calls — no per-request compilation, no per-request settings parsing. In practice routing costs a few microseconds (≈3 µs for 5 matchers, worst case), which is 100–700× cheaper than the single DB query it saves. If a request feels slow, the time is almost always in the SQL query, missing indexes, N+1 relations, or serialization — not routing.

For very large matcher sets you can add O(1) pre-filters so a matcher rejects an obviously-wrong value (by length or prefix) before the regex runs:

DYNAMIC_SEARCH = {
    "MATCHERS": {
        "national_code": {
            "pattern": r"^\d{10}$", "lookup": "exact",
            "min_len": 10, "max_len": 10,          # skip regex unless len == 10
        },
        "phone_number": {
            "pattern": r"^09\d{9}$", "lookup": "exact",
            "prefix": "09", "min_len": 11, "max_len": 11,
        },
    }
}

These hints are pure optimisations — a value that passes them still must satisfy the regex, so results never change. Also order your search_fields_config so the most common input shapes come first (routing early-exits on first match).

Resolving ambiguous matches with priority

When two matchers can both match the same input (e.g. an integer that is valid for both id and a numeric national_code), give the one that should win a higher priority. Routing tries matchers in descending priority, and ties fall back to declaration order in search_fields_config. Default is 0.

DYNAMIC_SEARCH = {
    "MATCHERS": {
        "id":            {"pattern": lambda v: v.isdigit(), "lookup": "exact", "priority": 0},
        "national_code": {"pattern": r"^\d{10}$",           "lookup": "exact", "priority": 100},
    }
}

Here a 10-digit value routes to national_code even if id is declared first, while shorter integers still fall through to id. Because ordering is resolved once when the engine is built (not per request), priority adds zero runtime cost.

How a field is bound to a matcher

Each config entry is bound to a matcher named after its field by default. So {"field": "national_code", ...} is routed by the national_code matcher automatically. Override with "matcher": "some_name", or disable typed routing for a field with "matcher": None (it then only participates in free-text).


The search_fields_config reference

Key Type Description
field str (required) Model field, related field, or annotation alias.
join str Relation prefix, e.g. "user" or "loan__user".
lookup str Override lookup (routing or free-text).
matcher str | None Matcher name; defaults to field; None disables routing.
annotate (qs, prefix) -> qs Add a computed annotation before filtering.
queryset_builder (qs, value) -> qs Fully custom filtering (trigram, full-text, …).
text bool Force into the free-text fallback.

Supported lookups

exact, iexact, contains, icontains, startswith, istartswith, endswith, iendswith.


Search flow

  1. Read the cached matcher registry.
  2. For each declared field (in order), test its bound matcher against the whole value.
  3. First match wins → build one precise, index-friendly filter.
  4. Otherwise → DRF-style free-text search (AND across terms, OR across fields, quoted phrases supported).
  5. If there are no text fields and nothing matched → none() (configurable).

The winning field name(s) are written to view.search_field for paginators/logging.


Annotations

Use the generic concat_annotation helper (no business logic) for computed fields:

from dynamic_search import concat_annotation

search_fields_config = [
    {
        "field": "full_name",
        "annotate": concat_annotation("full_name", ["first_name", "last_name"], join="user"),
        "lookup": "icontains",
        "matcher": None,
    },
]

Custom matchers

A matcher is just a regex or a callable in your settings — adding one never requires touching the package:

DYNAMIC_SEARCH = {
    "MATCHERS": {
        "iban": {"pattern": r"^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$", "lookup": "iexact"},
        "even_id": {"pattern": lambda v: v.isdigit() and int(v) % 2 == 0, "lookup": "exact"},
    }
}

For advanced needs (PostgreSQL trigram / full-text), use a queryset_builder:

from django.contrib.postgres.search import TrigramSimilarity

def fuzzy_name(qs, value):
    return (
        qs.annotate(sim=TrigramSimilarity("user__full_name", value))
          .filter(sim__gt=0.3)
          .order_by("-sim")
    )

search_fields_config = [
    {"field": "full_name", "queryset_builder": fuzzy_name, "matcher": "name_like"},
]

Comparison with DRF SearchFilter

DRF SearchFilter dynamic_search
Fields searched All configured fields, every request The one field the input shape matches
Lookup icontains (or prefixed) for everything Per-matcher (exact for IDs/codes, icontains for text)
Index usage Poor (%term% can't use B-tree) Excellent for typed routes (exact, startswith)
Extensibility Per-view search_fields Global matchers + per-view config, Strategy pattern
Custom SQL No Yes (queryset_builder)
Annotations Manual in get_queryset Declarative annotate

See docs/BENCHMARKS.md for numbers.


Limitations

  • Typed routing tests the whole value; it does not tokenise mixed inputs.
  • queryset_builder fields are typed-route only (excluded from the OR free-text chain).
  • Trigram/full-text helpers require the relevant PostgreSQL extensions.

Development

pip install -e ".[dev]"
pytest
ruff check . && black --check . && mypy

See CONTRIBUTING.md.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

drf_typed_search-1.1.0.tar.gz (32.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

drf_typed_search-1.1.0-py3-none-any.whl (22.7 kB view details)

Uploaded Python 3

File details

Details for the file drf_typed_search-1.1.0.tar.gz.

File metadata

  • Download URL: drf_typed_search-1.1.0.tar.gz
  • Upload date:
  • Size: 32.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.11.14

File hashes

Hashes for drf_typed_search-1.1.0.tar.gz
Algorithm Hash digest
SHA256 693ddfeff4077543ac8a4b194852c48efa5b16bae92a9b63e761ad5db231314f
MD5 038fe7dc728de6617fed3894e5221b9e
BLAKE2b-256 621041a47717980e93f630411444bb303aea66e041bb903fab3abcd526f8979d

See more details on using hashes here.

File details

Details for the file drf_typed_search-1.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for drf_typed_search-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ba52ab9baa82723e10469f063d556ff0e0cf627665c14b0353a0b45091b7f28d
MD5 67839c7d0d2c88df0f47a6371eb083a1
BLAKE2b-256 08f6b2074232f0c604597065716c644c07aa3794a76a6ab2be3cd54fc5bc7303

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page