MongoCustomConversions.java

/*
 * Copyright 2017-present the original author or authors.
 *
 * Licensed 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.springframework.data.mongodb.core.convert;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Predicate;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jspecify.annotations.Nullable;

import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.converter.ConverterFactory;
import org.springframework.core.convert.converter.ConverterRegistry;
import org.springframework.core.convert.converter.GenericConverter;
import org.springframework.data.convert.ConverterBuilder;
import org.springframework.data.convert.PropertyValueConversions;
import org.springframework.data.convert.PropertyValueConverter;
import org.springframework.data.convert.PropertyValueConverterFactory;
import org.springframework.data.convert.PropertyValueConverterRegistrar;
import org.springframework.data.convert.SimplePropertyValueConversions;
import org.springframework.data.convert.WritingConverter;
import org.springframework.data.mapping.model.SimpleTypeHolder;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;

/**
 * Value object to capture custom conversion. {@link MongoCustomConversions} also act as factory for
 * {@link org.springframework.data.mapping.model.SimpleTypeHolder}
 *
 * @author Mark Paluch
 * @author Christoph Strobl
 * @since 2.0
 * @see org.springframework.data.convert.CustomConversions
 * @see org.springframework.data.mapping.model.SimpleTypeHolder
 * @see MongoSimpleTypes
 */
public class MongoCustomConversions extends org.springframework.data.convert.CustomConversions {

	private static final Log LOGGER = LogFactory.getLog(MongoCustomConversions.class);
	private static final List<Object> STORE_CONVERTERS;

	static {

		List<Object> converters = new ArrayList<>();

		converters.add(CustomToStringConverter.INSTANCE);
		converters.addAll(MongoConverters.getConvertersToRegister());
		converters.addAll(GeoConverters.getConvertersToRegister());

		STORE_CONVERTERS = Collections.unmodifiableList(converters);
	}

	/**
	 * Converters to be registered with the {@code ConversionService} but hidden from CustomConversions to avoid
	 * converter-based type hinting.
	 */
	private final List<Converter<?, ?>> fallbackConversionServiceConverters = new ArrayList<>();

	/**
	 * Creates an empty {@link MongoCustomConversions} object.
	 */
	MongoCustomConversions() {
		this(Collections.emptyList());
	}

	/**
	 * Create a new {@link MongoCustomConversions} instance registering the given converters.
	 *
	 * @param converters must not be {@literal null}.
	 */
	public MongoCustomConversions(List<?> converters) {
		this(MongoConverterConfigurationAdapter.from(converters));
	}

	/**
	 * Create a new {@link MongoCustomConversions} given {@link MongoConverterConfigurationAdapter}.
	 *
	 * @param conversionConfiguration must not be {@literal null}.
	 * @since 2.3
	 */
	protected MongoCustomConversions(MongoConverterConfigurationAdapter conversionConfiguration) {
		this(conversionConfiguration.createConverterConfiguration());
	}

	private MongoCustomConversions(MongoConverterConfiguration converterConfiguration) {
		super(converterConfiguration);
		this.fallbackConversionServiceConverters.addAll(converterConfiguration.fallbackConversionServiceConverters);
	}

	/**
	 * Functional style {@link org.springframework.data.convert.CustomConversions} creation giving users a convenient way
	 * of configuring store specific capabilities by providing deferred hooks to what will be configured when creating the
	 * {@link org.springframework.data.convert.CustomConversions#CustomConversions(ConverterConfiguration) instance}.
	 *
	 * @param configurer must not be {@literal null}.
	 * @since 2.3
	 */
	public static MongoCustomConversions create(Consumer<MongoConverterConfigurationAdapter> configurer) {

		MongoConverterConfigurationAdapter adapter = new MongoConverterConfigurationAdapter();
		configurer.accept(adapter);

		return new MongoCustomConversions(adapter);
	}

	@Override
	public void registerConvertersIn(ConverterRegistry conversionService) {
		this.fallbackConversionServiceConverters.forEach(conversionService::addConverter);
		super.registerConvertersIn(conversionService);
	}

	@WritingConverter
	private enum CustomToStringConverter implements GenericConverter {

		INSTANCE;

		public Set<ConvertiblePair> getConvertibleTypes() {

			ConvertiblePair localeToString = new ConvertiblePair(Locale.class, String.class);
			ConvertiblePair booleanToString = new ConvertiblePair(Character.class, String.class);

			return new HashSet<>(Arrays.asList(localeToString, booleanToString));
		}

		public @Nullable Object convert(@Nullable Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
			return source != null ? source.toString() : null;
		}
	}

	/**
	 * {@link MongoConverterConfigurationAdapter} encapsulates creation of
	 * {@link org.springframework.data.convert.CustomConversions.ConverterConfiguration} with MongoDB specifics.
	 *
	 * @author Christoph Strobl
	 * @since 2.3
	 */
	public static class MongoConverterConfigurationAdapter {

		/**
		 * List of {@literal java.time} types having different representation when rendered via the native
		 * {@link org.bson.codecs.Codec} than the Spring Data {@link Converter}.
		 */
		private static final Set<Class<?>> JAVA_DRIVER_TIME_SIMPLE_TYPES = Set.of(LocalDate.class, LocalTime.class,
				LocalDateTime.class);

		private boolean useNativeDriverJavaTimeCodecs = false;
		private BigDecimalRepresentation bigDecimals = BigDecimalRepresentation.UNSPECIFIED;
		private final List<Object> customConverters = new ArrayList<>();

		private final PropertyValueConversions internalValueConversion = PropertyValueConversions.simple(it -> {});
		private PropertyValueConversions propertyValueConversions = internalValueConversion;

		/**
		 * Create a {@link MongoConverterConfigurationAdapter} using the provided {@code converters} and our own codecs for
		 * JSR-310 types.
		 *
		 * @param converters must not be {@literal null}.
		 * @return
		 */
		public static MongoConverterConfigurationAdapter from(List<?> converters) {

			Assert.notNull(converters, "Converters must not be null");

			MongoConverterConfigurationAdapter converterConfigurationAdapter = new MongoConverterConfigurationAdapter();
			converterConfigurationAdapter.useSpringDataJavaTimeCodecs();
			converterConfigurationAdapter.registerConverters(converters);

			return converterConfigurationAdapter;
		}

		/**
		 * Add a custom {@link Converter} implementation.
		 *
		 * @param converter must not be {@literal null}.
		 * @return this.
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter registerConverter(Converter<?, ?> converter) {

			Assert.notNull(converter, "Converter must not be null");
			customConverters.add(converter);
			return this;
		}

		/**
		 * Add {@link Converter converters}, {@link ConverterFactory factories}, {@link ConverterBuilder.ConverterAware
		 * converter-aware objects}, and {@link GenericConverter generic converters}.
		 *
		 * @param converters must not be {@literal null} nor contain {@literal null} values.
		 * @return this.
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter registerConverters(Collection<?> converters) {

			Assert.notNull(converters, "Converters must not be null");
			Assert.noNullElements(converters, "Converters must not be null nor contain null values");

			customConverters.addAll(converters);
			return this;
		}

		/**
		 * Add a custom {@link ConverterFactory} implementation.
		 *
		 * @param converterFactory must not be {@literal null}.
		 * @return this.
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter registerConverterFactory(ConverterFactory<?, ?> converterFactory) {

			Assert.notNull(converterFactory, "ConverterFactory must not be null");
			customConverters.add(converterFactory);
			return this;
		}

		/**
		 * Add a custom/default {@link PropertyValueConverterFactory} implementation used to serve
		 * {@link PropertyValueConverter}.
		 *
		 * @param converterFactory must not be {@literal null}.
		 * @return this.
		 * @since 3.4
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter registerPropertyValueConverterFactory(
				PropertyValueConverterFactory converterFactory) {

			Assert.state(valueConversions() instanceof SimplePropertyValueConversions,
					"Configured PropertyValueConversions does not allow setting custom ConverterRegistry");

			((SimplePropertyValueConversions) valueConversions()).setConverterFactory(converterFactory);
			return this;
		}

		/**
		 * Gateway to register property specific converters.
		 *
		 * @param configurationAdapter must not be {@literal null}.
		 * @return this.
		 * @since 3.4
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter configurePropertyConversions(
				Consumer<PropertyValueConverterRegistrar<MongoPersistentProperty>> configurationAdapter) {

			Assert.state(valueConversions() instanceof SimplePropertyValueConversions,
					"Configured PropertyValueConversions does not allow setting custom ConverterRegistry");

			PropertyValueConverterRegistrar propertyValueConverterRegistrar = new PropertyValueConverterRegistrar();
			configurationAdapter.accept(propertyValueConverterRegistrar);

			((SimplePropertyValueConversions) valueConversions())
					.setValueConverterRegistry(propertyValueConverterRegistrar.buildRegistry());
			return this;
		}

		/**
		 * Set whether to or not to use the native MongoDB Java Driver {@link org.bson.codecs.Codec codes} for
		 * {@link org.bson.codecs.jsr310.LocalDateCodec LocalDate}, {@link org.bson.codecs.jsr310.LocalTimeCodec LocalTime}
		 * and {@link org.bson.codecs.jsr310.LocalDateTimeCodec LocalDateTime} using a {@link ZoneOffset#UTC}.
		 *
		 * @param useNativeDriverJavaTimeCodecs
		 * @return this.
		 */
		@Contract("_ -> this")
		public MongoConverterConfigurationAdapter useNativeDriverJavaTimeCodecs(boolean useNativeDriverJavaTimeCodecs) {

			this.useNativeDriverJavaTimeCodecs = useNativeDriverJavaTimeCodecs;
			return this;
		}

		/**
		 * Use the native MongoDB Java Driver {@link org.bson.codecs.Codec codes} for
		 * {@link org.bson.codecs.jsr310.LocalDateCodec LocalDate}, {@link org.bson.codecs.jsr310.LocalTimeCodec LocalTime}
		 * and {@link org.bson.codecs.jsr310.LocalDateTimeCodec LocalDateTime} using a {@link ZoneOffset#UTC}.
		 *
		 * @return this.
		 * @see #useNativeDriverJavaTimeCodecs(boolean)
		 */
		@Contract("-> this")
		public MongoConverterConfigurationAdapter useNativeDriverJavaTimeCodecs() {
			return useNativeDriverJavaTimeCodecs(true);
		}

		/**
		 * Use SpringData {@link Converter Jsr310 converters} for
		 * {@link org.springframework.data.convert.Jsr310Converters.LocalDateToDateConverter LocalDate},
		 * {@link org.springframework.data.convert.Jsr310Converters.LocalTimeToDateConverter LocalTime} and
		 * {@link org.springframework.data.convert.Jsr310Converters.LocalDateTimeToDateConverter LocalDateTime} using the
		 * {@link ZoneId#systemDefault()}.
		 *
		 * @return this.
		 * @see #useNativeDriverJavaTimeCodecs(boolean)
		 */
		@Contract("-> this")
		public MongoConverterConfigurationAdapter useSpringDataJavaTimeCodecs() {
			return useNativeDriverJavaTimeCodecs(false);
		}

		/**
		 * Configures the representation to for {@link java.math.BigDecimal} and {@link java.math.BigInteger} values in
		 * MongoDB. Defaults to {@link BigDecimalRepresentation#DECIMAL128}.
		 *
		 * @param representation the representation to use.
		 * @return this.
		 * @since 4.5
		 */
		public MongoConverterConfigurationAdapter bigDecimal(BigDecimalRepresentation representation) {

			Assert.notNull(representation, "BigDecimalDataType must not be null");
			this.bigDecimals = representation;
			return this;
		}

		/**
		 * Optionally set the {@link PropertyValueConversions} to be applied during mapping.
		 * <p>
		 * Use this method if {@link #configurePropertyConversions(Consumer)} and
		 * {@link #registerPropertyValueConverterFactory(PropertyValueConverterFactory)} are not sufficient.
		 *
		 * @param valueConversions must not be {@literal null}.
		 * @return this.
		 * @since 3.4
		 * @deprecated since 4.2. Use {@link #withPropertyValueConversions(PropertyValueConversions)} instead.
		 */
		@Deprecated(since = "4.2")
		public MongoConverterConfigurationAdapter setPropertyValueConversions(PropertyValueConversions valueConversions) {
			return withPropertyValueConversions(valueConversions);
		}

		/**
		 * Optionally set the {@link PropertyValueConversions} to be applied during mapping.
		 * <p>
		 * Use this method if {@link #configurePropertyConversions(Consumer)} and
		 * {@link #registerPropertyValueConverterFactory(PropertyValueConverterFactory)} are not sufficient.
		 *
		 * @param valueConversions must not be {@literal null}.
		 * @return this.
		 * @since 4.2
		 */
		public MongoConverterConfigurationAdapter withPropertyValueConversions(PropertyValueConversions valueConversions) {

			Assert.notNull(valueConversions, "PropertyValueConversions must not be null");
			this.propertyValueConversions = valueConversions;
			return this;
		}

		PropertyValueConversions valueConversions() {

			if (this.propertyValueConversions == null) {
				this.propertyValueConversions = internalValueConversion;
			}

			return this.propertyValueConversions;
		}

		MongoConverterConfiguration createConverterConfiguration() {

			if (hasDefaultPropertyValueConversions()
					&& propertyValueConversions instanceof SimplePropertyValueConversions svc) {
				svc.init();
			}

			List<Object> storeConverters = new ArrayList<>(STORE_CONVERTERS.size() + 10);
			List<Converter<?, ?>> fallbackConversionServiceConverters = new ArrayList<>(5);
			fallbackConversionServiceConverters.addAll(MongoConverters.getBigNumberStringConverters());
			fallbackConversionServiceConverters.addAll(MongoConverters.getBigNumberDecimal128Converters());

			if (bigDecimals == null) {
				if (LOGGER.isInfoEnabled()) {
					LOGGER.info(
							"No BigDecimal/BigInteger representation set. Choose 'BigDecimalRepresentation.DECIMAL128' or 'BigDecimalRepresentation.String' to store values in desired format.");
				}
			} else {
				switch (bigDecimals) {
					case STRING -> storeConverters.addAll(MongoConverters.getBigNumberStringConverters());
					case DECIMAL128 -> storeConverters.addAll(MongoConverters.getBigNumberDecimal128Converters());
				}
			}

			fallbackConversionServiceConverters.removeAll(storeConverters);

			if (useNativeDriverJavaTimeCodecs) {

				/*
				 * We need to have those converters using UTC as the default ones would go on with the systemDefault.
				 */
				storeConverters.addAll(MongoConverters.getDateToUtcConverters());
				storeConverters.addAll(STORE_CONVERTERS);

				StoreConversions storeConversions = StoreConversions
						.of(new SimpleTypeHolder(JAVA_DRIVER_TIME_SIMPLE_TYPES, MongoSimpleTypes.HOLDER), storeConverters);

				return new MongoConverterConfiguration(storeConversions, fallbackConversionServiceConverters,
						this.customConverters, convertiblePair -> {

					// Avoid default registrations

					return !JAVA_DRIVER_TIME_SIMPLE_TYPES.contains(convertiblePair.getSourceType())
							|| !Date.class.isAssignableFrom(convertiblePair.getTargetType());
				}, this.propertyValueConversions);

			}

			storeConverters.addAll(STORE_CONVERTERS);
			return new MongoConverterConfiguration(
					StoreConversions.of(MongoSimpleTypes.createSimpleTypeHolder(), storeConverters),
					fallbackConversionServiceConverters, this.customConverters, convertiblePair -> true,
					this.propertyValueConversions);
		}

		private boolean hasDefaultPropertyValueConversions() {
			return propertyValueConversions == internalValueConversion;
		}

	}

	static class MongoConverterConfiguration extends ConverterConfiguration {

		private final List<Converter<?, ?>> fallbackConversionServiceConverters;

		public MongoConverterConfiguration(StoreConversions storeConversions,
				List<Converter<?, ?>> fallbackConversionServiceConverters, List<?> userConverters,
				Predicate<GenericConverter.ConvertiblePair> converterRegistrationFilter,
				@Nullable PropertyValueConversions propertyValueConversions) {
			super(storeConversions, userConverters, converterRegistrationFilter, propertyValueConversions);
			this.fallbackConversionServiceConverters = fallbackConversionServiceConverters;
		}
	}

	/**
	 * Strategy to represent {@link java.math.BigDecimal} and {@link java.math.BigInteger} values in MongoDB.
	 *
	 * @since 4.5
	 */
	public enum BigDecimalRepresentation {

		/**
		 * @deprecated since 5.0. Storing values as {@link Number#toString() String} retains precision, but prevents
		 *             efficient range queries. Prefer {@link #DECIMAL128} for better query support.
		 */
		@Deprecated(since = "5.0")
		STRING,

		/**
		 * Store numbers using {@link org.bson.types.Decimal128} (default). Requires MongoDB Server 3.4 or later.
		 */
		DECIMAL128,

		/**
		 * Pass on values to the MongoDB Java Driver without any prior conversion.
		 * @since 5.0
		 */
		UNSPECIFIED

	}

}