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.

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.0.0.tar.gz (24.7 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.0.0-py3-none-any.whl (20.3 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: drf_typed_search-1.0.0.tar.gz
  • Upload date:
  • Size: 24.7 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.0.0.tar.gz
Algorithm Hash digest
SHA256 6c8fa0798015a7897f406c85712414353f1a1e50a02f7af547df00fde0d59fee
MD5 98cbacb66ba91cdce8ed2b0c090d3f2c
BLAKE2b-256 a42ad823721b7d7cd55e1095c4e5df2e41bc2295567888a3388bc3527af4f152

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for drf_typed_search-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 876a607f3be3554ce567e7f983a85ad51459f7c401f28454038b55226dcf292b
MD5 37393ef8cb26ce3668c94118efa07bba
BLAKE2b-256 c48295e996ce4fe11c2aa7a7dd9ada0069e63f657785e03e4d0a56c024a2449f

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