Skip to content

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

Open
ruicleite96 wants to merge 2 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 2 commits into
litestar-org:mainfrom
ruicleite96:feat-filter-alias-generator-v2

Conversation

@ruicleite96

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.

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.

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.
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