Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@

package org.grails.datastore.bson.codecs.encoders

import java.util.concurrent.ConcurrentHashMap

import groovy.transform.CompileStatic

import org.bson.BsonWriter
import org.bson.codecs.EncoderContext
import org.bson.codecs.configuration.CodecRegistry
import org.bson.types.ObjectId
import org.slf4j.Logger
import org.slf4j.LoggerFactory

import org.grails.datastore.bson.codecs.PropertyEncoder
import org.grails.datastore.mapping.engine.EntityAccess
Expand All @@ -37,10 +41,36 @@ import org.grails.datastore.mapping.model.types.Identity
@CompileStatic
class IdentityEncoder implements PropertyEncoder<Identity> {

private static final Logger log = LoggerFactory.getLogger(IdentityEncoder)

// One warn per (owning entity class) so a misconfigured 'storedAs: ObjectId' on
// natural-key data is debuggable in the field without flooding logs at write rate.
private static final Set<String> warnedNonHexEntities = ConcurrentHashMap.newKeySet()

@Override
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)) {
Comment thread
codeconsole marked this conversation as resolved.
writer.writeObjectId(new ObjectId(hex))
return
}
warnNonHexFallback(property, hex)
}
Comment thread
codeconsole marked this conversation as resolved.
if (String.isAssignableFrom(storedAs) && !(id instanceof String)) {
writer.writeString(id.toString())
return
}
}

if (id instanceof ObjectId) {
writer.writeObjectId(id)
} else if (id instanceof Number) {
Expand All @@ -51,6 +81,24 @@ class IdentityEncoder implements PropertyEncoder<Identity> {

}

private static Class<?> resolveStoredAs(Identity property) {
try {
return property?.owner?.mapping?.identifier?.storedAs
} catch (Exception ignored) {
return null
}
}

private static void warnNonHexFallback(Identity property, String hex) {
if (!log.warnEnabled) return
String entityName = property?.owner?.javaClass?.name ?: 'unknown'
if (warnedNonHexEntities.add(entityName)) {
log.warn(
"Identity for {} is mapped storedAs: ObjectId but value '{}' is not a valid 24-char hex ObjectId; persisting as BSON String. Use storedAs: String for natural-key domains. (warned once per entity class)",
entityName, hex)
}
}

protected String getIdentifierName(Identity property) {
String[] identifierName = property.getOwner().mapping.identifier?.identifierName
if (identifierName != null) {
Expand Down
Comment thread
codeconsole marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import org.grails.datastore.mapping.model.types.Association
import org.grails.datastore.mapping.model.types.ToOne
import org.grails.datastore.mapping.mongo.engine.MongoCodecEntityPersister
import org.grails.datastore.mapping.mongo.engine.MongoEntityPersister
import org.grails.datastore.mapping.mongo.engine.MongoIdCoercion
import org.grails.datastore.mapping.mongo.engine.codecs.PersistentEntityCodec
import org.grails.datastore.mapping.mongo.query.MongoQuery
import org.grails.datastore.mapping.query.Query
Expand Down Expand Up @@ -152,7 +153,7 @@ class MongoCodecSession extends AbstractMongoSession {
DirtyCheckable changedObject = (DirtyCheckable) update.getNativeEntry()
PersistentEntityCodec codec = (PersistentEntityCodec) datastore.codecRegistry.get(changedObject.getClass())

final Object nativeKey = update.nativeKey
final Object nativeKey = coerceIdToStoredType(update.nativeKey, persistentEntity)
final Document id = new Document(MongoEntityPersister.MONGO_ID_FIELD, nativeKey)

EntityAccess entityAccess = update.entityAccess
Expand Down Expand Up @@ -200,7 +201,7 @@ class MongoCodecSession extends AbstractMongoSession {

if (delete.vetoed) continue

final Object k = delete.nativeKey
final Object k = coerceIdToStoredType(delete.nativeKey, persistentEntity)
if (k) {
nativeKeys << k
final List cascadeOperations = delete.cascadeOperations
Expand Down Expand Up @@ -286,6 +287,25 @@ 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) {
Comment thread
codeconsole marked this conversation as resolved.
MongoIdCoercion.coerceIdToStoredType(nativeKey, entity)
}
Comment thread
codeconsole marked this conversation as resolved.

@Override
protected Transaction beginTransactionInternal() {
return new SessionOnlyTransaction<MongoClient>(getNativeInterface(), this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
import org.bson.types.Decimal128;
import org.bson.types.ObjectId;
import org.bson.types.Symbol;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterRegistry;
Expand Down Expand Up @@ -101,6 +103,8 @@
*/
@SuppressWarnings("rawtypes")
public class MongoMappingContext extends DocumentMappingContext {

private static final Logger log = LoggerFactory.getLogger(MongoMappingContext.class);
private static final String DECIMAL_TYPE_CLASS_NAME = "org.bson.types.Decimal128";
/**
* Java types supported as mongo property types.
Expand Down Expand Up @@ -134,6 +138,21 @@ public class MongoMappingContext extends DocumentMappingContext {
private CodecRegistry codecRegistry;
private Map<Class, Boolean> hasCodecCache = new HashMap<>();

/**
Comment thread
codeconsole marked this conversation as resolved.
* 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;

public Class<?> getStringIdDefaultStoredAs() {
return stringIdDefaultStoredAs;
}

public void setStringIdDefaultStoredAs(Class<?> stringIdDefaultStoredAs) {
this.stringIdDefaultStoredAs = stringIdDefaultStoredAs;
}

public MongoMappingContext(String defaultDatabaseName) {
this(defaultDatabaseName, null);
}
Expand Down Expand Up @@ -165,7 +184,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'. " +
Comment thread
codeconsole marked this conversation as resolved.
"Falling back to default behavior (no coercion).",
value, MongoSettings.SETTING_STRING_IDS_DEFAULT_STORED_AS);
return null;
}
}

/**
Expand All @@ -176,6 +216,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);
initialize(classes);
}

Expand Down Expand Up @@ -333,7 +377,14 @@ protected Class<MongoCollection> getEntityMappedFormType() {
@Override
public Identity<MongoAttribute> createIdentity(PersistentEntity owner, MappingContext context, PropertyDescriptor pd) {
Identity<MongoAttribute> identity = super.createIdentity(owner, context, pd);
identity.getMapping().getMappedForm().setTargetName(MongoConstants.MONGO_ID_FIELD);
MongoAttribute mappedForm = identity.getMapping().getMappedForm();
mappedForm.setTargetName(MongoConstants.MONGO_ID_FIELD);
// Apply the global default storedAs for String-id domains that don't declare their own.
if (mappedForm.getStoredAs() == null &&
stringIdDefaultStoredAs != null &&
String.class.equals(pd.getPropertyType())) {
mappedForm.setStoredAs(stringIdDefaultStoredAs);
}
return identity;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'}.
Comment thread
codeconsole marked this conversation as resolved.
*
* <p>When set to {@code 'objectid'}, every domain that declares {@code String id}
* without an explicit {@code storedAs} will persist {@code _id} as a BSON ObjectId,
* while keeping the {@code String} ergonomics in application code. Domains that use
* natural string keys (slug, email, UUID) should opt out per-domain via
* {@code static mapping = { id storedAs: String }}.
*
* @since 7.1.1
*/
String SETTING_STRING_IDS_DEFAULT_STORED_AS = 'grails.mongodb.stringIds.defaultStoredAs'
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ abstract class AbstractMongoConnectionSourceSettings extends ConnectionSourceSet
*/
boolean decimalType = true

/**
* Nested settings for domains with {@code String id}. Holds the global default
* {@code defaultStoredAs} switch plus any future string-id configuration.
*
* <p>Exposes the property path {@code grails.mongodb.stringIds.defaultStoredAs},
* aligned with the Spring-style hierarchical namespace convention.
*
* @since 7.1.1
*/
StringIdSettings stringIds = new StringIdSettings()

/**
* The collection name to use to resolve connections when using {@link MongoConnectionSources}
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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.mapping.mongo.connections

import groovy.transform.CompileStatic
import groovy.transform.builder.Builder
import groovy.transform.builder.SimpleStrategy

/**
* Settings grouped under the {@code grails.mongodb.stringIds} namespace. The
* {@code @Builder(SimpleStrategy)} annotation lets {@code ConfigurationBuilder}
* recurse into this class so its fields bind at the nested property path.
*
* @since 7.1.1
*/
@Builder(builderStrategy = SimpleStrategy, prefix = '')
@CompileStatic
class StringIdSettings {

/**
* Global default storage type for {@code String id} fields that don't declare an
* explicit {@code id storedAs: ...} in their mapping. Accepted values are
* {@code 'string'} (default, current behavior) or {@code 'objectid'}.
*
* <p>Corresponds to the property path
* {@code grails.mongodb.stringIds.defaultStoredAs}.
*/
String defaultStoredAs

}
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ class MongoCodecEntityPersister extends ThirdPartyCacheEntityPersister<Object> {
// don't bother with query if list of keys is empty
return []
} else {
// Coerce each key to the storage type so {_id: {$in: [...]}} uses BSON values
// that actually match on disk when 'storedAs' differs from the declared type.
def coercedIds = idList.collect { coerceIdToStoredType(it, pe) }
createQuery()
.in(pe.identity.name, idList)
.in(pe.identity.name, coercedIds)
.list()

}
Expand All @@ -153,7 +156,7 @@ class MongoCodecEntityPersister extends ThirdPartyCacheEntityPersister<Object> {
return null
} else {
MongoCollection mongoCollection = getMongoCollection(pe)
Document idQuery = createIdQuery(key)
Document idQuery = createIdQuery(coerceIdToStoredType(key, pe))
o = mongoCollection
.withDocumentClass(persistentEntity.javaClass)
.withCodecRegistry(mongoDatastore.codecRegistry)
Expand All @@ -175,6 +178,24 @@ 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()}.
*
* <p>Exercised end-to-end (via {@code retrieveEntity}/{@code retrieveAllEntities}) by
* {@code StringIdWithObjectIdStorageSpec}, specifically:
* <ul>
* <li>"with storedAs ObjectId, point lookup by hex string works" — happy path, String&nbsp;&rarr;&nbsp;ObjectId</li>
* <li>"with storedAs ObjectId, batch getAll resolves all ids (coerces each key in the in-list filter)"</li>
* <li>"with storedAs ObjectId, point lookup of a non-hex id matches the BSON String the encoder wrote" — null-return fallback</li>
* <li>"with storedAs ObjectId, legacy documents written directly as BSON ObjectId are fully accessible"</li>
* </ul>
*/
protected Object coerceIdToStoredType(Object key, PersistentEntity entity) {
Comment thread
codeconsole marked this conversation as resolved.
MongoIdCoercion.coerceIdToStoredType(key, entity)
}
Comment thread
codeconsole marked this conversation as resolved.

@Override
protected Serializable persistEntity(PersistentEntity entity, Object obj, boolean isInsert) {

Expand Down
Loading
Loading