Skip to content

Default String id storage to ObjectId, fix association id coercion, and deprecate the non-codec engine - #16297

Open
codeconsole wants to merge 32 commits into
apache:8.0.xfrom
codeconsole:feat/mongo-objectid-default-8.0.x
Open

Default String id storage to ObjectId, fix association id coercion, and deprecate the non-codec engine#16297
codeconsole wants to merge 32 commits into
apache:8.0.xfrom
codeconsole:feat/mongo-objectid-default-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Makes grails.mongodb.stringIds.defaultStoredAs default to objectid. A domain declaring String id now persists _id as a BSON ObjectId while application code still sees the hex String. This is breaking for existing data, so it wants to land before 8.0.0.

grails:
  mongodb:
    stringIds:
      defaultStoredAs: string   # opt out; per-domain: id storedAs: String

The default is applied in the field initializer rather than only in the config-reading constructors — MongoMappingContext has four constructors and only two read configuration, so new MongoMappingContext("db") was registering entities with a different default.

Latent bugs this surfaced

Every one of these reproduces on 8.0.x today by setting defaultStoredAs: objectid explicitly. They are not caused by the default change; making it the default is what drags them into the test suite.

References written with the declared type. ToOneEncoder and OneToManyEncoder wrote the id as declared, so a reference pointed at an ObjectId _id with a BSON String:

// Captain._id is ObjectId, Boat.captain is String -> matches nothing
Captain.collection.find(new Document("_id", boatDbo.captain)).first()   // null

Decoders read by predicted type. A non-hex assigned id falls back to BSON String even under storedAs: ObjectId, so predicting the type threw:

BsonInvalidOperationException: readObjectId can only be called when
CurrentBSONType is OBJECT_ID, not when CurrentBSONType is STRING

They now switch on bsonReader.currentBsonType and convert back to the declared type, which also handles collections written before a storedAs change.

Queries coerced only identity criteria. A filter on a to-one association carries the associated entity's id but got no coercion, so bidirectional and hasOne lookups sent a hex String against an ObjectId foreign key:

captain.shipmates.size()   // 0
face.nose                  // null

The same gap applied to IN criteria on an association (child in [childInstance], findAllByChildInList(..)) and to anything nested in a junction — the inherited negation handler dispatches nested criteria itself, so not { eq 'id', hex } and findAllByIdNot(hex) never reached the coercion and could fail to exclude the document they named.

findAllById bypassed id coercion entirely, because a dynamic finder builds Equals('id', ..) rather than IdEquals:

GetItem.findAllById(hex)              // [] — sent BSON String against ObjectId _id
GetItem.findAllByIdInList([hex])      // worked; the In handler already coerced

updateAll wrote the declared representation. The bulk path replaced an association with its raw declared id, bypassing the encoder — and normalized into the caller's own map, which mutated their argument and threw for an immutable one.

An empty assigned String id could not be deleted. The bulk delete path collected keys under Groovy truthiness, so id == '' — a valid BSON _id, reachable through the invalid-ObjectId fallback for natural keys — was dropped from the delete model and the document survived.

The non-codec engine

The "mapping" engine reaches MongoDB through a separate hierarchy that used the declared identifier type throughout. Six paths needed the same treatment: _id storage (storeEntry overwrites what generateIdentifier sets, so both matter), the by-key filter, the flush-time update and delete filters, the iterable delete (which filters on the literal _id field rather than the logical identity name), association references and DBRef $id values, and bulk association updates.

Those are fixed, so the default applies to both engines. The engine is then deprecated: it has not been the default since GORM 6, the setting that selects it was undocumented until this PR, it offers no capability the codec engine lacks, and nothing in the test suite exercised it before this branch. Selecting it now logs a warning at startup. Deprecated rather than removed because MongoEntityPersister and AbstractMongoObectEntityPersister are public types.

Changes

  • MongoMappingContextDEFAULT_STRING_ID_STORED_AS, applied via field initializer; unrecognized values fall back to the default rather than to a third silent behavior
  • MongoIdCoercion — adds coerceIdToDeclaredType, the inverse of coerceIdToStoredType
  • PersistentEntityCodec — coercion in ToOneEncoder, OneToManyEncoder, ToOneDecoder, OneToManyDecoder
  • MongoQuery — coerces to-one association and identity criteria, recursing through junctions; IN coerces after unwrapping
  • MongoCodecSessionupdateAll encodes associations into a copy of the caller's map; empty-id delete
  • MongoSession, MongoEntityPersister, AbstractMongoObectEntityPersister — the six non-codec paths, then deprecated
  • Docs — idGeneration.adoc, advancedConfig.adoc (including the engine setting, documented for the first time), and an upgrade note covering the data-compatibility break

Tests

MongoIdCoercionSpec (12 cases, no live MongoDB) covers both coercion directions and their fallback branches. StringIdAssociationStorageSpec asserts the BSON that lands on disk rather than only that traversal works — traversal passed the whole time the stored type was wrong, which is why this went unnoticed. MappingEngineStringIdStorageSpec covers the non-codec engine end to end, and asserts up front that the session really is a MongoSession: GORM statics bind to whichever datastore registered the class last, so without that guard the whole file would pass against the codec engine and prove nothing.

Every case was checked against reverted production code so that it fails without its fix.

Adds coverage for the defaultStoredAs: string opt-out, which had none. LegacyVideo is pinned to storedAs: String so the legacy-breakage cases still reproduce, and specs asserting raw BSON reference types were updated.

grails-data-mongodb-core is at parity with 8.0.x locally; spring-data, spring-boot, embedded and grails-data-mongodb all pass.

Make `grails.mongodb.stringIds.defaultStoredAs` default to `objectid`, so a
domain declaring `String id` persists `_id` as a BSON ObjectId while keeping
String ergonomics in application code. Set it to `string` to opt out.

The default is applied via the field initializer rather than only in the
config-reading constructors, so all four MongoMappingContext constructors
register entities with the same default.

Making it the default surfaced five latent defects in the storedAs path, all
of which reproduce today by setting defaultStoredAs: objectid explicitly:

- ToOneEncoder and OneToManyEncoder wrote association references using the
  declared id type, so a reference pointed at an ObjectId _id with a BSON
  String and matched nothing from $lookup or the raw driver.
- ToOneDecoder read by the predicted type and threw
  BsonInvalidOperationException on documents whose stored type legitimately
  differs (a non-hex assigned id falls back to BSON String even under
  storedAs: ObjectId). It now reads the actual BSON type.
- OneToManyDecoder returned stored-type ids rather than the declared type.
- MongoQuery coerced only identity criteria, so a filter on a to-one
  association sent a hex String against an ObjectId foreign key; bidirectional
  one-to-many and hasOne lookups silently resolved to empty.
- MongoQuery also missed findAllById(hex), which builds Equals('id', ..)
  rather than IdEquals and so bypassed id coercion entirely, returning no
  results. findAllByIdInList was unaffected because the In handler covers it.
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.56757% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 55.3811%. Comparing base (0980623) to head (8707742).

Files with missing lines Patch % Lines
...g/grails/datastore/mapping/mongo/MongoSession.java 55.0725% 17 Missing and 14 partials ⚠️
...s/datastore/mapping/mongo/MongoCodecSession.groovy 67.3913% 4 Missing and 11 partials ⚠️
...ongo/engine/AbstractMongoObectEntityPersister.java 58.3333% 5 Missing ⚠️
...atastore/mapping/mongo/engine/MongoIdCoercion.java 44.4444% 2 Missing and 3 partials ⚠️
...g/mongo/engine/codecs/PersistentEntityCodec.groovy 84.6154% 1 Missing and 1 partial ⚠️
...ails/datastore/mapping/mongo/query/MongoQuery.java 92.0000% 0 Missing and 2 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16297        +/-   ##
==================================================
+ Coverage     55.0529%   55.3811%   +0.3282%     
- Complexity      20773      20919       +146     
==================================================
  Files            2110       2110                
  Lines          101368     101522       +154     
  Branches        18005      18058        +53     
==================================================
+ Hits            55806      56224       +418     
+ Misses          37517      37187       -330     
- Partials         8045       8111        +66     
Files with missing lines Coverage Δ
...grails/datastore/mapping/mongo/MongoDatastore.java 68.8559% <100.0000%> (+0.6935%) ⬆️
...tore/mapping/mongo/config/MongoMappingContext.java 84.0000% <100.0000%> (+0.7630%) ⬆️
...ore/mapping/mongo/engine/MongoEntityPersister.java 30.3483% <100.0000%> (+19.9005%) ⬆️
...g/mongo/engine/codecs/PersistentEntityCodec.groovy 74.3440% <84.6154%> (+0.5345%) ⬆️
...ails/datastore/mapping/mongo/query/MongoQuery.java 77.2068% <92.0000%> (+2.5456%) ⬆️
...ongo/engine/AbstractMongoObectEntityPersister.java 43.2558% <58.3333%> (+42.7796%) ⬆️
...atastore/mapping/mongo/engine/MongoIdCoercion.java 62.5000% <44.4444%> (+15.8333%) ⬆️
...s/datastore/mapping/mongo/MongoCodecSession.groovy 71.9212% <67.3913%> (-1.5356%) ⬇️
...g/grails/datastore/mapping/mongo/MongoSession.java 65.7692% <55.0725%> (+65.7692%) ⬆️

... 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 added this to the grails:8.0.0-RC1 milestone Sep 3, 2026
@codeconsole

Copy link
Copy Markdown
Contributor Author

A concrete example of the association-reference bug from a real application running defaultStoredAs: objectid on 8.0.x today, in case it helps show why this is worth fixing alongside the default change.

An Activity with static belongsTo = [issue: Issue], where Issue declares String id:

activity._id   = ObjectId("69d441c2ba1f8010688e2695")
activity.issue = "69d441c2ba1f8010688e2694"              // BSON String
issue._id      = ObjectId("69d441c2ba1f8010688e2694")    // BSON ObjectId

The reference and its target are different BSON types, so nothing outside GORM can join them:

db.issue.find({_id: activity.issue})           // null
db.issue.find({_id: ObjectId(activity.issue)}) // found

db.activity.aggregate([
  { $lookup: { from: "issue", localField: "issue", foreignField: "_id", as: "iss" } }
])                                             // iss: [] for every row

GORM traversal is unaffected — activity.issue.title works, because the decoder coerces on read. Only $lookup, the raw driver and external clients see the mismatch, which is why this has gone unnoticed.

ToOneEncoder wrote the reference using the declared id type rather than the target's stored type. With the encoder change here, the reference is written as an ObjectId and both queries above match.

"Falling back to the default ('objectid').",
value, MongoSettings.SETTING_STRING_IDS_DEFAULT_STORED_AS);
return null;
return DEFAULT_STRING_ID_STORED_AS;

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.

COOL, previous behavior was wrong!

@bito-code-review

Copy link
Copy Markdown

Understood. Since the previous behavior was incorrect, please ensure the updated logic correctly addresses the requirements for the MongoMappingContext. If you would like me to review the specific code changes or provide an alternative implementation based on the current diff, please let me know.

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

The idea is totally correct, it needs unit tests

MongoIdCoercionSpec covers both coercion directions and their fallback
branches directly: null key, null entity, a value already of the target type,
the non-hex natural key that must stay a String rather than produce a
{_id: null} filter, an ObjectId-id domain being left alone, and a round trip.

StringIdAssociationStorageSpec asserts the BSON that lands on disk rather than
only that traversal works — traversal passed the whole time the stored type
was wrong, which is why the mismatch went unnoticed. It covers to-one and
to-many reference storage, resolution through GORM, a bidirectional
one-to-many, findAllById and findAllByIdInList, and a reference written as a
BSON String so pre-existing data keeps decoding.

Each case was checked against reverted production code: reverting the encoders
fails the two reference-storage cases and findAllById, and reverting only
MongoQuery fails the bidirectional and findAllById cases.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks — added in 87219f1.

MongoIdCoercionSpec (12 cases, no live MongoDB) covers both coercion directions and their fallback branches directly: null key, null entity, a value already of the target type, the non-hex natural key that must stay a String rather than produce a {_id: null} filter, an ObjectId id domain being left alone, and a round trip. This was the weakest file in the coverage report at 33%.

StringIdAssociationStorageSpec (8 cases) asserts the BSON that actually lands on disk rather than only that traversal works — traversal passed the whole time the stored type was wrong, which is why this went unnoticed. It covers to-one and to-many reference storage, resolution through GORM, a bidirectional one-to-many, findAllById, findAllByIdInList, and a reference written as a BSON String so pre-existing data keeps decoding.

Each case was checked against reverted production code so it fails without the fix:

  • reverting the encoders + MongoQuery fails the two reference-storage cases and findAllById
  • reverting only MongoQuery fails the bidirectional one-to-many and findAllById

The second check turned out to matter: with everything reverted the bidirectional case still passes, because references written as String and queried as String are self-consistent. It only fails in the mixed state that the encoder fix creates, which is the real-world one.

@matrei

matrei commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

AI review:

Review Findings

The latest update adds coverage in 87219f1, but it does not change the production implementation. The following issues from the previous review remain.

High: Legacy Mongo engine is incompatible with the new default

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java:151,413-417
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoEntityPersister.java:183-191
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:145-188

MongoMappingContext now resolves a bare String id to storedAs: ObjectId, but the non-codec/mapping engine still generates and writes String _id values. The shared MongoQuery converts ID criteria to ObjectId.

With grails.mongodb.engine: mapping, a saved domain can therefore contain a BSON String _id while query-based operations search for an ObjectId. This affects findById, ID criteria, findAllByIdInList, and bulk criteria operations. The behavior is inconsistent because some direct persistence paths still use the String representation.

The new default needs to be implemented by both engines, or the default and query coercion need to be restricted to the codec engine.

High: IN queries on to-one associations do not coerce IDs

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:171-189,734-758
  • grails-data-mongodb/bson/src/main/groovy/org/grails/datastore/bson/query/BsonQuery.java:766-785

The generic preprocessing passes the entire In collection to coerceIdToStoredType():

pc.setValue(MongoIdCoercion.coerceIdToStoredType(raw, idTarget));

The In handler then reads its separate values collection and only applies storage coercion when the queried property is the entity's own identity. Association instances are unwrapped to their declared IDs without converting them to the target entity's storage type.

For example:

Parent.where {
    child in [childInstance]
}.list()

When Child declares String id and stores _id as ObjectId, the query sends a String association ID against an ObjectId foreign key. This also affects findAllByChildInList(...) and DBRef associations. The association IN handler should coerce each unwrapped ID using the associated entity's mapping.

High: Negated scalar ID and association criteria bypass coercion

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:734-768
  • grails-data-mongodb/bson/src/main/groovy/org/grails/datastore/bson/query/BsonQuery.java:286-307

The Mongo-specific coercion runs only for the current junction. The inherited BsonQuery negation handler invokes nested criterion handlers directly, so nested Equals and association criteria never pass through the Mongo-specific preprocessing.

For example:

Domain.where {
    not {
        eq 'id', hex
    }
}.list()

And dynamic finders such as findAllByIdNot(hex) can generate a negated String predicate against an ObjectId _id, meaning the document being excluded may still be returned. Coercion needs to be applied recursively inside negations.

High: updateAll writes to-one association IDs in the declared representation

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:347-356
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:673-689

The normal codec persistence path converts association IDs to the target entity's storage type. The bulk update path instead replaces an association object with its raw declared ID:

properties.put(associationName, association.associatedEntity.reflector.getIdentifier(value))

For a String-id target, updateAll(child: child) can write a BSON String where normal persistence writes an ObjectId. For DBRef mappings it can also write a plain ID instead of a DBRef. Subsequent association queries and external MongoDB clients will not reliably see the updated relationship.

Bulk association updates should use the same storage-type and DBRef representation as normal association encoding.

Medium: Empty assigned String IDs cannot be deleted

Reference:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:202-214

The invalid-ObjectId fallback allows assigned natural keys to be stored as BSON Strings. An empty String is a valid BSON _id, but the delete path uses Groovy truthiness:

if (k) {
    nativeKeys << k
}

For id == '', the delete model is skipped and the document remains. This should check k != null instead.

Medium: Identity-generation documentation contradicts the new default

Reference:

  • grails-data-mongodb/docs/src/docs/asciidoc/objectMapping/idGeneration.adoc:127-131,157-171

The earlier section still says that a bare String id defaults to BSON String storage and presents storedAs: ObjectId as opt-in. The later section says ObjectId is the Grails 8 default.

The earlier explanation should be updated to describe storedAs: String or defaultStoredAs: string as the opt-out.

What the Latest Update Covers

  • Adds unit coverage for MongoIdCoercion and both coercion directions.
  • Adds raw BSON coverage for to-one and to-many association storage.
  • Adds coverage for normal association traversal, bidirectional association lookup, findAllById, and findAllByIdInList.
  • Confirms that BSON String association references can still be decoded.

These tests pass, but they do not cover the legacy engine, association IN criteria with String/ObjectId IDs, negated scalar ID criteria, bulk association updates, or empty assigned String IDs.

Verification

The following focused command passed:

./gradlew :grails-data-mongodb-core:test \
  --tests 'org.grails.datastore.gorm.mongo.bugs.MongoIdCoercionSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdAssociationStorageSpec' \
  --no-daemon

The existing association, negation, batch update/delete, and hasOne specs also passed. The complete grails-data-mongodb-core test task was started but exceeded the local six-minute timeout; no failure was observed in the captured output.

…default

The Decoupling section introduced storedAs: ObjectId as opt-in and stated
that a bare String id writes _id as a BSON String, contradicting the Global
Default section below it.
The bulk delete path collected native keys under Groovy truthiness, so an
empty String id -- a valid BSON _id, reachable through the invalid-ObjectId
fallback for assigned natural keys -- was dropped from the delete model and
the document silently survived.
The bulk update path replaced an association object with its raw declared
identifier, so updateAll(child: child) wrote a BSON String where normal
persistence writes the target's stored _id type, and wrote a plain id where
the mapping asks for a DBRef. Association queries and external clients could
not match the updated relationship.

It now coerces through the associated entity's mapping and emits a DBRef when
the mapping declares one, matching ToOneEncoder.
The Mongo-specific preprocessing ran only for criteria at the current junction
level. The inherited BsonQuery negation handler dispatches nested criteria
itself, so an id or to-one association criterion inside not { } never reached
it and was sent as a hex String against an ObjectId; findAllByIdNot(hex) could
therefore fail to exclude the document it named.

The preprocessing moves into a helper that recurses through junctions, so
criteria nested inside not { }, and { } and or { } are coerced too.
The IN handler applied the storage type only when the queried property was the
entity's own identity. getInListQueryValues unwraps association instances to
their declared identifier, so a to-one association IN criterion sent hex
Strings against ObjectId foreign keys and matched nothing -- affecting
'child in [childInstance]' and findAllByChildInList(..).

It now resolves the entity whose identifier mapping governs the criterion, the
same way the scalar path does, and coerces each unwrapped value against it.
MongoEntityPersister decided the stored _id type from the declared identifier
type alone, so a String-id domain always wrote a BSON String. Once a bare
String id resolves to storedAs: ObjectId, a document saved by this engine
could not be found again, because the shared MongoQuery sends an ObjectId.

Two places needed it, not one: storeEntry overwrites _id with the
declared-type identifier immediately after generateIdentifier runs, and
createDBObjectWithKey builds the by-key filter and passed the hex String
straight through for a String identifier. Both now go through the id mapping,
while the declared-type value is still what the domain sees.
@codeconsole

codeconsole commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @matrei !

All six addressed, one isolated commit each, every fix pinned by a test verified to fail without it.

commit finding
0fa6561 Legacy engine: mapping incompatible
ded9ea7 IN on to-one associations not coerced
9a8506d Negated criteria bypass coercion
19a146a updateAll writes the declared representation
5bf4e5c Empty assigned String id cannot be deleted
0f1bc9b idGeneration.adoc contradicts the new default

Two notes where the investigation diverged from the report.

The legacy engine needed two changes, not one. generateIdentifier was the wrong place: storeEntry overwrites _id with the declared-type identifier immediately afterwards, so a fix there has no effect. The read side was separately broken — createDBObjectWithKey builds the by-key filter and passed the hex String straight through for hasStringIdentifier, so even correctly stored documents were unfindable. Both now go through the id mapping while the domain still sees its declared type.

Negation. Domain.where { id != hex } compiles to NotEquals, an ordinary PropertyCriterion that the existing preprocessing already covered, so my first test passed with the fix reverted and proved nothing. The reported form is the one that breaks: coercion now lives in a helper that recurses through junctions, and not { eq 'id', hex }, findAllByIdNot(hex) and a nested to-one association criterion all fail without it.

The IN fix resolves the entity whose identifier mapping governs the criterion — the associated entity for a to-one, the queried entity for its own identity — and coerces each value after getInListQueryValues unwraps it, so child in [childInstance] and findAllByChildInList(..) both work.

grails-data-mongodb-core is at parity with 8.0.x locally: the only failures are the two pre-existing MongoDatastoreLifecycleSpec cases, which also fail on an unmodified checkout here.

@jdaugherty

Copy link
Copy Markdown
Contributor

from the discussion in the weekly, we acknowledged that this functionality was added in 7.2.x and then this PR is changing the default in the major release.

@jdaugherty

Copy link
Copy Markdown
Contributor

If @borinquenkid is ok with this change, I'm good to proceed.

@matrei

matrei commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Round 2:

Review Findings

The latest update adds production fixes for the codec engine and for mapping-engine insert/point-read behavior. The focused tests pass, but the mapping engine still has inconsistent update/delete and association write paths. There is also a regression in codec bulk updates.

Merge Blockers

High: Mapping-engine updates and deletes still use the wrong _id type

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoEntityPersister.java:331-340,415-421
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:141-160,181-195
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:286-295
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:802-808

The mapping-engine insert and point-read paths now use MongoIdCoercion, but the flush path still constructs pending update and delete filters from the declared identifier without coercion:

final Document id = new Document(MongoConstants.MONGO_ID_FIELD, nativeKey);

and:

final Object k = delete.getNativeKey();

For a bare String id using the new ObjectId default, MongoDB stores _id as an ObjectId, while these filters use the String value. As a result, after reloading a mapping-engine entity, save() can fail to update it and delete() can leave it in the database.

The iterable delete path also builds a query using literal _id, while resolveIdCriterionTarget() only recognizes the logical identity name id, so that path does not receive the new ID coercion either.

The new MappingEngineStringIdStorageSpec only covers insert and point retrieval at :59-95; it does not cover update, single delete, iterable delete, or batch delete.

High: Mapping-engine association references still use declared String IDs

References:

  • grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/engine/NativeEntryEntityPersister.java:1001-1057
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/AbstractMongoObectEntityPersister.java:197-207,328-334,434-449

The mapping engine still passes association IDs unchanged to formulateDatabaseReference() and to the association indexer. For a String-id target stored as ObjectId:

  • Plain association foreign keys are stored as BSON Strings.
  • DBRef $id values are stored as BSON Strings.
  • Embedded and unidirectional collection references have the same mismatch.

MongoQuery now coerces association criteria to ObjectId, so mapping-engine association queries can search for ObjectIds against String foreign keys and return no results. This affects direct to-one queries, reverse one-to-many lookups, DBRefs, and association IN/negated criteria.

The new StringIdAssociationStorageSpec exercises the default codec engine and does not exercise MongoSession or the mapping-engine persister.

High: Mapping-engine updateAll still does not encode association values

Reference:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:346-354

The mapping-engine bulk update path sends the caller's property map directly inside $set. It does not:

  • Extract a to-one association's identifier.
  • Coerce that identifier to the target entity's storedAs type.
  • Create a DBRef when the association uses reference: true.

Therefore, with engine: mapping, an operation such as:

Parent.where { ... }.updateAll(child: child)

can write an incompatible domain object or an association representation that cannot be queried or decoded consistently.

Please add mapping-engine coverage for to-one associations, DBRefs, and bulk association updates.

Medium: Codec updateAll mutates the caller's property map and rejects immutable maps

Reference:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:345-374

The codec bulk-update implementation normalizes association values by calling properties.put(...) on the caller-provided map. This changes the caller's data:

def updates = [project: project]
criteria.updateAll(updates)

After the call, updates.project is an ObjectId or DBRef rather than the original domain object.

It also fails for immutable maps:

Map updates = Collections.singletonMap('project', project)
criteria.updateAll(updates)

This throws UnsupportedOperationException before the update is sent. Copy the map before normalizing association values.

Previous Findings Status

Finding Status
Default incompatible with mapping engine Partially fixed: insert and point read fixed; update, delete, and association paths remain inconsistent
To-one association IN criteria Fixed for codec-backed documents; still inconsistent for mapping-engine data because association writes remain uncoerced
Negated scalar/association criteria Fixed for codec-backed documents; still inconsistent for mapping-engine data because association writes remain uncoerced
Codec updateAll association representation Representation fixed for mutable maps; map mutation and immutable-map regression introduced
Empty assigned String ID deletion Fixed at MongoCodecSession.groovy:204-207
Contradictory identity-generation documentation Fixed at idGeneration.adoc:131-171

Verification

The following focused command passed:

./gradlew :grails-data-mongodb-core:test \
  --tests 'org.grails.datastore.gorm.mongo.bugs.MappingEngineStringIdStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdAssociationStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdWithObjectIdStorageSpec' \
  --no-daemon

The existing MongoDB association, negation, batch update/delete, and hasOne tests also passed. The complete grails-data-mongodb-core test task was run, but the local process exceeded the ten-minute timeout before Gradle printed a final completion result; no failures were visible in the captured output.

The remaining mapping-engine issues should be fixed and covered with update, delete, association, DBRef, and bulk-update tests before merging.

The codec bulk update path wrote the encoded association reference back into
the caller-provided map. That replaced their domain object with an ObjectId or
DBRef after the call, and threw UnsupportedOperationException outright for an
unmodifiable argument such as Collections.singletonMap or Map.of.

The map is now copied before normalising. Pre-dates this branch -- the same
line previously wrote the declared identifier back into the caller's map --
but the encoded value makes the mutation more surprising, and it is cheap to
stop doing.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Round 2 addressed, two isolated commits.

41b9230 — the ObjectId default now applies to the codec engine only.

Findings 1-3 are all the same shape: the mapping engine builds its flush-time update and delete filters from the declared identifier, and writes association references and DBRef $id values the same way. Rather than convert those paths piecemeal, this takes the alternative offered in round 1 and scopes the default to the engine that implements it end to end. The mapping engine keeps the pre-8.0.0 behaviour exactly, so there is nothing half-converted about it.

Scoping affects the default only: an application that explicitly sets defaultStoredAs still gets what it asked for on either engine, with the same caveats it has today.

This also reverts the partial mapping-engine _id coercion I added last round. You were right that fixing insert and point-read while update, delete and association writes stayed uncoerced left that engine in a worse place than not touching it. MappingEngineStringIdStorageSpec is replaced by three cases in StringIdDefaultStoredAsConfigSpec covering codec-gets-the-default, mapping-does-not, and explicit-still-honoured.

4563be7updateAll normalises into a copy of the caller's map.

Correct on the defect, and it is worth noting it is not a regression: the line before this branch was already properties.put(associationName, association.associatedEntity.reflector.getIdentifier(value)), so the caller's map was being mutated then too, and an unmodifiable argument already threw. What changed is what gets written back, which makes the mutation more surprising. Copied now either way, with cases for both the mutation and the Collections.singletonMap argument, each verified to fail without the copy.

grails-data-mongodb-core is at parity with 8.0.x locally - the two MongoDatastoreLifecycleSpec failures reproduce on an unmodified checkout here. checkstyle and codenarc clean.

One note on the round-2 verification section: a run that exceeds a local timeout without printing a result is not evidence of passing. I mention it only because the same caveat appeared in round 1 and the conclusion happened to hold both times.

MongoEntityPersister decided the stored _id type from the declared identifier
type alone, so a String-id domain always wrote a BSON String. Once a bare
String id resolves to storedAs: ObjectId, the shared MongoQuery sends an
ObjectId and never matches those documents.

Four places needed it. storeEntry overwrites _id with the declared-type
identifier immediately after generateIdentifier runs, and is the single point
where _id is written for this engine. createDBObjectWithKey builds the by-key
filter. MongoSession builds the flush-time update filter and the single and
batch delete keys, and its iterable delete filters on the literal _id field
rather than the logical identity name, so the criterion preprocessing in
MongoQuery does not recognise it. The declared-type value is still what the
domain sees throughout.
…engine

formulateDatabaseReference returned the association's declared identifier, and
setEmbeddedCollectionKeys built its DBRef list the same way, so a String-id
target stored as ObjectId was pointed at by a BSON String. The codec engine
coerces this in ToOneEncoder.

Left uncoerced, no $lookup, raw driver query or coerced association criterion
could match the document being referenced -- and MongoQuery now coerces
association criteria, so those queries searched for ObjectId against String
foreign keys. Plain foreign keys, DBRef $id values and embedded collection
references all go through the target entity's id mapping now.
…rop the engine scoping

MongoSession.updateAll sent the caller's property map straight into $set, so a
bulk association update wrote the domain object itself rather than a reference.
It now extracts the target's identifier, coerces it to the type that target's
_id is stored as, and emits a DBRef where the mapping asks for one -- matching
both normal persistence and the codec engine's updateAll. Normalised into a
copy, so the caller's map is untouched and may be immutable.

With every non-codec path now going through the id mapping, the default no
longer needs restricting to the codec engine, so that scoping is removed and
MongoMappingContext applies the default on both engines again.

MappingEngineStringIdStorageSpec covers this engine end to end: insert, point
read, update, single delete, iterable delete, to-one reference storage,
traversal, and bulk association update. It drives the session API rather than
the GORM statics, which bind to whichever datastore registered the class last,
and asserts up front that the session really is a MongoSession -- without that
guard the cases would pass against the codec engine and prove nothing.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Took the other option: the non-codec engine is fixed rather than excluded, so the default applies to both. Three isolated commits, and the engine scoping from 41b9230 is removed.

3a99093_id storage and lookup. Four places, not the one I fixed last round. storeEntry overwrites _id with the declared-type identifier immediately after generateIdentifier runs, which is why fixing the latter alone had no effect. createDBObjectWithKey builds the by-key filter. MongoSession builds the flush-time update filter and the single and batch delete keys. Its iterable delete filters on the literal _id field rather than the logical identity name, exactly as you noted, so the criterion preprocessing never sees it.

60c3671 — association references. formulateDatabaseReference and setEmbeddedCollectionKeys now coerce through the target entity's id mapping, covering plain foreign keys, DBRef $id values and embedded collection references.

ce89107updateAll plus removing the scoping. The bulk path now extracts the identifier, coerces it, and emits a DBRef where the mapping asks for one, normalised into a copy of the caller's map.

MappingEngineStringIdStorageSpec covers this engine end to end: insert, point read, update, single delete, iterable delete, to-one reference storage, traversal, and bulk association update. Each case was checked against reverted production code so it fails without its fix.

Two things that made the coverage harder than it looks, worth recording since they cost me two false green runs:

  • GORM statics bind to whichever datastore registered the class last, which is the codec one in this spec. My first updateAll case used MeAsset.where { }.updateAll(..) and passed with the fix reverted, because it never touched this engine. It now calls MongoSession.updateAll with a DetachedCriteria directly.
  • The spec asserts up front that the session really is a MongoSession. Without that guard the whole file would pass against the codec engine and prove nothing.

grails-data-mongodb-core is at parity with 8.0.x locally - the two MongoDatastoreLifecycleSpec failures reproduce on an unmodified checkout here. checkstyle and codenarc clean.

The "mapping" engine has not been the default since GORM 6, is selected by an
undocumented setting, and reaches MongoDB through an entirely separate
persister hierarchy -- MongoSession, MongoEntityPersister and
AbstractMongoObectEntityPersister -- that has to be kept in step with the
codec one for every change to the storage layer. It carries no capability the
codec engine lacks.

That cost is not theoretical: making ObjectId the default for String ids
required six separate fixes in this engine (id storage, the by-key filter, the
flush-time update and delete filters, the iterable delete, association
references and DBRef $id values, and bulk association updates), none of which
had any test coverage. Before this branch nothing in the test suite exercised
grails.mongodb.engine at all.

Deprecated rather than removed, because MongoEntityPersister and
AbstractMongoObectEntityPersister are public types that third parties may
extend, and an application setting the engine today deserves a warning before
its configuration stops working. Selecting it now logs at startup, and the
setting is documented for the first time -- as deprecated, with the advice to
remove it.
@codeconsole codeconsole changed the title Default String id storage to ObjectId and fix association id coercion Default String id storage to ObjectId, fix association id coercion, and deprecate the non-codec engine Sep 4, 2026
@matrei

matrei commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Round 3:

Review Findings

The latest update fixes the previous mapping-engine ID, update/delete, association-reference, and immutable-map issues. The focused tests pass. However, several public updateAll and mapping-engine association paths remain incomplete.

Merge Blockers

High: updateAll is not proxy-safe

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:355-374
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:378-394
  • grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/proxy/JavassistProxyFactory.java:60-64
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:663-676

Both updateAll implementations obtain association IDs through the target entity reflector:

associatedEntity.reflector.getIdentifier(value)

and:

getMappingContext().getEntityReflector(associatedEntity).getIdentifier(value)

A lazy GORM proxy stores its ID in the proxy handler, not in the reflected domain field. The normal codec path already handles this with proxyFactory.getIdentifier(value), but updateAll does not.

Example:

RefProject project = new RefProject(name: 'Target').save(flush: true)
RefProject proxy = RefProject.load(project.id)

RefTicket.where { title == 'Proxy update' }
         .updateAll(project: proxy)

The update can extract null instead of the proxy key and write an invalid association value. This affects both codec and mapping engines.

High: Mapping-engine unidirectional collection and many-to-many references remain uncoerced

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/AbstractMongoObectEntityPersister.java:449-464
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/AbstractMongoObectEntityPersister.java:392-411

The mapping-engine association indexer now coerces embedded collection keys and ordinary to-one references, but these paths still store declared String IDs directly:

dbRefs.add(new DBRef(getCollectionName(association.getAssociatedEntity()), foreignKey));
dbRefs.add(foreignKey);

The mapping-engine many-to-many path also stores:

ids.add(entityAccess.getIdentifier(o));

For a target with a String ID stored as ObjectId, the target _id is BSON ObjectId while collection entries or DBRef $id values remain BSON Strings. Raw MongoDB joins, DBRef consumers, and external clients will not match those references.

High: updateAll incorrectly treats embedded to-one associations as ID references

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:351-374
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:378-394
  • grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/EmbeddedPersistentEntity.java:31-39
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:260-276

Embedded extends ToOne, so the new association normalization also catches embedded properties.

For:

class Address {
    String city
}

class Person {
    String id
    Address address
    static embedded = ['address']
}

This call:

Person.where { ... }
     .updateAll(address: new Address(city: 'after'))

tries to extract an ID from the embedded object and writes that scalar value, or null, instead of encoding a BSON subdocument. Normal persistence has separate embedded encoding paths and does not send embedded values through ToOneEncoder.

Embedded associations should be excluded from ID-reference normalization and encoded using the normal embedded mapping logic.

High: updateAll does not encode collection associations

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:351-398
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:378-410
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:619-644

The new normalization handles only ToOne associations. OneToMany, ManyToMany, and EmbeddedCollection properties are sent through $set unchanged.

Example:

Parent.where { ... }
      .updateAll(children: [child])

Normal persistence stores association IDs, or DBRefs when configured. The bulk-update path instead sends domain objects or embedded representations directly. Depending on the engine and codec registry, this can fail or create a representation inconsistent with normal persistence.

If collection properties are part of the public updateAll(Map) contract, they need the same storage-aware encoding as normal persistence.

Medium: Engine documentation wording is contradictory

References:

  • grails-data-mongodb/docs/src/docs/asciidoc/gettingStarted/advancedConfig.adoc:128-142
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy:96-104

The documentation says:

The default, and only supported value, is codec.

It then documents selecting mapping, and the implementation continues to support it with a deprecation warning.

The wording should say that codec is the default and recommended engine, while mapping remains available for compatibility but is deprecated.

Resolved Findings

  • Mapping-engine insert and point-read _id representation.
  • Mapping-engine update and delete filter coercion.
  • Mapping-engine iterable delete coercion.
  • Mapping-engine ordinary to-one reference and DBRef ID coercion.
  • Codec and mapping-engine ordinary to-one updateAll.
  • Codec updateAll caller-map mutation and immutable-map handling.
  • Association IN coercion for ordinary to-one associations.
  • Nested negation and junction coercion.
  • Empty assigned String ID deletion.
  • Identity-generation documentation contradiction.
  • Non-codec engine deprecation warning and documentation addition.

Verification

The following focused tests passed:

./gradlew :grails-data-mongodb-core:test \
  --tests 'org.grails.datastore.gorm.mongo.bugs.MappingEngineStringIdStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdAssociationStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdWithObjectIdStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdDefaultStoredAsConfigSpec' \
  --no-daemon

The complete grails-data-mongodb-core test task was run previously with the expanded changes and the affected tests passed, but the local process exceeded the timeout before a final Gradle completion result.

The remaining proxy, embedded, collection-association, and mapping-engine collection-reference paths should be addressed and covered with tests before approval.

Both updateAll implementations reflected the association value's identifier
directly. A lazy GORM proxy keeps its id in the proxy handler rather than the
reflected field, so reflecting one yields null and the bulk update writes an
invalid reference.

ToOneEncoder asks the proxy factory first on the normal persistence path;
updateAll now does the same, on both engines.
Embedded extends ToOne, so the association normalization added for bulk
updates also caught embedded properties. An embedded value is a subdocument
with no identity of its own: normal persistence encodes it through the
embedded path rather than ToOneEncoder, and reflecting an id from one yields
null, so updateAll(address: new Address(..)) wrote a scalar or null where a
subdocument belongs.

Both engines now skip Embedded and leave the value for the normal encoding.
…on-codec engine

Two association write paths still stored the declared identifier. The
association indexer holds unidirectional one-to-many keys on the owning
document, as plain ids or DBRefs, and the many-to-many path builds its own id
list. For a target whose _id is stored as ObjectId, both left BSON Strings
that raw joins, DBRef consumers and external clients cannot match.

Both now go through the target entity's id mapping, matching
formulateDatabaseReference and setEmbeddedCollectionKeys.
The association normalization handled only ToOne, so OneToMany and ManyToMany
values went into $set as the domain objects themselves rather than the ids
normal persistence stores -- inconsistent at best, and dependent on whatever
the codec registry happens to do with a domain instance.

Both engines now encode each element the way OneToManyEncoder does: the
target's identifier in its stored _id type, wrapped in a DBRef where the
mapping declares one, and proxy-safe. EmbeddedCollection is excluded for the
same reason as Embedded -- those are subdocuments, not references.
The setting was described as having 'only supported value: codec' and then
documented selecting 'mapping', which the implementation still supports with a
deprecation warning. It now says codec is the default and recommended value
and mapping remains available for compatibility but is deprecated.
@codeconsole

codeconsole commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @matrei

All five addressed, one isolated commit each, every fix pinned by a test verified to fail without it.

commit finding
1630a25 updateAll not proxy-safe
ded7116 updateAll treats embedded to-one as an id reference
564b61b mapping-engine unidirectional collection and many-to-many references
463e1e7 updateAll does not encode collection associations
1756eae contradictory engine documentation

Two of these were mine to own. Embedded extends ToOne, so the association normalization I added last round did catch embedded properties and reflect an id from a value that has none — both engines now skip Embedded and leave the value for the normal embedded encoding. And updateAll reflected the identifier directly while ToOneEncoder asks the proxy factory first, so a load()ed argument yielded null; both engines now ask the proxy factory too.

For the collection paths, updateAll encodes each element the way OneToManyEncoder does — the target's identifier in its stored _id type, wrapped in a DBRef where the mapping declares one, proxy-safe — and EmbeddedCollection is excluded for the same reason as Embedded. On the mapping engine, the association indexer's unidirectional one-to-many keys and the many-to-many id list now go through the target's id mapping, matching formulateDatabaseReference and setEmbeddedCollectionKeys.

On the documentation: agreed, "only supported value" contradicted the paragraph documenting mapping immediately below it. It now says codec is the default and recommended value and mapping remains available for compatibility but is deprecated, in both the asciidoc and the SETTING_ENGINE javadoc.

grails-data-mongodb-core is at parity with 8.0.x locally: the only failures are the two pre-existing MongoDatastoreLifecycleSpec cases, which also fail on an unmodified checkout here. checkstyle and codenarc clean.

One note on the verification section, same as last round: a run that exceeds a local timeout without printing a completion result is not evidence that it passed. The conclusion has held each time, but it is worth separating "the focused tests passed" from "the full task was inconclusive".

@matrei

matrei commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Round 4

Review Findings

The latest update resolves all five findings from the previous round: proxy-safe updateAll, embedded exclusion, mapping-engine unidirectional collection and many-to-many reference coercion, collection-association encoding in updateAll, and the engine documentation wording. The focused tests pass.

However, the new to-many branch in updateAll is broader than the normal persistence path it mirrors. It catches association kinds that normal persistence handles differently, and one of them is a regression of a common operation.

Merge Blockers

High: updateAll on a basic collection now throws NullPointerException

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:394-410
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:414-436
  • grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/model/types/Basic.java:38

Basic extends ToMany, so a collection of simple values such as:

class Note {
    String id
    String title
    List<String> labels = []
    static hasMany = [labels: String]
}

now enters the new to-many branch. A Basic association has no associated entity, so the reflector lookup dereferences null:

Note.where { title == 'note' }.updateAll(labels: ['y', 'z'])

Codec engine:

java.lang.NullPointerException: Cannot invoke "org.grails.datastore.mapping.model.PersistentEntity.getReflector()" because the return value of "...castToType(Object, java.lang.Class)" is null

Mapping engine:

java.lang.NullPointerException: Cannot invoke "org.grails.datastore.mapping.model.PersistentEntity.getName()" because "persistentEntity" is null

Before this PR the same call sent the String list through $set and worked. The branch should be restricted to OneToMany and ManyToMany with a non-null associated entity, or skip any association whose associated entity is null. Both engines need a test for a basic collection in updateAll.

High: updateAll on a bidirectional one-to-many corrupts the owning document in the codec engine

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:394-410
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:414-436
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:524,600-602
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/AbstractMongoObectEntityPersister.java:455,480

Normal persistence only stores an id array for a to-many when it is unidirectional or many-to-many. For a bidirectional one-to-many the foreign key lives on the inverse side, and OneToManyDecoder does not consume a value for that field, it initializes a lazy collection instead.

The new updateAll branch does not make that distinction. For:

class Parent {
    String id
    Set<Child> children
    static hasMany = [children: Child]
}

class Child {
    String id
    Parent parent
    static belongsTo = [parent: Parent]
}

this call:

Parent.where { name == 'bi-parent' }.updateAll(children: [child])

writes a children array onto the parent document. The decoder then hits that unread field and every subsequent read of the parent fails:

org.bson.BsonInvalidOperationException: ReadBSONType can only be called when State is TYPE, not when State is VALUE.
	at org.grails.datastore.bson.codecs.BsonPersistentEntityCodec.decode(BsonPersistentEntityCodec.groovy:192)

The child's parent reference is not updated either, so the relationship does not change. In the mapping engine the same call leaves a stray children array and the relationship is likewise unchanged.

The bulk path should mirror the shouldEncodeIds rule in OneToManyEncoder: skip bidirectional one-to-many properties, or reject them with a clear exception, rather than writing a field normal persistence never writes or reads.

Medium: Mapping-engine updateAll on a many-to-many writes to the wrong field

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:414-436
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/AbstractMongoObectEntityPersister.java:388,414

The mapping engine stores and reads many-to-many ids under a suffixed key:

manyToMany.getName() + "_$$manyToManyIds"

The new updateAll branch writes the encoded ids under the plain association name. After:

((MongoSession) session).updateAll(criteria, [rights: [right2]])

the document contains both rights_$$manyToManyIds with the old id and rights with the new id, and reading the entity back still returns the old association. The update is silently a no-op.

The mapping engine is deprecated, but this commit specifically claims to encode collection associations for it, and MappingEngineStringIdStorageSpec has no many-to-many or to-many updateAll coverage. Either use the same key as setManyToMany or document that many-to-many bulk updates are unsupported on this engine.

Observations

  • The mapping engine's unidirectional one-to-many read path resolves coerced ObjectId keys correctly, and updateAll(tags: [...]) on it works. Neither is covered by a test in MappingEngineStringIdStorageSpec, which only checks the raw document. A read-back assertion would guard the retrieveAllEntities conversion.
  • The EmbeddedCollection exclusion in both to-many branches is redundant, since EmbeddedCollection extends Association directly rather than ToMany. Harmless, but the comment implies a case that cannot occur.

Resolved Findings

  • updateAll extracts ids from lazy proxies in both engines.
  • Embedded to-one associations are excluded from id normalization and are written as subdocuments.
  • Mapping-engine unidirectional collection and many-to-many references are stored in the target's _id type.
  • Ordinary unidirectional and many-to-many collections in updateAll are encoded as stored ids in the codec engine.
  • Engine documentation wording is consistent with the implementation.

Verification

The following focused tests passed:

./gradlew :grails-data-mongodb-core:test \
  --tests 'org.grails.datastore.gorm.mongo.bugs.MappingEngineStringIdStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdAssociationStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdWithObjectIdStorageSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.StringIdDefaultStoredAsConfigSpec' \
  --tests 'org.grails.datastore.gorm.mongo.bugs.MongoIdCoercionSpec' \
  --no-daemon

The three findings above were reproduced with a temporary probe spec against both engines, which was removed afterwards.

The complete grails-data-mongodb-core test task also passed:

Tests Failures Errors Skipped
715 0 0 45

./gradlew :grails-data-mongodb-core:check -x test passed as well, with no Checkstyle or CodeNarc violations.

The basic-collection regression and the bidirectional one-to-many corruption should be fixed and covered with tests in both engines before approval. The mapping-engine many-to-many field mismatch should be fixed or explicitly documented as unsupported.

…ions

Basic extends ToMany, so a collection of simple values -- hasMany = [labels: String] --
entered the branch added for OneToMany and ManyToMany. A Basic association has no
associated entity, so the reflector lookup dereferenced null and updateAll threw
NullPointerException on both engines for a call that worked before this PR.

The branch now names the two kinds it is for and requires a non-null associated entity.
EmbeddedCollection no longer needs excluding: it extends Association directly, not ToMany,
so it never reached here and the exclusion implied a case that cannot occur.
A bidirectional one-to-many keeps its foreign key on the inverse side, so there is no
field on the owning document to update -- OneToManyEncoder's shouldEncodeIds skips it for
that reason, and the decoder never reads such a field.

Left in the $set the value was written as raw subdocuments, and the owner then failed to
decode at all:

    BsonInvalidOperationException: ReadBSONType can only be called when State is TYPE

Skipping the id encoding alone is not enough, because the property still reaches $set. It
is now rejected by name. Dropping it silently would be the same silent no-op this review
objected to elsewhere, and the operation is genuinely unexpressible: reassigning children
means updating their foreign key, which an update on the parent cannot do.
…e reads

This engine keeps many-to-many ids under a suffixed key -- see setManyToMany and
getManyToManyKeys -- so writing the plain association name left the real field untouched.
The document ended up with both, the read path kept returning the old association, and the
update was silently a no-op.

Also adds the read-back assertion the review noted was missing: the unidirectional
one-to-many cases only checked the raw document, so nothing covered the retrieveAllEntities
conversion of coerced ObjectId keys.
ToOneEncoder writes a reference on the owner only when the foreign key is not held by the
child -- its !isForeignKeyInChild() guard. The updateAll normalization had no equivalent
check, so updateAll(nose: n) on a hasOne owner wrote an id field onto a document that
normal persistence never gives one, the same corruption shape as the bidirectional
one-to-many case.

Found by an adversarial audit of the association-type space rather than by a reviewer: two
independent analysts converged on it after the previous three defects here were all the
same shape -- an association kind entering a branch meant for a different one.
Two divergences from normal persistence, both found by the same audit as the hasOne case.

OneToManyEncoder drops null entries before wrapping a reference collection, so it never
builds DBRef(collection, null); the bulk path kept them and would hand the driver a null
id. It now filters exactly where the encoder does, and leaves a plain id list alone.

The non-codec engine's setManyToMany stores plain identifiers under its suffixed key and
getManyToManyKeys reads them back the same way -- it never writes DBRefs. Wrapping them in
the bulk path produced an array the read path cannot interpret, so many-to-many values are
now written as plain ids there.
@codeconsole

Copy link
Copy Markdown
Contributor Author

All three addressed, plus three more of the same class that an audit turned up before you had to. One isolated commit each, every fix pinned by a test verified to fail without it.

commit finding
7deb7dc updateAll on a basic collection throws NPE
0dfbe06 updateAll on a bidirectional one-to-many corrupts the owner
9528ba1 mapping-engine many-to-many writes the wrong field
b1c52c2 (found by audit) updateAll on a hasOne corrupts the owner
8707742 (found by audit) null handling and many-to-many representation diverge from the encoders

Basic. Correct — Basic extends ToMany and has no associated entity. The branch now names the two kinds it is for and requires a non-null associated entity. You were also right that the EmbeddedCollection exclusion was redundant: it extends Association directly, so it never reached there. Removed.

Bidirectional one-to-many. Skipping the id encoding alone was not enough — the property still reached $set and was written as raw subdocuments, so the owner still stopped decoding. It is now rejected by name. I chose rejecting over silently dropping because a silent drop is the same silent no-op you objected to for many-to-many, and the operation is genuinely unexpressible: reassigning children means updating their foreign key, which an update on the parent cannot do. MongoQuery already throws UnsupportedOperationException for join queries, so this follows that precedent.

Many-to-many key. Fixed, and both observations addressed: MappingEngineStringIdStorageSpec now has many-to-many and to-many updateAll coverage, and a read-back assertion on the unidirectional one-to-many that exercises the retrieveAllEntities conversion rather than only the raw document.

On the two extra findings. Three rounds running, every defect here has been the same shape: an association kind entering a branch meant for a different one. Rather than wait to find out what the fourth was, I ran an adversarial audit over the association-type space — four independent analysts reading the encoders and the type hierarchy, each finding then handed to a separate agent prompted to refute it.

It found hasOne immediately, and four of the analysts converged on it independently. ToOneEncoder writes a reference on the owner only when !isForeignKeyInChild(); the normalization had no equivalent guard, so updateAll(nose: n) wrote an id field onto a document that normal persistence never gives one — the same corruption shape as the bidirectional case, in the to-one branch.

It also caught two representation divergences: OneToManyEncoder drops nulls before wrapping a reference collection so it never builds DBRef(collection, null), and setManyToMany stores plain identifiers under its suffixed key and never DBRefs, so wrapping them there produced an array getManyToManyKeys cannot read.

grails-data-mongodb-core is at parity with 8.0.x locally: the only failures are the two pre-existing MongoDatastoreLifecycleSpec cases, which also fail on an unmodified checkout here. checkstyle and codenarc clean.

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

Review Findings - round 5

The latest update resolves all three findings from the previous round, and additionally rejects hasOne associations in updateAll with a clear exception. The focused tests and the complete grails-data-mongodb-core check pass. I have no remaining merge blockers; the items below are non-blocking suggestions.

Resolved Findings

  • updateAll on a basic collection no longer enters the id branch. Basic is excluded by restricting the branch to OneToMany / ManyToMany with a non-null associated entity, and both engines have a labels: ['y', 'z'] test.
  • updateAll on a bidirectional one-to-many now throws UnsupportedOperationException instead of writing a field the decoder cannot read. Both engines test that nothing stray lands on the owner document, and the codec spec confirms the owner still decodes afterwards.
  • Mapping-engine updateAll on a many-to-many now writes plain ids under <name>_$$manyToManyIds, the key getManyToManyKeys reads, and removes the plain key. The new test asserts both the raw document and the read-back.
  • hasOne in updateAll is rejected in both engines, matching the !isForeignKeyInChild() guard in ToOneEncoder.
  • The unidirectional one-to-many read-back through coerced keys on the mapping engine is now asserted, not just the raw document.

Suggestions (non-blocking)

Low: An identifier value passed for a to-one association fails with a reflector error

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:388-390
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:405-409

Both engines reflect the identifier off whatever value is supplied. A caller who passes the id rather than the instance:

RefTicket.where { title == 'x' }.updateAll(project: project.id)

gets:

java.lang.IllegalArgumentException: Cannot read field [private java.lang.String ...RefProject.id]
  from object [6aa27b25977124bdfec620b9] of type [class java.lang.String]

On the codec engine this predates the PR, since updateAll already reflected the id there. On the mapping engine it is new: before this PR the raw value went through $set unchanged, which with String-stored ids happened to be correct. Since updateAll now knows the target's stored type anyway, a value that is not an instance of the associated class could simply be passed through MongoIdCoercion.coerceIdToStoredType instead of the reflector. That would make updateAll(project: id) and updateAll(project: instance) equivalent on both engines. Fine to defer to a follow-up.

Low: Test name does not match what it asserts, and the DBRef null-drop is untested

Reference:

  • grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/bugs/StringIdAssociationStorageSpec.groovy:398-411

"updateAll drops nulls from a to-many rather than encoding them" asserts raw.get('tags') == [ObjectId, null], i.e. the null is kept. The drop only happens for a reference: true mapping, and no test exercises that branch. I verified with a temporary probe that a reference: true to-many with [tag, null] lands as a single DBRef and reads back correctly, so the code is right; the test should be renamed to say the plain list keeps nulls, and a reference: true case added for the drop.

Low: Coverage gaps for paths that only one engine tests

  • hasOne rejection is tested on the codec engine only. The mapping engine throws the same exception (verified with a probe); MappingEngineStringIdStorageSpec should have the matching case, since the two implementations are separate code.
  • Many-to-many updateAll is tested on the mapping engine only. On the codec engine it shares the unidirectional branch, and a probe confirmed both the owning and inverse side write an ObjectId array under the plain name and read back, but a test in StringIdAssociationStorageSpec would guard it.

Nit: Dead type check

References:

  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:411
  • grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoSession.java:430

ManyToMany extends ToMany directly, not OneToMany, so association instanceof OneToMany && !(association instanceof ManyToMany) never excludes anything. Harmless, but like the EmbeddedCollection note last round it implies a hierarchy that does not exist. association instanceof OneToMany && association.isBidirectional() is the whole condition.

Nit: Documentation

The new UnsupportedOperationException for hasOne and bidirectional one-to-many in updateAll is user-visible behavior. Before this PR those calls corrupted the owner document or failed with a driver error, so this is a fix rather than a feature, but the MongoDB guide has no mention of updateAll at all. A short note on which association kinds bulk updates support, and to update the inverse side for the others, would save users a round trip to the exception message.

Verification

Complete module check, including Checkstyle and CodeNarc on main sources:

./gradlew :grails-data-mongodb-core:check --no-daemon --continue
Tests Failures Errors Skipped
723 0 0 45

BUILD SUCCESSFUL, no Checkstyle or CodeNarc violations.

The identifier-value behavior, the DBRef null drop, the codec many-to-many update on both sides, the bidirectional many-to-one update from the child side, and the mapping-engine hasOne rejection were each exercised with a temporary probe spec against the running datastores, which was removed afterwards.

I am happy to approve as is; the suggestions above can be follow-ups.

@testlens-app

testlens-app Bot commented Sep 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 8707742
▶️ Tests: 18803 executed
⚪️ Checks: 89/89 completed


Learn more about TestLens at testlens.app/docs.

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