Skip to content

feat(fastapi): let FilterConfig control generated query parameter names - #785

Open
ruicleite96 wants to merge 6 commits into
litestar-org:mainfrom
ruicleite96:feat-filter-alias-generator-v2
Open

feat(fastapi): let FilterConfig control generated query parameter names#785
ruicleite96 wants to merge 6 commits into
litestar-org:mainfrom
ruicleite96:feat-filter-alias-generator-v2

Conversation

@ruicleite96

@ruicleite96 ruicleite96 commented Aug 13, 2026

Copy link
Copy Markdown

Stacked on #784 — the first commit here is that fix, which this depends on to be demonstrable. Review #784 first; this diff is the second commit.

Problem

The query parameter names provide_filters generates are hardcoded. Eleven string literals:

ids  createdBefore  createdAfter  updatedBefore  updatedAfter
currentPage  pageSize  searchString  searchIgnoreCase  orderBy  sortOrder

plus camelize() applied to the model's own field names for the per-field filters (statusIn, isActive, expenseCategoryId, …).

There is no hook to change them. An application whose public API is snake_case cannot use the generator at all, and one using suffix conventions (issue_date__gte, parent_id__is_null) has no way to express them. The only escape is to hand-roll the parameter layer, which means reimplementing the inspect.Parameter assembly this module already does well.

Change

An optional alias_generator on FilterConfig, receiving each parameter's snake_case name and returning the query parameter to expose:

provide_filters({
    "search": "name",
    "pagination_type": "limit_offset",
    "alias_generator": lambda name: name,          # snake_case throughout
})

Every generated name now derives from a snake_case canonical form, and the default generator is camelize, which reproduces the existing names exactly:

canonical camelize (default)
created_before createdBefore
page_size pageSize
search_ignore_case searchIgnoreCase
sort_order sortOrder

So output is byte-identical unless a generator is supplied. test_default_names_are_unchanged pins that.

The cache had to change with it

Putting a callable in the config exposed a real hazard. The key was hash((_CACHE_NAMESPACE, make_hashable(config))) — an int. Functions hash by identity, identity is the address, and reducing the key to an int drops the last reference to the function. CPython then reuses that address for the next generator, so two different generators produce the same key and DependencyCache returns the wrong providers.

This is not theoretical — I hit it while testing:

default  : ['createdBefore', ...]
snake    : ['created_before', ...]
upper    : ['created_before', ...]   # wrong: got snake's dependency

Two changes fix it:

  • the cache key is the hashable tuple rather than hash() of it, so the tuple retains the callable;
  • make_hashable keeps hashable callables as-is instead of str()-ing them into an address.

test_distinct_generators_get_distinct_dependencies covers it.

tests/unit/test_extensions/test_fastapi/test_providers.py::test_create_filter_dependencies_cache_miss asserted the key was hash(...); it now asserts the tuple. That is the one intentional test change.

Naming things opens the door to naming two things the same

A generator is free to be non-injective, and the result is silent. FastAPI binds the one query value
to both parameters, so a created_before/created_after pair collapsed onto a single name asks for
< x AND > x and matches nothing, while the schema shows one parameter where there should be two:

provide_filters({"created_at": True, "alias_generator": lambda n: n.split("_")[0]})
# openapi parameters: ['created']
# GET /x?created=2020-06-01  ->  BeforeAfter(before=2020-06-01, after=2020-06-01)

The default generator collides too, and always could — this part is a pre-existing bug rather than
something the generator introduces:

provide_filters({"boolean_fields": ["status_in"], "in_fields": ["status"]})
# both reach 'statusIn'; openapi parameters: ['statusIn']

Every generated parameter asks for its name exactly once, so a name asked for twice is always two
parameters colliding. Building the dependency now raises ImproperConfigurationError naming both
sides instead of producing a filter surface that quietly does not work.

Worth flagging for review: a config that hits the pre-existing statusIn case starts raising at
startup where it previously built. It was already broken — one of the two filters was unreachable —
but it is a behaviour change, so say the word if you would rather it warn than raise.

Tests

tests/unit/test_extensions/test_fastapi/test_provider_alias_generator.py:

  • default names unchanged, asserted as a full ordered list;
  • a generator renames every parameter, including the per-field ones;
  • distinct generators get distinct dependencies, and the default config is unaffected;
  • a colliding generator is rejected, and so is the status_in/status pair under the default.

Full tests/unit/test_extensions/ suite passes. ruff check, ruff format and mypy clean.

Scope

FastAPI only. The Litestar provider names its parameters the same way and could take the same hook — I left it out to keep this reviewable, and am happy to add it here or in a follow-up if you want parity.

I am also happy to change the generator's signature if you would prefer something richer than Callable[[str], str] — for example receiving (field, operator) separately, which is what fastapi-filters does. One string keeps the fixed parameters and the per-field ones under a single transform, which is why I started there.

`_create_filter_aggregate_function_fastapi` assigned `__signature__` to the
module-level `_aggregate_filter_function` and returned that same object, so
every call to `provide_filters` overwrote the parameters of every dependency
built before it.

Two routers with different configs therefore both served whichever config was
built last. Given `provide_filters({"search": "name"})` for one router and
`provide_filters({"created_at": True})` for another, both expose only
`createdBefore` and `createdAfter`: the first silently loses the search
parameter it was configured with, and gains date parameters it never asked for.
The per-config cache masks this whenever a process happens to build only one
config.

Builds a fresh function per config instead, delegating to the shared
implementation. The cache still returns one object per identical config.
The names `provide_filters` generates were hardcoded: eleven literals
(`currentPage`, `searchString`, `orderBy`, `createdBefore`, ...) plus
`camelize()` applied to model field names for the per-field filters. An
application whose API is snake_case had no way to reach them, and no way to
express suffix conventions such as `issue_date__gte`.

Adds an optional `alias_generator` to `FilterConfig`, receiving each
parameter's snake_case name and returning the query parameter to expose.

Every generated name is now derived from a snake_case canonical form, and the
default generator is `camelize` — which reproduces the previous names exactly
(`created_before` -> `createdBefore`, `page_size` -> `pageSize`), so output is
unchanged unless a generator is supplied. `alias_generator=lambda name: name`
keeps snake_case throughout.

The dependency cache key is now the hashable tuple rather than `hash()` of it.
A config may now carry a function, functions hash by identity, and identity is
the address — reducing the key to an int drops the last reference, letting
CPython hand the same address to the next generator so the cache returns the
wrong providers for it. Keeping the tuple retains the reference.
`make_hashable` preserves hashable callables for the same reason, instead of
stringifying them into an address.
`provide_filters` takes a `FilterConfig` TypedDict, and mypy accepts a dict
literal there but not a `dict[str, Any]` variable — nor `dict(config)`, which
degrades a TypedDict to `dict[str, object]`.

Build the config in a factory returning `FilterConfig`. Each call still yields a
distinct, equal dict, so the test goes on exercising the cache by value rather
than by identity.
…dary

The helper takes a plain dict because its callers build configs with `{**CONFIG,
...}` spreads, which mypy widens away from the `FilterConfig` TypedDict that
`provide_filters` expects. Cast where it is handed over, matching how
`test_providers.py` already does it.
Letting a config name its parameters lets it name two of them the same, and the
result is silent: FastAPI binds the one value to both, so a
`created_before`/`created_after` pair collapsed onto one name asks for
`< x AND > x` and matches nothing, while the OpenAPI schema just shows one
parameter where there should be two.

The default generator collides too, and always could: a boolean field named
`status_in` reaches `statusIn` alongside `in_fields=["status"]`, and one of the
two filters has been unreachable ever since. Every generated parameter asks for
its name exactly once, so a name asked for twice is always a collision — raise
`ImproperConfigurationError` naming both sides rather than build a filter
surface that quietly does not work.
The cache keyed on the config alone, but `dep_defaults` shapes the generated
signature just as much — it supplies the parameter names and the default page
size. Two callers passing the same config with different defaults therefore
shared one dependency, and whichever was built first won for the whole process:
a caller asking for `DEFAULT_PAGINATION_SIZE = 100` silently served 20.

This is the same failure this branch already fixes one level up, so fix it here
rather than leave a second way for a router to serve a config it never asked
for. The key uses the values rather than the instance, so two equal
`DependencyDefaults` still share one dependency and the cache stays useful.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant