fix: stop keyset paging from dropping NULL sort rows - #781
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a keyset-pagination correctness bug when sorting sessions by nullable columns (notably user_id and token_id), where rows with NULL sort values could be silently skipped depending on dialect and sort direction. It does so by preserving NULL through cursor encoding/decoding, explicitly stating NULLS FIRST/LAST where supported, and routing keyset predicates on nullable columns through null-aware SQL generation.
Changes:
- Introduces schema-level
Nullablemetadata (plusNullableValue) so bindings and compilers can reliably treat nullable columns asNULLinstead of zero values. - Updates dialect ORDER BY compilation (Postgres/SQLite) to explicitly emit
NULLS FIRST/NULLS LASTfor nullable columns; Spanner documents its fixed behavior. - Adds integration pagination tests for sessions to ensure
NULLblocks are paged correctly in both ASC and DESC directions, plus compiler unit tests pinning emitted SQL.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| internal/storage/v2/stmttest/session_pagination_test.go | New cross-dialect integration tests to ensure paging over (user_id, id) doesn’t drop NULL user_id sessions. |
| internal/storage/v2/dialect/sqlite/session.go | Marks token_id and user_id as nullable and binds SQL NULL instead of "" for absent values. |
| internal/storage/v2/dialect/sqlite/compiler.go | Uses null-aware compare expansion when required; emits NULLS FIRST/LAST in ORDER BY for nullable columns. |
| internal/storage/v2/dialect/sqlite/compiler_test.go | Adds unit tests pinning ORDER BY NULLS behavior and nullable-keyset compare SQL. |
| internal/storage/v2/dialect/spanner/session.go | Marks token_id and user_id as nullable and binds SQL NULL instead of "" for absent values. |
| internal/storage/v2/dialect/spanner/compiler.go | Switches to new null-aware decision function; documents Spanner’s fixed NULL ordering (no clause emitted). |
| internal/storage/v2/dialect/spanner/compiler_test.go | Adds unit tests ensuring Spanner emits no NULLS clause and pins nullable-keyset compare SQL. |
| internal/storage/v2/dialect/postgres/session.go | Marks token_id and user_id as nullable and binds SQL NULL instead of "" for absent values. |
| internal/storage/v2/dialect/postgres/compiler.go | Uses null-aware compare expansion when required; emits NULLS FIRST/LAST in ORDER BY for nullable columns. |
| internal/storage/v2/dialect/postgres/compiler_test.go | Adds unit tests pinning ORDER BY NULLS behavior and nullable-keyset compare SQL. |
| internal/storage/v2/dialect/compare/null_aware.go | Adds NeedsNullAware logic for nullable keyset columns and refines null-safe ordered predicate generation. |
| internal/storage/v2/database/schema.go | Adds Nullable field to bindings, Schema.Nullable, and NullableValue helper for untyped-nil SQL NULL binding. |
| internal/storage/v2/database/filter.go | Adds Keyset flag to CompareFilter; CompareGreater/Less now mark keyset cursor predicates. |
| internal/storage/v2/database/coerce.go | Updates CoerceString to preserve JSON null as Go nil so nullable string cursors bind SQL NULL. |
| internal/storage/v2/database/coerce_test.go | Adds tests for CoerceString(nil) and NullableValue behavior. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 37 changed files in this pull request and generated 5 comments.
Suppressed comments (5)
internal/storage/v2/dialect/sqlite/nullable_bindings_test.go:54
new("user")is invalid Go (built-innewneeds a type). Use a local variable and take its address for the optionalObjectTypepointer.
schema := &domain.JSONSchema{ObjectType: new("user")}
assert.Equal(t, []any{"user"}, jsonSchemaSchema.ValuesFrom(schema, cols))
internal/storage/v2/dialect/postgres/nullable_bindings_test.go:54
new("user")is invalid Go (built-innewneeds a type). Use a local variable and take its address for the optionalObjectTypepointer.
schema := &domain.JSONSchema{ObjectType: new("user")}
assert.Equal(t, []any{"user"}, jsonSchemaSchema.ValuesFrom(schema, cols))
internal/storage/v2/dialect/spanner/nullable_bindings_test.go:54
new("user")is invalid Go (built-innewneeds a type). Use a local variable and take its address for the optionalObjectTypepointer.
schema := &domain.JSONSchema{ObjectType: new("user")}
assert.Equal(t, []any{"user"}, jsonSchemaSchema.ValuesFrom(schema, cols))
internal/storage/v2/database/coerce_test.go:92
new("usr_1")is invalid Go (built-innewrequires a type). Use a local string variable and pass its address intoNullableValue.
assert.Equal(t, "usr_1", database.NullableValue(new("usr_1")))
internal/storage/v2/database/schema.go:21
- The doc comment is inaccurate: the typed-nil issue happens when returning
p(the pointer) asany, not when returning*p(which dereferences). Please adjust the comment to match the actual behavior.
// NullableValue flattens a nil pointer to untyped nil so it binds as SQL NULL.
// Returning *p directly would box a typed nil that fails == nil checks.
func NullableValue[V any](p *V) any {
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Thermo-nuclear code quality reviewVerdict: REQUEST CHANGES Behavior looks correct (stmttest paging + compiler SQL pins are the right proof layers). I’m not approving on that alone. The PR adds a hand-maintained
Happy to re-review once the flag/layer is deleted or there’s a strong justification for keeping a manually synced DDL shadow. |
| if term.Value == nil { | ||
| return true | ||
| } | ||
| if filter.Keyset && schema.Nullable(term.Column) { |
There was a problem hiding this comment.
NeedsNullAware should not consult schema.Nullable. if filter.Keyset (or any term is nil), take the null-aware path. the per-column flag turns one concept into a mode product and recreates the silent-miss hazard this PR is trying to close.
| // The absent state must bind untyped nil: a typed nil slips past the compare | ||
| // compiler's nil checks and binds NULL into an ordered compare. | ||
|
|
||
| func TestTokenSchemaNullableBindings(t *testing.T) { |
There was a problem hiding this comment.
this file is byte-identical to sqlite/ and spanner/ except the package line. that violates the stmttest/dialect test-layering rule and mostly exists to assert the Nullable flag. if we delete the flag, drop these clones; if anything remains, one shared helper — not ×3.
| writeValue(w, term.Value, term.Column) | ||
| w.WriteString(" OR ") | ||
| w.WriteString(col) | ||
| w.WriteString(" IS NULL)") |
There was a problem hiding this comment.
this is a simplicity regression. pre-PR, null-aware OpLess always emitted OR IS NULL — which is a no-op on NOT NULL columns. gating on schema.Nullable adds a branch that only exists to serve the flag. please restore the unconditional form inside this path.
| // Keyset marks cursor predicates built from OrderBy columns. Ordered | ||
| // keyset compares over nullable columns must admit NULL rows beyond the | ||
| // cursor; plain range filters keep standard SQL semantics and exclude NULL. | ||
| Keyset bool |
There was a problem hiding this comment.
Keyset-always on CompareGreater/Less is fine (GreaterThan/LessThan stay the range API). what is not fine is Keyset × Nullable as two orthogonal bits. keep Keyset as the single mode that changes SQL shape; don’t make callers reason about a second flag.
| // Nullable marks columns that can hold SQL NULL. Keyset compares over | ||
| // nullable columns need null-aware SQL, and ORDER BY states their NULL | ||
| // position explicitly (ASC NULLS FIRST / DESC NULLS LAST on every dialect). | ||
| Nullable bool |
There was a problem hiding this comment.
this Nullable flag is the structural problem. it has to be hand-kept against DDL/accessors, and the PR already admits unmarked columns fail closed only by accident. code judo: Keyset-driven always-null-aware compare + always NULLS FIRST/LAST on PG/SQLite makes this field unnecessary. can we delete it instead of sweeping Nullable: true across every binding?
| c.WriteString(" NULLS LAST") | ||
| } | ||
| } else if schema.Nullable(column) { | ||
| c.WriteString(" NULLS FIRST") |
There was a problem hiding this comment.
NULLS FIRST/LAST here is load-bearing for the OpLess-only IS NULL admission in compare. that invariant is currently tribal knowledge across packages. either always emit NULLS on every ORDER BY column (and drop the Nullable check), or move a shared WriteNullsOrder helper into dialect/compare so both sides share one policy.
Fixes #766.
Paging a list ordered by a nullable column silently lost every row whose sort value was NULL, on all three dialects. Two defects combined: nullable bindings flattened nil to a zero value so the cursor never carried a NULL, and no dialect stated NULL ordering in ORDER BY, so the null-aware compare helper's assumption (ASC NULLS FIRST / DESC NULLS LAST) held only by accident on SQLite and Spanner and was contradicted by Postgres.
Changes:
user_idandtoken_idbindings now bind SQL NULL instead of"", via a newdatabase.NullableValuehelper.token_idis NULL until the session token is created;""is never stored.CoerceStringgained a nil branch so a NULL cursor value survives the token round-trip, as timestamps already did.NULLS FIRST(ASC) /NULLS LAST(DESC) in ORDER BY for columns markedNullablein the schema. Spanner rejects the clause but guarantees that ordering by default.Keysetflag onCompareGreater/CompareLessplus theNullableschema flag route keyset compares through null-aware SQL even when the cursor value is non-NULL, so DESC paging keeps admitting the NULL block beyond the cursor.stmttestpagination tests page sessions by(user_id, id)in both directions, including a NULL block spanning a page boundary. Compiler unit tests pin the emitted SQL per dialect.The second commit marks every remaining nullable binding, so the fix holds for whatever becomes a sort column next. Verified against the DDL, that covers the issue's list (
user.lifecycle_owner_team_id, the credential schemas' verification and check timestamps,json_schema.object_type,token.expires_at) plus bindings the sweep found beyond it: crypto keysactivated_at/retired_at,tokens.user_id, the three token session id columns, andusertotp.verified_at. Two accessors (token.expires_at, crypto keys) returned typed nil pointers, which is worse than zero-flattening: a typed nil slips past the compiler's nil checks and binds NULL into an ordered compare. Sentinel-typed fields (token.user_id,usertotp.verified_at) map their never-stored sentinel to NULL, with the convention documented at the binding.Every nullable binding now has a unit test asserting the
Nullableflag is set, the absent state binds untyped nil (asserted with== nil, sinceassert.Nilpasses for typed nils), and the present state binds the dereferenced value. Still open: nothing cross-checks binding flags against the live DDL, so a future nullable column added without the flag goes unnoticed until it becomes a sort column.🤖 Generated with Claude Code