BindableMongoExpression.java

/*
 * Copyright 2021-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;

import java.util.Arrays;

import org.bson.Document;
import org.bson.codecs.DocumentCodec;
import org.bson.codecs.configuration.CodecRegistry;
import org.jspecify.annotations.Nullable;
import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec;
import org.springframework.data.util.Lazy;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

/**
 * A {@link MongoExpression} using the {@link ParameterBindingDocumentCodec} for parsing a raw ({@literal json})
 * expression. The expression will be wrapped within <code>{ ... }</code> if necessary. The actual parsing and parameter
 * binding of placeholders like {@code ?0} is delayed upon first call on the target {@link Document} via
 * {@link #toDocument()}. <br />
 *
 * <pre class="code">
 * $toUpper : $name                -> { '$toUpper' : '$name' }
 *
 * { '$toUpper' : '$name' }        -> { '$toUpper' : '$name' }
 *
 * { '$toUpper' : '?0' }, "$name"  -> { '$toUpper' : '$name' }
 * </pre>
 *
 * Some types might require a special {@link org.bson.codecs.Codec}. If so, make sure to provide a {@link CodecRegistry}
 * containing the required {@link org.bson.codecs.Codec codec} via {@link #withCodecRegistry(CodecRegistry)}.
 *
 * @author Christoph Strobl
 * @author Giacomo Baso
 * @since 3.2
 */
public class BindableMongoExpression implements MongoExpression {

	private final String expressionString;

	private final @Nullable CodecRegistryProvider codecRegistryProvider;

	private final Object @Nullable [] args;

	private final Lazy<Document> target;

	/**
	 * Create a new instance of {@link BindableMongoExpression}.
	 *
	 * @param expression must not be {@literal null}.
	 * @param args must not be {@literal null} but may contain {@literal null} elements.
	 */
	public BindableMongoExpression(String expression, Object @Nullable [] args) {
		this(expression, null, args);
	}

	/**
	 * Create a new instance of {@link BindableMongoExpression}.
	 *
	 * @param expression must not be {@literal null}.
	 * @param codecRegistryProvider can be {@literal null}.
	 * @param args must not be {@literal null} but may contain {@literal null} elements.
	 */
	public BindableMongoExpression(String expression, @Nullable CodecRegistryProvider codecRegistryProvider,
			Object @Nullable [] args) {

		Assert.notNull(expression, "Expression must not be null");

		this.expressionString = expression;
		this.codecRegistryProvider = codecRegistryProvider;
		this.args = args;
		this.target = Lazy.of(this::parse);
	}

	/**
	 * Provide the {@link CodecRegistry} used to convert expressions.
	 *
	 * @param codecRegistry must not be {@literal null}.
	 * @return new instance of {@link BindableMongoExpression}.
	 */
	@Contract("_ -> new")
	public BindableMongoExpression withCodecRegistry(CodecRegistry codecRegistry) {
		return new BindableMongoExpression(expressionString, () -> codecRegistry, args);
	}

	/**
	 * Provide the arguments to bind to the placeholders via their index.
	 *
	 * @param args must not be {@literal null}.
	 * @return new instance of {@link BindableMongoExpression}.
	 */
	@Contract("_ -> new")
	public BindableMongoExpression bind(Object... args) {
		return new BindableMongoExpression(expressionString, codecRegistryProvider, args);
	}

	@Override
	public Document toDocument() {
		return target.get();
	}

	@Override
	public String toString() {
		return "BindableMongoExpression{" + "expressionString='" + expressionString + '\'' + ", args="
				+ Arrays.toString(args) + '}';
	}

	private Document parse() {

		String expression = wrapJsonIfNecessary(expressionString);

		if (ObjectUtils.isEmpty(args)) {

			if (codecRegistryProvider == null) {
				return Document.parse(expression);
			}

			return Document.parse(expression, codecRegistryProvider.getCodecFor(Document.class)
					.orElseGet(() -> new DocumentCodec(codecRegistryProvider.getCodecRegistry())));
		}

		ParameterBindingDocumentCodec codec = codecRegistryProvider == null ? new ParameterBindingDocumentCodec()
				: new ParameterBindingDocumentCodec(codecRegistryProvider.getCodecRegistry());
		return codec.decode(expression, args);
	}

	private static String wrapJsonIfNecessary(String json) {

		if (!StringUtils.hasText(json)) {
			return json;
		}

		String raw = json.trim();
		return (raw.startsWith("{") && raw.endsWith("}")) ? raw : "{%s}".formatted(raw);
	}
}