jdaugherty commented on code in PR #15583:
URL: https://github.com/apache/grails-core/pull/15583#discussion_r3141979599
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java:
##########
@@ -133,6 +137,20 @@ public class MongoMappingContext extends
DocumentMappingContext {
private CodecRegistry codecRegistry;
private Map<Class, Boolean> hasCodecCache = new HashMap<>();
+ /**
Review Comment:
Add a new line between the comment and the other variable?
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java:
##########
@@ -133,6 +137,20 @@ public class MongoMappingContext extends
DocumentMappingContext {
private CodecRegistry codecRegistry;
private Map<Class, Boolean> hasCodecCache = new HashMap<>();
+ /**
+ * Global default storage type for {@code String id} fields that don't
declare an explicit
+ * {@code id storedAs: ...} in their mapping. Null means "no default — use
the declared
+ * Java type" (current GORM behavior). See {@link
MongoSettings#SETTING_STRING_IDS_DEFAULT_STORED_AS}.
+ */
+ private Class<?> stringIdDefaultStoredAs;
Review Comment:
`stringIdDefaultStoredAs` has a public setter but is neither `final` nor
`volatile`. In practice the field is set in the constructor before
`initialize(classes)`, but `MongoDocumentMappingFactory.createIdentity` reads
it later for every entity — and any caller that uses the public setter from a
different thread than the constructor thread has no guaranteed visibility.
Either make it `final` and drop the setter (constructor-only assignment is
JMM-safe via the publication rules), or mark it `volatile`. Same applies to the
accessor pair if you keep the setter.
##########
grails-data-mongodb/bson/src/main/groovy/org/grails/datastore/bson/codecs/encoders/IdentityEncoder.groovy:
##########
@@ -41,6 +41,25 @@ class IdentityEncoder implements PropertyEncoder<Identity> {
void encode(BsonWriter writer, Identity property, Object id, EntityAccess
parentAccess, EncoderContext encoderContext, CodecRegistry codecRegistry) {
writer.writeName(getIdentifierName(property))
+ Class<?> storedAs = resolveStoredAs(property)
+ if (storedAs != null && id != null) {
+ if (ObjectId.isAssignableFrom(storedAs) && !(id instanceof
ObjectId)) {
+ String hex = id.toString()
+ // Guard against natural-key strings accidentally paired with
storedAs: ObjectId.
+ // new ObjectId(<non-hex>) throws IllegalArgumentException,
which would surface
+ // deep inside the BSON write pipeline. Fall through to
writeString for consistency
+ // with the converter-based paths (MongoCodecSession,
MongoCodecEntityPersister).
+ if (ObjectId.isValid(hex)) {
Review Comment:
From AI:
When `storedAs: ObjectId` is set but the value is not valid hex, the encoder
silently writes a BSON String. The doc caveat acknowledges this and points
users at `storedAs: String`, but at runtime there is no signal — a
misconfigured `id generator: assigned, storedAs: ObjectId` domain just quietly
persists Strings. Consider a one-shot `log.warn` (e.g. cached per `(entity,
id-shape)` to avoid log spam) so this is debuggable in the field. Today the
only way to discover the misconfiguration is to peek at raw BSON.
##########
grails-data-mongodb/bson/src/main/groovy/org/grails/datastore/bson/codecs/encoders/IdentityEncoder.groovy:
##########
@@ -51,6 +70,14 @@ class IdentityEncoder implements PropertyEncoder<Identity> {
}
+ private static Class<?> resolveStoredAs(Identity property) {
+ try {
+ return property?.owner?.mapping?.identifier?.storedAs
+ } catch (Throwable ignored) {
Review Comment:
`catch (Throwable ignored)` is broader than it needs to be: it swallows
`OutOfMemoryError`, `StackOverflowError`, etc. Why can't you use Exception
ignored?
Same pattern repeats in `MongoCodecSession#coerceIdToStoredType`,
`MongoCodecEntityPersister#coerceIdToStoredType`, and the two `try` blocks in
`MongoQuery` (`IdEquals` and the new `In` handler)
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:
##########
@@ -286,6 +286,44 @@ class MongoCodecSession extends AbstractMongoSession {
return entityWrites
}
+ /**
+ * If the entity's id mapping declares {@code storedAs} and it differs
from the in-memory
+ * native key type, coerce the key so that update/delete filters target
BSON values of
+ * the correct type (otherwise {@code {_id: "<hex>"}} sent as a BSON
String would never
+ * match an {@code _id: ObjectId(...)} document on disk, and the write
would silently miss,
+ * surfacing as a misleading {@link OptimisticLockingException}).
+ *
+ * <p>Exercised end-to-end by {@code StringIdWithObjectIdStorageSpec}:
+ * <ul>
+ * <li>"with storedAs ObjectId, updates persist (no phantom
OptimisticLockingException)" — happy path on update filter</li>
+ * <li>"with storedAs ObjectId, update of a non-hex id document lands on
the right row" — null-return fallback on update filter</li>
+ * <li>"with storedAs ObjectId, delete of a non-hex id document removes
the row" — null-return fallback on delete filter</li>
+ * <li>"with storedAs ObjectId, legacy documents written directly as
BSON ObjectId are fully accessible" — update path against legacy BSON ObjectId
_id</li>
+ * </ul>
+ */
+ protected Object coerceIdToStoredType(Object nativeKey, PersistentEntity
entity) {
Review Comment:
This looks identical to `MongoCodecEntityPersister#coerceIdToStoredType` &
the same logic seems to exist in `MongoQuery`'s `IdEquals`/`In` handlers. Lets
extract it into a static helper so the logic doesn't diverge?
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/codecs/PersistentEntityCodec.groovy:
##########
@@ -361,7 +361,40 @@ class PersistentEntityCodec extends
BsonPersistentEntityCodec {
}
}
else {
- // TODO: Support non-dirty checkable objects?
+ // Non-DirtyCheckable values: no per-property change history
available,
+ // so when the caller is encoding this as an embedded update
(null→non-null
+ // transition on a single-valued embedded field), encode every
persistent
+ // property. Without this, the parent's $set on the embedded path
stays
+ // empty and the sub-document is silently dropped.
+ if (embedded) {
Review Comment:
This is from AI:
(1) non-DirtyCheckable embedded with a discriminator (subclass) — the new
branch writes `_class` only at the embedded root, but nested embedded objects
via `Embedded`/`EmbeddedCollection` recurse through the same `else` branch and
may need the same treatment
(2) confirm that the existing DirtyCheckable path is genuinely never hit for
POGOs that have been touched by `markDirty` upstream — otherwise this branch
can fire double-encodes.
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:
##########
@@ -286,6 +286,44 @@ class MongoCodecSession extends AbstractMongoSession {
return entityWrites
}
+ /**
+ * If the entity's id mapping declares {@code storedAs} and it differs
from the in-memory
+ * native key type, coerce the key so that update/delete filters target
BSON values of
+ * the correct type (otherwise {@code {_id: "<hex>"}} sent as a BSON
String would never
+ * match an {@code _id: ObjectId(...)} document on disk, and the write
would silently miss,
+ * surfacing as a misleading {@link OptimisticLockingException}).
+ *
+ * <p>Exercised end-to-end by {@code StringIdWithObjectIdStorageSpec}:
Review Comment:
Listing Spock test method names verbatim in javadoc (`"with storedAs
ObjectId, updates persist…"`, etc.) is brittle: those names tend to get renamed
during PR cycles, and there is no compile-time link to flag the doc as stale
when that happens. A short semantic pointer (e.g. `see
StringIdWithObjectIdStorageSpec for end-to-end coverage of update/delete
filters under storedAs`) carries the same value without the rot risk. Same
comment applies to the parallel javadoc in
`MongoCodecEntityPersister#coerceIdToStoredType` and the inline comment block
above the new `In` handler in `MongoQuery`.
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java:
##########
@@ -176,6 +215,10 @@ public MongoMappingContext(PropertyResolver configuration,
Class... classes) {
*/
public MongoMappingContext(AbstractMongoConnectionSourceSettings settings,
Class... classes) {
super(settings.getDatabase(), settings);
+ // Must run BEFORE initialize(classes) so that
MongoDocumentMappingFactory.createIdentity
+ // (invoked during entity registration) can read the global default.
+ String storedAsDefault = settings.getStringIds() != null ?
settings.getStringIds().getDefaultStoredAs() : null;
+ this.stringIdDefaultStoredAs = parseStoredAs(storedAsDefault);
Review Comment:
Asymmetry to flag: the deprecated `(PropertyResolver, Class...)` constructor
and this `(AbstractMongoConnectionSourceSettings, Class...)` constructor both
read `stringIdDefaultStoredAs` from settings, but the public
`MongoMappingContext(String defaultDatabaseName, Closure defaultMapping,
Class... classes)` constructor does not — so anyone wiring the context manually
with that signature silently loses the global default. Either factor a private
`applyStoredAsFromConfig(...)` helper and invoke it from all constructors that
have the relevant inputs, or document on the bare constructor that it does not
honor `grails.mongodb.stringIdsDefaultStoredAs`. Today this is the kind of
inconsistency that gets discovered only when a test using the bare constructor
passes locally and the production datastore — using the settings constructor —
diverges.
##########
grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/bugs/StringIdWithObjectIdStorageSpec.groovy:
##########
@@ -0,0 +1,388 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.grails.datastore.gorm.mongo.bugs
+
+import grails.persistence.Entity
+import groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import org.apache.grails.data.mongo.core.GrailsDataMongoTckManager
+import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec
+import org.bson.Document
+import org.bson.types.ObjectId
+import org.grails.datastore.mapping.core.OptimisticLockingException
+
+/**
+ * Reproduces a decoder/encoder asymmetry in GORM MongoDB when a domain class
+ * declares {@code String id} but the underlying {@code _id} is stored as a
BSON
+ * {@code ObjectId} (e.g. legacy data from when the domain used {@code
ObjectId id}).
+ *
+ * <ul>
+ * <li>{@code IdentityDecoder} has a fallback: if the declared type is String
+ * but the BSON is ObjectId, it still decodes the document (via Spring's
+ * {@code ConversionService}). Scan-style reads work.</li>
+ * <li>{@code MongoQuery.IdEquals} and {@code IdentityEncoder} do
<em>not</em>
+ * have this forgiveness: point lookups send {@code {_id: "hex"}} as a
+ * BSON String, which does not match {@code _id: ObjectId("hex")} in
+ * storage; updates write BSON String and miss for the same reason.</li>
+ * </ul>
+ *
+ * The net effect is that a domain can silently "read" its legacy documents but
+ * silently fail to {@code get(id)}, update, or delete them.
+ */
+class StringIdWithObjectIdStorageSpec extends
GrailsDataTckSpec<GrailsDataMongoTckManager> {
+
+ void setupSpec() {
+ manager.domainClasses.addAll([LegacyVideo, ObjectIdVideo,
StoredAsVideo, AssignedNonHexVideo])
+ }
+
+ void "scan read decodes a BSON ObjectId _id into a String-typed id
field"() {
+ given:
+ ObjectId legacyId = new ObjectId()
+ rawCollection().insertOne(new Document('_id',
legacyId).append('title', 'Legacy'))
+
+ when:
+ manager.session.clear()
+ List<LegacyVideo> all = LegacyVideo.list()
+
+ then: 'decoder fallback coerces ObjectId -> String'
+ all.size() == 1
+ all[0].id == legacyId.toHexString()
+ all[0].title == 'Legacy'
+ }
+
+ void "point lookup by hex string returns null because the query sends BSON
String"() {
+ given:
+ ObjectId legacyId = new ObjectId()
+ rawCollection().insertOne(new Document('_id',
legacyId).append('title', 'Legacy'))
+
+ when:
+ manager.session.clear()
+ LegacyVideo found = LegacyVideo.get(legacyId.toHexString())
+
+ then: 'MongoQuery.IdEquals builds {_id: "<hex>"} as BSON String, which
does not match the ObjectId _id'
+ found == null
+ }
+
+ void "update-through-read throws a misleading OptimisticLockingException
because the update filter misses the legacy doc"() {
+ given:
+ ObjectId legacyId = new ObjectId()
+ rawCollection().insertOne(new Document('_id',
legacyId).append('title', 'Original'))
+
+ when: 'load via scan (works), mutate, save'
+ manager.session.clear()
+ LegacyVideo v = LegacyVideo.list().first()
+ v.title = 'Updated'
+ v.save(flush: true)
+
+ then: '''the update targets {_id: "<hex>"} as BSON String which
matches zero docs;
+ GORM interprets the zero-match as a concurrency conflict and
throws
+ OptimisticLockingException, even though nothing else touched
the data.
+ This error message is misleading — the real cause is the
id-type asymmetry.'''
+ OptimisticLockingException ex = thrown()
+ ex.message.contains('updated by another user')
+
+ and: 'the legacy ObjectId-_id document is unchanged'
+ manager.session.clear()
+ Document raw = rawCollection().find(new Document('_id',
legacyId)).first()
+ raw.getString('title') == 'Original'
+ }
+
+ void "reflective JSON serialization of an ObjectId-id domain emits the id
as a nested object, not a hex string"() {
+ given: 'a domain with ObjectId id saved via GORM'
+ ObjectIdVideo v = new ObjectIdVideo(title: 'Symposium').save(flush:
true, failOnError: true)
+
+ when: '''we serialize it the way a controller would when no ObjectId
marshaller
+ is registered — reflective bean walk over every property,
which is exactly
+ what Grails\' default JSON converter falls back to for
unknown types'''
+ String json = JsonOutput.toJson([id: v.id, title: v.title])
+ Map parsed = new JsonSlurper().parseText(json) as Map
+
+ then: '''the id field is an object with internal ObjectId fields
(timestamp/date)
+ rather than the hex string — this is the root cause of
client-side bugs
+ like data-video-id="[object Object]" when HTML datasets or
URLs are built
+ from the parsed value'''
+ parsed.id instanceof Map
+ parsed.id.containsKey('timestamp')
+ !(parsed.id instanceof String)
+
+ and: 'compare to what a String id would serialize as — a plain hex
string'
+ LegacyVideo wouldBeFine = new LegacyVideo(title: 'OK').save(flush:
true, failOnError: true)
+ Map parsedString = new JsonSlurper().parseText(JsonOutput.toJson([id:
wouldBeFine.id, title: wouldBeFine.title])) as Map
+ parsedString.id instanceof String
+ parsedString.id.length() == 24 // hex ObjectId generated by GORM for
String-id domains
+ }
+
+ // ----------------------------------------------------------------------
+ // Cases covering `id storedAs: ObjectId` — the feature that makes the
+ // three failure modes above go away without requiring a data migration.
+ // ----------------------------------------------------------------------
+
+ void "with storedAs ObjectId, a String-id domain writes BSON ObjectId to
_id"() {
+ given:
+ StoredAsVideo v = new StoredAsVideo(title: 'Symposium').save(flush:
true, failOnError: true)
+ String hex = v.id
+
+ when: 'peek at the raw document via the driver'
+ Document raw = storedAsRawCollection().find(new Document('_id', new
ObjectId(hex))).first()
+
+ then: '''_id is BSON ObjectId, not BSON String. Query by ObjectId
finds it;
+ query by String would not.'''
+ raw != null
+ raw.get('_id') instanceof ObjectId
+ raw.get('_id').toString() == hex
+ storedAsRawCollection().find(new Document('_id', hex)).first() == null
+ }
+
+ void "with storedAs ObjectId, point lookup by hex string works"() {
+ given:
+ StoredAsVideo v = new StoredAsVideo(title: 'Symposium').save(flush:
true, failOnError: true)
+ String hex = v.id
+
+ when:
+ manager.session.clear()
+ StoredAsVideo found = StoredAsVideo.get(hex)
+
+ then: 'MongoQuery.IdEquals converts to the storedAs type, matches BSON
ObjectId'
+ found != null
+ found.id == hex
+ found.title == 'Symposium'
+ }
+
+ void "with storedAs ObjectId, updates persist (no phantom
OptimisticLockingException)"() {
+ given:
+ StoredAsVideo v = new StoredAsVideo(title: 'Original').save(flush:
true, failOnError: true)
+ String hex = v.id
+
+ when:
+ manager.session.clear()
+ StoredAsVideo reloaded = StoredAsVideo.get(hex)
+ reloaded.title = 'Updated'
+ reloaded.save(flush: true, failOnError: true)
+ manager.session.clear()
+
+ and:
+ Document raw = storedAsRawCollection().find(new Document('_id', new
ObjectId(hex))).first()
+
+ then:
+ raw != null
+ raw.getString('title') == 'Updated'
+ }
+
+ void "with storedAs ObjectId, batch getAll resolves all ids (coerces each
key in the in-list filter)"() {
+ given:
+ StoredAsVideo a = new StoredAsVideo(title: 'A').save(flush: true,
failOnError: true)
+ StoredAsVideo b = new StoredAsVideo(title: 'B').save(flush: true,
failOnError: true)
+ StoredAsVideo c = new StoredAsVideo(title: 'C').save(flush: true,
failOnError: true)
+
+ when:
+ manager.session.clear()
+ List<StoredAsVideo> found = StoredAsVideo.getAll([a.id, b.id, c.id])
+
+ then: 'regression test for in-list handler: batch queries must coerce
each key to BSON ObjectId'
+ found.size() == 3
+ found*.title.sort() == ['A', 'B', 'C']
+ }
+
+ void "with storedAs ObjectId, findAllByIdInList resolves all ids"() {
+ given:
+ StoredAsVideo a = new StoredAsVideo(title: 'A').save(flush: true,
failOnError: true)
+ StoredAsVideo b = new StoredAsVideo(title: 'B').save(flush: true,
failOnError: true)
+
+ when:
+ manager.session.clear()
+ List<StoredAsVideo> found = StoredAsVideo.findAllByIdInList([a.id,
b.id])
+
+ then: 'regression test: dynamic-finder in-list must coerce each id to
BSON ObjectId'
+ found.size() == 2
+ found*.title.sort() == ['A', 'B']
+ }
+
+ void "with storedAs ObjectId, criteria in('id', [...]) resolves all ids"()
{
+ given:
+ StoredAsVideo a = new StoredAsVideo(title: 'A').save(flush: true,
failOnError: true)
+ StoredAsVideo b = new StoredAsVideo(title: 'B').save(flush: true,
failOnError: true)
+
+ when:
+ manager.session.clear()
+ List<StoredAsVideo> found = StoredAsVideo.createCriteria().list {
+ 'in' 'id', [a.id, b.id]
+ }
+
+ then: 'regression test: criteria in-list on id must coerce to BSON
ObjectId'
+ found.size() == 2
+ found*.title.sort() == ['A', 'B']
+ }
+
+ void "with storedAs ObjectId, encoding a non-hex id falls back to BSON
String instead of throwing"() {
+ given: '''a domain using an assigned natural key (not a valid ObjectId
hex).
+ This is a misconfiguration — the user should not be combining
storedAs: ObjectId
+ with natural keys — but the library should degrade
predictably rather than
+ throwing IllegalArgumentException deep inside the BSON write
pipeline.'''
+ AssignedNonHexVideo v = new AssignedNonHexVideo(id: 'my-slug', title:
'Slug-Keyed').save(flush: true)
+
+ expect: 'save did not throw; a doc was written with BSON String _id
(the fallback path)'
+ v != null
+ !v.hasErrors()
+
+ when:
+ Document raw =
manager.mongoClient.getDatabase('test').getCollection('assignedNonHexVideo')
+ .find(new Document('_id', 'my-slug')).first()
+
+ then:
+ raw != null
+ raw.getString('title') == 'Slug-Keyed'
+ }
+
+ void "with storedAs ObjectId, point lookup of a non-hex id matches the
BSON String the encoder wrote"() {
+ given: '''save path falls back to BSON String for non-hex; the read
path must mirror
+ that fallback — otherwise the ConversionService returns null
for invalid hex
+ and the query targets {_id: null}, stranding the document.'''
+ new AssignedNonHexVideo(id: 'my-slug', title:
'Slug-Keyed').save(flush: true, failOnError: true)
+
+ when:
+ manager.session.clear()
+ AssignedNonHexVideo found = AssignedNonHexVideo.get('my-slug')
+
+ then: 'regression: String→ObjectId converter returns null for non-hex;
IdEquals handler keeps original'
+ found != null
+ found.id == 'my-slug'
+ found.title == 'Slug-Keyed'
+ }
+
+ void "with storedAs ObjectId, update of a non-hex id document lands on the
right row"() {
+ given:
+ new AssignedNonHexVideo(id: 'my-slug', title: 'Original').save(flush:
true, failOnError: true)
+
+ when:
+ manager.session.clear()
+ AssignedNonHexVideo reloaded = AssignedNonHexVideo.get('my-slug')
+ reloaded.title = 'Updated'
+ reloaded.save(flush: true, failOnError: true)
+
+ and:
+ Document raw =
manager.mongoClient.getDatabase('test').getCollection('assignedNonHexVideo')
+ .find(new Document('_id', 'my-slug')).first()
+
+ then: 'regression: without the null-return fallback the update filter
targets {_id: null} and silently misses'
+ raw != null
+ raw.getString('title') == 'Updated'
+ }
+
+ void "with storedAs ObjectId, batch getAll with non-hex ids falls back to
BSON String in the in-list"() {
+ given:
+ new AssignedNonHexVideo(id: 'slug-a', title: 'A').save(flush: true,
failOnError: true)
+ new AssignedNonHexVideo(id: 'slug-b', title: 'B').save(flush: true,
failOnError: true)
+
+ when:
+ manager.session.clear()
+ List<AssignedNonHexVideo> found =
AssignedNonHexVideo.getAll(['slug-a', 'slug-b'])
+
+ then: 'regression: In handler converts each value to ObjectId (returns
null for non-hex); fallback keeps original'
+ found.size() == 2
+ found*.title.sort() == ['A', 'B']
+ }
+
+ void "with storedAs ObjectId, delete of a non-hex id document removes the
row"() {
+ given:
+ new AssignedNonHexVideo(id: 'my-slug', title: 'Delete Me').save(flush:
true, failOnError: true)
+
+ when:
+ manager.session.clear()
+ AssignedNonHexVideo reloaded = AssignedNonHexVideo.get('my-slug')
+ reloaded.delete(flush: true)
+
+ and:
+ Document raw =
manager.mongoClient.getDatabase('test').getCollection('assignedNonHexVideo')
+ .find(new Document('_id', 'my-slug')).first()
+
+ then: 'regression: delete filter must target {_id: "my-slug"} (BSON
String), not {_id: null}'
+ raw == null
+ }
+
+ void "with storedAs ObjectId, legacy documents written directly as BSON
ObjectId are fully accessible"() {
+ given: 'a document inserted outside GORM with _id as BSON ObjectId
(simulates legacy data)'
+ ObjectId legacyId = new ObjectId()
+ storedAsRawCollection().insertOne(new Document('_id',
legacyId).append('title', 'Legacy'))
+
+ when: 'point lookup'
+ manager.session.clear()
+ StoredAsVideo found = StoredAsVideo.get(legacyId.toHexString())
+
+ then: 'works — no migration needed'
+ found != null
+ found.id == legacyId.toHexString()
+
+ when: 'update'
+ found.title = 'Updated'
+ found.save(flush: true, failOnError: true)
+ manager.session.clear()
+
+ and:
+ Document raw = storedAsRawCollection().find(new Document('_id',
legacyId)).first()
+
+ then: 'update lands on the legacy ObjectId-_id doc'
+ raw.getString('title') == 'Updated'
+ }
+
+ private com.mongodb.client.MongoCollection<Document> rawCollection() {
+ manager.mongoClient
+ .getDatabase('test')
+ .getCollection('legacyVideo')
+ }
+
+ private com.mongodb.client.MongoCollection<Document>
storedAsRawCollection() {
+ manager.mongoClient
+ .getDatabase('test')
+ .getCollection('storedAsVideo')
+ }
+}
+
+@Entity
+class LegacyVideo {
+ String id
+ String title
+}
+
+@Entity
+class ObjectIdVideo {
+ ObjectId id
+ String title
+}
+
+@Entity
+class StoredAsVideo {
+ String id
+ String title
+
+ static mapping = {
+ id storedAs: ObjectId
+ version false // orthogonal to storedAs; avoids version-field noise
in raw-insert tests
Review Comment:
Both `StoredAsVideo` and `AssignedNonHexVideo` declare `version false`. The
headline regression for this PR — `with storedAs ObjectId, updates persist (no
phantom OptimisticLockingException)` — therefore cannot actually exercise the
optimistic-locking path it claims to cover, because there is no `version`
field. Worth adding a parallel domain that keeps versioning on (the default) so
the original failure mode (where a missed update filter throws
`OptimisticLockingException`) is genuinely reproduced and proven fixed.
Otherwise this regression test will pass even if a future change accidentally
reintroduces the bug under versioned domains, which is the more common case in
real apps.
--
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]