API reference

The public surface is small: one call for the common case, parse()/build_q() when you want the AST in between, a field registry, and one exception. The optional admin integration adds a single mixin. See Query language for the syntax these compile, and the Tutorial to run them end to end.

Core

django-search-query. search_query_to_q() is the one call most sites need; it chains parse() (string to AST) and build_q() (AST to Q). Call the two directly only when you want to inspect or transform the AST before compiling.

django_search_query.search_query_to_q(query, *, registry, field_map, default_fields)
function[source]
function[source]
django_search_query.search_query_to_q(query, *, registry, field_map, default_fields)

Parse query and compile it to a Django Q in one call.

Parameters:
  • query (str) – The user-supplied search string.

  • registry (FieldRegistry) – Field schema used to validate field names.

  • field_map (Mapping[str, str]) – Maps canonical field names to ORM lookup paths.

  • default_fields (Sequence[str]) – ORM paths searched for bare terms and phrases.

Returns:

The compiled query.

Return type:

Q

Raises:

QueryParseError – If query cannot be parsed.

Examples

>>> from django_search_query.registry import FieldRegistry, FieldSpec
>>> reg = FieldRegistry(specs=(FieldSpec(name="status", kind="enum"),))
>>> search_query_to_q(
...     "status:open", registry=reg, field_map={"status": "status"},
...     default_fields=("title",),
... )
<Q: (AND: ('status__iexact', 'open'))>
django_search_query.parse(query, registry)
function[source]
function[source]
django_search_query.parse(query, registry)

Parse query into an AST, validating fields against registry.

Parameters:
  • query (str) – The user-supplied search string.

  • registry (FieldRegistry) – Field schema; unknown fields raise a positioned error.

Returns:

Root of the parsed tree. Single terms return a bare Term (no one-child And/Or wrapper).

Return type:

Node

Raises:

QueryParseError – On any lexical or grammatical failure, with a position.

Examples

>>> from django_search_query.registry import FieldRegistry, FieldSpec
>>> reg = FieldRegistry(specs=(FieldSpec(name="status", kind="enum"),))
>>> node = parse("hello world", reg)
>>> type(node).__name__, len(node.children)
('And', 2)
>>> parse("status:open", reg)
Field(field='status', value='open', kind='enum')
django_search_query.build_q(node, field_map, *, default_fields)
function[source]
function[source]
django_search_query.build_q(node, field_map, *, default_fields)

Compile node into a Django Q object.

Parameters:
  • node (Node) – Root of a parsed AST.

  • field_map (Mapping[str, str]) – Maps canonical field names to ORM lookup paths. Unmapped names fall back to themselves.

  • default_fields (Sequence[str]) – ORM paths searched (OR-ed) for bare terms and phrases.

Returns:

The compiled query, ready for queryset.filter.

Return type:

Q

Examples

>>> from django_search_query.ast import Term
>>> build_q(Term(value="hi"), {}, default_fields=("title",))
<Q: (AND: ('title__icontains', 'hi'))>
>>> from django_search_query.ast import Field
>>> build_q(Field("status", "open", "enum"), {"status": "state"},
...         default_fields=())
<Q: (AND: ('state__iexact', 'open'))>
class django_search_query.registry.FieldRegistry
class django_search_query.registry.FieldRegistry

Bases: object

Ordered collection of FieldSpec with alias-aware lookup.

Parameters:

specs (tuple[FieldSpec, ...]) – The registered field specs. Duplicate names or aliases raise.

Examples

>>> registry = FieldRegistry(
...     specs=(
...         FieldSpec(name="status", kind="enum"),
...         FieldSpec(name="author", kind="string", aliases=("by",)),
...     ),
... )
>>> registry.get("by").name
'author'
>>> registry.get("missing") is None
True
>>> registry.known_names()
('status', 'author')
class django_search_query.registry.FieldSpec
class django_search_query.registry.FieldSpec

Bases: object

Schema entry describing one queryable field.

Parameters:
  • name (str) – Canonical, user-facing field name (the token left of :).

  • kind (FieldKind) – Value kind, controlling how the compiler lowers field:value.

  • path (str) – ORM lookup path the field resolves to; defaults to name.

  • enum_values (tuple[str, ...]) – Allowed values when kind == "enum" (informational for v1).

  • aliases (tuple[str, ...]) – Alternate names that resolve to this spec.

  • supports_comparison (bool) – Whether >, <, >=, <= are accepted.

  • supports_range (bool) – Whether [a TO b] / {a TO b} ranges are accepted.

class django_search_query.errors.QueryParseError
class django_search_query.errors.QueryParseError

Bases: ValueError

Raised when a query string cannot be tokenized or parsed.

Carries the byte offset into the original query so callers can render a caret-style pointer at the offending character.

Parameters:
  • message (str) – Human-readable description of the failure.

  • position (int) – Zero-based offset into the source query where the error occurred.

Examples

>>> from django_search_query.errors import QueryParseError
>>> err = QueryParseError("unexpected token", position=4)
>>> err.position
4
>>> isinstance(err, ValueError)
True

Highlighting

A second, presentation-only lexer colorizes a query for a live search box. Unlike the parser it never raises – it tokenizes even a half-typed query so every keystroke can be highlighted – which is why it is a separate function rather than a mode of parse(). See the pipeline for why the two lexers stay independent, and colored-input for the JavaScript port that colors the admin search box.

django_search_query.highlight.highlight_query_spans(query)
function[source]
function[source]
django_search_query.highlight.highlight_query_spans(query)

Lex query into contiguous (start, role, text) highlight spans.

Never raises: malformed input (an unterminated quote, a lone bracket) still yields a full, gap-free span list so a live editor can colorize an in-progress query character-for-character.

Parameters:

query (str) – The raw search string, exactly as typed.

Returns:

Spans in source order whose text fields concatenate back to query.

Return type:

list[Span]

Examples

>>> highlight_query_spans("status:open")
[Span(start=0, role='field', text='status'), Span(start=6, role='punct', text=':'), Span(start=7, role='value', text='open')]
>>> [s.role for s in highlight_query_spans("a OR b")]
['value', 'whitespace', 'keyword', 'whitespace', 'value']
>>> [s.role for s in highlight_query_spans('-draft "half')]
['negation', 'value', 'whitespace', 'phrase']
>>> "".join(s.text for s in highlight_query_spans("created:>2024-01-01"))
'created:>2024-01-01'
django_search_query.highlight.apply_registry_errors(spans, registry)
function[source]
function[source]
django_search_query.highlight.apply_registry_errors(spans, registry)

Return spans with registry-rejected fields and values re-roled error.

A second pass over the lexer output that the plain lexer cannot do alone: it consults registry to flag an unknown field (both the field span and its value are re-roled error) and an out-of-enum value (only the value span). Spans the registry accepts are returned unchanged.

Parameters:
Returns:

A new list; inputs are never mutated.

Return type:

list[Span]

Examples

>>> from django_search_query.registry import FieldRegistry, FieldSpec
>>> reg = FieldRegistry(
...     specs=(FieldSpec(name="status", kind="enum",
...                      enum_values=("open", "draft")),),
... )
>>> [s.role for s in apply_registry_errors(
...     highlight_query_spans("status:bogus"), reg)]
['field', 'punct', 'error']
>>> [s.role for s in apply_registry_errors(
...     highlight_query_spans("author:tony"), reg)]
['error', 'punct', 'error']
class django_search_query.highlight.Span
class django_search_query.highlight.Span

Bases: NamedTuple

One contiguous highlight span with its source offset.

Parameters:
  • start (int) – Zero-based offset of the span in the source query.

  • role (str) – One of HIGHLIGHT_ROLES.

  • text (str) – The exact source substring the span covers.

django_search_query.highlight.HIGHLIGHT_ROLES: frozenset[str] = frozenset({'error', 'field', 'keyword', 'negation', 'operator', 'phrase', 'punct', 'value', 'whitespace', 'wildcard'})
data
data
django_search_query.highlight.HIGHLIGHT_ROLES: frozenset[str] = frozenset({'error', 'field', 'keyword', 'negation', 'operator', 'phrase', 'punct', 'value', 'whitespace', 'wildcard'})

Every role highlight_query_spans() and apply_registry_errors() emit.

error is never produced by the plain lexer; only apply_registry_errors() promotes a span to it.

Admin

django-admin-search-query (optional). SearchQueryAdminMixin is the one class this package ships: mount it ahead of ModelAdmin (see the admin how-to) and it wires the query language into get_search_results(), adds two JSON endpoints behind the colored input, and injects the URLs those endpoints need into the changelist template. Everything below is optional configuration on top of that one mixin – most sites only ever set the three class attributes from the how-to guide.

class django_admin_search_query.mixin.SearchQueryAdminMixin
class django_admin_search_query.mixin.SearchQueryAdminMixin

Bases: object

Parse the changelist search box with the structured query language.

Subclasses configure the language through the three class attributes below, or override the matching get_* hooks for dynamic behavior.

Hook contracts

Each class attribute has a matching get_* method, so dynamic behavior – a registry that depends on request.user, say – only needs to override the method, not chase every place the attribute is read. Called with no overrides, the hooks read the class attributes straight through, and get_search_query_default_fields() falls back to search_fields (stripped of Django’s lookup sigils – ^, =, @, $) whenever search_query_default_fields is left empty:

>>> from django_admin_search_query.mixin import SearchQueryAdminMixin
>>> class ExampleAdmin(SearchQueryAdminMixin):
...     search_fields = ("^title", "=slug")
>>> ExampleAdmin().get_search_query_default_fields()
('title', 'slug')

get_search_results() is the method Django’s changelist actually calls. It strips the search term and returns super().get_search_results(...) unchanged – Django’s own search_fields behavior – whenever the term is blank, no registry is configured, or QueryParseError is raised while compiling it; otherwise it returns a filtered QuerySet: queryset.filter(q), False. That second element is Django’s may_have_duplicates flag; it is False for the local-column field maps this integration targets, where filtering by Q adds no joins. A field map that instead points at a to-many relation can introduce duplicate rows – collapse them with .distinct() in that case.

JSON endpoints

get_urls() prepends two model-scoped endpoints to the admin’s URLs, each wrapped in admin_site.admin_view so it inherits the admin’s staff-login gate, and each additionally checking has_view_permission() before responding:

  • search-tokens/ (named <app>_<model>_search_tokens) – the registry’s fields, kinds, operators, enum values, and field aliases, plus the default fields, as JsonResponse.

  • search-highlight/ (named <app>_<model>_search_highlight) – the highlight spans for a ?q= query string, as JsonResponse.

Both endpoints are always registered once the mixin is mounted. The changelist template only advertises them – as data-* attributes the colored input reads – when search_query_registry is configured; without a registry, the search box renders as stock admin with no extra markup. See colored-input for the exact JSON shape of each endpoint and how the JavaScript input consumes it.