refactor(roms): give the ROM filters a single home, and match them against the facets mirror - #4487
refactor(roms): give the ROM filters a single home, and match them against the facets mirror#4487sdornan wants to merge 13 commits into
Conversation
The ten JSON-array facet filters (genres, franchises, collections, companies, publishers, developers, age_ratings, regions, languages, tags) had ten near-identical `_filter_by_*` methods differing only in the column they matched on, and `filter_roms` then restated the same vocabulary twice more: once in `filters_to_apply` and once in the `needs_metadata_join` list that decides whether the query joins `roms_metadata`. That second list is the risk: a new metadata-backed filter that is added to `filters_to_apply` but forgotten in `needs_metadata_join` compiles and runs, but silently cross-joins `roms` against `roms_metadata` under `group_by_meta_id`, ranking the whole library instead of the matches. Introduce `ROM_FILTER_SPECS`, one declarative row per filter carrying its name, how its values are matched and the column they match against. The join decision is now derived from the column's own mapper, so it cannot drift from the filters it guards, and one `_apply_filter_spec` replaces the ten methods. `statuses` stays in `filter_roms`: it matches against RomUser, runs after the grouping window rather than with the rest, and carries the default "hide hidden roms" behaviour. The generated SQL is byte-identical across 18 cases covering every filter, all three logic operators, grouping and per-user scoping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
The filter vocabulary (13 multi-value filters with their logic operators, plus the boolean and scope filters) was written out in five places: the `/api/roms` query parameters, `filter_roms`'s 63-line signature, the `kwargs.get` block in `get_roms_scalar`, the criteria translation in `get_smart_collection_criteria`, and the `is_unscoped` test that decides what may be cached. That last one is the dangerous copy. The gallery's sidecar caches are keyed on user/order/grouping but not on the filters, so a filter missing from `is_unscoped` would let one user's narrowed gallery be stored under, and served from, the shared "all" key. `RomFilterParams` becomes the single home. The route takes it as a dependency, `filter_roms` reads its fields, and smart collections rebuild it from their stored criteria (legacy `selected_*` keys and the `platform_id` scalar included, keeping `from_stored_criteria` the only place that knows about them). `has_filters` now reads the model's own fields, so a filter added later counts towards the cache gate without anyone remembering to list it, and a test asserts that for every field. The route reaches the model through `as_query_dependency` rather than `Annotated[RomFilterParams, Query()]`, because FastAPI expands a query model into its fields only when the model is a route's *only* query parameter. `/api/roms` has a dozen others, so the native form would leave one required parameter named `filters`: the schema would advertise it, the generated client would be built from that, and a request carrying the fields themselves would be rejected. A dependency's parameters are flattened individually, so building one from the model keeps the fields. An endpoint test pins that, since the failure is silent. The API contract is unchanged: `/api/roms` still takes the same 58 query parameters with identical types, descriptions, defaults and constraints, and regenerating the frontend types from the new schema produces a byte-identical `src/__generated__`. Only the order of the parameters in the OpenAPI array differs, since FastAPI lists a dependency's parameters after the route's own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
f067a97 to
983e24d
Compare
Every multi-value filter matched against either `roms` or the
`roms_metadata` view over it. Both carry the raw provider-metadata blobs
inline, so evaluating a facet predicate reads the whole wide table, and no
index can serve a JSON containment test.
`roms_facets` already holds exactly these values in a narrow, trigger-
maintained mirror (0100, extended by 0103 and 0112), and the dropdown
queries and the stats coverage breakdown already read it for this reason.
The filters now read it too, so applying any of them costs one join to a
table that is a fraction of the size.
On a seeded 40k-game library (139 MB of `roms` against 8 MB of
`roms_facets`, stock 128 MB buffer pool), through `filter_roms`:
total count 374 ms -> 70 ms
rom id index 615 ms -> 74 ms
page (limit 50) 1.5 ms -> 1.3 ms
Both sidecars run on every gallery request, since `with_total` and
`with_rom_id_index` default on. The page query was already fast: it stops
early on an indexed sort, so it never had to evaluate the predicate for
the whole library.
Matching is unchanged: every mirrored column is byte-equal to its source
across all 40k rows, no rom is missing its facets row, and each filter
selects the same ids as before under every logic operator. The mirror also
covers the provider id columns, so `metadata_providers` moves with the
rest rather than being the one filter left reading `roms`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
Greptile SummaryThis PR centralizes ROM filtering in a shared
Confidence Score: 5/5The PR appears safe to merge, with no concrete correctness, security, or repository-rule failures identified. The refactored callers consistently use the shared filter model, known legacy criteria remain normalized, request parsing and constraints are covered, and the facet mirror is backfilled and maintained by database triggers. Important Files Changed
Reviews (1): Last reviewed commit: "perf(roms): match the gallery filters ag..." | Re-trigger Greptile |
There was a problem hiding this comment.
🟢 Approval recommended
No blocking issues remain; the outstanding documentation update is only a minor nit.
Pull request overview
Refactors ROM filtering around a shared model and registry, using the narrow roms_facets mirror while preserving the API contract.
Changes:
- Centralizes filter definitions and smart-collection criteria.
- Routes filtering and cache checks through shared models.
- Adds facet-join, grouping, API, and regression coverage.
File summaries
| File | Reviewed change |
|---|---|
backend/utils/router.py |
Flattens model fields into query parameters. |
backend/tests/handler/database/test_roms_verified_filter.py |
Updates verified-filter coverage. |
backend/tests/handler/database/test_roms_missing_filter.py |
Updates missing-filter coverage. |
backend/tests/handler/database/test_roms_group_by_index.py |
Updates grouped-query coverage. |
backend/tests/handler/database/test_roms_facet_filter_join.py |
Tests facet joins during grouping. |
backend/tests/handler/database/test_rom_filter_params.py |
Tests filter modeling and registry behavior. |
backend/tests/handler/database/test_collections_handler.py |
Tests stored criteria conversion. |
backend/tests/endpoints/roms/test_rom.py |
Verifies individual OpenAPI query parameters. |
backend/handler/database/roms_handler.py |
Applies registry filters against roms_facets; one documentation nit remains. |
backend/handler/database/rom_filters.py |
Defines shared filter parameters and registry logic. |
backend/handler/database/collections_handler.py |
Uses shared filters for smart collections. |
backend/endpoints/roms/__init__.py |
Integrates modeled filters and cache gating. |
Review details
Suppressed comments (1)
backend/handler/database/roms_handler.py:1153
- These mappings now read provider IDs from
RomFacets, but the docstring above still says they are matched against columns onRom. Please update the documentation so it preserves the facet-mirror invariant for future changes.
METADATA_SOURCE_FACET_COLUMNS[value]
for value in values
if value in METADATA_SOURCE_FACET_COLUMNS
- Files reviewed: 12/12 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…migration-vu20z2 # Conflicts: # backend/endpoints/roms/__init__.py
…migration-vu20z2 # Conflicts: # backend/endpoints/roms/__init__.py # backend/handler/database/roms_handler.py # backend/tests/handler/database/test_roms_group_by_index.py
List mode kept the set of sortable keys twice: `getListColumns` marked each column `sortable`, and `GalleryShell` carried a hand-written Set of the same keys. They drifted. `platform_id` is a sortable column and a member of `ListSortKey`, but was missing from the Set, and the Set is what `listSortKey` tests before telling the header which column is active. So clicking Platform sorted the gallery, but the header never showed as active, never drew the direction arrow, and could not be toggled to descending: the toggle reads `sortKey === col.key`, which stayed false because `listSortKey` returned null for it. The set is now read off the columns themselves, through an `isListSortKey` guard that also removes the two casts at the call site. `isSortableColumn` moves next to the type it narrows, where the header was keeping its own copy. A test walks every sortable column and asserts the guard accepts it, so a column added later cannot land half-sortable. Against the old hand-kept Set that test fails on `platform_id`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
The previous commit fixed the gallery's copy of the list-mode sort keys but not this one: `MissingGamesSection` maps the store's order key to the header's accepted keys through its own inline chain of comparisons, carrying the same six keys and the same missing `platform_id`. It renders the platform column (`showPlatformColumn` defaults to true and this call site does not override it), so the column is sortable there and had the same broken affordance: no active styling, no arrow, stuck ascending. Both surfaces now narrow through `isListSortKey`, so the accepted keys are read off the columns in one place rather than restated per call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
Three defects the review pass turned up: `from_stored_criteria` raised on a smart collection whose stored `filter_criteria` holds a value the model rejects. That JSON is written unvalidated and read in a loop over every smart collection, so one bad row stopped all the later ones from refreshing on any ROM write, and 500'd a public smart collection. It now drops the entries it cannot honour, which is what the criteria reader did before it validated anything. Two comments claimed `metadata_providers` matches id columns on `Rom`. It matches them on the facets mirror, so a reader trusting the comment concludes the filter needs no join and cross-joins `roms_facets` into the query. Corrected, and pinned by a test asserting every registered filter joins the mirror exactly once. `as_query_dependency` silently mis-built a route for a field with an alias or a `default_factory`, the latter sharing one mutable default across requests. Both now fail at startup, and the helper has the tests it shipped without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
The tolerant validator rebuilds its input instead of deleting from it in place, and tests the dropped names against the keys it holds rather than filtering them out of the error list first. The router test built two near-identical routes behind a branch; one route with a plain parameter alongside the model covers both cases it asked about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
Review polish over the whole change: every comment block over two lines cut back, and the two carrying change history rather than current behaviour rewritten to describe what the code does now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
`known_first_party` lives in `.trunk/configs/.isort.cfg`, so `handler` and `models` group after the third-party block, not with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
The real /code-review and /simplify passes over this PR, which an earlier run substituted hand review for. `from_stored_criteria` dropped the entries the model rejects. Dropping a filter drops the constraint with it, so a row holding `platform_ids: [3, "gba"]` widened from one platform to the whole library. It now answers None, and the one caller turns that into a query matching nothing: the loop over every smart collection still cannot be stopped by one bad row, and a collection never shows more than its criteria claim. `/api/roms/download` declared `collection_id` and `smart_collection_id` unbounded while `get_roms_scalar` validates them through the model, so `collection_id=0` raised inside the handler as a 500. They now carry the `ge=1` the other two routes already declare, and fail at the boundary as a 422. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
`from_stored_criteria` answers None now, so the tests reading fields off it need the same narrowing helper the collections tests use, and two splats need annotating where the model's fields are heterogeneous. These are mypy findings the local check missed: `.trunk/configs/mypy.ini` sets `check_untyped_defs`, without which mypy skips the bodies of unannotated test functions entirely, and Trunk passes changed files explicitly, which bypasses that config's `exclude` of `tests`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz
Description
Three commits, in order: collapse the duplicated filter definitions into a registry, carry the filter vocabulary as one model, then repoint every filter at the narrow
roms_facetsmirror. Plus two merges ofmaster, and four follow-up commits from the review passes (both covered further down).1. The registry
The ROM filter vocabulary was written out in five places: the
/api/romsquery parameters,filter_roms's 63-line signature, thekwargs.getblock inget_roms_scalar, the criteria translation inget_smart_collection_criteria, and theis_unscopedtest that decides what may be cached. Ten near-identical_filter_by_*methods differed only in the column they matched on.Two of those copies are latent bugs rather than just duplication:
needs_metadata_joinwas a hand-kept list of the filters that readroms_metadata. A filter added tofilters_to_applybut forgotten there still runs, but undergroup_by_meta_idit cross-joins and the dedup window ranks the whole library instead of the matches.is_unscopedgates the gallery's sidecar caches, which are keyed on user/order/grouping but not on the filters. A filter missing from that list would let one user's narrowed gallery be stored under, and served from, the shared"all"key.ROM_FILTER_SPECSis now one declarative row per filter — name, how values are matched, which column — and the join and dispatch are derived from it.2. The model
RomFilterParamsis the vocabulary in one place. The route takes it as a dependency,filter_romsreads its fields, smart collections rebuild it from stored criteria (legacyselected_*keys and theplatform_idscalar included).has_filters()reads the model's own fields, so a filter added later counts towards the cache gate whether or not anyone remembers it, and a test asserts that for all 27 narrowing fields.statusesdeliberately stays outside the registry: it matches againstRomUser, is applied after the grouping window, and carries the default "hide hidden roms" behaviour.3. The facets mirror
Every filter matched against either
romsor theroms_metadataview over it. Both carry the raw provider-metadata blobs inline, so evaluating a facet predicate reads the whole wide table, and no index can serve a JSON containment test.roms_facetsalready holds exactly these values in a narrow, trigger-maintained mirror (0100, extended by 0103 and 0112) — the dropdown queries and the stats coverage breakdown already read it for this reason. The filters now read it too.On a seeded 40k-game library (139 MB of
romsagainst 8 MB ofroms_facets, stock 128 MB buffer pool), measured throughfilter_roms:Both sidecars run on every gallery request, since
with_totalandwith_rom_id_indexdefault on. The page query was already fast — it stops early on an indexed sort, so it never evaluated the predicate over the whole library. That is also why this went unnoticed: the visible list is quick, while the count and the virtual-scroll index behind it are not.The mirror covers the provider id columns too (
METADATA_SOURCE_FACET_COLUMNS, already used by stats), sometadata_providersmoves with the rest rather than being the one filter left readingroms.Matching is unchanged, verified three ways on the seeded library:
<=>), and no rom is missing its facets rowany/all/noneUPDATEto a rom's metadata, so the mirror does not go staleA test asserts every registry column belongs to
RomFacets, so a filter cannot quietly be repointed back at the wide table.Why
as_query_dependencyand notAnnotated[RomFilterParams, Query()]FastAPI expands a Pydantic query model into individual parameters only when the model is a route's sole query parameter —
_get_flat_fields_from_paramsbails out atlen(fields) == 1./api/romshas a dozen others, so the native form leaves a single required parameter namedfilters. That is not only a documentation problem: the generated TypeScript client is built from that schema, and a request carrying the actual fields is rejected with422 filters: Field required.as_query_dependencybuilds a dependency whose signature carries the model's fields, each with its own constraints; a dependency's parameters are flattened individually, so they survive. Because the failure is silent, an endpoint test asserts/api/romsexposesgenres/matched/platform_idsand nofiltersparameter — verified to fail if the route reverts to the native form.The API contract
/api/romsstill takes the same 58 query parameters, with identical names, types, descriptions, defaults and constraints. Verified against a schema captured before any change, and re-verified after both merges and all four review commits:GET /api/roms/download, which deliberately gainsminimum: 1oncollection_idandsmart_collection_id(see review finding 2 below)src/__generated__is byte-identical (273 files, no diff) andnpm run generateis a no-opThe only other difference is the order of parameters within the OpenAPI array: FastAPI lists a dependency's parameters after the route's own. OpenAPI treats
parametersas unordered and clients key by name — which the identical generated client demonstrates.The
*_logicparameters stay plainstrrather than becoming an enum: today an unrecognised value falls through to "any" behaviour, and rejecting it would be a real contract change for third-party clients. Worth doing separately.A note on the model's field style
Fields are written
name: Annotated[T, Field(description=...)] = defaultrather thanname: T = Field(default, description=...). Without thepydantic.mypyplugin — which this repo does not enable — mypy readsField(None, ...)as a value rather than a default and reports everyRomFilterParams(...)call as missing 40-odd required arguments. TheAnnotatedform gives mypy a real default and needs no config change. Enabling the plugin repo-wide is the alternative (it needspydanticadded to Trunk's mypy environment viapackages:) and is worth considering on its own merits, but it is a repo-wide change that does not belong here.Verification
masteralone produces in the same environment (OAuth, scan sockets, invite-link URLs, a refresh-token case and a root-permissions filesystem case).isort/black/ruffclean on every file this PR touches;mypyreports no new errors against a baseline of the same five modules.The benchmark above is synthetic: generated uniform rows on MariaDB 10.11 in a container, reporting the best of five runs. Real libraries carry several provider blobs per row rather than the one seeded here, so the ratio should if anything be conservative — but treat the shape of the win as the claim, not the absolute milliseconds. Happy to add the seed/benchmark scripts under
backend/tools/if reviewers want to reproduce it against a real library.Merges with
masterTwo merge commits, both in areas that overlap this PR, so they are worth a reviewer's attention:
sidecar_cache_keyand added a separatebuild_unscoped_filter_values_cache_key. This PR's per-site key building is dropped in favour of theirs; the hoisted key now readsfilters.group_by_meta_id. Their newtest_rom_sidecar_cache.pypasses against this PR's cache gate.sort_key/order_by/order_dirtofilter_romsand a new grouped-sort ordering path. Those parameters are kept alongsidefilters, and the grouped-sort block'ssearch_termnow comes off the model. Their new sort tests were updated to constructRomFilterParams, since this PR changesfilter_roms's calling convention.Still open (not in this PR)
roms_facet_values (rom_id, kind, value)child table for MariaDB/MySQL, which have no JSON index.roms_facetscarries onlyidx_roms_facets_platform_idtoday, so the remaining ~70 ms is still a scan.galleryFilter.ts,useGalleryFilterUrlandsmartCollectionCriteria.ts. Deriving oneFILTER_KEYSfrom the generated types would make the compiler catch a backend-added filter the URL serializer missed — which this PR makes cheaper to cause, since adding a filter is now one registry row.Validate— done upstream by fix(roms): sort grouped galleries by the best sibling's key #4465, which landed while this PR was open:order_by_mapped_sort_columnnow requires aColumnProperty, so a relationship name falls back to the name sort instead of raising. All that is left of the original idea is advertising the valid sort keys in the OpenAPI schema, which is a documentation nicety rather than a fix.filter_criteriaat the write boundary, andget_random_rom/download_romshand-repeating constraints that live onRomFilterParams— both discussed under review finding 1 below.Review passes (four follow-up commits)
The repo's
pr-readygauntlet was run over the whole range. The security audit came back clean: no new egress hosts, no dependency, lockfile, CI or workflow changes at all, no binaries or encoded blobs, and everyprotected_route/Scope./hidden_platform_ids/hidden_rom_idsoccurrence in the diff is a context line, not an added or removed one.hidden_*are keyword-only Python parameters bound fromget_permissions(request); they are not fields ofRomFilterParams, and the model ignores extras, so no client-supplied key can reach them.Two findings are worth a reviewer's eye:
Unusable stored criteria now make a smart collection match nothing.
filter_criteriais stored as unvalidated JSON, so a row can hold{"collection_id": 0},{"hltb_main_story_min": -1}or{"platform_ids": [3, "not-an-id"]}. Validating them raised, and becauserefresh_smart_collections_for_romsloops every smart collection with no per-item guard, one bad row aborted the refresh of every collection after it on any ROM write and 500'd anyone opening a public smart collection with it. Dropping the offending entry instead is not safe either: dropping a filter drops the constraint with it, soplatform_ids: [3, "not-an-id"]would widen from one platform to the whole library.from_stored_criterianow answersNone, and its one caller turns that into a query matching nothing. The refresh loop still cannot be stopped by one bad row, and a collection never shows more than its criteria claim. Note the consequence:refresh_smart_collectionwrites that empty result intorom_count/rom_ids/path_covers_*, so a corrupt row visibly empties its collection, with alog.warningnaming the field. Validatingfilter_criteriaon write (endpoints/collections.py) is the deeper fix and would give the user a signal at the point of error; it is not in this PR because it does not retire the read path (rows written by earlier versions are already stored) and because the stored blob's vocabulary is deliberately wider than the model's (get_smart_collection_membersreadsorder_by/order_dirstraight off the dict), so it would have to be validate-only, never normalize-and-store./api/roms/downloadgainsge=1on two parameters, which is a small deliberate contract change. It declaredcollection_idandsmart_collection_idunbounded whileget_roms_scalarvalidates them throughRomFilterParams, which constrains both, so?collection_id=0raisedValidationErrorinside the handler and returned 500. Both now carry the samege=1that/api/romsand/api/roms/randomalready declare, so the same request returns 422 from FastAPI instead. This is the only OpenAPI difference in the PR:minimum: 1on those two parameters ofGET /api/roms/download, verified by diffing the schema. The generated frontend client covers response models only, not query parameters, sosrc/__generated__is still byte-identical andnpm run generateremains a no-op. Worth noting for a follow-up:ge=1is now typed out in three places for the same field (the model,get_random_rom,download_roms).as_query_dependencyalready builds exactly the annotation needed, so exposing a single-field version of it would remove all four restatements.The remaining passes inlined a now-single-use validator, replaced a hand-built log message with pydantic's own error, folded a redundant test into an existing parametrize list, moved the new membership test to
test_smart_collections.py(which owns that behaviour and already had the fixture), extracted a narrowing helper for eight repeated assertions, and trimmed every comment block over two lines.Also fixed along the way:
as_query_dependencysilently mis-built a route for a field carrying an alias or adefault_factory(one mutable default built at decoration time and shared across requests). Neither shape exists onRomFilterParams; both now raise at startup, and the helper has the tests it shipped without. And two comments claimedmetadata_providersmatches id columns onRomwhen it matches them onroms_facets(METADATA_SOURCE_FACET_COLUMNS), which invites a reader to skip its join and cross-join the mirror; corrected, and pinned by a test asserting every registered filter joins the mirror exactly once.Re-verified after all four: 790 backend tests green across the affected handler, router and endpoint suites; frontend
typecheckclean, 1236 tests green,buildclean;isort/black/ruffclean andmypyreporting no new errors against the same-file baseline. One local-tooling note:trunkitself cannot run in this environment (its plugin bundle download returns HTTP 403 through the sandbox proxy), so the tools it wraps were run directly at the versions in.trunk/trunk.yamland with the config files in.trunk/configs/— running them on their own defaults is what let an isort ordering slip past into a redtrunk_checkearlier in this PR. CI'strunk_checkis the authority.Checklist
AI assistance disclosure
Per
CONTRIBUTING.md: this PR was written with AI assistance (Claude Code), to a large extent. The analysis, design, implementation, the benchmark harness and the verification harnesses (OpenAPI schema diff, generated-TypeScript diff, SQL compilation diff, column equivalence, mypy baseline diff) were produced in an agent session and reviewed against the checks reported above. Please read the diff as machine-authored work: those checks constrain it tightly, but they are not a substitute for human review. The two merge resolutions above especially, since they touch code that landed in parallel.Note on the branch name
The branch is named
claude/graphql-api-migration-vu20z2because the session began by evaluating whether RomM should move to GraphQL. The conclusion was no — the API is largely binary/transfer-shaped, types are already generated from OpenAPI, and the third-party client ecosystem (Playnite, Argosy, grout, the iOS and desktop clients) makes a second API surface expensive — and this filter work is what came out of that discussion instead. The name no longer describes the contents.🤖 Generated with Claude Code
https://claude.ai/code/session_01MLreYqGLgHJLei6NThVXCz