Skip to content

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

Merged
ferenc-csaky merged 21 commits into
mainfrom
feat/graphql-pagination-metadata
Aug 10, 2026
Merged

feat: Add opt-in OffsetPageInfo metadata for paginated GraphQL queries#2191
ferenc-csaky merged 21 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 29.33754% with 224 lines in your changes missing coverage. Please review.
✅ Project coverage is 18.69%. Comparing base (23297b1) to head (ebbea3e).
⚠️ Report is 7 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% 73 Missing ⚠️
...atasqrl/plan/global/PagedRowTimeIndexRewriter.java 0.00% 38 Missing ⚠️
...java/com/datasqrl/server/GraphqlSchemaFactory.java 0.00% 31 Missing ⚠️
...ava/com/datasqrl/server/GraphqlModelGenerator.java 0.00% 20 Missing ⚠️
...tasqrl/server/jdbc/VertxQueryExecutionContext.java 54.54% 17 Missing and 3 partials ⚠️
.../java/com/datasqrl/server/GraphqlSchemaWalker.java 0.00% 14 Missing ⚠️
...va/com/datasqrl/server/GraphqlSchemaValidator.java 0.00% 10 Missing ⚠️
...ain/java/com/datasqrl/server/jdbc/PageRequest.java 64.70% 4 Missing and 2 partials ⚠️
.../java/com/datasqrl/compile/CompilationProcess.java 0.00% 2 Missing ⚠️
...rc/main/java/com/datasqrl/engine/PhysicalPlan.java 0.00% 2 Missing ⚠️
... and 6 more
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2191      +/-   ##
============================================
+ Coverage     18.34%   18.69%   +0.35%     
- Complexity     1157     1212      +55     
============================================
  Files           624      629       +5     
  Lines         18073    18336     +263     
  Branches       2195     2241      +46     
============================================
+ Hits           3315     3428     +113     
- Misses        14431    14577     +146     
- Partials        327      331       +4     

☔ 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 12 commits July 10, 2026 10:02
…dex paged rowtime columns

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…nd document pagination

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…rging main

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…pt-in COUNT query

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…rom main

Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
…aggregate query

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

@ferenc-csaky ferenc-csaky left a comment

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.

Mostly looks good, added 2 minor comments

Comment thread sqrl-planner/src/main/java/com/datasqrl/server/GraphqlModelGenerator.java Outdated
Signed-off-by: Marvin Froeder <marvin@datasqrl.com>
@ferenc-csaky
ferenc-csaky disabled auto-merge August 10, 2026 15:13
@ferenc-csaky
ferenc-csaky enabled auto-merge (squash) August 10, 2026 15:13
@ferenc-csaky ferenc-csaky added enhancement New feature or request graphql labels Aug 10, 2026
@ferenc-csaky ferenc-csaky added this to the 0.11.0 milestone Aug 10, 2026
@ferenc-csaky
ferenc-csaky merged commit 1e37f70 into main Aug 10, 2026
16 checks passed
@ferenc-csaky
ferenc-csaky deleted the feat/graphql-pagination-metadata branch August 10, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request graphql

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants