Skip to content

feat: Add opt-in OffsetPageInfo metadata for paginated GraphQL queries - #2191

Open
velo wants to merge 14 commits into
mainfrom
feat/graphql-pagination-metadata
Open

feat: Add opt-in OffsetPageInfo metadata for paginated GraphQL queries#2191
velo wants to merge 14 commits into
mainfrom
feat/graphql-pagination-metadata

Conversation

@velo

@velo velo commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

What

Adds opt-in pagination metadata for user-authored GraphQL schemas. A query whose result type is a page wrapper — an object with a list field plus a OffsetPageInfo field — returns its rows plus pagination metadata computed from a companion COUNT(*) query.

type Query {
  activityLogs(fromTime: DateTime!, toTime: DateTime!, limit: Int = 100, offset: Int = 0): ActivityLogEntryPage!
}
type ActivityLogEntryPage {
  results: [ActivityLogEntry!]
  pagination: OffsetPageInfo
}

The standard OffsetPageInfo type is auto-injected when referenced but not defined (and validated for an exact match when the user defines it):

type OffsetPageInfo {
  totalRecords: Long!
  pageSize: Int!
  currentPage: Int!
  totalPages: Int!
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  nextOffset: Int          # null when no next page; feeds straight back into `offset`
  prevOffset: Int          # null on first page
  firstEventTime: DateTime # MIN(rowtime col); null if the result has no rowtime column
  lastEventTime: DateTime  # MAX(rowtime col)
}

How

  • Opt-in. Existing list queries are untouched. For inferred/no-schema mode, the new compiler setting compiler.api.paginated-results (default false) generates <Type>Page {results, pagination} wrappers plus the OffsetPageInfo type for every multi-row query and relationship; with the default the inferred schema is unchanged.
  • Detection is shape-based (flexible field names): an object with exactly two fields — one a list of an object type, one of type OffsetPageInfo. Only valid for MANY query functions that declare limit/offset.
  • Compiler (sqrl-planner): GraphqlSchemaWalker sees through the wrapper and validates/walks the element type against the table-function row type; GraphqlModelGenerator emits two companion aggregate queries over FROM (<baseSql>) x: countSql (COUNT(*) only) and countWithEventTimesSql (COUNT(*) + MIN/MAX over the rowtime column, absent when the result has no rowtime). OffsetPageInfoUtil injects/validates the standard type.
  • Runtime (sqrl-server): SqlQuery gains nullable countSql/countWithEventTimesSql (@JsonInclude(NON_NULL) → old vertx.json still loads, existing snapshots unchanged). VertxQueryExecutionContext computes metadata lazily from the selection set: the aggregate query only runs when totalRecords/totalPages (count variant) or firstEventTime/lastEventTime (count+MIN/MAX variant) are selected, and runs in parallel with the data query. When only hasNextPage/nextOffset are selected, no aggregate runs — the data query fetches LIMIT+1 rows and the extra row is trimmed. Selecting only results (or only offset-derived fields like currentPage/hasPreviousPage) adds no extra work at all. Metadata math is a pure static method.

Tests

  • PaginationMetadataTest — 7 unit cases for the metadata math (empty, first/middle/last/beyond-end pages, limit=0, event-time passthrough).
  • UseCaseCompileTest/DAGWriterJsonTest — new clickstream/package-paginated.json usecase snapshots the generated paginated schema (wrappers, OffsetPageInfo, count SQL with bound parameters); all other usecase snapshots are byte-identical, proving the default is backwards compatible.
  • PagedQueryIT — real-Postgres test with a recording SqlClient proving the lazy execution: no aggregate query when only results or offset-derived fields are selected, LIMIT+1 (and trimming) for hasNextPage alone, plain-COUNT variant for totals, COUNT+MIN/MAX variant for event times.
  • GraphQLValidationTest — 6 new schema cases: 2 positive (auto-injected type; user-defined type with non-conventional field names + a paged relationship) and 4 negative (wrong pagination type, missing limit/offset, paged subscription, unknown element field).
  • All touched-module unit tests pass; the 3 aggregate validation snapshots changed only because they enumerate every input .graphqls.

Follow-up

Runtime execution (count query + wrapper assembly) is exercised by unit tests and consistent with the existing list path, but a full-pipeline usecase under FullUseCaseIT (Postgres/Flink/Kafka containers) asserting OffsetPageInfo payloads across page boundaries is a recommended follow-up — it needs the heavy container harness.

🤖 Generated with Claude Code

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.66894% with 209 lines in your changes missing coverage. Please review.
✅ Project coverage is 16.70%. Comparing base (1a5a4fb) to head (3a461bb).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...n/java/com/datasqrl/server/OffsetPageInfoUtil.java 0.00% 69 Missing ⚠️
...atasqrl/plan/global/PagedRowtimeIndexRewriter.java 0.00% 31 Missing ⚠️
...java/com/datasqrl/server/GraphqlSchemaFactory.java 0.00% 31 Missing ⚠️
...tasqrl/server/jdbc/VertxQueryExecutionContext.java 73.68% 24 Missing and 6 partials ⚠️
...ava/com/datasqrl/server/GraphqlModelGenerator.java 0.00% 15 Missing ⚠️
.../java/com/datasqrl/server/GraphqlSchemaWalker.java 0.00% 14 Missing ⚠️
...va/com/datasqrl/server/GraphqlSchemaValidator.java 0.00% 10 Missing ⚠️
.../java/com/datasqrl/compile/CompilationProcess.java 0.00% 4 Missing ⚠️
.../main/java/com/datasqrl/server/PaginationType.java 0.00% 2 Missing ⚠️
...ava/com/datasqrl/config/CompilerApiConfigImpl.java 0.00% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2191      +/-   ##
============================================
+ Coverage     16.34%   16.70%   +0.35%     
- Complexity     1027     1061      +34     
============================================
  Files           618      620       +2     
  Lines         17910    18164     +254     
  Branches       2193     2241      +48     
============================================
+ Hits           2928     3034     +106     
- Misses        14683    14826     +143     
- Partials        299      304       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@mbroecheler

Copy link
Copy Markdown
Contributor

I like the idea to add this. Would be nice if we could also generate this GraphQL schema code.

Couple of thoughts:

  1. I would prefer a neutral name like OffsetPageInfo instead of SqrlPagination. Since this needs to be hardcoded, we don't want sqrl to be leaking through user's API schema.
  2. A critical aspect that OffsetPageInfo is computed lazily based on what users are actually selecting in their queries. In particular first/lastEventTime is expensive to compute and should only be retrieved when it is actually needed. Same for hasNextPage - which I assume is computed by fetching LIMIT +1 - we don't need to fetch that extra record if that field isn't queried. Same goes for totalRecords / totalPages.

@velo

velo commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Would be nice if we could also generate this GraphQL schema code.

I think we would need a hint for that right?!

Or do we want to always generate as paginated?

…azily from the selection set

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@velo velo changed the title feat: Add opt-in SqrlPagination metadata for paginated GraphQL queries feat: Add opt-in OffsetPageInfo metadata for paginated GraphQL queries Jul 8, 2026
@mbroecheler

Copy link
Copy Markdown
Contributor

I think we would need a hint for that right?!

Or do we want to always generate as paginated?

Yes, but not a hint - it would be a compiler config setting to generated paginated, default false (for now to be backwards compatible).

velo added 2 commits July 8, 2026 12:11
Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…raphQL schema

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@velo

velo commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Neutral name OffsetPageInfo ✔️
Lazy on OffsetPageInfo fields ✔️
Configuration to enable OffsetPageInfo ✔️

@velo
velo requested a review from ferenc-csaky July 9, 2026 14:11
Comment thread sqrl-planner/src/main/java/com/datasqrl/config/GraphqlSourceLoader.java Outdated
velo added 3 commits July 9, 2026 13:59
…ecting it

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…e when limit is unbounded

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…wtime column

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@velo
velo enabled auto-merge (squash) July 10, 2026 11:55
Comment thread sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java Outdated
Comment thread sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes in this class are write-only AI code completely and i do not like it one single bit.

The newly introduced OffsetPageInfo reuses the LIMIT_AND_OFFSET code and it results in some weird mixed execution I do not want to debug 3months from now.

I suggested to introduce a new pagination type in my other comment. We should use that here and separate the executions, possibly reusing any common logic needed for LIMIT_AND_OFFSET, but we need clear abstraction layers and an execution flow that a human can debug without losing their sanity.

velo and others added 6 commits July 10, 2026 10:02
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.

3 participants