Skip to content

fix(db): find() sort contract — dotted paths, nulls-last, and limit/sort pushdown - #31

Merged
eldonm merged 9 commits into
mainfrom
fix/find-sort-dotted-path
Jul 31, 2026
Merged

fix(db): find() sort contract — dotted paths, nulls-last, and limit/sort pushdown#31
eldonm merged 9 commits into
mainfrom
fix/find-sort-dotted-path

Conversation

@eldonm

@eldonm eldonm commented Jul 31, 2026

Copy link
Copy Markdown
Member

Started as a one-function change (_find_sort_key did not resolve dotted paths) and grew once the behavior was checked against each backend. Three of the five fixes are pre-existing wrong-results bugs in the same area.

The contract

sort is a list of (field, direction). Every adapter must produce the same ordering whether it pushes the sort into the backend or falls back to finalize_find_results. SPEC §4.1 now states it, with a Known divergences table for the cases that are documented rather than normalized.

Fixes

1. In-memory sort ignored dotted field paths (db/database.py)
_find_sort_key used a flat record.get(field), so sort=[("context.started_at", -1)] produced None for every row and left results in arbitrary order. SQLite/Postgres pushdowns and Mongo's native sort already resolved dotted paths, so the same query ordered correctly there and silently did not on JsonDB/DynamoDB — or on SQLite/Postgres whenever the query fell back.

2. Descending sorts placed missing values first (db/database.py)
finalize_find_results sorts with reverse=True, which flipped the None flag along with the values:

in-memory desc: ['none', 'b', 'a']      <- missing first
sqlite desc   : (json_extract(data,'$.v') IS NULL), json_extract(data,'$.v') DESC
pg desc       : (data #>> '{v}') DESC NULLS LAST

Both SQL translators and Mongo put missing last. A "newest N" sort + limit therefore returned real rows on SQLite/Postgres/Mongo and a window of holes in memory. The comment in _sqlite_translate.translate_sort asserting the in-memory path already matched was wrong and is corrected.

3. Postgres pushed LIMIT even when the sort pushdown failed (db/postgres.py, both find paths)
translate_sort returns None for an unsafe field path, so ordering fell to memory — but the LIMIT still went into the SQL. find(sort=..., limit=10) returned the top 10 of an arbitrary 10. SQLiteDB.find and DynamoDB.find already withheld it. Vector $near queries also no longer have their distance ordering overwritten by an in-memory re-sort.

4. find_page keyset cursor broke on dotted fields and could not reach the missing-value tail (core/context.py)
The cursor was minted with a flat last.get(primary_field), so a dotted sort encoded sort: None every page and the next page raised TypeError: '>' not supported between instances of 'int' and 'NoneType' from QueryEngine. Separately, {field: {"$lt": value}} can never match a record with no value, so iteration stopped at the last record that had one. Cursors now use resolve_sort_value; the filter carries a {field: None} branch, and a cursor minted inside the tail walks it by id.

5. ObjectPager re-sorted each page with a key that disagreed with the slice (core/pager.py)
item.get("context", {}).get(order_by, 0) ordered a missing value as 0 — among the real values — while the DB-side slice had placed it in the trailing run. Records could appear on two pages or none. Now routed through finalize_find_results, so it is a genuine no-op when the backend honored the sort.

Plus: SQLiteDB.find treated sort=[] as untranslatable and loaded the whole collection; resolve_sort_value extracted and exported so cursor minting and adapter sorting cannot drift apart again.

Verification

Each of fixes 3–5 was verified by reverting the source file and confirming the new tests fail. New coverage: SQLite end-to-end through both branches (pushdown and in-memory fallback) — the two backends the contract binds hardest previously had only string-matched SQL fragments; stubbed-pool Postgres tests plus a DSN-gated integration case; find_page multi-page walks asserting no duplicates or omissions in both directions.

pytest: 2037 passed, 131 skipped. pre-commit run --all-files green.

Reviewer note

Commit bb024ee (missing-values-last) changes ordering for existing flat-field descending sorts. It is split out so it can be taken or dropped independently of the dotted-path fix.

🤖 Generated with Claude Code

eldonm and others added 8 commits July 31, 2026 11:27
`_find_sort_key` looked up sort fields with a flat `record.get(field)`, so a
spec like `sort=[("context.started_at", -1)]` produced `None` for every row
and left results in arbitrary order.

The SQLite and Postgres sort pushdowns (`translate_sort`) and Mongo's native
sort already resolve dotted paths, so the same query ordered correctly on
those backends and silently did not on JsonDB/DynamoDB — or on SQLite and
Postgres whenever the query fell back to the in-memory path.

Resolve dotted paths in `_find_sort_key`; a non-dict segment along the path
yields `None` rather than raising.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nding

`finalize_find_results` sorts descending via `reverse=True`, which flipped
`_find_sort_key`'s `(value is None, value)` flag along with the values and
floated records missing the sort field to the front.

Both SQL translators emit NULLS LAST for descending — SQLite via a leading
`(col IS NULL)` term that is itself sorted ASC, Postgres via an explicit
`DESC NULLS LAST` — and Mongo sorts missing values last. A "newest N"
`sort` + `limit` fetch therefore returned real rows on SQLite/Postgres/Mongo
and a window of records missing the field on the in-memory path.

Invert the None flag for descending sorts so missing values land last in both
directions. All missing values share a flag, so None is never compared against
a real value.

Also correct the comment in `_sqlite_translate.translate_sort`, which asserted
the in-memory sort already put NULLs last.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the two properties every adapter has to produce identically whether it
pushes the sort into the backend or falls back to `finalize_find_results`:
dotted paths resolve into nested documents, and missing values sort last in
both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`translate_sort` returns None for a field path it cannot safely interpolate
(e.g. `context.my-field`), so the ordering has to happen in
`finalize_find_results`. The LIMIT was still pushed into the SQL, so the
database returned an arbitrary N rows and the in-memory sort ordered that
arbitrary subset: `find(sort=..., limit=10)` returned the top 10 of an
arbitrary 10 rather than the true top 10.

Withhold the LIMIT whenever the sort falls back to memory, then apply it
after sorting. `SQLiteDB.find` and `DynamoDB.find` already did this.

Also stop re-sorting vector (`$near`) results by the user's `sort`. The
comment says the vector ORDER BY wins when both are present, but forcing
`sort_sql = None` for vector queries meant the in-memory branch fired and
discarded the distance ordering.

Applies to both `PostgresDB.find` and the verbatim copy in
`PostgresTransaction.find`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…value tail

Two defects in `GraphContext.find_page` keyset pagination.

The cursor payload was minted with a flat `last.get(primary_field)`. For a
dotted sort field the value is nested, so every cursor encoded `sort: None`
and the next page compared against it — raising `TypeError: '>' not supported
between instances of 'int' and 'NoneType'` out of `QueryEngine` on JsonDB
rather than paging. Mint the cursor with `resolve_sort_value`, the same path
walk the adapters and `finalize_find_results` use.

Records missing the sort field sort last in both directions, but the keyset
filter `{field: {"$lt": value}}` can never match a record that has no value,
so iteration stopped at the last record that had one and the trailing run was
unreachable. Add a `{field: None}` branch — which matches both an explicit
null and a missing key — and, when the cursor itself was minted inside that
run, walk it by `id` alone.

Extract the path walk from `_find_sort_key` into `resolve_sort_value` and
export it so cursor minting and adapter sorting cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ObjectPager.get_page` fetched a slice with a DB-side `sort` + `limit`, then
re-sorted that page in Python with
`item.get("context", {}).get(order_by, 0)`. A record missing `order_by`
became `0` and sorted among the real values, while the slice that produced
the page had already placed it in the trailing missing-value run. The two
orderings disagree, so a record could appear on two pages or on none.

The blanket `contextlib.suppress(KeyError, TypeError)` around it also left a
page silently in DB order whenever the key raised.

Route the safety net through `finalize_find_results` instead. It is now a
genuine no-op when the backend honored the sort, and matches the nulls-last
contract when it did not.

`test_get_page_with_ordering` was passing on the in-Python re-sort alone —
its mock ignored `sort` and `limit` entirely, so it could not have caught
this. Switch it to `mock_find_respecting_limit`, which the keyset tests in
the same file already use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ract

Three docstrings still said "NULLs sort last for ascending, first for
descending, mirroring finalize_find_results" — the opposite of what the code
emits and of the contract: the module docstring and `translate_sort` docstring
in `_sqlite_translate.py`, and `translate_sort` in `_postgres_translate.py`.
Only the inline comment was corrected when the in-memory ordering was fixed.

Move the `find` sort contract from SPEC §4.2 (capability flags) to §4.1,
beside the `Database` method table it governs, and extend it with the rule
that `limit` must not be pushed down when the sort is not, plus a Known
divergences table: MongoDB's native `cursor.sort()` places missing values
first on ascending sorts (documented, not normalized — compensating needs an
aggregation pipeline on every find), array-index path segments, and
heterogeneous value types.

Document the contract on `Database.find` for adapter authors, and correct
`Database.find_iter`, which claimed a composite `(sort_value, id)` cursor it
does not implement — it tracks `id` only, so a non-`id` sort drops records
that sort late but carry a lower `id`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_find_sort_nulls_last.py` only string-matches the SQL fragments the
translators emit, so the two backends the contract binds hardest had no
runtime assertion. Add end-to-end `SQLiteDB` coverage that runs the same
data through both branches of `find` — ORDER BY pushed into SQL, and the
in-memory `finalize_find_results` fallback reached via an unsafe field path —
and asserts they agree on values-then-missing ordering in both directions and
on the true top N under `limit`.

Also normalize a falsy `sort` to `None` in `SQLiteDB.find`. `sort=[]` failed
the `sort is None` guard and took the untranslatable-sort branch, loading the
whole collection and applying `limit` in memory. Same rows, needless work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.051658 0.037997 -26.4% IMPROVED (-26.4%)
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.050786 0.037792 -25.6% IMPROVED (-25.6%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.540245 0.399139 -26.1% IMPROVED (-26.1%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.190978 0.817302 -31.4% IMPROVED (-31.4%)
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.229175 0.972131 -20.9% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.963805 0.748745 -22.3% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001772 0.001443 -18.6% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.357989 0.232884 -34.9% IMPROVED (-34.9%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.416590 0.283685 -31.9% IMPROVED (-31.9%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.400185 0.274264 -31.5% IMPROVED (-31.5%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.401298 0.279279 -30.4% IMPROVED (-30.4%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.371018 0.247333 -33.3% IMPROVED (-33.3%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.434869 0.279202 -35.8% IMPROVED (-35.8%)

@eldonm eldonm self-assigned this Jul 31, 2026
Resolve the CHANGELOG conflict: both branches added entries under
[Unreleased] at the same spot. The two entry sets are disjoint, so keep
all of them, regrouped under shared Added/Fixed/Changed/Documentation
headings.
@github-actions

Copy link
Copy Markdown

Benchmark comparison

Threshold: ±25% (informational, does not block merge)

benchmark baseline (s) current (s) delta status
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_deferred_save_batched_100 0.050432 0.040029 -20.6% OK
tests/benchmarks/test_deferred_save_benchmarks.py::test_bench_immediate_save_100 0.049681 0.038133 -23.2% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_batched_saves_500 0.501760 0.402373 -19.8% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_empty_query 1.113774 0.850106 -23.7% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_count_filtered 1.201245 1.030541 -14.2% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_find_filtered 0.912655 0.772495 -15.4% OK
tests/benchmarks/test_jsondb_benchmarks.py::test_bench_jsondb_save_throughput 0.001741 0.001482 -14.9% OK
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_empty 0.337289 0.245939 -27.1% IMPROVED (-27.1%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_fallback_via_regex 0.404049 0.296020 -26.7% IMPROVED (-26.7%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_count_pushdown 0.374869 0.253832 -32.3% IMPROVED (-32.3%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_fallback_via_regex 0.389976 0.273743 -29.8% IMPROVED (-29.8%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_find_pushdown 0.353239 0.245399 -30.5% IMPROVED (-30.5%)
tests/benchmarks/test_sqlite_benchmarks.py::test_bench_sqlite_sort_limit_pushdown 0.395112 0.280168 -29.1% IMPROVED (-29.1%)

@eldonm
eldonm merged commit 46e04f0 into main Jul 31, 2026
6 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