Skip to content

feat(query): order_by nulls= first/last placement - #369

Merged
0x054 merged 4 commits into
mainfrom
feat/order-by-nulls
Aug 29, 2026
Merged

feat(query): order_by nulls= first/last placement#369
0x054 merged 4 commits into
mainfrom
feat/order-by-nulls

Conversation

@0x054

@0x054 0x054 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Pin NULL sort-key placement on every order_by() with nulls="first" | "last" (keyword-only). Native NULLS FIRST / NULLS LAST on Postgres and SQLite. Omit the kwarg → backend default, no warning.

cards = (
    await Card.select()
    .order_by(lambda card: card.pinned_at, "desc", nulls="last")
    .order_by(lambda card: card.updated_at, "desc")
    .all()
)
# ORDER BY pinned_at DESC NULLS LAST, updated_at DESC
  • IR v7: optional nulls on an order_by term; key omitted when unset
  • Python API on Query and ProjectedQuery (lambda, string, traversal, aggregate, output alias)
  • Golden vector + backend-matrix e2e
  • Queries guide leads with pinned-first + rendered SQL; dialect split stated once

Stacked: #365 (IR/SQL) → #366 (API) → #367 (e2e) → #368 (docs)

Closes #358
Closes #361
Closes #362
Closes #363
Closes #364

Test plan

Made with Cursor

0x054 and others added 4 commits August 28, 2026 20:08
…#365)

## Summary
- Add optional `nulls` on QueryIR `order_by` terms (`"first"` /
`"last"`, case-insensitive); omit the key when unset so existing
fixtures stay byte-identical at `ir_version` 7
- SELECT walker uses sea-query `order_by_expr_with_nulls` to emit native
`NULLS FIRST` / `NULLS LAST` on Postgres and SQLite; absent `nulls`
keeps plain `ASC`/`DESC`
- Junk tokens (e.g. `"sideways"`) fail loudly as `PyValueError` at
plan/render time

Closes #361

## Test plan
- [x] `cargo test -p ferro-schema-ir` (fixture round-trips + new wire
pin)
- [x] `cargo test --no-default-features --features testing` (includes
nulls SQL render pins on both dialects)
- [ ] CI green on this PR

## Exit steps
- [x] Issue status updates encoded in PR body (`Closes #361`)

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

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
- Add keyword-only `nulls="first"|"last"` to `Query.order_by` and
`ProjectedQuery.order_by` (lambda, string, traversal, aggregate, output
alias), with case-insensitive build-time validation matching
`direction`.
- Thread `nulls` through frozen `OrderByEntry`; `to_ir_dict` emits
`"nulls"` only when set (omit when unset). `ir_version` stays 7.
- Stops at `compile_query` — no Rust SELECT renderer, no golden IR
vectors, no live-DB e2e (those are #361/#363).

Closes #362

## Test plan
- [x] `uv run pytest tests/test_query_column_validation.py
tests/test_order_by_nulls_wire.py -q`
- [x] `nulls=` accepted on Query/ProjectedQuery; keyword-only third
positional is `TypeError`
- [x] Junk `nulls` is `ValueError` naming `first`/`last`;
case-insensitive `FIRST`/`LAST` ok
- [x] Omitted `nulls=` keeps legacy clause equality / no `nulls` key on
wire
- [x] `compile_query` emits `"nulls"` only on set terms; `ir_version ==
7`

## Exit steps
- [x] Issue #362 auto-closed via `Closes #362` in this PR body
- [x] Assignee claimed on issue


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

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary
- Hand-authored `query_card_nulls_v7` golden vector (first `order_by`
term with `nulls`, next without; `ir_version` 7) asserted from the
Python emitter and Rust decoder; existing vectors unchanged.
- Backend-matrix e2e (`tests/test_order_by_nulls.py`) pins identical row
order on SQLite and Postgres for `DESC`+`nulls="last"`, `nulls="first"`,
chained terms, left-joined related `NOT NULL` columns, and projected
`SUM` aggregates with NULL empty groups.
- Contract validator accepts optional `nulls` and restricts present
values to `"first"`/`"last"`.

Closes #363

## Test plan
- [x] `uv run pytest tests/test_order_by_nulls.py
tests/test_query_wire_vectors.py tests/test_ir_vectors_contract.py -q`
- [x] `cargo test -p ferro-schema-ir`
- [ ] CI backend matrix (sqlite + postgres)

## Exit steps
- [x] Issue status: `Closes #363` in PR body; assignee claimed

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

Co-authored-by: Cursor <cursoragent@cursor.com>
## Summary

- Document `order_by(..., nulls="first" | "last")` in the Queries guide
with a pinned-first `Card` example (assignment + Annotated model tabs)
and the rendered `NULLS LAST` SQL.
- State once that omitting `nulls=` on a nullable sort key is
dialect-defined (Postgres `DESC` → NULLs first; SQLite `DESC` → NULLs
last); widen the former `left_join`-only note to cover nullable root
columns and point at `nulls=`.
- Mention the kwarg in the Queries API page; extend runnable
`docs/examples/predicates*.py` companions.

Closes #364

## Test plan

- [x] `uv run pytest tests/test_docs_examples.py -q` (172 passed)
- [x] `uv run python docs/examples/predicates.py`
- [x] `uv run python docs/examples/predicates_annotated.py`
- [ ] Confirm issue #364 closes on merge into the parent stack


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

Co-authored-by: Cursor <cursoragent@cursor.com>
@0x054

0x054 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor Author

Gate pass (coordinator). Full integration vs main reviewed: optional IR nulls (v7, omit when unset), native NULLS FIRST/LAST on both dialects, nulls= on Query + ProjectedQuery, golden + backend-matrix e2e, docs lead with pinned-first. No CHANGELOG.md / uv.lock in the diff. CI green (lint, rust, python, backend matrix). Ready for you to merge — I will not merge to main.

@0x054
0x054 merged commit ef6c8b4 into main Aug 29, 2026
7 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

1 participant