Files
chitai/backend/src/chitai/services/dependencies.py
T
patrick a48be517e4 fix: type the ids filter from its configured id type
advanced_alchemy annotates the `ids` query parameter as list[str] whatever the
config says, so a bigint primary key was compared against strings and Postgres
refused. Nothing had called ?ids= until now.
2026-08-16 20:20:44 -04:00

384 lines
13 KiB
Python

# src/chitai/services/dependencies.py
# Standard library
from __future__ import annotations
from typing import Any, AsyncGenerator, Callable, NotRequired, Optional
# Third-party libraries
from advanced_alchemy.extensions.litestar.providers import (
create_service_provider,
FilterConfig,
DEPENDENCY_DEFAULTS,
DependencyDefaults,
)
from advanced_alchemy.exceptions import NotFoundError
from advanced_alchemy.filters import CollectionFilter, StatementFilter
from advanced_alchemy.service import FilterTypeT
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from litestar import Request
from litestar.params import Dependency, Parameter
from litestar.security.jwt import Token
from litestar.exceptions import HTTPException
from litestar.di import Provide
from advanced_alchemy.extensions.litestar.providers import create_filter_dependencies
# Local imports
from chitai import schemas as s
from chitai.database import models as m
from chitai.services import (
UserService,
BookService,
LibraryService,
BookProgressService,
ShelfService,
TagService,
AuthorService,
PublisherService,
KosyncDeviceService,
KosyncProgressService,
)
from chitai.config import settings
from chitai.services.filters.book import (
AuthorFilter,
BookshelfFilter,
CustomOrderBy,
ProgressFilter,
TagFilter,
TrigramSearchFilter,
)
async def provide_book_service(
db_session: AsyncSession, current_user: m.User = Dependency(skip_validation=True)
) -> AsyncGenerator[BookService, None]:
"""
Provide a BookService with per-user data scoped to the caller.
`current_user` is a required dependency, not an optional argument. It used to
default to None with the scoping below wrapped in `if current_user:` — and
when it was not injected, that block was silently skipped, so
`Book.progress_records` and `Book.list_links` loaded *every* user's rows.
`Book.progress` returns `progress_records[0]`, so one user could see another
user's reading position; the shelf checkboxes leaked the same way. Failing
loudly on a missing user is the point of the change.
"""
load = [
selectinload(m.Book.author_links).selectinload(m.BookAuthorLink.author),
selectinload(m.Book.tag_links).selectinload(m.BookTagLink.tag),
m.Book.publisher,
m.Book.files,
m.Book.identifiers,
m.Book.series,
# Reading progress, restricted to the caller.
#
# The restriction lives on the relationship via .and_() rather than in a
# separate with_loader_criteria(). advanced_alchemy's
# get_abstract_loader_options() keeps only _AbstractLoad,
# InstrumentedAttribute, RelationshipProperty and "*" entries and drops
# everything else — and with_loader_criteria() is none of those, so the
# previous criteria were discarded before reaching a query. A
# selectinload() carrying its own .and_() survives that filter.
selectinload(
m.Book.progress_records.and_(m.BookProgress.user_id == current_user.id)
),
# Bookshelf membership, restricted to the caller
selectinload(
m.Book.list_links.and_(
m.BookListLink.book_list.has(m.BookList.user_id == current_user.id)
)
).selectinload(m.BookListLink.book_list),
]
provider_func = create_service_provider(
BookService,
load=load,
uniquify=True,
error_messages={
"integrity": "Book operation failed.",
"not_found": "The book does not exist.",
},
config=settings.alchemy_config,
)
async for service in provider_func(db_session=db_session):
yield service
def create_book_filter_dependencies(
config: FilterConfig,
dep_defaults: DependencyDefaults = DEPENDENCY_DEFAULTS,
) -> dict[str, Provide]:
"""Create filter dependencies for books, including custom progress filters.
Overrides:
- SearchFilter: Uses trigram search for better fuzzy matching
- OrderBy: Adds "random" sort order option
Args:
config: FilterConfig instance with desired settings.
dep_defaults: Dependency defaults to use for the filter dependencies
Returns:
Dictionary of filter provider functions including base and custom filters.
"""
# Get base filters first
filters = create_filter_dependencies(config, dep_defaults)
# OVERRIDE: id filter typed by the configured id type, not always `str`
#
# advanced_alchemy's `provide_id_filter` annotates `ids` as `list[str]` and
# ignores `config["id_filter"]` entirely, so `?ids=12` reaches the database as
# the string "12" and Postgres refuses `bigint = character varying`. Nothing
# called `?ids=` until the duplicates screen needed to fetch a handful of books
# by id, which is why it went unnoticed.
if id_type := config.get("id_filter"):
id_field = config.get("id_field", "id")
def provide_typed_id_filter(
ids=Parameter(query="ids", default=None, required=False),
) -> CollectionFilter:
return CollectionFilter(field_name=id_field, values=ids)
# Attached as a type object rather than written as an annotation: this module
# has `from __future__ import annotations`, so a written one is stored as the
# string "Optional[list[id_type]]" and resolved against module globals, where
# a local named `id_type` does not exist.
provide_typed_id_filter.__annotations__["ids"] = Optional[list[id_type]]
filters[dep_defaults.ID_FILTER_DEPENDENCY_KEY] = Provide(
provide_typed_id_filter, sync_to_thread=False
)
# OVERRIDE: Custom search filter with trigram search
if config.get("search"):
search_fields = config.get("search")
def provide_trigram_search_filter(
search_string: str | None = Parameter(
title="Field to search",
query="searchString",
default=None,
required=False,
),
ignore_case: bool | None = Parameter(
title="Search should be case sensitive",
query="searchIgnoreCase",
default=config.get("search_ignore_case", False),
required=False,
),
) -> TrigramSearchFilter | None:
if not search_string:
return None
# field_names = set(search_fields.split(",")) if isinstance(search_fields, str) else set(search_fields)
return TrigramSearchFilter(
field_name="book.title",
value=search_string,
ignore_case=ignore_case or False,
)
filters[dep_defaults.SEARCH_FILTER_DEPENDENCY_KEY] = Provide(
provide_trigram_search_filter, sync_to_thread=False
)
# OVERRIDE: Custom order by with "random" option
if config.get("sort_field"):
sort_field = config.get("sort_field")
def provide_custom_order_by(
field_name: str | None = Parameter(
title="Order by field",
query="orderBy",
default=sort_field,
required=False,
),
sort_order: str | None = Parameter(
title="Sort order (asc, desc, or random)",
query="sortOrder",
default=config.get("sort_order", "desc"),
required=False,
),
current_user: m.User | None = Dependency(default=None),
) -> CustomOrderBy | None:
if not field_name:
return None
# Validate sort_order
valid_orders = {"asc", "desc", "random"}
if sort_order not in valid_orders:
raise ValueError(f"sort_order must be one of {valid_orders}")
return CustomOrderBy(
field_name=field_name, sort_order=sort_order, user=current_user
)
filters[dep_defaults.ORDER_BY_FILTER_DEPENDENCY_KEY] = Provide(
provide_custom_order_by, sync_to_thread=False
)
return filters
def provide_libraries_filter(
library_ids: Optional[list[int]] = Parameter(
title="Filter by libraries", query="libraries", default=None, required=None
),
) -> CollectionFilter | None:
if not library_ids:
return None
return CollectionFilter(field_name="library_id", values=library_ids)
def provide_authors_filter(
author_ids: Optional[list[int]] = Parameter(
title="Filter by authors", query="authors", default=None, required=False
),
) -> AuthorFilter | None:
if not author_ids:
return None
return AuthorFilter(authors=author_ids)
def provide_publishers_filter(
publisher_ids: Optional[list[int]] = Parameter(
title="Filter by publishers", query="publishers", default=None, required=False
),
) -> CollectionFilter | None:
if not publisher_ids:
return None
return CollectionFilter(field_name="publisher_id", values=publisher_ids)
def provide_tags_filter(
tag_ids: Optional[list[int]] = Parameter(
title="Filter by tags", query="tags", default=None, required=False
),
) -> TagFilter | None:
if not tag_ids:
return None
return TagFilter(tags=tag_ids)
def provide_bookshelves_filter(
shelf_ids: Optional[list[int]] = Parameter(
title="Filter by bookshelves", query="shelves", default=None, required=False
),
current_user: m.User = Dependency(skip_validation=True),
) -> BookshelfFilter | None:
if not shelf_ids:
return None
return BookshelfFilter(lists=shelf_ids, user_id=current_user.id)
def provide_progress_filter(
progress_statuses: Optional[list[str]] = Parameter(
title="Filter by progress status",
query="progress",
default=None,
required=False,
),
current_user: m.User = Dependency(skip_validation=True),
) -> ProgressFilter | None:
if not progress_statuses:
return None
return ProgressFilter(user_id=current_user.id, statuses=set(progress_statuses))
def provide_book_filters(
libraries_filter: CollectionFilter | None = Dependency(skip_validation=True),
authors_filter: AuthorFilter | None = Dependency(skip_validation=True),
publishers_filter: CollectionFilter | None = Dependency(skip_validation=True),
tags_filter: TagFilter | None = Dependency(skip_validation=True),
bookshelves_filter: BookshelfFilter | None = Dependency(skip_validation=True),
progress_filter: ProgressFilter | None = Dependency(skip_validation=True),
) -> list[StatementFilter]:
"""Combine all optional filters into a single list."""
return [
f
for f in [
libraries_filter,
authors_filter,
publishers_filter,
tags_filter,
bookshelves_filter,
progress_filter,
]
if f is not None
]
provide_library_service = create_service_provider(
LibraryService,
)
provide_user_service = create_service_provider(
UserService,
error_messages={
"duplicate_key": "Verification token already exists.",
"integrity": "User operation failed.",
},
)
provide_shelf_service = create_service_provider(
ShelfService,
)
provide_progress_service = create_service_provider(
BookProgressService,
)
provide_tag_service = create_service_provider(TagService)
provide_author_service = create_service_provider(AuthorService)
provide_publisher_service = create_service_provider(PublisherService)
async def get_library_by_id(
library_service: LibraryService,
books_service: BookService,
library_id: int | None = None,
book_id: int | None = Dependency(),
) -> m.Library:
"""Retrieves the library matching the id."""
if not library_id:
try:
book = await books_service.get(book_id)
library_id = book.library_id
except NotFoundError:
raise HTTPException(status_code=404, detail="The given book does not exist")
try:
return await library_service.get(library_id)
except NotFoundError:
raise HTTPException(status_code=404, detail="The given library does not exist")
def provide_user(request: Request[m.User, Token, Any]) -> m.User:
return request.user
def provide_optional_user(request: Request[m.User, Token, Any]) -> m.User | None:
if request.user:
return request.user
return None
async def provide_user_via_basic_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user
async def provide_user_via_kosync_auth(request: Request[m.User, None, Any]) -> m.User:
return request.user
provide_kosync_device_service = create_service_provider(KosyncDeviceService)
provide_kosync_progress_service = create_service_provider(KosyncProgressService)