Copilot commented on code in PR #15583:
URL: https://github.com/apache/grails-core/pull/15583#discussion_r3123886439
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContext.java:
##########
@@ -165,7 +183,28 @@ public MongoMappingContext(String defaultDatabaseName,
Closure defaultMapping, C
*/
@Deprecated
public MongoMappingContext(PropertyResolver configuration, Class...
classes) {
- this(getDefaultDatabaseName(configuration),
configuration.getProperty(MongoSettings.SETTING_DEFAULT_MAPPING, Closure.class,
null), classes);
+ super(getDefaultDatabaseName(configuration),
configuration.getProperty(MongoSettings.SETTING_DEFAULT_MAPPING, Closure.class,
null));
+ // Must run BEFORE initialize(classes) so that
MongoDocumentMappingFactory.createIdentity
+ // (invoked during entity registration) can read the global default.
+ String storedAsDefault =
configuration.getProperty(MongoSettings.SETTING_STRING_IDS_DEFAULT_STORED_AS,
String.class, null);
+ this.stringIdDefaultStoredAs = parseStoredAs(storedAsDefault);
+ initialize(classes);
+ }
+
+ private static Class<?> parseStoredAs(String value) {
+ if (value == null) return null;
+ switch (value.toLowerCase()) {
+ case "objectid":
+ case "object_id":
+ return ObjectId.class;
+ case "string":
+ return String.class;
+ default:
+ log.warn("Unrecognized value '{}' for {}; accepted values are
'objectid' or 'string'. " +
Review Comment:
`parseStoredAs` accepts both `objectid` and `object_id`, but the warning
message says the only accepted values are 'objectid' or 'string'. This is
misleading when users supply `object_id` (they won't get a warning, but the
message implies it would be invalid). Consider either removing the `object_id`
alias or updating the warning/doc text to reflect all accepted values.
```suggestion
log.warn("Unrecognized value '{}' for {}; accepted values
are 'objectid', 'object_id', or 'string'. " +
```
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/engine/MongoCodecEntityPersister.groovy:
##########
@@ -175,6 +178,32 @@ class MongoCodecEntityPersister extends
ThirdPartyCacheEntityPersister<Object> {
new Document(AbstractMongoObectEntityPersister.MONGO_ID_FIELD, key)
}
+ /**
+ * Coerce an identifier value to the {@code storedAs} type declared in the
entity's id
+ * mapping, so that point-lookup queries target the BSON type actually on
disk.
+ * See {@code IdentityMapping#getStoredAs()}.
+ */
+ protected Object coerceIdToStoredType(Object key, PersistentEntity entity)
{
+ if (key == null) return key
+ Class<?> storedAs = null
+ try {
+ storedAs = entity?.mapping?.identifier?.storedAs
+ } catch (Throwable ignored) {
+ return key
+ }
+ if (storedAs == null || storedAs.isInstance(key)) return key
+ try {
+ def converted = mappingContext.conversionService.convert(key,
storedAs)
+ // Symmetry with IdentityEncoder's non-hex fallback: a null return
(vs a throw)
+ // means the converter rejected the value — e.g. a natural-key
String being
+ // converted to ObjectId. Keep the original so the query matches
the document
+ // the encoder actually wrote.
+ return converted != null ? converted : key
+ } catch (Throwable ignored) {
+ return key
+ }
+ }
Review Comment:
`coerceIdToStoredType` converts to any non-null `storedAs`. Since the
encoder currently only honors storedAs for String/ObjectId, using another
storedAs type that Spring can convert to (e.g. UUID) will make `retrieveEntity`
/ `retrieveAllEntities` query using a BSON type that was never written,
guaranteeing misses. Either limit coercion to supported storedAs types
(String/ObjectId) or update `IdentityEncoder` to encode ids using the same
ConversionService for arbitrary storedAs so persistence and retrieval are
symmetric.
##########
grails-data-mongodb/docs/src/docs/asciidoc/objectMapping/idGeneration.adoc:
##########
@@ -67,4 +67,64 @@ p.id = "Fred"
p.insert()
// to update
p.save()
-----
\ No newline at end of file
+----
+
+
+==== Decoupling the Declared Type from the Storage Type
+
+When a domain declares `String id`, GORM by default writes `_id` as a BSON
`String`. This is convenient in application code — HTTP controllers return
clean JSON, URLs embed ids directly, and you never need `new ObjectId(...)` at
call sites — but it gives up the native `ObjectId` benefits at the storage
layer (12-byte index entries vs. 24-byte hex strings, embedded creation
timestamp, etc.).
+
+You can get both using the `storedAs` mapping option. The domain sees a
`String id` (hex form), but `_id` on disk is a BSON `ObjectId`:
+
+[source,groovy]
+----
+import org.bson.types.ObjectId
+
+class Person {
+ String id
+
+ static mapping = {
+ id storedAs: ObjectId
+ }
+}
+----
+
+With `storedAs: ObjectId`:
+
+* `_id` is written as BSON `ObjectId` (native index size, sort order, creation
timestamp)
+* `person.id` in application code is the 24-char hex string (clean JSON, no
conversion at boundaries)
+* `Person.get(hexString)`, `findAllByIdInList([hexA, hexB])`, updates, and
deletes all coerce the declared-type value to the storage type automatically
+* Existing documents that already have BSON `ObjectId` `_id` values continue
to load without a data migration
+
+This is especially useful when migrating a legacy `ObjectId id` domain to
`String id` for ergonomic reasons, because neither a data migration nor any
change in application call sites is required.
+
+===== Global Default
+
+You can opt in globally with an `application.yml` setting, which applies to
every domain declaring `String id` that does not specify its own `storedAs`:
+
+[source,yaml]
+----
+grails:
+ mongodb:
+ stringIdsDefaultStoredAs: objectid
+----
+
+Valid values are `string` (current default, BSON String) and `objectid` (BSON
ObjectId). Unrecognized values fall back to the default with a warning logged
at startup.
+
+Per-domain `storedAs` always wins over the global default. Domains using a
natural string key (slug, email, UUID) should opt out explicitly:
+
+[source,groovy]
+----
+class UserProfile {
+ String id // e.g. "[email protected]"
+
+ static mapping = {
+ id generator: 'assigned', storedAs: String
+ }
+}
+----
+
+===== Caveats
+
+* `storedAs` is currently honored only for converting between `String` and
`ObjectId`. Other combinations are accepted but behave as if `storedAs` were
unset.
Review Comment:
This caveat says non-String/ObjectId `storedAs` values "behave as if
`storedAs` were unset", but the current implementation coerces ids in
query/update/delete paths for any storedAs type that Spring can convert to.
This can create surprising behavior (and even mismatches with
`IdentityEncoder`) for types like UUID. Either tighten the implementation to
only honor String↔ObjectId (so this doc stays true) or update the documentation
to describe exactly what combinations are supported and how mismatches are
handled.
```suggestion
* `storedAs` is primarily intended for converting between `String` and
`ObjectId`. Other `storedAs` values are not treated uniformly as "unset": if
Spring can convert the id type, query/update/delete paths may still coerce
values to the declared `storedAs` type. This can differ from identity encoding
and persistence behavior, so non-`String`/`ObjectId` combinations (for example
`UUID`) should be avoided unless you have verified the full read/write/query
lifecycle for your mapping.
```
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:
##########
@@ -138,11 +138,78 @@ public void handle(EmbeddedQueryEncoder queryEncoder,
IdEquals criterion, Docume
Object value = criterion.getValue();
MappingContext mappingContext = entity.getMappingContext();
PersistentProperty identity = entity.getIdentity();
- Object converted =
mappingContext.getConversionService().convert(value, identity.getType());
+ // Prefer the configured storage type ('storedAs' on the id
mapping) so query BSON
+ // matches what's actually on disk. Falls back to the declared
Java type otherwise.
+ Class<?> targetType = null;
+ try {
+ if (entity.getMapping() != null &&
entity.getMapping().getIdentifier() != null) {
+ targetType =
entity.getMapping().getIdentifier().getStoredAs();
+ }
+ } catch (Throwable ignored) {
+ // defensive: some mapping implementations may not expose
storedAs yet
+ }
+ if (targetType == null) {
+ targetType = identity.getType();
+ }
+ Object converted =
mappingContext.getConversionService().convert(value, targetType);
+ // Symmetry with IdentityEncoder's non-hex fallback: if the
converter returns
+ // null for a non-null input (e.g. a natural-key String being
converted to
+ // ObjectId), keep the original value so the query targets
what the encoder
+ // actually wrote rather than {_id: null}.
+ if (converted == null && value != null) {
+ converted = value;
+ }
query.put(MongoEntityPersister.MONGO_ID_FIELD, converted);
}
});
+ // Override the In handler so that criteria targeting the identity
(findAllByIdInList,
+ // Domain.createCriteria().list { 'in'('id', [...]) }, etc.) honor
'storedAs' the same way
+ // IdEquals does. Without this, a domain declaring storedAs: ObjectId
would send BSON Strings
+ // in {_id: {$in: [...]}} and miss all stored ObjectId documents.
+ queryHandlers.put(In.class, new QueryHandler<In>() {
+ public void handle(EmbeddedQueryEncoder queryEncoder, In in,
Document query, PersistentEntity entity) {
+ Document inQuery = new Document();
+ List<Object> values = getInListQueryValues(entity, in);
+
+ Class<?> storedAs = null;
+ try {
+ PersistentProperty identityProp = entity.getIdentity();
+ if (identityProp != null &&
identityProp.getName().equals(in.getProperty()) &&
+ entity.getMapping() != null &&
entity.getMapping().getIdentifier() != null) {
+ storedAs =
entity.getMapping().getIdentifier().getStoredAs();
+ }
+ } catch (Throwable ignored) {
+ // defensive: mapping implementations without storedAs
support fall through
+ }
+ if (storedAs != null) {
+ MappingContext mappingContext = entity.getMappingContext();
+ List<Object> coerced = new ArrayList<>(values.size());
+ for (Object v : values) {
+ if (v == null || storedAs.isInstance(v)) {
+ coerced.add(v);
+ } else {
+ Object c;
+ try {
+ c =
mappingContext.getConversionService().convert(v, storedAs);
+ } catch (Throwable ignored) {
+ c = v;
+ }
+ // A null return (vs a throw) means the converter
rejected the value —
+ // e.g. a natural-key String being converted to
ObjectId. Fall back to
+ // the original so the $in list matches what the
encoder actually wrote.
+ coerced.add(c != null ? c : v);
+ }
+ }
+ values = coerced;
+ }
Review Comment:
The overridden `In` handler applies `storedAs` coercion for any non-null
storedAs. This can desynchronize query behavior from how ids are actually
encoded (IdentityEncoder only handles String/ObjectId storedAs), leading to
`$in` queries that can never match when storedAs is set to another convertible
type (e.g. UUID). Recommend gating coercion to supported storedAs values
(String/ObjectId) or making encoding use the same conversion logic so the
stored BSON type and query BSON type are always aligned.
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/MongoCodecSession.groovy:
##########
@@ -286,6 +286,36 @@ 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}).
+ */
+ protected Object coerceIdToStoredType(Object nativeKey, PersistentEntity
entity) {
+ if (nativeKey == null) return null
+ Class<?> storedAs = null
+ try {
+ storedAs = entity?.mapping?.identifier?.storedAs
+ } catch (Throwable ignored) {
+ return nativeKey
+ }
+ if (storedAs == null) return nativeKey
+ if (storedAs.isInstance(nativeKey)) return nativeKey
+ try {
+ def converted =
mappingContext.conversionService.convert(nativeKey, storedAs)
+ // Symmetry with IdentityEncoder's non-hex fallback: a null return
(vs a throw)
+ // means the converter rejected the value — e.g. a natural-key
String being
+ // converted to ObjectId. Keep the original so update/delete
filters match the
+ // document the encoder actually wrote (BSON String), rather than
targeting
+ // {_id: null} and surfacing as a misleading
OptimisticLockingException.
+ return converted != null ? converted : nativeKey
+ } catch (Throwable ignored) {
+ return nativeKey
+ }
+ }
Review Comment:
`coerceIdToStoredType` converts to whatever
`entity.mapping.identifier.storedAs` is. Because `IdentityEncoder` only changes
the BSON encoding for storedAs=String/ObjectId, setting storedAs to another
type that Spring can convert to (e.g. UUID) will cause update/delete filters to
target a different BSON type than what was written, resulting in silent misses
and potential `OptimisticLockingException`s. Consider restricting coercion to
the same supported storedAs types as the encoder, or enhancing the encoder to
perform the same conversion so writes and filters stay consistent.
##########
grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/bugs/StringIdDefaultStoredAsConfigSpec.groovy:
##########
@@ -0,0 +1,132 @@
+/*
+ * 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 org.bson.types.ObjectId
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.mongo.config.MongoMappingContext
+import org.grails.datastore.mapping.mongo.config.MongoSettings
+import
org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettingsBuilder
+import spock.lang.Specification
+
+/**
+ * Exercises the global config {@code
grails.mongodb.stringIds.defaultStoredAs}.
+ *
+ * Uses {@link MongoMappingContext} directly (no live MongoDB) — this spec is
purely about
+ * how the mapping layer resolves {@code storedAs} from config. The plumbing
into the
+ * encoder/decoder/query paths is exercised by {@link
StringIdWithObjectIdStorageSpec}.
+ */
Review Comment:
The config key referenced here (`grails.mongodb.stringIds.defaultStoredAs`)
doesn't match the actual setting introduced in this PR
(`grails.mongodb.stringIdsDefaultStoredAs`, per
`MongoSettings.SETTING_STRING_IDS_DEFAULT_STORED_AS`). This is likely to
confuse future readers since it appears in both this class-level doc and later
test names.
##########
grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/bugs/StringIdDefaultStoredAsConfigSpec.groovy:
##########
@@ -0,0 +1,132 @@
+/*
+ * 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 org.bson.types.ObjectId
+import org.grails.datastore.mapping.core.DatastoreUtils
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.mongo.config.MongoMappingContext
+import org.grails.datastore.mapping.mongo.config.MongoSettings
+import
org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettingsBuilder
+import spock.lang.Specification
+
+/**
+ * Exercises the global config {@code
grails.mongodb.stringIds.defaultStoredAs}.
+ *
+ * Uses {@link MongoMappingContext} directly (no live MongoDB) — this spec is
purely about
+ * how the mapping layer resolves {@code storedAs} from config. The plumbing
into the
+ * encoder/decoder/query paths is exercised by {@link
StringIdWithObjectIdStorageSpec}.
+ */
+class StringIdDefaultStoredAsConfigSpec extends Specification {
+
+ private MongoMappingContext contextFor(Map<String, ?> props, Class...
domainClasses) {
+ def settings = new
MongoConnectionSourceSettingsBuilder(DatastoreUtils.createPropertyResolver(props)).build()
+ new MongoMappingContext(settings, domainClasses)
+ }
+
+ void "without the global config, String id has no storedAs (default
behavior unchanged)"() {
+ given:
+ MongoMappingContext ctx = contextFor([:], PlainStringIdDomain)
+
+ when:
+ PersistentEntity entity =
ctx.getPersistentEntity(PlainStringIdDomain.name)
+
+ then:
+ entity != null
+ entity.mapping.identifier.storedAs == null
+ }
+
+ void "with grails.mongodb.stringIds.defaultStoredAs=objectid, String id
picks up ObjectId storedAs"() {
+ given:
+ MongoMappingContext ctx = contextFor(
+ [(MongoSettings.SETTING_STRING_IDS_DEFAULT_STORED_AS):
'objectid'],
+ PlainStringIdDomain
+ )
+
Review Comment:
This test name string also refers to
`grails.mongodb.stringIds.defaultStoredAs`, but the implemented setting is
`grails.mongodb.stringIdsDefaultStoredAs`. Aligning the description with the
real key will make failures easier to interpret.
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/query/MongoQuery.java:
##########
@@ -138,11 +138,78 @@ public void handle(EmbeddedQueryEncoder queryEncoder,
IdEquals criterion, Docume
Object value = criterion.getValue();
MappingContext mappingContext = entity.getMappingContext();
PersistentProperty identity = entity.getIdentity();
- Object converted =
mappingContext.getConversionService().convert(value, identity.getType());
+ // Prefer the configured storage type ('storedAs' on the id
mapping) so query BSON
+ // matches what's actually on disk. Falls back to the declared
Java type otherwise.
+ Class<?> targetType = null;
+ try {
+ if (entity.getMapping() != null &&
entity.getMapping().getIdentifier() != null) {
+ targetType =
entity.getMapping().getIdentifier().getStoredAs();
+ }
+ } catch (Throwable ignored) {
+ // defensive: some mapping implementations may not expose
storedAs yet
+ }
+ if (targetType == null) {
+ targetType = identity.getType();
+ }
+ Object converted =
mappingContext.getConversionService().convert(value, targetType);
+ // Symmetry with IdentityEncoder's non-hex fallback: if the
converter returns
+ // null for a non-null input (e.g. a natural-key String being
converted to
+ // ObjectId), keep the original value so the query targets
what the encoder
+ // actually wrote rather than {_id: null}.
+ if (converted == null && value != null) {
+ converted = value;
+ }
Review Comment:
The IdEquals handler coerces to `identifier.storedAs` for any non-null
storedAs. Since `IdentityEncoder` only actively encodes `storedAs` for
String/ObjectId, a user setting `storedAs` to another type that Spring can
convert to (e.g. UUID) will make point lookups query using that BSON type while
writes still store a String, causing guaranteed misses. Consider restricting
this coercion to the same supported storedAs types as the encoder
(String/ObjectId), or updating the encoder to use the same ConversionService
for arbitrary storedAs so read/write/query remain symmetric.
##########
grails-data-mongodb/core/src/main/groovy/org/grails/datastore/mapping/mongo/config/MongoSettings.groovy:
##########
@@ -95,4 +95,18 @@ interface MongoSettings extends Settings {
String SETTING_ENGINE = 'grails.mongodb.engine'
+ /**
+ * Global default storage type for {@code String id} fields when no
per-domain
+ * {@code id storedAs: ...} mapping is declared. Accepted values are the
names (or hex
+ * aliases) {@code 'string'} (default, current behavior) and {@code
'objectid'}.
Review Comment:
Javadoc mentions "hex aliases" for the accepted values, but the only
non-canonical alias currently handled is `object_id` (underscore), not a hex
alias. Suggest rewording to avoid confusion (and to align with the docs/log
message).
```suggestion
* {@code id storedAs: ...} mapping is declared. Accepted values are the
names (or
* aliases) {@code 'string'} (default, current behavior), {@code
'objectid'}, and
* {@code 'object_id'}.
```
--
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]