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
fullmatchsemantics — 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
- Read the cached matcher registry.
- For each declared field (in order), test its bound matcher against the whole value.
- First match wins → build one precise, index-friendly filter.
- Otherwise → DRF-style free-text search (AND across terms, OR across fields, quoted phrases supported).
- 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_builderfields 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6c8fa0798015a7897f406c85712414353f1a1e50a02f7af547df00fde0d59fee
|
|
| MD5 |
98cbacb66ba91cdce8ed2b0c090d3f2c
|
|
| BLAKE2b-256 |
a42ad823721b7d7cd55e1095c4e5df2e41bc2295567888a3388bc3527af4f152
|
File details
Details for the file drf_typed_search-1.0.0-py3-none-any.whl.
File metadata
- Download URL: drf_typed_search-1.0.0-py3-none-any.whl
- Upload date:
- Size: 20.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
876a607f3be3554ce567e7f983a85ad51459f7c401f28454038b55226dcf292b
|
|
| MD5 |
37393ef8cb26ce3668c94118efa07bba
|
|
| BLAKE2b-256 |
c48295e996ce4fe11c2aa7a7dd9ada0069e63f657785e03e4d0a56c024a2449f
|