Skip to content

refactor: replace type checks with polymorphism (handler, auth, pagination, table mapping) - #40

Closed
iklobato wants to merge 7 commits into
refactor/single-crud-pathfrom
refactor/polymorphism
Closed

iklobato wants to merge 7 commits into
refactor/single-crud-pathfrom
refactor/polymorphism

Conversation

@iklobato

@iklobato iklobato commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #39, which is stacked on #38. Merge in that order; GitHub retargets each PR as the one under it lands.

What

Places where the code asked "what type is this?" and branched now call a method on the object instead. Six commits, plus one fix:

  1. EndpointHandler replaces make_collection_handler and make_detail_handler, two 80-line closures that were the same except for an if/elif on request.method. Each HTTP method is a row in a table: which user method overrides it, which CRUD method serves it, whether it reads a body, whether GET caching applies. body_reader.py and response_wrapper.py (one function each, used only here) move in as private helpers.
  2. Sync verb overrides are served. Only async def get/post/... was used; a plain def get(self, request) was skipped without a warning, although three doc pages teach that form. A column that happens to be called post is still a column.
  3. Auth backends build themselves. if backend.__name__ == "JWTAuthentication" (twice) becomes BaseAuthentication.from_config. Authentication gets permission_for, requires_login and build_backend. EndpointGuard replaces check_auth, and LoginEndpoint replaces login_handler plus a closure that patched validate_credentials at runtime. This also removes the object.__setattr__ on the frozen Authentication dataclass.
  4. Paginators share render(), and Pagination.build_paginator() picks one from a table. NoPagination is the third implementation, so _list has no branch on style.
  5. Table sources. The metaclass stored five flags on each endpoint class and register() read them back with getattr. It now stores one object, DeclaredTable or ReflectedTable, with map(). table_mapping.py goes from five classes and a singleton to two classes, 239 lines to 133.
  6. Reflected column types come from SQLAlchemy's column.type.python_type instead of a 70-line chain of type checks. A seventh commit (67b8d78) keeps every Numeric, which includes Float, Double and REAL, as Decimal, and every Uuid as UUID, because that is where python_type and the old chain disagree. See the correction under Checks.

Behaviour changes

  • Sync verb overrides run (item 2).
  • /auth/login calls validate_credentials() of the backend the endpoint configured, sync or async def. Before, it always used a bare JWTAuthentication(), so the "custom authentication" example in the README returned 401. An app-level login_validator still wins.
  • jwt_expiration, jwt_algorithm and jwt_extra_claims now reach subclasses of JWTAuthentication. They used to reach only a class named exactly JWTAuthentication. A subclass that defines its own __init__ is still built with no arguments.

All three are in CHANGELOG.md.

What does not change

Public names and signatures: RestEndpoint and its CRUD methods, _*_async helpers, paginate, paginate_async, wrap, JWTAuthentication(algorithm, expiration, rate_limiter), BasicAuthentication(rate_limiter, login_validator), generate_token, SchemaFactory.build_from_reflected_table, route names, _current_request and _background.

I left normalise_serializer, resolve_fields, _row_to_dict and _apply_fields alone. They have about 30 assertions of their own and .cursorrules names them as a project invariant.

Checks

All run on my machine. The test workflow does not run for PRs into dev.

  • pytest: 370 passed, 1 xfailed, 2 xpassed. That is 335 from refactor: one CRUD path for sync and async engines #39, 10 new ones, and 25 that came with the fix.
  • New tests fail when I break the code on purpose: 3 of 4 in test_sync_verb_override.py, 3 of 6 in test_login_backend_subclass.py. The others pin neighbours that must keep working.
  • The existing suites pass unchanged, including test_login_auth.py (1,002 lines) and both reflection suites.
  • Coverage on lightapi/: 84% before, 85% now (2,123 statements, 319 missed).
  • ruff check, ruff format --check, mypy: clean.
  • Correction. I first wrote here that python_type agrees with the old chain for the 13 column types it knew. That was wrong: I had compared it with what I expected the chain to return, not with what it returned. The differential run in test: pin the public API and compare every response with the published 0.1.29 #43 caught it. A reflected float column came back as the JSON number 0.5 where 0.1.29 returns the string "0.5". Running the real old function next to the new one over 43 column types showed 8 differences: every float type, and Uuid(as_uuid=False). Commit 67b8d78 restores the old answers, the same comparison now shows 0 differences, and tests/test_reflected_annotations.py pins 25 column types, 8 of which fail without the fix.

Not checked: Postgres. Reflection and auth were only run on SQLite.

iklo added 6 commits September 18, 2026 10:39
make_collection_handler and make_detail_handler were two 80-line
closures with the same prologue and epilogue and an if/elif on
request.method in the middle. Each branch repeated the same three-way
choice: async override, async engine, sync engine.

EndpointHandler keeps that choice in one place (_serve) and describes
each HTTP method as data: which user method overrides it, which CRUD
method serves it on each engine kind, whether it reads a body, whether
GET caching applies. Collection and detail routes are the same class
with a different verb table.

body_reader.py and response_wrapper.py each held one small function used
only here, so they move in as private helpers. The per-request lookups
and the order of steps are unchanged: pre middlewares, auth, verb,
dict-to-JSON, cache invalidation, background tasks, post middlewares.
Route names stay `<Class>_collection` and `<Class>_detail`.

Tests: 335 passed, 1 xfailed, 2 xpassed.
The handler only used a user's get/post/put/patch/delete when it was a
coroutine function. A plain `def get(self, request)` was skipped without
any warning and the built-in list() ran instead, although the tutorial,
the endpoints guide and the REST reference all teach the sync form.

Sync overrides now run through run_blocking on a sync engine. On an
async engine they run inside AsyncSession.run_sync, so an override that
calls self.list() or self.create() reaches the async driver.

Only real functions count as overrides: an endpoint with a column named
`post` must keep working, and a test pins that.

tests/test_sync_verb_override.py: three of the four tests fail without
this change.

Tests: 339 passed, 1 xfailed, 2 xpassed.
The framework built backends by comparing class names:
`if backend.__name__ == "JWTAuthentication"` in register() and again in
check_auth(). A subclass with another name fell into the `else` branch
and was built with no settings. The login route was worse: it validated
through a bare JWTAuthentication() with validate_credentials patched at
runtime, so the subclass override the README tells users to write was
never called and login answered 401.

Backends now build themselves: BaseAuthentication.from_config, overridden
by JWTAuthentication and BasicAuthentication. A subclass that defines its
own __init__ is still built without arguments, as before.

Authentication answers the questions that were asked about it from
outside: permission_for(method), requires_login(method), build_backend().
EndpointGuard (was check_auth) uses them and receives the app's
login_validator through its constructor. That removes the
object.__setattr__ on the frozen Authentication dataclass that smuggled
it in.

LoginEndpoint (was login_handler plus a closure) gets the endpoint's real
backend. What a successful login returns is the backend's call:
login_response() gives token and user for JWT, user for Basic, which
removes the has_jwt flag and four jwt_* parameters. validate_credentials
may be sync or async.

The login rate limits (1000/10000/100000) were written twice in
lightapi.py; they are one constant now.

tests/test_login_backend_subclass.py: three of six fail without the fix.
The 1002-line test_login_auth.py passes unchanged.

Tests: 345 passed, 1 xfailed, 2 xpassed.
_list picked the paginator with `if pagination_cfg.style == "cursor"` and
then had to know that the two paginators return different tuples and
take different wrap() arguments. No pagination was a third code path in
the same method.

Each paginator now has render(request, qs, session, serialize) -> dict,
which is its own paginate() followed by its own wrap(). NoPagination is
the third implementation. Pagination.build_paginator() picks the class
from a table, and _list is five lines with no branch on style.

paginate(), paginate_async() and wrap() keep their signatures.
PageNumberPaginator() and CursorPaginator() still work with no
arguments; page_size is an optional constructor argument now, which is
also what docs/api-reference/pagination.md already shows.

Tests: 345 passed, 1 xfailed, 2 xpassed.
The metaclass left five loose attributes on every endpoint class
(_reflect_deferred, _schema_deferred, _all_columns,
_reflect_partial_columns and, until recently,
_test_isolation_table_name) and register() read them back with
getattr(cls, "_flag", False) to decide how to map the table. The mapping
itself went through TableMapper, ImperativeTableBuilder,
ReflectedTableBuilder, TableNameResolver and FieldInfoStripper plus a
module singleton, for two operations. The test-isolation naming rule was
written twice, in register() and in TableNameResolver.

The metaclass now stores one object, cls._table_source: DeclaredTable
(columns from the annotated fields) or ReflectedTable (Meta.reflect).
register() calls cls._table_source.map(cls, session_manager) and knows
nothing else. ReflectedTable also builds the schemas, since they depend
on the reflected columns.

SessionManager answers what was being read from its private attributes:
is_async, table_name_for(base_name) and create_tables_now(). The
isolation naming rule lives only there.

table_mapping.py: 239 lines to 133. register() loses 70 lines.

Tests: 345 passed, 1 xfailed, 2 xpassed, including the 460-line
test_reflection.py and test_async_reflection.py unchanged.
build_from_reflected_table mapped column types to Python types with a
70-line chain: isinstance checks, then __mro__ checks, then a second pass
over lowercase type names, plus two try/except ImportError blocks for
Uuid types that the dependency floor already guarantees.

SQLAlchemy keeps that mapping itself as column.type.python_type. The
function now asks for it and accepts the answer when it is one of the
nine scalar types the chain knew (int, str, float, bool, Decimal, UUID,
datetime, date, time). Anything else, or a type with no python_type,
stays Any with the same warning, so JSON and binary columns validate as
before.

Checked that python_type agrees with the old chain for Integer,
BigInteger, SmallInteger, String, Text, Numeric, Float, Boolean,
DateTime, Date, Time, Uuid and the PostgreSQL UUID.

schema.py: 302 lines to 241. The reflection suites pass unchanged.

Tests: 345 passed, 1 xfailed, 2 xpassed.
The previous commit replaced the 70-line type chain with
column.type.python_type and said the two agreed. They did not. I had
compared python_type with what I expected the chain to return, not with
what it returned.

In SQLAlchemy Float, Double and REAL are subclasses of Numeric, and the
old chain tested Numeric first, so every reflected float column has
always been annotated as Decimal. python_type says float. Clients see
the difference: a Decimal is serialized as a JSON string ("0.5"), a float
as a JSON number (0.5). The old chain also returned UUID for a Uuid
column with as_uuid=False, where python_type says str.

Running the old function from dev next to the new one over 43 column
types (generic, PostgreSQL, MySQL, SQLite) showed 8 differences, all in
those two families. _reflected_annotation now answers Decimal for any
Numeric and UUID for any Uuid before asking python_type, and the same
comparison shows 0 differences.

Whether a reflected float should be a JSON number is a fair question,
but it is a change to the wire format and belongs in the changelog with
a decision behind it, not in a refactor.

tests/test_reflected_annotations.py pins 25 column types. 8 of them fail
without this fix. Found by tools/differential, which compares every
response with the published 0.1.29.

Tests: 370 passed, 1 xfailed, 2 xpassed.
@iklobato

Copy link
Copy Markdown
Owner Author

Consolidated into #44 for a single review pass (one PR covering all five refactors plus the validation work, instead of six stacked ones). Closing this one, no commits were rewritten, they're all still in #44's history.

@iklobato iklobato closed this Sep 18, 2026
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