Skip to content

feat(query): omitted order_by nulls= means last - #397

Merged
0x054 merged 2 commits into
feat/position-pagingfrom
feat/392-null-placement-last
Aug 30, 2026
Merged

feat(query): omitted order_by nulls= means last#397
0x054 merged 2 commits into
feat/position-pagingfrom
feat/392-null-placement-last

Conversation

@0x054

@0x054 0x054 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Omitted nulls= on order_by now compiles to last on every backend (breaking change: Postgres DESC and SQLite ASC no longer use dialect defaults).
  • New explicit escape: nulls="native" restores plain ASC/DESC per dialect.
  • QueryIR 11 → 12: every order_by term carries required nulls (first | last | native); missing key fails at decode.

Breaking change

Before (omitted nulls=) After
Postgres DESC → NULLs first NULLs last
SQLite ASC → NULLs first NULLs last

Use nulls="native" when you want the old dialect-default behavior.

Test plan

  • pytest tests/test_order_by_nulls.py — omitted nulls= cross-asserted as last on SQLite
  • pytest tests/test_order_by_nulls_wire.py — v12 wire, every term has nulls, native token
  • pytest tests/test_query_wire_vectors.py — golden vectors at ir_version 12
  • pytest tests/test_ir_vectors_contract.py — contract requires nulls on every order_by term
  • cargo test -p ferro-schema-ir query_order_by query_card_nulls — required nulls decode/roundtrip
  • pytest tests/test_query_column_validation.py — builder accepts native, omitted → last

Closes #392

Made with Cursor

0x054 and others added 2 commits August 30, 2026 10:56
Omitted nulls= compiles to last on every backend; native is the explicit
dialect-default escape. QueryIR bumps to v12 with required nulls on every
order_by term.

Closes #392

Co-authored-by: Cursor <cursoragent@cursor.com>
Future-reject test now uses ir_version 13; add v11 stale-envelope reject.
Repair broken IR vectors README paragraph.

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054
0x054 merged commit ac64cd6 into feat/position-paging Aug 30, 2026
0x054 added a commit that referenced this pull request Aug 30, 2026
## Summary

Keyset `after()` / `before()` derived from a query's `order_by`,
NULL-placement aware (ADR-0018). A position is the ordered tuple of
order-key values; `position_of(row)` reads it; `after(row)` /
`before(row)` are sugar. Cursor encoding stays the caller's.

- Omitted `nulls=` is `"last"` on every dialect (ADR-0017 — **breaking**
vs native PG DESC / SQLite ASC). `"native"` is the explicit escape.
- `after(position)` pages forward (exclusive). `None` is legal in every
non-PK slot so a pinned-first list can cross into unpinned rows in one
query.
- `before(position).limit(n)` is the adjacent previous page in declared
order; unbounded `before()` is the earlier-row prefix (`first()` and
`all()[0]` disagree — documented).
- Traversed order keys and projected records page from a tuple
(`include()` not required). `position_of` still requires populated
relations / selected keys.

QueryIR is v14. One Rust expander (`exclusive_stepwise_compare`) owns
the compare tree; `before` inverts keys then reuses it.

## Test plan

- [x] Slice CI on `feat/position-paging`: #397#403 (rust, python PR,
sqlite+postgres matrix)
- [ ] Confirm CI on this PR against `main`
- [ ] Smoke: `order_by(pinned_at, "desc").order_by(id).after((None,
id))` and `before(pos).limit(n)`

Closes #372


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
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