diff --git a/docs/module/crud.md b/docs/module/crud.md index 422d935..ff711dc 100644 --- a/docs/module/crud.md +++ b/docs/module/crud.md @@ -36,7 +36,7 @@ class UserCrud(AsyncCrud[User]): default_load_options = [selectinload(User.role)] ``` -Subclassing [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud) directly is the preferred style when you need to add custom methods or when the configuration is complex enough to benefit from a named class body. +Subclassing [`AsyncCrud`](../reference/crud.md#fastapi_toolsets.crud.factory.AsyncCrud) directly is the preferred style when you need to add custom methods or when the configuration is complex enough to benefit from a named class body. ### Adding custom methods @@ -474,7 +474,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated "filter_attributes": { "status": ["active", "inactive"], "country": ["DE", "FR", "US"], - "name": ["admin", "editor", "viewer"] + "role__name": ["admin", "editor", "viewer"] } } ``` @@ -482,7 +482,7 @@ The distinct values are returned in the `filter_attributes` field of [`Paginated Use `filter_by` to pass the client's chosen filter values directly — no need to build SQLAlchemy conditions by hand. Any unknown key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError). !!! info "The keys in `filter_by` are the same keys the client received in `filter_attributes`." - Keys are normally the terminal `column.key` (e.g. `"name"` for `Role.name`). When two facet fields share the same column key (e.g. `(Build.project, Project.name)` and `(Build.os, Os.name)`), the relationship name is prepended automatically: `"project__name"` and `"os__name"`. + Keys use `__` as a separator for the full relationship chain. A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. `filter_by` and `filters` can be combined — both are applied with AND logic. @@ -515,9 +515,9 @@ async def list_users( Both single-value and multi-value query parameters work: ``` -GET /users?status=active → filter_by={"status": ["active"]} -GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} -GET /users?role=admin&role=editor → filter_by={"role": ["admin", "editor"]} (IN clause) +GET /users?status=active → filter_by={"status": ["active"]} +GET /users?status=active&country=FR → filter_by={"status": ["active"], "country": ["FR"]} +GET /users?role__name=admin&role__name=editor → filter_by={"role__name": ["admin", "editor"]} (IN clause) ``` ## Sorting diff --git a/src/fastapi_toolsets/crud/search.py b/src/fastapi_toolsets/crud/search.py index efc5f31..987dc5b 100644 --- a/src/fastapi_toolsets/crud/search.py +++ b/src/fastapi_toolsets/crud/search.py @@ -2,7 +2,6 @@ import asyncio import functools -from collections import Counter from collections.abc import Sequence from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, Literal @@ -151,7 +150,7 @@ def build_search_filters( def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]: - """Return a key for each facet field, disambiguating duplicate column keys. + """Return a key for each facet field. Args: facet_fields: Sequence of facet fields — either direct columns or @@ -160,22 +159,12 @@ def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]: Returns: A list of string keys, one per facet field, in the same order. """ - raw: list[tuple[str, str | None]] = [] + keys: list[str] = [] for field in facet_fields: if isinstance(field, tuple): - rel = field[-2] - column = field[-1] - raw.append((column.key, rel.key)) + keys.append("__".join(el.key for el in field)) else: - raw.append((field.key, None)) - - counts = Counter(col_key for col_key, _ in raw) - keys: list[str] = [] - for col_key, rel_key in raw: - if counts[col_key] > 1 and rel_key is not None: - keys.append(f"{rel_key}__{col_key}") - else: - keys.append(col_key) + keys.append(field.key) return keys diff --git a/tests/test_crud_search.py b/tests/test_crud_search.py index f86f063..0933a01 100644 --- a/tests/test_crud_search.py +++ b/tests/test_crud_search.py @@ -646,7 +646,7 @@ class TestFacetsRelationship: result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead) assert result.filter_attributes is not None - assert set(result.filter_attributes["name"]) == {"admin", "editor"} + assert set(result.filter_attributes["role__name"]) == {"admin", "editor"} @pytest.mark.anyio async def test_relationship_facet_none_excluded(self, db_session: AsyncSession): @@ -661,7 +661,7 @@ class TestFacetsRelationship: result = await UserRelFacetCrud.offset_paginate(db_session, schema=UserRead) assert result.filter_attributes is not None - assert result.filter_attributes["name"] == [] + assert result.filter_attributes["role__name"] == [] @pytest.mark.anyio async def test_relationship_facet_deduplicates_join_with_search( @@ -689,7 +689,7 @@ class TestFacetsRelationship: ) assert result.filter_attributes is not None - assert result.filter_attributes["name"] == ["admin"] + assert result.filter_attributes["role__name"] == ["admin"] class TestFilterBy: @@ -755,7 +755,7 @@ class TestFilterBy: ) result = await UserRelFacetCrud.offset_paginate( - db_session, filter_by={"name": "admin"}, schema=UserRead + db_session, filter_by={"role__name": "admin"}, schema=UserRead ) assert isinstance(result.pagination, OffsetPagination) @@ -824,7 +824,7 @@ class TestFilterBy: result = await UserRoleFacetCrud.offset_paginate( db_session, - filter_by={"name": "admin", "id": str(admin.id)}, + filter_by={"role__name": "admin", "role__id": str(admin.id)}, schema=UserRead, ) @@ -916,15 +916,15 @@ class TestFilterParamsSchema: param_names = set(inspect.signature(dep).parameters) assert param_names == {"username", "email"} - def test_relationship_facet_uses_column_key(self): - """Relationship tuple uses the terminal column's key.""" + def test_relationship_facet_uses_full_chain_key(self): + """Relationship tuple uses the full chain joined by __ as the key.""" import inspect UserRoleCrud = CrudFactory(User, facet_fields=[(User.role, Role.name)]) dep = UserRoleCrud.filter_params() param_names = set(inspect.signature(dep).parameters) - assert param_names == {"name"} + assert param_names == {"role__name"} def test_raises_when_no_facet_fields(self): """ValueError raised when no facet_fields are configured or provided.""" @@ -978,6 +978,22 @@ class TestFilterParamsSchema: keys = facet_keys([(rel_a, col_a), (rel_b, col_b)]) assert keys == ["project__name", "os__name"] + def test_deep_chain_joins_all_segments(self): + """Three-element tuple produces all relation segments joined by __.""" + from unittest.mock import MagicMock + + from fastapi_toolsets.crud.search import facet_keys + + rel_a = MagicMock() + rel_a.key = "role" + rel_b = MagicMock() + rel_b.key = "permission" + col = MagicMock() + col.key = "name" + + keys = facet_keys([(rel_a, rel_b, col)]) + assert keys == ["role__permission__name"] + def test_unique_column_keys_kept_plain(self): """Fields with unique column keys are not prefixed.""" from fastapi_toolsets.crud.search import facet_keys diff --git a/tests/test_example_pagination_search.py b/tests/test_example_pagination_search.py index dda6dd6..c01d461 100644 --- a/tests/test_example_pagination_search.py +++ b/tests/test_example_pagination_search.py @@ -182,8 +182,7 @@ class TestOffsetPagination: body = resp.json() fa = body["filter_attributes"] assert set(fa["status"]) == {"draft", "published"} - # "name" is unique across all facet fields — no prefix needed - assert set(fa["name"]) == {"backend", "python"} + assert set(fa["category__name"]) == {"backend", "python"} @pytest.mark.anyio async def test_filter_attributes_scoped_to_filter(