Skip to content

Return Long from count() instead of silently narrowing to Integer - #16323

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/gorm-count-returns-long-8.0.x
Open

Return Long from count() instead of silently narrowing to Integer#16323
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/gorm-count-returns-long-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

count() returns Long instead of Integer, matching what the datastore actually produces.

The problem

GormStaticOperations declares:

/**
 * Counts the number of persisted entities
 * @return The number of persisted entities
 */
Integer count()

How wide the value underneath actually is depends on the datastore:

Datastore Underlying count value
Hibernate 5 / 7 SQL COUNT(*) — 64-bit
Neo4j Cypher count(*) — 64-bit
MongoDB {$sum: 1} — Int32, promoting to Int64 once the total exceeds it
Simple map (in-memory) entityList.size()int

GormStaticApi normalises all of them with longValue(), and the declared return type then narrowed the result again. Every site did that narrowing a different way:

Site Narrowing
GormStaticApi ((Number)res).intValue()
Hibernate 5 builds a CriteriaQuery<Long>, then casts (Integer)
Hibernate 7 select count(*) ... as Integer
GraphQL CountEntityDataFetcher declared Integer, so the returned Long is narrowed on return
GraphQL schema field typed Int, which is 32-bit by GraphQL specification

MongoDB and Neo4j need no change of their own: neither overrides count(), so both inherit
GormStaticApi and are fixed by the core change. MongoDB is the sharpest case for declaring Long, since
the width it returns depends on how many documents are in the collection — a collection that grows past
2³¹ starts returning Int64, which is the correct answer, and intValue() then wrapped it.

intValue() truncates silently, so a table with more than Integer.MAX_VALUE rows reported a wrong and possibly negative count with no error at all. Long is the only type that holds every value a datastore can produce here, so declaring it removes the narrowing rather than moving it around.

RestfulServiceController gets simpler as a direct consequence — it was calling Math.toIntExact(getService().count(params)) on a service that already returned a Long, narrowing purely to fit the controller signature. That call is gone.

Scope

GormStaticOperations.count() / getCount(), the count() / count members generated on every domain class via GormEntity, TenantDelegatingGormOperations, both Hibernate backends, and RestfulController.countResources().

Plus the GraphQL count fetcher and its schema field, which changes the published schema from personCount: Int to personCount: Long (graphql-java maps Long to ExtendedScalars.GraphQLLong). Queries selecting the field and the JSON they return are unchanged, but generated clients and schema snapshots need regenerating.

grails-datamapping-rx needed no change: RxGormStaticOperations.count() already returns Observable<Number> and never committed to Integer.

Compatibility

Dynamic Groovy is unaffected. Book.count() == 5, int n = Book.count(), and arithmetic on the result all continue to work, because Groovy converts between numeric types on assignment and compares them by value. What breaks is statically compiled code assigning the result to an Integer, Java callers relying on unboxing, and subclasses overriding countResources(). Documented as section 54 of the 8.0 upgrade notes with before/after examples.

Typed views

Worth calling out, because it is the same defect twice. Typed view models are bound by reflective Field.set with no coercion — in GSP (GroovyPage.applyModelFieldsFromBinding) and in JSON views (WritableScriptTemplate.FieldSetter) alike. A view that names a concrete numeric type therefore fails outright when a controller supplies the other one, and the two scaffolding paths supply different types: generate-views pairs with a generated service declaring Long count(), while static scaffold = X is backed by RestfulController.countResources().

So the scaffolded index.gsp and both product/index.gson views now declare Number, which accepts whatever a controller supplies. HalViewHelper.paginate accordingly takes a Number total (converted once at the internal getPaginationLinks boundary) so a count can reach it without being narrowed again on the way in.

The paginate signature change is the one piece here that is not strictly about count(). It is included because without it the in-tree JSON views cannot compile against a Long count. Happy to split it out if reviewers prefer.

Relationship to #16322

#16322 changes the scaffolded index.gsp count field to Number for the same underlying reason. This branch is cut from 8.0.x, where that file still declares Integer, and the grails-test-examples-gorm scaffolding functional tests fail without it — so the identical one-line change is included here to keep this PR green on its own. The two resolve to the same line and should merge cleanly in either order.

@codeconsole
codeconsole force-pushed the feat/gorm-count-returns-long-8.0.x branch 2 times, most recently from 9f921fd to cdeffcf Compare September 8, 2026 02:37
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.47059% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.0410%. Comparing base (0980623) to head (af45fc2).

Files with missing lines Patch % Lines
...rm/hibernate/AbstractHibernateGormStaticApi.groovy 50.0000% 0 Missing and 1 partial ⚠️
...ovy/org/grails/datastore/gorm/GormStaticApi.groovy 50.0000% 0 Missing and 1 partial ⚠️
...plugin/scaffolding/RestfulServiceController.groovy 0.0000% 1 Missing ⚠️
...json/view/api/internal/DefaultHalViewHelper.groovy 50.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16323        +/-   ##
==================================================
- Coverage     55.0529%   55.0410%   -0.0118%     
  Complexity      20773      20773                
==================================================
  Files            2110       2110                
  Lines          101368     101368                
  Branches        18005      18005                
==================================================
- Hits            55806      55794        -12     
- Misses          37517      37532        +15     
+ Partials         8045       8042         -3     
Files with missing lines Coverage Δ
...multitenancy/TenantDelegatingGormOperations.groovy 96.4602% <ø> (ø)
...c/main/groovy/grails/rest/RestfulController.groovy 1.0309% <ø> (ø)
...son/view/api/internal/DefaultJsonViewHelper.groovy 77.4194% <100.0000%> (ø)
...rm/hibernate/AbstractHibernateGormStaticApi.groovy 68.4834% <50.0000%> (ø)
...ovy/org/grails/datastore/gorm/GormStaticApi.groovy 76.1290% <50.0000%> (ø)
...plugin/scaffolding/RestfulServiceController.groovy 0.0000% <0.0000%> (ø)
...json/view/api/internal/DefaultHalViewHelper.groovy 53.8235% <50.0000%> (ø)

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@jdaugherty

Copy link
Copy Markdown
Contributor

Under compilestatic code wouldn't this be considered a breaking change since there wouldn't be type coercion? Would it then be a better change to return a type that extends Number, ie:

     T <T extends Number> count()

@codeconsole

codeconsole commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

You're right that it's a breaking change for statically compiled callers — that's intentional, and 8.0.x is the branch where it's allowed. But the generic form is worth testing rather than reasoning about, so I ran all three signatures against Groovy 5.1.2, each called from @CompileStatic code written against the old API (Integer n = api.count()), with an implementation returning 3_000_000_000L:

Signature Result for Integer n = api.count()
<T extends Number> T count() compiles, returns -1294967296
Long count() compile error — loss of precision from java.lang.Long to java.lang.Integer
Number count() compile error — Cannot assign value of type java.lang.Number to variable of type java.lang.Integer

The generic signature does remove the compile error, but it doesn't add coercion — it erases to Object and lets the call site insert the cast. T is chosen by the caller, while the implementation can only ever return the one type the datastore produced, so the truncation survives and now shows up as a negative row count at runtime instead of a build failure. That is the bug this PR exists to remove, made invisible. Erasure also means the implementation can't inspect T and convert to it, so there's no version of that signature that could coerce.

Number breaks the same callers, so it doesn't avoid the migration either — it just hands callers a type they have to unwrap before using.

So there's no non-breaking option here: any type wide enough to hold what the datastores actually return is unassignable to Integer under STC. Given that, the compile error seems like the feature rather than the cost — it points at precisely the call sites that were silently truncating, and the fix at each is a one-word type change. §54 of the upgrade notes covers the migration, including the Java-caller and GraphQL-client cases.

Worth noting where this actually bites: MongoDB aggregates counts with {$sum: 1}, which returns Int32 and promotes to Int64 once the total exceeds it. So on a large collection GORM was receiving the correct 64-bit count and intValue() was wrapping it — no error, just a wrong number.

@jdaugherty

Copy link
Copy Markdown
Contributor

We discussed this in the weekly and there was agreement the breaking change is acceptable for 8.0. Reviews can proceed now.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should keep this review focused on count() if we're only going to partially implement the Long in the pagination code. If we fully implement Long in the pagination, then I'm fine with updating the paging logic

not an error in Groovy, so the probe changed nothing it could observe. An application that relied on the
import being *omitted* when the package was absent sees no difference in compiled output.

==== 54. `count()` Returns `Long`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This file conflicts with 8.0.x on merge, and 8.0.x now numbers sections through 57, so after a rebase this becomes §58.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Rebased onto current 8.0.x — it's §58 now.

Separately: 8.0.x already has ==== 54. twice, Request Processing Behaviour Changes (3256) and Non-Public Bean Classes Are Marshalled, and Reported Once (3454). Left both alone since neither is mine, but you may want to renumber the second.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you go ahead and fix the other numbering problem please? @codeconsole

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in af45fc2 — should have just fixed it rather than pointing at it, sorry.

"Non-Public Bean Classes Are Marshalled, and Reported Once" is §58 and count() moves to §59. Top-level sections now run 1–59 with no duplicates and no gaps.

Comment thread grails-doc/src/en/guide/upgrading/upgrading80x.adoc Outdated
n == 0L
}

void "count() returns a Long so a large table is not truncated"() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The name promises the truncation case, but the body only checks declared types. Nothing pushes a value past Integer.MAX_VALUE through the longValue() branch this PR changed, which is the behaviour that was broken. Stubbing the session gets at it directly, and this passes on the branch (needs org.grails.datastore.mapping.query.Query imported):

void "count() preserves a datastore count above Integer.MAX_VALUE"() {
    given:
    def ds = Stub(Datastore)
    def session = Stub(Session)
    def query = Mock(Query)
    ds.getMappingContext() >> datastore.mappingContext
    ds.connect() >> session
    session.getDatastore() >> ds
    session.createQuery(GormStaticApiThing) >> query
    query.projections() >> Mock(Query.ProjectionList)
    query.singleResult() >> 3_000_000_000L
    def api = new GormStaticApi(GormStaticApiThing, ds, [])

    expect:
    api.count() == 3_000_000_000L
}

Worth also asserting the trait path, since that is what domain classes actually expose: GormStaticApiThing.count() instanceof Long and GormStaticApiThing.count instanceof Long both hold against the SimpleMapDatastore this spec already has. The two getMethod(...).returnType == Long lines can go once the behaviour is covered; they restate the signature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Taken as written, plus GormStaticApiThing.count() / .count for the trait path, and the two getMethod(...).returnType lines are gone.

Checked it isn't vacuous the way the old one was: reverting longValue() to intValue() makes it fail, restoring it makes it pass.


final String countFieldName = namingConvention.getCount(entity)
final GraphQLOutputType countOutputType = (GraphQLOutputType) typeManager.getType(Integer)
final GraphQLOutputType countOutputType = (GraphQLOutputType) typeManager.getType(Long)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing asserts the type the count field is built with; ReadOnlyOpSpec only checks the field exists. A type == ExtendedScalars.GraphQLLong assertion next to that check (or in SchemaSpec) would pin this.

Also worth a sentence in the upgrade note: paginated list responses already type totalCount as Long through the same type manager (DefaultGraphQLPaginationResponseHandler), so the count field now matches an existing scalar in the schema rather than introducing a new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added — in SchemaSpec rather than ReadOnlyOpSpec, because the only test in the latter is @Ignored, so an assertion there would never execute. It asserts every *Count query field is ExtendedScalars.GraphQLLong, with a non-empty guard so it can't pass by matching nothing.

Added the totalCount sentence to the upgrade note too — confirmed at DefaultGraphQLPaginationResponseHandler:52, which already builds that field with typeManager.getType(Long).

* @param total The total number of objects to be paginated
*/
void paginate(Object object, Integer total)
void paginate(Object object, Number total)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The paginate options seem additional to this review, Number allows BigDecimal, why aren't we making this a Long too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — Number admits BigDecimal and says less than the code knows. All five paginate overloads take Long now, and so does links(Map, Object, Long total), which was pre-existing but truncating for the same reason.

*/
//TODO: Once GROOVY-9662 is fixed, remove explicit delegate call and typecast to StreamingJsonDelegate
void paginate(Object object, Integer total, Integer offset = null, Integer max = null, String sort = null, String order = null) {
void paginate(Object object, Number total, Integer offset = null, Integer max = null, String sort = null, String order = null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why aren't we changing this to Long?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — Long total.

((StreamingJsonBuilder.StreamingJsonDelegate) delegate).call(TYPE_ATTRIBUTE, contentTypeMimeType ?: contentType)
}
List<Link> links = getPaginationLinks(object, total, max, offset, sort, order) as List<Link>
List<Link> links = getPaginationLinks(object, total?.intValue(), max, offset, sort, order) as List<Link>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why are we forcing this to intValue() and not converting it to long too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gone. getPaginationLinks takes Long, and so do the offsets derived from a total — getNextOffset, getLastOffset, getPrevOffset, paramsWithOffset, buildPaginateParams.

The offsets mattered as much as the total: getLastOffset computes (ceil(total / max) - 1) * max, so the offset overflows the moment the total stops fitting in an Int. Parameters already exposed long(name, default) for the request path.

@codeconsole
codeconsole force-pushed the feat/gorm-count-returns-long-8.0.x branch 2 times, most recently from 4d95dc6 to 38f736e Compare September 9, 2026 22:13
GormStaticOperations declared `Integer count()`, but the datastore has
always produced a Long: COUNT(*) is a 64-bit value in SQL. Every backend
narrowed the result to satisfy the declared type, and each did it
differently — GormStaticApi called intValue(), Hibernate 5 cast the
Number, Hibernate 7 coerced with `as Integer`. intValue() truncates
silently, so a table with more than Integer.MAX_VALUE rows reported a
wrong and possibly negative count without failing.

Declare what the query actually returns. RestfulServiceController loses
its Math.toIntExact call, because the service it delegates to already
returned a Long and was narrowing only to fit the controller signature.

Dynamic Groovy is unaffected: `Book.count() == 5` and `int n = Book.count()`
still work, because Groovy converts and compares numeric types by value.
Statically compiled callers and anything overriding these methods need
updating, so this is documented in the 8.0 upgrade notes.

Typed views bind their model by reflective Field.set with no coercion, in
GSP and in JSON views alike, so a view naming a concrete numeric type
fails when the other scaffolding path supplies the other type. The
scaffolded index.gsp and the two product index.gson views now declare
Number, and HalViewHelper.paginate accepts a Number total so a count can
reach it without being narrowed again on the way in.

The scaffolded index.gsp change also appears in the fix for the Integer
declaration it previously carried; the two resolve to the same line.
CountEntityDataFetcher declared Integer and returned staticApi.count()
directly, so it narrowed the same way the datastore APIs did. The class is
@CompileStatic, so once count() returns Long this no longer compiles:

    [Static type checking] - Possible loss of precision
    from java.lang.Long to java.lang.Integer

The schema field was typed with typeManager.getType(Integer), and GraphQL's
Int is 32 bits by specification, so counts above Integer.MAX_VALUE were
truncated here as well. Type the field with Long, which the type manager
maps to ExtendedScalars.GraphQLLong.

This changes the published schema from `personCount: Int` to
`personCount: Long`. Queries selecting the field and the JSON they return
are unchanged, but generated clients and schema snapshots need
regenerating, so it is called out in the upgrade notes.
The note claimed the datastore always produced a Long. It does not.
Hibernate counts with SQL COUNT(*) and Neo4j with Cypher count(*), both
64-bit, but MongoDB aggregates with {$sum: 1}, which returns Int32 and
promotes to Int64 once the total exceeds it, and the in-memory datastore
returns a list size. GormStaticApi normalises all of them with
longValue(), so the code was already correct; only the explanation was
wrong. Long is the right declaration because it is the only type that
holds every value a datastore can produce, not because every datastore
already produced one.
Renumber the upgrade note to 58, since 8.0.x has since used 54 through
57, and fix the Java example: the trait's static methods land on the
domain class, so the caller writes Book.count(), and `long total =
Book.count()` shows the auto-unboxing directly.

Replace the count spec with one that exercises the behaviour instead of
restating the signature. It stubs a datastore count of 3_000_000_000 and
asserts the value survives, which fails if longValue() goes back to
intValue(), and covers the trait members a domain class actually exposes.

Assert the GraphQL count scalar, in SchemaSpec rather than ReadOnlyOpSpec
because the only test in the latter is @ignore'd and would never run. Note
in the upgrade note that paged results already type totalCount as Long
through the same type manager.

Carry Long through the JSON view pagination rather than converting on the
way in: paginate, links, getPaginationLinks, and the offsets derived from
a total. An offset computed from a total past 2^31 would otherwise
overflow the moment the total stopped doing so. Parameters already exposed
long(name, default) for the request path, and the two product index.gson
views now declare the Long their controller supplies.
Two sections were numbered 54: "Request Processing Behaviour Changes" and
"Non-Public Bean Classes Are Marshalled, and Reported Once", the latter
having been appended after 55 through 57 already existed. Number it 58,
and move the count() section that followed it to 59. The top-level
sections now run 1 to 59 with no duplicates and no gaps.
int offset = params.int(PAGINATION_OFFSET, 0)
protected List<Link> getPaginationLinks(Object object, Long total, Parameters params) {
long offset = params.long(PAGINATION_OFFSET, 0L)
int max = params.int(PAGINATION_MAX, 10)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't max be a long too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No — max is a page size, and GORM takes it as an int at the source (Query.max(int), Query.maxResults(int)). total and offset are positions in the result set, which is what can exceed 2^31.

Every expression mixing them already widens through the Long operand — offset + max, Math.max(offset - max, 0L), laststep * max — so nothing truncates.

@testlens-app

testlens-app Bot commented Sep 10, 2026

Copy link
Copy Markdown

🚨 TestLens detected 1 failed test 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

CI / Functional Tests (Java 21, indy=true) > :grails-test-examples-scaffolding:integrationTest

Test Runs Flakiness
UserControllerSpec > User list ❌ 🚫 ❌ 5% 🟠

🏷️ Commit: af45fc2
▶️ Tests: 29901 executed
⚪️ Checks: 91/91 completed

Test Failures

UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 21, indy=true))
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more
UserControllerSpec > User list (:grails-test-examples-scaffolding:integrationTest in CI / Functional Tests (Java 21, indy=true) | Attempt 1/3)
geb.waiting.WaitTimeoutException: condition did not pass in 30 seconds (failed with exception)
	at geb.waiting.Wait.waitFor(Wait.groovy:128)
	at geb.waiting.DefaultWaitingSupport.doWaitFor(DefaultWaitingSupport.groovy:55)
	at geb.waiting.DefaultWaitingSupport.waitFor(DefaultWaitingSupport.groovy:41)
	at geb.Page.waitFor(Page.groovy:120)
	at com.example.pages.LoginPage.login(LoginPage.groovy:39)
	at com.example.UserControllerSpec.User list(UserControllerSpec.groovy:48)
Caused by: Assertion failed: 

title != pageTitle && $('input', name: 'username').empty
|     |  |         |
|     |  |         false
|     |  'Please sign in'
|     false
'Please sign in'

	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy:39)
	at com.example.pages.LoginPage.login_closure1(LoginPage.groovy)
	at geb.waiting.Wait.waitFor(Wait.groovy:117)
	... 5 more

Rerun Controls

Select tests to mute in this pull request:

  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app/docs.

@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

AI Review

Thanks, the direction is right and the core change is complete: every GormStaticOperations.count() implementation in the repo is updated (Mongo and Neo4j go through GormStaticApi.count() and need nothing), and the tests for grails-datamapping-core, grails-views-gson and grails-data-graphql-core pass locally. The GSP template, the RestfulServiceController and the section-58 renumbering all look correct.

A few things to address before merge.

1. The JSON API pagination path still truncates

DefaultJsonApiViewHelper (around line 478) was not touched:

Integer total = (Integer) paginationArgs.get(PAGINATION_TOTAL)
...
List<Link> links = getPaginationLinks(resource, total, params)

Under @CompileStatic that cast is Groovy's numeric cast, not a checkcast, so a Long coming from Book.count() is silently wrapped (I verified (Integer) 3_000_000_000L gives -1294967296). That is the exact bug this PR is fixing, still present in jsonapi.render(list, [pagination: [total: Book.count(), resource: Book]]). Suggest:

Long total = ((Number) paginationArgs.get(PAGINATION_TOTAL)).longValue()

and a row in IterableRenderSpec with a total above Integer.MAX_VALUE that asserts the last link offset.

2. PaginationSpec was not updated for the changed signatures

getPaginationLinks, getPrevOffset, getNextOffset and getLastOffset all changed from Integer to Long, but PaginationSpec still declares the results as Integer and only exercises int-range values. It passes because dynamic Groovy converts on assignment, so it no longer pins the type it is testing. Please switch the declarations to Long and add at least one row above Integer.MAX_VALUE, for example getLastOffset(3_000_000_000L, 10) == 2_999_999_990L. That row is the behaviour the change exists to enable, and it also covers the Math.round(Math.ceil(...)) to (long) Math.ceil(...) rewrite.

3. offset in HalViewHelper.paginate stayed Integer

total is now Long, and the protected helpers all take Long offset, but the public paginate(Object, Long total, Integer offset, ...) overloads still take Integer offset. A page past row 2^31 can be described by total but not requested through paginate. Low priority, but widening offset in the same PR keeps the surface consistent instead of doing it in a second breaking change later.

4. Upgrade note

  • "GORM normalises whatever it gets with longValue(), then the declared return type narrowed it again with intValue()" describes the new code as if it were the old pipeline. The pre-change GormStaticApi.count() called intValue() directly. Suggest: "GormStaticApi.count() narrowed the datastore result with intValue(), which truncates silently, so ...".
  • The PR had to change Integer productCount to Long productCount in two .gson model {} blocks, but the note only mentions the GSP @{ model=... } directive. Please add a sentence for JSON views: a model { Integer fooCount } declaration should become Long (or Number).
  • Worth a line that HalViewHelper.paginate() and links() now declare total as Long. Callers are unaffected because Groovy widens Integer to Long in both dynamic and statically compiled code (verified), but anyone implementing HalViewHelper has to update.

Minor / no action needed

  • GormStaticApiSpec's new large-count test drives everything through Datastore, Session and Query, so it stays on the public surface. Good.
  • SchemaSpec pinning the GraphQLLong scalar is a good regression guard for the schema-level change.
  • Custom GraphQL count fetchers registered through the data fetcher manager that still return Integer keep working, since the Long scalar coerces integers, so the type change is only breaking for generated clients, which the note already says.

@jdaugherty

Copy link
Copy Markdown
Contributor

@codeconsole looks like Mattias found some more ints for you to change =) I'm good to merge once these are changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants