GeospatialIndex.java

/*
 * Copyright 2010-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.index;

import java.util.Optional;

import org.bson.Document;
import org.jspecify.annotations.Nullable;
import org.springframework.data.core.TypedPropertyPath;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
 * Value object to capture data to create a geo index.
 *
 * @author Jon Brisbin
 * @author Oliver Gierke
 * @author Laurent Canet
 * @author Christoph Strobl
 * @author Mark Paluch
 */
public class GeospatialIndex implements IndexDefinition {

	private final String field;
	private @Nullable String name;
	private @Nullable Integer min;
	private @Nullable Integer max;
	private @Nullable Integer bits;
	private GeoSpatialIndexType type = GeoSpatialIndexType.GEO_2D;
	private @Nullable String additionalField;
	private Optional<IndexFilter> filter = Optional.empty();
	private Optional<Collation> collation = Optional.empty();

	/**
	 * Creates a new {@link GeospatialIndex} for the given field.
	 *
	 * @param field must not be empty or {@literal null}.
	 */
	public GeospatialIndex(String field) {

		Assert.hasText(field, "Field must have text");

		this.field = field;
	}

	/**
	 * Create a new {@link GeospatialIndex} definition for the given {@link TypedPropertyPath property}.
	 *
	 * @param property must not be {@literal null}.
	 * @param <T> Property owing root type
	 * @param <P> Target property reachable via path.
	 * @since 5.1
	 */
	public <T, P> GeospatialIndex(TypedPropertyPath<T, P> property) {
		this(property.toDotPath());
	}

	/**
	 * @param name must not be {@literal null} or empty.
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex named(String name) {

		this.name = name;
		return this;
	}

	/**
	 * @param min
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex withMin(int min) {
		this.min = min;
		return this;
	}

	/**
	 * @param max
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex withMax(int max) {
		this.max = max;
		return this;
	}

	/**
	 * @param bits
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex withBits(int bits) {
		this.bits = bits;
		return this;
	}

	/**
	 * @param type must not be {@literal null}.
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex typed(GeoSpatialIndexType type) {

		Assert.notNull(type, "Type must not be null");

		this.type = type;
		return this;
	}

	/**
	 * @param fieldName
	 * @return this.
	 */
	@Contract("_ -> this")
	public GeospatialIndex withAdditionalField(String fieldName) {
		this.additionalField = fieldName;
		return this;
	}

	/**
	 * Only index the documents in a collection that meet a specified {@link IndexFilter filter expression}.
	 *
	 * @param filter can be {@literal null}.
	 * @return this.
	 * @see <a href=
	 *      "https://docs.mongodb.com/manual/core/index-partial/">https://docs.mongodb.com/manual/core/index-partial/</a>
	 * @since 1.10
	 */
	@Contract("_ -> this")
	public GeospatialIndex partial(@Nullable IndexFilter filter) {

		this.filter = Optional.ofNullable(filter);
		return this;
	}

	/**
	 * Set the {@link Collation} to specify language-specific rules for string comparison, such as rules for lettercase
	 * and accent marks.<br />
	 * <strong>NOTE:</strong> Only queries using the same {@link Collation} as the {@link Index} actually make use of the
	 * index.
	 *
	 * @param collation can be {@literal null}.
	 * @return this.
	 * @since 2.0
	 */
	@Contract("_ -> this")
	public GeospatialIndex collation(@Nullable Collation collation) {

		this.collation = Optional.ofNullable(collation);
		return this;
	}

	@Override
	public Document getIndexKeys() {

		Document document = new Document();

		switch (type) {
			case GEO_2D -> document.put(field, "2d");
			case GEO_2DSPHERE -> document.put(field, "2dsphere");
			case GEO_HAYSTACK -> {
				document.put(field, "geoHaystack");
				if (!StringUtils.hasText(additionalField)) {
					throw new IllegalArgumentException("When defining geoHaystack index, an additional field must be defined");
				}
				document.put(additionalField, 1);
			}
			default -> throw new IllegalArgumentException("Unsupported geospatial index " + type);
		}

		return document;
	}

	@Override
	public Document getIndexOptions() {

		Document document = new Document();
		if (StringUtils.hasText(name)) {
			document.put("name", name);
		}

		switch (type) {

			case GEO_2D:

				if (min != null) {
					document.put("min", min);
				}
				if (max != null) {
					document.put("max", max);
				}
				if (bits != null) {
					document.put("bits", bits);
				}
				break;

			case GEO_2DSPHERE:
				break;

			case GEO_HAYSTACK:
				break;
		}

		filter.ifPresent(val -> document.put("partialFilterExpression", val.getFilterObject()));
		collation.ifPresent(val -> document.append("collation", val.toDocument()));

		return document;
	}

	@Override
	public String toString() {
		return String.format("Geo index: %s - Options: %s", getIndexKeys(), getIndexOptions());
	}
}