Skip to content

GORM Native Datastore Id Support - #16222

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/gorm-native-id-type-8.0.x
Open

GORM Native Datastore Id Support #16222
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:feat/gorm-native-id-type-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

A domain class that declares no id has always been given a Long one. That is right for Hibernate and wrong for MongoDB, where a String id holding a generated ObjectId needs no sequence collection and shards cleanly. Declaring String id gets that, but ties the source to MongoDB — the same class compiled against Hibernate would need Long id instead.

This adds a build setting that lets each domain class take the identity type of the GORM implementation it is actually mapped with:

// build.gradle
grails {
    gorm {
        defaultIdType = 'native'
    }
}
// grails-app/domain/example/Person.groovy — names no store
class Person {
    String name
}

Compiled with GORM for MongoDB, Person is given a String id. Compiled with Hibernate, it keeps Long. In an application using both, each domain class gets the right type from the single setting, resolved from its mapWith property. A domain class that declares an id keeps the type it declares.

The default is defaultIdType = 'long', which is the behaviour of every earlier release.

Commit 1 — the identity type

How it resolves

GormEntityTransformation already worked out which GORM implementation an entity belongs to, at compile time, in pickGormEntityTrait — from mapWith plus the GormEntityTraitProviders on the compilation classpath. That resolution now also supplies the identity type, through a new default method on the SPI:

interface GormEntityTraitProvider {
    Class getEntityTrait()
    boolean isAvailable()
    default Class getDefaultIdentityType() { Long }
}

MongoEntityTraitProvider returns String. Hibernate and Neo4j inherit Long. Since the trait and the identity type now come from a single resolution, the two cannot disagree.

The setting reaches the compiler as a system property published by the Gradle plugin, the same way grails { compileStatic { } } already does — a CommandLineArgumentProvider on GroovyCompile.groovyOptions.forkOptions.jvmArgumentProviders, with the effective value exposed as an @Input so that changing it invalidates the compile task. A stale class file would otherwise keep the type the previous setting asked for.

EntityASTTransformation now runs the discovered domain injectors before DefaultGrailsDomainClassInjector. It ran that injector first, and it unconditionally adds a Long id, so GORM never got to decide the type on the @grails.persistence.Entity path. Each of the default injector's injections is guarded on the property not already being present, so it still fills in everything GORM did not.

Limitations

  • The identity type is compiled into the class. The setting reaches the compiler through the Gradle plugin, so an IDE configured to compile without Gradle produces Long ids.
  • Turning this on changes the type of a column or field that already holds data. It is a setting to choose when an application is written, not one to switch on over an existing database.
  • A domain class compiled into a published plugin jar has its identity type fixed at plugin build time, not at consuming-application build time.
  • RX entities are unchanged and keep Long.

Commit 2 — bind a Serializable action parameter

An action parameter typed Serializable was treated as a command object type. Being an interface it could not be one, so ControllerActionTransformer warned "Interface types and abstract class types are not supported as command objects. This parameter will be ignored" and bound null.

Serializable is the type a domain class identifier is declared as when the action does not know the type itself, and it is what GormEntity.get(Serializable) accepts. It is now bound the way a String parameter is — the raw request value, which is a String and so a Serializable. GORM converts it to the identity type on the way into get(), returning null for a value that cannot be converted, so a non-numeric id against a Long-id domain still gives a 404 rather than an error.

The dispatch compares the declared type exactly rather than testing assignability, so a command object that implements Serializable — as many do — is still data bound as a command object. That case is covered by a test.

Commit 3 — generate scaffolded controllers with a Serializable id

The generated controllers declared show, edit, update and delete as taking a Long id. A MongoDB domain class declaring String id — the form the GORM for MongoDB guide recommends — bound null instead, and every one of those actions returned notFound(). That is a pre-existing bug, independent of the setting above.

They now declare Serializable id, which works for Long, String and ObjectId domain classes alike. The generated services already declared get(Serializable id) and delete(Serializable id), so this makes the controller agree with the service it calls. Applies to both the grails-scaffolding templates and the rest-api profile.

Documentation

  • grails-data-mongodb — new Native Identity Types section under Identity Generation
  • grails-doc — What's New in 8.0

A domain class that declares no id has always been given a Long one. That
is right for Hibernate and wrong for MongoDB, where a String id holding a
generated ObjectId needs no sequence collection and shards cleanly.
Declaring String id gets that, but ties the source to MongoDB: the same
class compiled against Hibernate would need Long id instead.

Add a build setting that asks each domain class for the identity type of
the GORM implementation it is mapped with:

    grails {
        gorm {
            defaultIdType = 'native'
        }
    }

GormEntityTransformation already resolved that implementation at compile
time, in pickGormEntityTrait, from mapWith plus the GormEntityTraitProviders
on the compilation classpath. That resolution now also supplies the identity
type, through a new default method on the SPI, so the trait and the id type
come from a single decision and cannot disagree. MongoEntityTraitProvider
returns String; Hibernate and Neo4j inherit Long.

The setting reaches the compiler as a system property published by the
Gradle plugin, the same way grails { compileStatic { } } already does, with
the effective value exposed as an @input so that changing it invalidates the
compile task. The default is 'long', which is the behaviour of every earlier
release.

EntityASTTransformation now runs the discovered domain injectors before
DefaultGrailsDomainClassInjector. It ran that injector first, and it
unconditionally adds a Long id, so GORM never got to decide the type on the
@grails.persistence.Entity path. Each of the default injector's injections
is guarded on the property not already being present, so it still fills in
everything GORM did not.
An action parameter typed Serializable was treated as a command object
type. Being an interface it could not be one, so it produced the warning
"Interface types and abstract class types are not supported as command
objects. This parameter will be ignored" and bound to null.

Serializable is the type a domain class identifier is declared as when the
action does not know the type itself - Long under Hibernate, String under
MongoDB - and it is what GormEntity.get(Serializable) accepts. Bind it the
way a String parameter is bound: the raw request parameter, which is a
String and so a Serializable. GORM converts it to the identity type on the
way into get(), returning null for a value that cannot be converted, so a
non-numeric id against a Long-id domain still gives a 404 rather than an
error.

The dispatch compares the declared type exactly rather than testing
assignability, so a command object that implements Serializable - as many
do - is still data bound as a command object.
The generated controllers declared show, edit, update and delete as taking
a Long id, which is only right for a domain class whose identifier is a
Long. A MongoDB domain class declaring String id - the form the GORM for
MongoDB guide recommends - bound null instead and every one of those
actions returned notFound().

Declare the parameter Serializable, which binds the raw request value and
lets GORM convert it to whatever the domain class declares: Long, String or
ObjectId. The generated services already declared get(Serializable id) and
delete(Serializable id), so this makes the controller agree with the
service it calls.
@codeconsole codeconsole changed the title Let a GORM domain class take its identity type from its datastore GORM Native Datastore Id Support Aug 25, 2026
The provider was tested in isolation and the entity transformation was
tested against the system property, but nothing exercised the step between
them: the plugin attaching the provider to compileGroovy. Deleting that line
left every test passing while the setting stopped reaching the compiler and
every domain class silently kept a Long id.

Add a TestKit consumer project that states the setting the way an
application does, through grails { gorm { defaultIdType } }, and reads the
compiler worker JVM arguments back off compileGroovy. Removing the wiring
now fails two of its four cases.
The Grails Gradle plugin publishes the property name from BuildSettings and
the entity transformation reads it from a constant of its own. They are in
separate Gradle builds that cannot reference each other, so the name is
duplicated - as the compileStatic artefact opt-ins already duplicate theirs.

Nothing stopped the two drifting apart. The Gradle side asserts the literal
already; the transformation side referred to its constant symbolically, so
renaming that constant broke the feature with every test still passing, and
a build publishing a name the compiler no longer reads fails silently.

Assert the literal on the transformation side too, so a rename on either
side fails.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.00000% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.1231%. Comparing base (27b6097) to head (6e2655d).

Files with missing lines Patch % Lines
...ails/compiler/gorm/GormEntityTransformation.groovy 79.1667% 2 Missing and 3 partials ⚠️
...rails/compiler/gorm/GormEntityTraitProvider.groovy 0.0000% 1 Missing ⚠️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16222        +/-   ##
==================================================
+ Coverage     54.1129%   54.1231%   +0.0103%     
- Complexity      20301      20321        +20     
==================================================
  Files            2107       2110         +3     
  Lines          101146     101173        +27     
  Branches        17922      17928         +6     
==================================================
+ Hits            54733      54758        +25     
- Misses          38605      38606         +1     
- Partials         7808       7809         +1     
Files with missing lines Coverage Δ
...ails/compiler/web/ControllerActionTransformer.java 61.6142% <100.0000%> (+0.5470%) ⬆️
...er/injection/DefaultGrailsDomainClassInjector.java 62.8571% <ø> (-16.1905%) ⬇️
...ls/compiler/injection/EntityASTTransformation.java 62.1622% <100.0000%> (ø)
...l/src/main/groovy/grails/util/BuildSettings.groovy 19.0476% <ø> (ø)
...g/grails/gradle/plugin/core/GrailsExtension.groovy 55.3571% <100.0000%> (+4.3768%) ⬆️
...gradle/plugin/core/GrailsGormIdTypeProvider.groovy 100.0000% <100.0000%> (ø)
...grails/gradle/plugin/core/GrailsGormOptions.groovy 100.0000% <100.0000%> (ø)
...rails/compiler/gorm/GormEntityTraitProvider.groovy 0.0000% <0.0000%> (ø)
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% <0.0000%> (ø)
...ails/compiler/gorm/GormEntityTransformation.groovy 77.5862% <79.1667%> (+2.0048%) ⬆️

... and 3 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.

@testlens-app

testlens-app Bot commented Aug 25, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

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

Test Runs Flakiness
UserControllerSpec > User list ❌ ✅ 1% 🟡

🏷️ Commit: 6e2655d
▶️ Tests: 18685 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

@codeconsole
codeconsole requested review from borinquenkid, jdaugherty and matrei and removed request for matrei August 25, 2026 22:37

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this — commits 2 and 3 (binding a Serializable action parameter, and generating scaffolded controllers with Serializable id) look like solid, independent bug fixes. Both hit any domain class that already follows the current GORM for MongoDB guide (i.e. one that already declares String id), so I'd support merging those regardless of what happens below.

My concern is with commit 1 (defaultIdType): the type of an id already has a knob, and it's the declaration in the domain class itself. String id is one line, per-entity, visible in source, works under IDE compilation (which this system-property mechanism doesn't, per the PR's own limitations), and is what the GORM for MongoDB guide has recommended all along. The implicit-Long path this compensates for only works because GORM for MongoDB fakes auto-increment through an internal sequence/counter collection — the same shard-hostile bottleneck this PR's description calls out. As far as I can tell, the only concrete behavior change here is that an undeclared id on a MongoDB-mapped class gets String instead of Long; Hibernate's and Neo4j's trait providers are untouched.

So the effect of the new SPI method (getDefaultIdentityType()), the Gradle plugin class, and the system property threaded through the compiler is to let the build config supply, invisibly and at a distance, a fact that belongs in the source of truth — at the cost that reading the domain class no longer tells you its id type (you need the build config plus the store the class resolves to), plus the IDE/Gradle divergence and the plugin-jar freezing noted in the limitations. Unlike id generator:, which is a real degree of freedom the type can't express, the id type has an existing, better home.

I'd rather see a compile-time warning when a MongoDB-mapped domain class has no declared id — nudging the developer to write String id (or whatever they intend) explicitly — than machinery that makes the omission work.

Requesting changes on commit 1 pending discussion of the above; happy to be convinced there's a use case that needs automatic inference rather than a warning-and-explicit-declare approach.

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.

2 participants