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]

Reply via email to