Skip to content

Make Filter/Sorter/Paginator/QueryStrategy backend-agnostic; add ClickHouse reference backend (0.4.0) - #21

Merged
danielhasan1 merged 5 commits into
devfrom
feature/backend-agnostic-query-context
Aug 16, 2026
Merged

Make Filter/Sorter/Paginator/QueryStrategy backend-agnostic; add ClickHouse reference backend (0.4.0)#21
danielhasan1 merged 5 commits into
devfrom
feature/backend-agnostic-query-context

Conversation

@danielhasan1

Copy link
Copy Markdown
Owner

Summary

fastapi-listing's Filter/Sorter/Paginator/QueryStrategy contracts were pluggable in theory, but every
shipped default talked to a raw SQLAlchemy Query directly, and four modules imported SQLAlchemy
unconditionally at load time — the library couldn't even be imported without it. This introduces
QueryContext as the real seam and ships ClickHouseQueryContext/ClickHouseDao as a non-ORM
reference backend, proving the abstraction actually generalizes rather than just being SQLAlchemy with
extra steps.

  • Replace raw query params with QueryContext across Filter/Sorter/Paginator/QueryStrategy.
    SqlAlchemyQueryContext is the default; context.native is the escape hatch for anything the
    canonical Op vocabulary doesn't cover.
  • Rename CommonFilterImplCanonicalFilter (deprecated alias kept, warns for one release). The 14
    built-in filters now just declare an Op and work unchanged against any backend.
  • import fastapi_listing no longer requires SQLAlchemy — it's genuinely optional now, matching what
    setup.py already claimed.
  • Add PaginationStrategy.postprocess() — a governed seam for post-fetch logic that previously had no
    designated home.
  • Add HavingMixin, order_by_raw(), add_raw_condition(), ClickHouseQueryContext.from_raw_sql() as
    escape hatches for aggregated filters, compound sorts, and backend-specific SQL builtins — all
    validated against a real ClickHouse server, not just a fake client.
  • Fix count_compile() for grouped ClickHouse queries (was counting per-group, not the total).
  • Fix NotRegisteredApiException: 409 → 422 (correct HTTP semantics for an unregistered filter/sort
    field, not a resource-state conflict).
  • Fix a ContextVar leak in middlewares.py's manager() cleanup.
  • Loud, actionable FastapiListingMigrationError for anyone upgrading a custom subclass without
    migrating, instead of a generic traceback.

Breaking only for custom Filter/Sorter/QueryStrategy/PaginationStrategy subclasses — see
CHANGELOG.md migration table. The plain README flow (GenericDao + generic_filters + default
strategies) is unaffected.

Test plan

  • Full suite green across Python 3.7–3.11 in CI, real MySQL + real ClickHouse server (not fake
    clients) provisioned in every matrix job.
  • Coverage ~97%.
  • Fresh venv without SQLAlchemy installed: import fastapi_listing succeeds.
  • All four escape hatches (from_raw_sql, HavingMixin, order_by_raw, add_raw_condition)
    validated against a real local ClickHouse instance.

…kHouse reference backend

fastapi-listing's default filter/sort/paginate implementations talked to a raw
SQLAlchemy Query directly, and four modules imported sqlalchemy unconditionally
at load time, so the library couldn't even be imported without it - let alone
plugged into a different ORM or a non-ORM backend. Introduces a QueryContext
contract (fastapi_listing.context) that every Filter/Sorter/Paginator/
QueryStrategy now speaks instead, with SqlAlchemyQueryContext as a thin
pass-through adapter and ClickHouseQueryContext + ClickHouseDao as a reference
non-ORM backend (parameterized raw SQL via clickhouse-driver) proving the
abstraction actually generalizes rather than being SQLAlchemy-only in disguise.

- generic_filters.CommonFilterImpl -> CanonicalFilter: filters declare a
  canonical Op (fastapi_listing.ops.Op) and work unchanged against any backend
  (deprecated alias kept for one release)
- PaginationStrategy gains a postprocess(rows, extra_context) hook for
  post-fetch business logic that previously had no governed home
- import fastapi_listing no longer requires SQLAlchemy; clickhouse-driver
  ships as an opt-in extra, neither is in install_requires
- FastapiListingMigrationError replaces bare TypeError/AttributeError when
  pre-0.4.0 custom Filter/Sorter/QueryStrategy subclasses run unmigrated
- loader.py's DAO check now validates against DaoAbstract instead of
  hardcoding GenericDao, so non-SQLAlchemy DAOs register the same way
- typing.Literal (3.8+) guarded with a typing_extensions fallback in the new
  context modules, matching the existing ctyping.py/service/config.py pattern,
  so import fastapi_listing still works on Python 3.7

Breaking change for custom Filter/Sorter/QueryStrategy/PaginationStrategy
subclasses (signature/return-type change); the default GenericDao +
generic_filters + default-strategies flow is unaffected. See CHANGELOG.md.

Closes #10
CHANGELOG.md already covered from_raw_sql, HavingMixin, order_by_raw, and
add_raw_condition; the README's "Backend support" section and docs/query.rst
only described the basic canonical-Op story, undercutting the actual point of
these additions - full developer control when canonical filters/sort aren't
enough.
409 Conflict is for resource-state conflicts (edit conflicts, duplicate
creation) - requesting a filter/sort field outside filter_mapper/sort_mapper
isn't that, it's an invalid request parameter. The same file already used 422
for the closely related "filter/sort request itself is malformed" case, so
this was an internal inconsistency as well as a spec misuse.

Both exception classes are plain fastapi.HTTPException subclasses, so this
was always overridable per-app via @app.exception_handler(...) without any
library change - the fix here is just to make the library's own default
spec-correct, not to add configuration for it.
…al-DB CI

- Fixed a real bug in middlewares.py's manager(): the finally block inferred
  whether *this* call set a session token from the shared ContextVar's current
  value, which could be a leftover from an earlier, unrelated call (especially
  with implicit_close=False, which deliberately never resets) - causing an
  UnboundLocalError. Now tracked locally per call.
- Added ~10 test files closing real, previously-untested branches (validated
  against the real MySQL CI's line-by-line coverage first, not padding):
  paginator validation/pagination-math, factory registry error paths, the
  class-based ListingService flow via SQLite (dao_factory fallback, MetaInfo,
  switch()), loader's page-size check, and an automated check that `import
  fastapi_listing` actually works without SQLAlchemy installed (previously
  only verified manually in a throwaway venv).
- Added tests/test_clickhouse_real_integration.py: the ClickHouse backend had
  only ever been validated against a fake client. This runs the same
  canonical-filter/HAVING/from_raw_sql scenarios against a real ClickHouse
  server via the real clickhouse-driver package - skips gracefully if none is
  reachable locally, always provisioned in CI (new docker-compose.dev.yml for
  local parity, tests.yml now provisions ClickHouse alongside the existing
  MySQL container).
- Removed a dead try/except in context/__init__.py (ClickHouseQueryContext
  never actually imports clickhouse-driver, so it could never raise) and
  marked two Protocol-only interface classes `# pragma: no cover` so the
  coverage report doesn't misrepresent structural type stubs as real gaps.
- setup.py: Development Status 5 (Production/Stable) -> 4 (Beta). Shipping a
  breaking rework with a brand-new, not-yet-battle-tested backend under the
  same "stable" label as the well-worn SQLAlchemy path overstated it.
- Added CONTRIBUTING.md documenting local test setup for both real databases.

Full suite verified locally against both a real MySQL instance (the same
danielhasan1/mysql_employees_test_db image CI uses) and a real ClickHouse
server: 119 passed, 97% coverage.
CLAUDE.md is local AI-assistant working notes - not something to impose
on other contributors of a public repo, so it stays untracked rather
than committed or even gitignored. .DS_Store is macOS Finder clutter
that never should have been tracked in the first place.
@danielhasan1
danielhasan1 merged commit 841c8a7 into dev Aug 16, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant