Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Generate row-value tuple comparison in keyset queries when dialect supports it #4250

Description

@revenant20

Update (2026-05-10): investigation found a simpler smart-OR rewrite that achieves the same Postgres plan without requiring tuple support, on every dialect. See the comment below — the reproduction repo and the proposal can be implemented with that single algebraic rewrite inside KeysetScrollDelegate.createPredicate. The original text is preserved below for context.


Summary

KeysetScrollDelegate.createPredicate always emits keyset condition as a disjunctive
normal form (OR-form), even on databases that natively support row-value tuple
comparison (a, b) < (?, ?). On Postgres this turns what should be a sub-millisecond
Index Cond into a filter scan over millions of rows. I propose detecting the
relevant preconditions and auto-using a tuple shape when they hold, keeping OR-form
as the fallback (Postgres/MySQL/H2 + uniform sort direction + non-null keys is a
common case).

Current behavior

KeysetScrollDelegate.createPredicate (spring-data-jpa
KeysetScrollDelegate.java#L76)
algorithmically builds:

WHERE (a < :ts) OR (a = :ts AND b < :id)

This is implemented through QueryStrategy.compare(Order, expression, value) which
operates on a single column (see KeysetScrollSpecification$JpqlStrategy.compare,
line 155).
A tuple comparison cannot be expressed through this API.

Performance impact (Postgres 17, 2M rows, page ~50 000)

Composite index (status, created_at, id), query: WHERE status = 'PAID' [+ keyset] ORDER BY created_at DESC, id DESC LIMIT 5.

Predicate shape Plan Time Buffers
OR-form (current) Index Cond: status + Filter (Rows Removed by Filter: 999996) 692 ms 1 005 833
(created_at, id) < (?, ?) Index Cond: status AND ROW(...) < ROW(...) 0.27 ms 8

Full EXPLAIN (ANALYZE, BUFFERS) for both shapes is printed by
KeysetSqlShapeTest.explainAnalyze_orForm_vs_rowValue in the reproduction repo
(mvn test runs it).

Two orders of magnitude on a single keyset hop, three on a representative deep page.

The Filter happens because Postgres cannot use the composite index for
(a < ?) OR (a = ? AND b < ?) — it scans matching status rows linearly.
Row-value comparison is a single index range, so it stops at LIMIT. (Postgres can in
theory plan the OR-form via BitmapOr, but does not in this case on tested versions
13–17 because per-branch selectivity estimates remain too high.)

Hibernate already handles HQL tuple comparison: (o.createdAt, o.id) < (:ts, :id)
parses as SqmTuple (HqlParser.g4 TupleExpression and BinaryExpressionPredicate)
and translates to SqlTuple. On dialects where supportsRowValueConstructorGtLtSyntax()
returns true (Postgres/MySQL/H2/CockroachDB/MariaDB), the translator emits the tuple
verbatim; on dialects where it returns false, Hibernate emulates the comparison —
which is the same OR-form rewrite Spring Data is doing now, just one layer up.
Verified by capturing org.hibernate.SQL=DEBUG output of an @Query HQL string.

Why the current shape exists

Three real constraints make OR-form a safe universal default:

  1. Dialect support is mixed. Per Hibernate 7.2.12 the default of
    Dialect.supportsRowValueConstructorGtLtSyntax()
    is true, overridden to false (directly or via supportsRowValueConstructorSyntax)
    in: Oracle, SQL Server, DB2, Sybase, HANA, Spanner, HSQLDB. Asking the dialect
    covers all current and future cases without an explicit list in Spring Data.
  2. Mixed sort directions. Row-value (a, b) < (?, ?) cannot express
    ORDER BY a ASC, b DESC — directions must align. Sort accepts arbitrary mixes,
    so OR-form is the only universal lowering for that shape.
  3. NULL semantics. (NULL, 5) < (1, 10) evaluates to UNKNOWN, which loses rows
    silently. The current JpqlStrategy.compare explicitly handles isNullsLast /
    requiresNonNullTail; equivalent NULL-aware row-value would compose
    (a, b) < (?, ?) OR a IS NULL and partly defeat the optimization.

OR-form is the right universal default. The proposal below is to keep it as a
fallback and use the tuple shape only when all three constraints are statically
known to be safe.

Why not fix it in Hibernate?

A reasonable alternative would be: have Hibernate's SqlAstTranslator recognize
the OR-shape Spring Data emits and rewrite it back to a tuple on dialects that
support GtLt. This does not work — by the time SQL AST sees the predicate it is
just a hand-written disjunction of conditions; the information that "these N
predicates form a keyset tuple" only exists at the Spring Data layer and is lost
in the lowering. The fix has to be made where the intent is still known.

Proposed solution

Auto-detect the three preconditions, no API change. When all of these hold:

  1. Dialect reports supportsRowValueConstructorGtLtSyntax() == true
  2. All Sort.Order entries point in the same direction
  3. All keyset properties are mapped to non-nullable columns (or caller opts in
    declaring NULLs forbidden)

…short-circuit inside KeysetScrollDelegate.createPredicate
to emit a tuple node before the diagonal loop runs. Falls back to the existing
OR-form construction otherwise. The QueryStrategy interface stays the same;
only JpqlStrategy (and its Criteria sibling) gain an internal helper that
produces the tuple expression via JpqlQueryBuilder.

The tuple Predicate composes with arbitrary Specification-built predicates
identically to the OR-form — it's just a different shape produced by the same
strategy — so Specification interop and the existing AOT path are preserved.

If touching JpqlStrategy is undesirable, the alternative is to add the same
logic at the JpqlQueryBuilder level and let it pick the shape based on dialect
metadata available there. Either way no public API changes.

Workaround used today

Custom repository fragment with EntityManager + manual Window.from(items, positionFunction, hasNext) and HQL string with tuple comparison. About 35 lines per
entity. Reference implementation in the reproduction repo.

This is the same shape KeysetScrollDelegate would emit, just bypassed at the
strategy layer.

Related

Reproduction

https://github.com/revenant20/spring-data-keyset-rowvalue-repro — minimal Spring Boot 4.0.6
project. Runs against Testcontainers Postgres. Two test methods capture the SQL emitted
by Spring Data's derived query (Window<T> via findFirst20ByStatus) and by a custom
repository fragment using HQL row-value, asserting the difference. EXPLAIN ANALYZE
output is printed to stdout for both shapes.

mvn test is the full reproduction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions