Skip to content

refactor: give the big functions one job each (metaclass, register, Repository, YAML loader) - #41

Closed
iklobato wants to merge 6 commits into
refactor/polymorphismfrom
refactor/single-responsibility
Closed

iklobato wants to merge 6 commits into
refactor/polymorphismfrom
refactor/single-responsibility

Conversation

@iklobato

@iklobato iklobato commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #40 (which sits on #39 and #38). Merge in order.

What

The functions that did everything are split so each one has a single job. Five commits, no behaviour change:

  1. Metaclass. RestEndpointMeta._process was 150 lines with seven numbered steps. Each step is a function now (_field_annotations, _declared_columns, _column_for, _auto_columns, _parsed_meta, _allowed_methods), and _process is the 20 lines that order them.
  2. LightApi. register() was 270 lines on dev. It now validates, detects async mode, checks the engine and registers each endpoint, with each check in its own function. run() was a copy of build_app() plus debug; it calls build_app(debug=debug) now. The login routes are composed when the app is built instead of being removed and re-inserted on every register() call. lightapi.py: 910 lines on dev, 395 now.
  3. Repository. _update was 100 lines that validated input, built a pydantic model, inspected the mapper, ran three SQL statements and picked between four HTTP responses. Repository(model, session) owns storage: get, add, update, delete, the timestamps and the optimistic lock on version. It raises RowNotFound and VersionConflict; RestEndpoint maps those to 404 and 409 through one table. rest.py: 819 lines on dev, 505 now.
  4. YAML loader. One 705-line file becomes yaml_names.py (name resolution), yaml_schema.py (the pydantic models) and yaml_loader.py (the translation, 358 lines). The rule "endpoint settings win, defaults fill the gaps" was written out field by field twice; AuthConfig.merged_over(defaults) states it once.
  5. SchemaFactory. build and build_from_reflected_table ended with the same twenty lines. _schema_pair does that once. Field order, which is the key order of every JSON response, is unchanged.

Also: the PATCH schema was rebuilt with pydantic.create_model on every PATCH request. patch_schema() builds it once per endpoint and caches it.

Removed because it did nothing

  • cls._fields_info: stored on every endpoint, never read.
  • A block in the metaclass that removed "fields inherited from RestEndpoint itself". It could not match: RestEndpoint only has underscore annotations, which are skipped one line earlier.
  • ReflectedTable's partial_columns: always an empty list, because columns are only built when Meta.reflect is falsy.
  • The "queryset is async but engine is sync" warning. An async queryset switches the app to async mode before the code that checked for it runs.

What I left alone, and why

_meta stays a dict. The plan was a typed dataclass, but the README and the REST reference both document _meta as dict, and a custom filter backend receives the view and can read view._meta.get(...).

Checks

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

  • pytest: 375 passed, 1 xfailed, 2 xpassed. That is 370 from refactor: replace type checks with polymorphism (handler, auth, pagination, table mapping) #40 (which includes the 25 tests of the reflected-type fix, merged up from there) plus 5 new ones in tests/test_repository.py.
  • The new tests fail when I remove the version check from Repository.update or the cache from patch_schema.
  • One existing test changed, and only its import: _resolve_name is resolve_name in yaml_names. The 491-line test_yaml_config.py and the 1,002-line test_login_auth.py are otherwise untouched.
  • Coverage on lightapi/: 85% before, 86% now (2,090 statements, 296 missed).
  • ruff check, ruff format --check, mypy: clean. The new modules are type-checked; they are not in the ignore_errors list.

Largest functions left: load_from_dict (137 lines, not touched because it is planned for removal), _build_endpoint_class (66), load_config (55).

iklo added 5 commits September 18, 2026 11:10
RestEndpointMeta._process did seven numbered steps in one function:
collect annotations, build columns, inject the auto columns, build
schemas, parse Meta, scan the MRO for HTTP method mixins, pick the table
source. Reading how one field becomes one column meant scrolling past
the other six.

Each step is a function now: _field_annotations, _declared_columns,
_column_for, _auto_columns, _parsed_meta, _allowed_methods and
_reject_redeclared_auto_fields. _process is the 20 lines that put them
in order, and a reflected endpoint returns early instead of threading
`if reflect` through every step.

Three things that did nothing are gone:

- cls._fields_info was stored on every endpoint and never read.
- The block that removed "fields inherited from RestEndpoint itself"
  could not match anything: RestEndpoint only has underscore
  annotations, which the line above already skips.
- ReflectedTable's partial_columns was always an empty list, because
  columns are only built when Meta.reflect is falsy. Partial reflection
  maps columns that already exist in the database; it never added any.

Tests: 345 passed, 1 xfailed, 2 xpassed.
register() was 270 lines when this work started. After the table sources
and the auth change it still validated the mapping, detected async mode,
checked the engine, mapped tables, built routes and rebuilt the login
routes, all inline.

Now it reads as the four steps it performs. The checks are functions
(_require_rest_endpoint, _has_async_handlers, _require_async_engine) and
one endpoint's routes are built in _register_endpoint. The constructor
delegates to _engine_from_url, _checked_mode and _as_rate_limiter.

run() built its own Starlette app, a copy of build_app() with debug
added. It calls build_app(debug=debug) now; debug is a new keyword-only
argument with the old default.

The login routes were torn out of self._routes and re-inserted on every
register() call. They are composed in _asgi_routes() when the app is
built: login routes, endpoint routes, /healthz, in the same order as
before.

The "queryset is async but engine is sync" warning is removed. It could
not fire: an async queryset switches the app to async mode in the first
pass, before the second pass that checked for it.

lightapi.py: 910 lines on dev, 395 now.

Tests: 345 passed, 1 xfailed, 2 xpassed.
_update was 100 lines that validated input, built a pydantic model,
inspected the mapper, ran three SQL statements and chose between four
HTTP responses. _retrieve, _create and _destroy mixed SQL and HTTP the
same way.

Repository(model, session) now owns storage: get, add, update, delete,
the created_at/updated_at timestamps and the optimistic lock on
`version`. It knows nothing about HTTP and reports problems as
RowNotFound and VersionConflict. RestEndpoint validates, calls it, and
turns those two errors into 404 and 409 through one table, so the status
and wording of each error exist once.

The PATCH schema (every field optional) was rebuilt with
pydantic.create_model on every PATCH request. patch_schema() builds it
once per create schema and caches it.

Public CRUD methods and the _*_async helpers are untouched; they still
call _retrieve, _create, _update and _destroy.

tests/test_repository.py covers the lock directly: a stale version
raises and leaves the row alone, a current one bumps the version. It
fails if the version check or the cache is removed.

rest.py: 819 lines on dev, 505 now.

Tests: 350 passed, 1 xfailed, 2 xpassed.
yaml_loader.py was 705 lines doing three jobs: resolving class names,
describing the YAML document as pydantic models, and turning a validated
document into endpoint classes.

- yaml_names.py: the built-in name registry and dotted-path resolution.
- yaml_schema.py: the pydantic models and the field type map.
- yaml_loader.py: the translation and load_config (358 lines).

The rule "endpoint settings win, defaults fill the gaps" was written out
field by field twice, once in _make_authentication and again inside the
per-method branch of _build_meta_class. AuthConfig.merged_over(defaults)
states it once, on the object that owns the fields.

_build_meta_class was 123 lines; its per-method permission logic is now
_per_method_authentication and _permission_name. The branch that handled
a default permission that was neither str nor dict is gone: the pydantic
model only allows those two.

The one test change is an import: _resolve_name is resolve_name in
yaml_names. The 491-line test_yaml_config.py is otherwise untouched.

Tests: 350 passed, 1 xfailed, 2 xpassed.
SchemaFactory.build and build_from_reflected_table ended with the same
twenty lines: add the four auto-managed read fields, create the two
pydantic models, rebuild them. build also imported Optional, datetime
and ConfigDict a second time inside the function, and set read_fields
identically in both arms of an if/else.

_schema_pair does that tail once. Field order is unchanged (the
endpoint's fields first, then any auto field not already present), and
that order is the key order of every JSON response.

build: 73 lines to 40. Tests unchanged: 350 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