codeconsole opened a new pull request, #15583:
URL: https://github.com/apache/grails-core/pull/15583
# GORM MongoDB: `storedAs` Identifier Coercion
## The Problem
Before this change, GORM MongoDB had a silent asymmetry in how it handled
identifier types:
| Code path | `String id` declared + `_id: ObjectId` on disk |
|-----------|-------------------------------------------------|
| **Scan read** (`.list()`) | ✅ Decoder falls back via Spring
ConversionService |
| **Point lookup** (`get(hex)`) | ❌ Returns `null` — query sends `{_id:
"<hex>"}` as BSON String |
| **Update** (`save()`) | ❌ Throws misleading `OptimisticLockingException` |
| **Batch** (`getAll([hex])`, `findAllByIdInList`, `in('id', [...])`) | ❌
Returns empty — `$in` list sent as BSON Strings |
The reader was forgiving, but the query and write paths weren't. This made
"declare `String id` on a domain that previously used `ObjectId id`" a silent
landmine — scans looked fine, everything else quietly broke.
The ergonomic consequence: teams were forced to either keep `ObjectId id`
(which serializes to `{"timestamp":..., "date":...}` in JSON and requires `new
ObjectId(...)` at every call site) or do a full data migration of every `_id`
value in storage.
## The Fix
Two new knobs that let a domain keep `String id` ergonomics while persisting
`_id` as a BSON ObjectId — no data migration required.
### Per-domain mapping option
```groovy
import org.bson.types.ObjectId
class Person {
String id
static mapping = {
id storedAs: ObjectId
}
}
```
### Global config default
```yaml
grails:
mongodb:
stringIdsDefaultStoredAs: objectid # or 'string' (default — current
behavior)
```
Per-domain `storedAs` always wins over the global default. Natural-key
domains opt out with `id storedAs: String`.
## What Changed
10 production files across three modules, all coerce between declared type
and storage type:
| Layer | File | Change |
|-------|------|--------|
| Core interface | `IdentityMapping.java` | New `default Class<?>
getStoredAs() { return null; }` |
| Core property | `Property.groovy` | New `Class<?> storedAs` field |
| Core factory | `MappingFactory.java` | Both `createDefaultIdentityMapping`
overloads expose `storedAs` dynamically (composite-key-safe) |
| Mongo config | `MongoSettings.groovy` | New
`SETTING_STRING_IDS_DEFAULT_STORED_AS` |
| Mongo settings | `AbstractMongoConnectionSourceSettings.groovy` | New
`String stringIdsDefaultStoredAs` field |
| Mongo context | `MongoMappingContext.java` | Reads global default; applies
to String-id domains without explicit `storedAs`; warns on unrecognized values |
| Mongo encoder | `IdentityEncoder.groovy` | String → ObjectId on write
(with `ObjectId.isValid` guard for natural keys) |
| Mongo query | `MongoQuery.java` | `IdEquals` + `In` handlers coerce to
storage type (fixes `get(hex)`, `findAllByIdInList`, criteria `in('id', ...)`) |
| Mongo persister | `MongoCodecEntityPersister.groovy` | Coerces keys in
`retrieveEntity` and `retrieveAllEntities` (fixes `get` and `getAll`) |
| Mongo session | `MongoCodecSession.groovy` | Coerces `nativeKey` in
update/delete filters (fixes saves and deletes) |
## Why It's Better
- **Ergonomics + performance, not either/or.** Domain code sees `String id`
(clean JSON, no `new ObjectId(...)` dance, no `[object Object]` bugs at HTTP
boundaries). Storage keeps 12-byte BSON ObjectId `_id` (half the index size of
hex strings, embedded creation timestamp, native Mongo sort order).
- **No data migration required.** Legacy documents with `_id: ObjectId(...)`
continue to load via the existing decoder fallback; new writes produce BSON
ObjectId too — everything stays consistent on disk.
- **The silent failure modes are gone.** `get(hex)`, `findAllByIdInList`,
saves, deletes, and criteria in-lists all now match stored ObjectIds when the
domain opts in. The misleading `OptimisticLockingException` on save (which
previously pointed developers at concurrency rather than the real id-type
mismatch) no longer fires.
- **Fully backward compatible.** Default config is `null`/`string` — nothing
changes for existing apps unless they explicitly opt in.
- **Natural-key safety.** Combining `generator: 'assigned'` with `storedAs:
ObjectId` and a non-hex value (e.g. a slug) falls back to BSON String rather
than throwing `IllegalArgumentException` deep in the BSON pipeline.
## Testing
**23 tests across 2 new specs**, all against a real MongoDB testcontainer or
real `MongoMappingContext` init:
- `StringIdWithObjectIdStorageSpec` (18 tests) — documents the original
bugs, then proves each code path (read, point lookup, save, batch `getAll`,
`findAllByIdInList`, criteria `in('id', ...)`, delete, update of legacy
ObjectId-`_id` docs, non-hex natural-key fallback, JSON serialization demo).
- `StringIdDefaultStoredAsConfigSpec` (5 tests) — global config plumbing:
default off, global on, per-domain override, declared-type filter (ObjectId-id
domains ignore the String-id global), unrecognized-value safe fallback.
Full `:grails-data-mongodb-core:test` + `:grails-data-hibernate5-core:test`
green. Checkstyle clean on all three modified modules. Three rounds of
automated code review completed, each round surfaced real bugs, all fixed.
## Documentation
- `objectMapping/idGeneration.adoc` — new section "Decoupling the Declared
Type from the Storage Type" + "Global Default" + "Caveats".
- `gettingStarted/advancedConfig.adoc` — one-line pointer to the new setting.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]