Skip to content

GORM Native Datastore Id Support - #16222

Merged
codeconsole merged 12 commits into
apache:8.0.xfrom
codeconsole:feat/gorm-native-id-type-8.0.x
Aug 29, 2026
Merged

GORM Native Datastore Id Support #16222
codeconsole merged 12 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 82.05128% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.7268%. Comparing base (9b58a8a) to head (ebee450).
⚠️ Report is 13 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...ails/compiler/gorm/GormEntityTransformation.groovy 79.1667% 2 Missing and 3 partials ⚠️
...ls/boot/config/GrailsEnvironmentPostProcessor.java 77.7778% 2 Missing ⚠️
...ls/compiler/injection/EntityASTTransformation.java 50.0000% 0 Missing and 2 partials ⚠️
...tore/mapping/mongo/config/MongoMappingContext.java 84.6154% 2 Missing ⚠️
...rails/gradle/plugin/core/GrailsGradlePlugin.groovy 0.0000% 2 Missing ⚠️
...rails/compiler/gorm/GormEntityTraitProvider.groovy 0.0000% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16222        +/-   ##
==================================================
+ Coverage     54.7116%   54.7268%   +0.0151%     
- Complexity      20402      20429        +27     
==================================================
  Files            2098       2101         +3     
  Lines          100931     100978        +47     
  Branches        17900      17907         +7     
==================================================
+ Hits            55221      55262        +41     
- Misses          37847      37852         +5     
- Partials         7863       7864         +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%) ⬇️
...piler/injection/GrailsAwareInjectionOperation.java 79.2683% <100.0000%> (+0.2559%) ⬆️
...oovy/org/grails/compiler/gorm/GormTransformer.java 37.5000% <100.0000%> (+4.1667%) ⬆️
...g/core/connections/ConnectionSourceSettings.groovy 33.3333% <ø> (ø)
...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%> (ø)
... and 5 more

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

@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.

@matrei

matrei commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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.

@borinquenkid I think the use case is that a domain class in a plugin can be used by both hibernate and mongodb.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@borinquenkid The key use case is reusable domain classes supplied by a plugin. Such a class cannot declare String id without coupling the plugin to MongoDB, or Long id without coupling it to Hibernate. With defaultIdType = "native", the same undeclared-id domain model can adopt the datastore selected by the consuming application: String for MongoDB and Long for Hibernate. An explicitly declared id still takes precedence.

@codeconsole

codeconsole commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

@matrei @borinquenkid I guess the only weakness in this design is that it does not work for binary plugins, but this wasn't my intention. The plugins I use this for are built with the application. I am going to explore the options for binary plugins.

I am looking at... bundling source, building variants, serializable id support, app side ast transformations, etc.

@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.

One naming nit before merge: the new runtime property grails.gorm.default.idType sits a little too close to the existing compile-time DSL grails { gorm { defaultIdType } } (which is itself published to the compiler as grails.compile.gorm.default.id.type) — three similarly-shaped names for two different mechanisms invites someone transposing them from memory, and both default to 'long' so the mistake would fail silently. Since the code already has a name for this concept internally (portableIdentityType, resolvePortableIdentityType, "portable identity" in the docs), I'd rename the property to grails.gorm.default.portableIdType and let that vocabulary do the disambiguating work instead of "default id type" meaning two different things.

Approving with that suggestion — not blocking.

@codeconsole

codeconsole commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

@borinquenkid I agree on the confusion. I just collapsed all 3 into the same settings and they are applied in the context in which they are used. gradle: compile, runtime: runtime.

@testlens-app

This comment has been minimized.

@matrei

matrei commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

I guess the only weakness in this design is that it does not work for binary plugins

@codeconsole If this does not work with binary plugins, I think the feature becomes of limited use. It would be great if your investigation uncovers a solution for that.

Otherwise, I'm hesitant to add the feature, since I don't think we should introduce different behavior depending on how a plugin is distributed.

@codeconsole

codeconsole commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

I guess the only weakness in this design is that it does not work for binary plugins

@codeconsole If this does not work with binary plugins, I think the feature becomes of limited use. It would be great if your investigation uncovers a solution for that.

Otherwise, I'm hesitant to add the feature, since I don't think we should introduce different behavior depending on how a plugin is distributed.

@matrei you are reading an old message without seeing the updates after. It works in binary plugins now with the commit "Support native IDs for compiled plugin domains"

@codeconsole
codeconsole merged commit 2fd29c6 into apache:8.0.x Aug 29, 2026
79 of 82 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants