Fast, structured POST .../query/ endpoints for every object type - #913
Conversation
GET /api/people/?page=1 and ?page=999 both take ~8 seconds flat on a 100k-person tree, regardless of page, because the existing endpoints fully deserialize every object in the table before filtering, sorting, or paging any of it in Python -- cost scales with table size, not with what's actually requested. Adds POST /api/<type>/query/ for every primary object type: a structured query (select/where/order_by/limit/after, or a where_expr string shorthand) that compiles to a single parameterized SQL statement against the columns Gramps already indexes server-side, with real keyset pagination instead of materialize-then-slice. The compiler, AST, and expression parser live in the separately maintained gramps-object-query-language package (not yet on PyPI, see its own repo) rather than in this codebase -- it has no Web API-specific concepts in it (no Flask, no permissions, nothing HTTP-shaped), just Gramps-object-to-SQL compilation, so there was no reason to fold it in here. This repo owns the policy: which columns are selectable, how a request maps to that AST, and -- the part that matters most -- deciding per request whether the query is even allowed to reach the SQL compiler at all. That decision is the core of object_query.py's dispatch: a request from a user with full access compiles straight to SQL, as above; a request from a restricted user (someone without permission to see private records) never touches the SQL compiler at all -- it's evaluated instead through Gramps' own internal record-filtering machinery, so every value a restricted caller can see has already passed through the real, authoritative privacy rules rather than a second implementation of them living in this endpoint. Slower for that path, but correct by construction, and it holds for any future kind of access restriction this project adds later without this endpoint needing to know what that restriction even is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gramps-object-query-language is now published on PyPI, which was the reason the previous CI run failed (pyproject.toml depends on gramps-object-query-language>=0.1.0, unresolvable before it was published). No code change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mypy couldn't infer the type of `lambda obj, c=column: ...` used as
list.sort()'s key= -- the defaulted second parameter (the usual trick
for capturing a loop variable by value) breaks its unification with
sort()'s expected single-argument callable ("Cannot infer type of
lambda"). Replaced with _sort_key_for_column(), a small closure factory
with explicit annotations, which sidesteps the inference issue entirely
and needs no default-argument trick since the closure captures `column`
directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previous commit added the typed replacement but never swapped the call site over to it, so the exact lambda mypy was complaining about was still there (just shifted a few lines down) -- caught by CI re-running and reporting the same error at the new line number. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit dropped it from `dependencies` while mistakenly adding it to `tool.setuptools.packages.find` as if it were vendored in this repo, breaking imports in object_query.py on CI. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Narrow _default_key_for's parameter type to str | JsonPath | RelatedObject, matching what resolve_column_path actually returns (never CollectionCount/FlatColumnRef), fixing two union-attr errors on .segments. - Pass a list to where_list_to_ast in _build_where (it expects List[dict], not Sequence[dict]). - Restore the _sort_key_for_column closure-factory helper, lost when 08289de synced object_query.py from the gramps-object-query-language library and reintroduced the lambda obj, c=column: ... pattern mypy can't infer a type for. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…aller _parse_column_ref returns the full ColumnRef union (shared with the "count_of" branch), so mypy couldn't see that the "json_path" branch's result is always narrower (str | JsonPath | RelatedObject, per _default_key_for's own contract). cast() it at the one call site instead of widening _default_key_for back out, since a CollectionCount/ FlatColumnRef genuinely can't reach it there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ref.field is typed as the full ColumnRef union upstream, but the recursive call only ever receives str/JsonPath/RelatedObject.
|
Thanks! I'm quite busy the next few days, please don't worry if review takes a but longer than usual. If the sort and paging are just one-liners, should they be in this PR? |
They don't live in this PR. They are fixes for |
run_query() (gramps-object-query-language) now natively supports order_by/ limit/after/select, so _post_proxied's own hand-rolled workarounds for that gap are no longer needed: the full Python re-sort of every match (_sort_key/_sort_key_for_column), the manual linear-scan cursor resolution, and the manual limit slicing. Replaced with a direct run_query(..., order_by=, limit=, after=, select=) call, mirroring _post_sql's own response-building shape. Adds _resolve_after_proxied, the evaluator-path counterpart to _resolve_after, to turn a client's after=<handle> into the value tuple run_query's keyset seek expects. The X-Total-Count header now costs a genuinely separate run_query call, gated behind the count flag -- matching _post_sql's own documented "second query, opt-in" contract, which the old code didn't actually honor (it built the full sorted match list unconditionally regardless of whether count was requested). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
I've updated |
|
Thanks! Issues identified in local Opus review: Bugs1. Dependency floor is wrong — breaks the restricted-user path
At the floor, every request from a user without 2.
|
|
A general comment: I think there's no reason we cannot use the SQL pushdown for users with Opus comment: The existing list endpoints load every object in the table into memory, filter and sort in Python, and only then slice out a page — so cost scales with table size rather than with what was requested. Crucially, full_object(), which applies extend, profile, backlinks and keys, already runs after that slice, on the page alone. That means the rich response and the fast path were never in tension. Pushdown just replaces "load everything, filter, sort, slice" with a single SQL statement returning the page's handles plus a count; the endpoint then materialises those ~20 objects and calls full_object() exactly as today. The response is unchanged. Requests using arguments that cannot compile to SQL (filter, rules, gql, oql, dates, filemissing) and requests from proxied users fall back to the current path. Because both paths remain, correctness is verifiable directly: for any request, fast and slow must return identical responses. The win lands on the unfiltered paged list view — the frontend's main screen, and today's worst case. |
I think you are right that now that these endpoints allow filters (as slow as the old system, and equivalent). I appreciate the willingness to to swap the old ones out, but I'd prefer some more testing. I propose having them separate, battle test them, and once proved correct, then we swap them out. (By the way, if you really want to keep GQL, we could probably compile it to the JSON query format. But I think that the GOQL could be a QL that we settle on for a variety of uses as it automatically creates JOINS for SQL and Python-based code.) Thank you for the detailed review. I will address those issues today. |
… locale, privacy predicate - Bump gramps-object-query-language floor to >=0.3.3, matching the order_by/limit/after/select kwargs _post_proxied actually calls; at the old floor (>=0.3.1) every restricted-user request 500'd. - Reject an explicit `locale` on the proxied path with 422 instead of silently ignoring it -- run_query has no COLLATE equivalent, so the same request could return a different sort order depending on the caller's permissions alone. - Add require_permissions([PERM_VIEW_PRIVATE]) at the top of _post_sql so the "unproxied db implies PERM_VIEW_PRIVATE" invariant is asserted locally, not just relied on two modules away in get_db_handle(). - Fix next_after to over-fetch by one row and trim it back off, instead of inferring "more pages" from len(rows) == limit -- a result set that's an exact multiple of limit no longer costs a wasted, empty follow-up request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 502838d addressing the bugs and security issue from the Opus review:
Left as-is for now (both flagged "minor"):
All existing tests plus 2 new ones pass (107 total across the object-query test files). On the general architecture question about merging into the existing endpoints: agreed with keeping them separate for now per the earlier reply — happy to revisit once these have some mileage. |
…is everything When a proxied query's first page already contains every match (no `after`, no more pages beyond what was fetched), X-Total-Count is just len(rows) -- reuse it instead of re-running run_query a second time with no limit, which re-deserializes and re-sanitizes every candidate row just to recompute a number the first call already established. Only falls back to the second, unlimited query when the page genuinely doesn't cover the whole result set. Left the other minor item (Tag bypassing the proxy for the SQL path) unaddressed deliberately: Tag has no privacy today, but special-casing it would walk back the "any current or future proxy rule is honored without this module needing to know it" invariant the rest of the dispatch logic relies on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 1622f0b addressing the remaining minor items:
109 tests passing across the object-query test files. |
|
There are some minor differences between old endpoints and new pathways. We can resolve these of the next weeks. I think some of these can be fixed in I've traced through the full sort/collation pipeline on both sides. Here's what I found: The surname flat column is internally consistent, but doesn't match the old ?sort=surname semantics.
So for anyone with a compound/multiple surname (patronymic+matronymic, married name kept alongside birth name, etc.), or wherever two people share the same primary surname text, order_by: surname on the new endpoint and ?sort=surname on the old one will produce different orders. There's currently no workaround: OrderBy.column (query.py:1274) only accepts a flat SQL column name — a json_path into surname_list[i].prefix/.suffix can be used in select/where but not order_by at all today. Two more differences, both intentional but easy to trip over:
What does line up: given_name (get_first_name() vs the DB column) matches on both sides, and when the old GET endpoint is called without an explicit sort=, it also just does get_person_handles(sort_handles=True) — i.e., DB ORDER BY surname, the same flat column the fast endpoint uses. So default-order GET vs. fast-endpoint order_by: surname should agree (modulo the locale-default difference above); it's specifically ?sort=surname that diverges. |
|
Thanks for the thorough fixes, and agreed to keep the endpoints separate for now. I think we're just a couple of review 🏓 away from merging 😉 Remaining issues: 1. Backend detection fails open. 2. Standing up real PostgreSQL/SharedPostgreSQL CI is a gap in this repository rather than anything 3. PostgreSQL-dialect rendering is never asserted. The physical-name overrides 4. No equivalence test between the two paths. 5. No upper bound on Minor. The 422 message reads "not supported for a caller without PERM_VIEW_PRIVATE", but the |
…eid/dialect/equivalence test coverage Backend detection previously failed open: an unrecognized database backend (neither SQLite, single-user PostgreSQL, nor SharedPostgreSQL by class name) silently ran an unscoped query on the treeid path and guessed PostgreSQL on the dialect path, either of which can leak another tenant's rows or emit SQL the connection can't run. Both now abort with 501 instead. Adds test coverage that was missing: treeid threading through the main query, COUNT query, and RelatedObject subqueries; PostgreSQL physical-name column rendering (desc/description) asserted directly via compile_query's explicit dialect param, no Postgres instance needed; and a parametrised SQL-vs-proxied equivalence matrix across every object type to pin the SQL compiler and Python evaluator together. Also caps the gramps-object-query-language dependency at <0.4 and rewords the proxied locale-rejection message, which incorrectly implied a permission gate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Pushed 73a3694 addressing the remaining review findings:
All new/updated tests pass locally (125 tests across |
|
Great, I think it's ready! |
|
Merge at will! |
Previously value_column was only blocked for 'in'/'like', so a raw where JSON leaf with op 'regex' and value_column could compile to Regex(column, RelatedObject(...)) with no field-vs-field support. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Added a small update: regex matches. |
|
@DavidMStraub you want to merge this and make a release? Then I can make a release of gramps-connect |

Summary
GET /api/people/?page=1and?page=999both take ~8 seconds flat on a 100k-person tree, regardless of page, because the existing endpoints fully deserialize every object in the table before filtering, sorting, or paging any of it in Python — cost scales with table size, not with what's actually requested.Adds
POST /api/<type>/query/for every primary object type (person, family, event, place, repository, source, citation, media, note, tag): a structured query (select/where/order_by/limit/after, or awhere_exprstring shorthand) that compiles to a single parameterized SQL statement against the columns Gramps already indexes server-side, with real keyset pagination instead of materialize-then-slice.Where the pieces live
The compiler, query AST, and expression-language parser live in a separately maintained package,
gramps-object-query-language(on PyPI) — it has no Web API-specific concepts in it at all (no Flask, no permissions, nothing HTTP-shaped), just Gramps-object-to-SQL compilation, so there's no reason for it to live in this codebase. This repo owns the policy layer: which columns are selectable, how an HTTP request maps to the query AST, and — the part that matters most — deciding per request whether a query is even allowed to reach the SQL compiler at all.gramps-object-query-language>=0.1.0is declared as a normal dependency inpyproject.toml.The privacy dispatch
Every query request is routed one of two ways, decided per request from the resolved database handle:
Benchmark
Measured live against a real 100k-person tree, comparing this PR's
POST /api/people/query/(gender == MALE, 42,884 matches) against the existingGET /api/people/(no filter — always a full scan, regardless of page), on both SQLite and aSharedPostgreSQLinstance, for both a full-access user and a restricted user (no permission to view private records):GET /api/people/GET /api/people/GET /api/people/GET /api/people/POST /api/people/query/gender == MALEPOST /api/people/query/gender == MALEPOST /api/people/query/gender == MALEPOST /api/people/query/gender == MALEPOST /api/people/query/POST /api/people/query/POST /api/people/query/POST /api/people/query/Two things to take from this:
gender == MALE, and effectively unmeasurable-vs-8-seconds for the unfiltered case, because the query never deserializes a single object; it's pushed down entirely to SQL.GETendpoint, not worse — both paths pay the same underlying per-object privacy-sanitization cost (see "The privacy dispatch" above), since neither one takes the SQL shortcut for this case.Does
limitchange any of this? Testedlimit=1vslimit=50vslimit=1000on the unfiltered query, both paths: the SQL path stays flat (sub-millisecond throughout, both backends) becauseLIMITis compiled directly into the SQL statement. The restricted path is also flat regardless oflimit(32–35s SQLite, 95–99s Postgres, no consistent trend withlimitsize) — but for a less happy reason: it evaluates and sanitizes every row in the table beforelimitis ever applied, so asking for 1 row costs the same as asking for 1000.Test plan
tests/test_object_query_parsing.py— pure unit tests for the endpoint's request-parsing helperstests/test_endpoints/test_object_query.py— per-type endpoint teststests/test_endpoints/test_people_query.py— person-endpoint tests, including both the full-access and restricted-access dispatch pathsgramps-object-query-languageinstalled from PyPI🤖 Generated with Claude Code