"""
Query string parsing with PHP/Rails/jQuery bracket-notation support.
.. deprecated:: 2.0.0
**Use of this module is strongly discouraged.** New code should use
Django's built-in form machinery (:class:`~django.forms.Form`,
:class:`~django.forms.ModelForm`) together with standard
:class:`~django.http.QueryDict` access patterns. Django forms provide
validation, error reporting, rendering, and CSRF protection that this
module does not.
This module was written to handle query strings that use the bracket-notation
convention popularised by PHP, Ruby on Rails, and jQuery's ``$.param()``:
* ``foo=bar`` → ``{"foo": "bar"}`` (scalar)
* ``items[]=1&items[]=2`` → ``{"items": ["1", "2"]}`` (list)
* ``addr[city]=Berlin&addr[zip]=10115`` → ``{"addr": {"city": "Berlin", "zip": "10115"}}`` (dict)
It also provides :func:`is_htmx_request` for detecting
`HTMX <https://htmx.org>`_ driven requests, and :func:`sanitize_uuid` /
:func:`sanitize_decimal` converters used by the :class:`Form` class.
The :class:`Form` class mimics the ``django.forms.Form`` API at a surface
level (``is_valid()`` / ``cleaned_data``) but lacks Django's validation
pipeline, localisation support, and widget rendering.
Migration guide
---------------
Replace :class:`Form` subclasses with :class:`~django.forms.Form` subclasses
and :func:`restructure_query` calls with direct ``request.GET``/``request.POST``
access or a DRF serializer. Replace :func:`is_htmx_request` with
``django_htmx`` or an inline header check.
"""
from decimal import Decimal
from enum import Enum
import re
from typing import Any, Callable, List, Mapping
import uuid
from django.http import HttpRequest, QueryDict
SanitizerMap = Mapping[str, Callable[[str], Any]]
[docs]
class QueryFieldType(Enum):
"""Classifies a bracket-notation query string key by its expected value shape.
Determines how values are aggregated when :func:`restructure_query`
processes a :class:`~django.http.QueryDict`.
Attributes:
SCALAR: A plain key with no brackets. Only a single value is stored.
Even if the key contains brackets that make it *look* subscriptable
the field type is still ``SCALAR`` when no brackets are present.
LIST: A key ending in ``[]``. All values for this key are collected
into a list.
DICT: A key with a non-empty subscript, e.g. ``addr[city]``. Values
are stored in a dict keyed by the subscript string.
"""
SCALAR = 1
LIST = 2
DICT = 3
[docs]
class DecimalField(FormField):
"""A :class:`FormField` that sanitizes values with :func:`sanitize_decimal`.
.. deprecated::
Use :class:`~django.forms.DecimalField` instead.
"""
def __init__(self, **kwargs):
super().__init__(
sanitizer=sanitize_decimal,
**kwargs,
)
[docs]
class IntegerField(DecimalField):
"""A :class:`DecimalField` alias for integer inputs.
.. deprecated::
Use :class:`~django.forms.IntegerField` instead.
"""
pass
[docs]
class UUIDField(FormField):
"""A :class:`FormField` that sanitizes values with :func:`sanitize_uuid`.
.. deprecated::
Use :class:`~django.forms.UUIDField` instead.
Args:
**kwargs: Passed through to :class:`FormField`.
"""
def __init__(self, **kwargs):
super().__init__(
sanitizer=sanitize_uuid,
**kwargs,
)
[docs]
def is_htmx_request(request: HttpRequest) -> bool:
"""Return ``True`` if the request was issued by the HTMX JavaScript library.
HTMX sets the ``HX-Request: true`` header on every request it makes.
This can be used to return partial HTML fragments instead of full pages.
Args:
request: The incoming Django HTTP request.
Returns:
``True`` if the ``HX-Request`` header is present and equals
``"true"``; ``False`` otherwise.
"""
return request.headers.get("HX-Request", "") == "true"
[docs]
def sanitize_uuid(input: str) -> uuid.UUID:
"""Parse a UUID from a string, raising on invalid input.
Args:
input: A string in any UUID format accepted by :class:`uuid.UUID`.
Returns:
The parsed :class:`uuid.UUID`.
Raises:
ValueError: If ``input`` is not a valid UUID representation.
"""
return uuid.UUID(input)
[docs]
def sanitize_decimal(input: str) -> Decimal:
"""Parse a :class:`~decimal.Decimal` from a string, treating empty input as zero.
Args:
input: A numeric string, or ``""`` which is interpreted as ``"0"``.
Returns:
The parsed :class:`~decimal.Decimal`.
"""
return Decimal("0" if input == "" else input)
[docs]
def sanitize_list(input: List[str], sanitizer):
"""Apply a sanitizer function to every element of a list.
Args:
input: List of raw string values.
sanitizer: A callable that converts a single string to the desired type.
Returns:
A new list with ``sanitizer`` applied to each element.
"""
result = []
for item in input:
result.append(sanitizer(item))
return result
[docs]
def restructure_query(
query: QueryDict, sanitizers: SanitizerMap = None
) -> Mapping[str, Any]:
"""Convert a :class:`~django.http.QueryDict` to a nested Python structure.
Follows the PHP / Ruby on Rails / jQuery ``$.param()`` bracket-notation
convention (lightly):
* Plain keys (no brackets) are stored as scalars.
* Keys ending with ``[]`` are collected into a list.
* Keys with a named subscript (e.g. ``addr[city]``) are stored in a dict.
If a sanitizer is provided for a root key, it is applied to every value
for that key before storage.
Args:
query: The :class:`~django.http.QueryDict` to parse (e.g.
``request.GET``).
sanitizers: Optional mapping from root key name to a callable that
converts a raw string value to the desired type. Keys without a
sanitizer entry are stored as raw strings.
Returns:
A :class:`dict` with scalar, list, or dict values depending on the
key notation used.
Raises:
ValueError: If a key does not match the expected pattern, if a list
field carries more than one value while typed as scalar, or if
the same root key is used with conflicting types (e.g. first as a
scalar then as a dict).
Example::
# Query string: foo=bar&items[]=1&items[]=2&addr[city]=Berlin
result = restructure_query(request.GET)
# {"foo": "bar", "items": ["1", "2"], "addr": {"city": "Berlin"}}
"""
result = {}
for query_key, query_values in query.lists():
root_key, field_type, subscript = _parse_query_key(query_key)
if len(query_values) == 0:
raise ValueError("query values is empty")
elif len(query_values) > 1 and field_type != QueryFieldType.LIST:
raise ValueError("only list field can store more than one value")
_ensure_uniform_field_type(root_key, field_type, result)
sanitizers = sanitizers or {}
sanitizer = sanitizers[root_key] if root_key in sanitizers else None
sanitized_values = []
for val in query_values:
sanitized_values.append(sanitizer(val) if sanitizer is not None else val)
match field_type:
case QueryFieldType.SCALAR:
result[root_key] = sanitized_values[0]
case QueryFieldType.LIST:
result[root_key] = sanitized_values
case QueryFieldType.DICT:
if root_key not in result:
result[root_key] = {}
result[root_key][subscript] = sanitized_values[0]
return result
#: Compiled regex that parses a bracket-notation query key into
#: (root_key, optional_brackets, optional_subscript).
_key_pattern = re.compile("^([a-zA-Z_]+)(\\[(.*)\\])?$")
def _parse_query_key(raw_key: str):
"""Parse a bracket-notation query key into its components.
Args:
raw_key: A raw query string key such as ``"foo"``, ``"items[]"``, or
``"addr[city]"``.
Returns:
A 3-tuple of ``(root_key, field_type, subscript)`` where ``subscript``
is the string inside the brackets, or ``None`` for scalar and list
fields.
Raises:
ValueError: If ``raw_key`` does not match :data:`_key_pattern`.
RuntimeError: For bracket patterns that are syntactically valid but
semantically ambiguous (should not occur in practice).
"""
match = _key_pattern.match(raw_key)
if match is None:
raise ValueError("query key does not match {}".format(_key_pattern.pattern))
root_key, brackets, subscript = match.groups()
if brackets is None:
field_type = QueryFieldType.SCALAR
elif brackets == "[]":
field_type = QueryFieldType.LIST
elif subscript != "":
field_type = QueryFieldType.DICT
else:
raise RuntimeError("should not happen")
return root_key, field_type, subscript
def _ensure_uniform_field_type(root_key: str, field_type: QueryFieldType, result: dict):
"""Raise :exc:`ValueError` if a root key is used with a conflicting field type.
Prevents a single root key from being treated as both a scalar and a list,
or as both a list and a dict, within the same query string.
Args:
root_key: The root portion of the query key (without brackets).
field_type: The :class:`QueryFieldType` inferred from the current key.
result: The result dict accumulated so far.
Raises:
ValueError: If ``root_key`` is already present in ``result`` with an
incompatible Python type.
"""
if root_key in result:
is_dict = isinstance(result[root_key], dict)
is_list = isinstance(result[root_key], list)
if field_type == QueryFieldType.SCALAR and (is_dict or is_list):
raise ValueError("refusing to overwrite dict or list with scalar value")
elif field_type == QueryFieldType.LIST and not is_list:
raise ValueError("refusing to overwrite non-list value with list value")
elif field_type == QueryFieldType.DICT and not is_dict:
raise ValueError("refusing to overwrite non-dict value with dict value")