NearQuery.java

/*
 * Copyright 2011-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.query;

import java.util.Arrays;

import org.bson.Document;
import org.jspecify.annotations.Nullable;

import org.springframework.data.domain.Pageable;
import org.springframework.data.geo.CustomMetric;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.Metric;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.mongodb.core.ReadConcernAware;
import org.springframework.data.mongodb.core.ReadPreferenceAware;
import org.springframework.data.mongodb.core.geo.GeoJsonPoint;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;

import com.mongodb.ReadConcern;
import com.mongodb.ReadPreference;

/**
 * Builder class to build near-queries. <br />
 * MongoDB {@code $geoNear} operator allows usage of a {@literal GeoJSON Point} or legacy coordinate pair. Though
 * syntactically different, there's no difference between {@code near: [-73.99171, 40.738868]} and {@code near: { type:
 * "Point", coordinates: [-73.99171, 40.738868] } } for the MongoDB server<br />
 * <br />
 * Please note that there is a huge difference in the distance calculation. Using the legacy format (for near) operates
 * upon {@literal Radians} on an Earth like sphere, whereas the {@literal GeoJSON} format uses {@literal Meters}. The
 * actual type within the document is of no concern at this point.<br />
 * To avoid a serious headache make sure to set the {@link Metric} to the desired unit of measure which ensures the
 * distance to be calculated correctly.<br />
 * <br />
 * In other words: <br />
 * Assume you've got 5 Documents like the ones below <br />
 *
 * <pre>
 *     <code>
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a5"),
 *     "name" : "Penn Station",
 *     "location" : { "type" : "Point", "coordinates" : [  -73.99408, 40.75057 ] }
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a6"),
 *     "name" : "10gen Office",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.99171, 40.738868 ] }
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a9"),
 *     "name" : "City Bakery ",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796aa"),
 *     "name" : "Splash Bar",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796ab"),
 *     "name" : "Momofuku Milk Bar",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.985839, 40.731698 ] }
 * }
 *      </code>
 * </pre>
 *
 * Fetching all Documents within a 400 Meter radius from {@code [-73.99171, 40.738868] } would look like this using
 * {@literal GeoJSON}:
 *
 * <pre>
 *     <code>
 * {
 *     $geoNear: {
 *         maxDistance: 400,
 *         num: 10,
 *         near: { type: "Point", coordinates: [-73.99171, 40.738868] },
 *         spherical:true,
 *         key: "location",
 *         distanceField: "distance"
 *     }
 * }
 *
 *     </code>
 * </pre>
 *
 * resulting in the following 3 Documents.
 *
 * <pre>
 *     <code>
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a6"),
 *     "name" : "10gen Office",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.99171, 40.738868 ] }
 *     "distance" : 0.0 // Meters
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a9"),
 *     "name" : "City Bakery ",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 *     "distance" : 69.3582262492474 // Meters
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796aa"),
 *     "name" : "Splash Bar",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 *     "distance" : 69.3582262492474 // Meters
 * }
 *     </code>
 * </pre>
 *
 * Using legacy coordinate pairs one operates upon radians as discussed before. Assume we use {@link Metrics#KILOMETERS}
 * when constructing the geoNear command. The {@link Metric} will make sure the distance multiplier is set correctly, so
 * the command is rendered like
 *
 * <pre>
 *     <code>
 * {
 *     $geoNear: {
 *         maxDistance: 0.0000627142377, // 400 Meters
 *         distanceMultiplier: 6378.137,
 *         num: 10,
 *         near: [-73.99171, 40.738868],
 *         spherical:true,
 *         key: "location",
 *         distanceField: "distance"
 *     }
 * }
 *     </code>
 * </pre>
 *
 * Please note the calculated distance now uses {@literal Kilometers} instead of {@literal Meters} as unit of measure,
 * so we need to take it times 1000 to match up to {@literal Meters} as in the {@literal GeoJSON} variant. <br />
 * Still as we've been requesting the {@link Distance} in {@link Metrics#KILOMETERS} the {@link Distance#getValue()}
 * reflects exactly this.
 *
 * <pre>
 *     <code>
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a6"),
 *     "name" : "10gen Office",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.99171, 40.738868 ] }
 *     "distance" : 0.0 // Kilometers
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796a9"),
 *     "name" : "City Bakery ",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 *     "distance" : 0.0693586286032982 // Kilometers
 * }
 * {
 *     "_id" : ObjectId("5c10f3735d38908db52796aa"),
 *     "name" : "Splash Bar",
 *     "location" : { "type" : "Point", "coordinates" : [ -73.992491, 40.738673 ] }
 *     "distance" : 0.0693586286032982 // Kilometers
 * }
 *     </code>
 * </pre>
 *
 * @author Oliver Gierke
 * @author Thomas Darimont
 * @author Christoph Strobl
 * @author Mark Paluch
 */
public final class NearQuery implements ReadConcernAware, ReadPreferenceAware {

	private final Point point;
	private @Nullable Query query;
	private @Nullable Distance maxDistance;
	private @Nullable Distance minDistance;
	private Metric metric;
	private boolean spherical;
	private @Nullable Long limit;
	private @Nullable Long skip;
	private @Nullable ReadConcern readConcern;
	private @Nullable ReadPreference readPreference;

	/**
	 * Creates a new {@link NearQuery}.
	 *
	 * @param point must not be {@literal null}.
	 * @param metric must not be {@literal null}.
	 */
	private NearQuery(Point point, Metric metric) {

		Assert.notNull(point, "Point must not be null");
		Assert.notNull(metric, "Metric must not be null");

		this.point = point;
		this.spherical = false;
		this.metric = metric;
	}

	/**
	 * Creates a new {@link NearQuery} starting near the given coordinates.
	 *
	 * @param x
	 * @param y
	 * @return
	 */
	public static NearQuery near(double x, double y) {
		return near(x, y, Metrics.NEUTRAL);
	}

	/**
	 * Creates a new {@link NearQuery} starting at the given coordinates using the given {@link Metric} to adapt given
	 * values to further configuration. E.g. setting a {@link #maxDistance(double)} will be interpreted as a value of the
	 * initially set {@link Metric}.
	 *
	 * @param x
	 * @param y
	 * @param metric must not be {@literal null}.
	 * @return
	 */
	public static NearQuery near(double x, double y, Metric metric) {
		return near(new Point(x, y), metric);
	}

	/**
	 * Creates a new {@link NearQuery} starting at the given {@link Point}. <br />
	 * <strong>NOTE:</strong> There is a difference in using {@link Point} versus {@link GeoJsonPoint}. {@link Point}
	 * values are rendered as coordinate pairs in the legacy format and operate upon radians, whereas the
	 * {@link GeoJsonPoint} uses according to its specification {@literal meters} as unit of measure. This may lead to
	 * different results when using a {@link Metrics#NEUTRAL neutral Metric}.
	 *
	 * @param point must not be {@literal null}.
	 * @return new instance of {@link NearQuery}.
	 */
	public static NearQuery near(Point point) {
		return near(point, Metrics.NEUTRAL);
	}

	/**
	 * Creates a {@link NearQuery} starting near the given {@link Point} using the given {@link Metric} to adapt given
	 * values to further configuration. E.g. setting a {@link #maxDistance(double)} will be interpreted as a value of the
	 * initially set {@link Metric}. <br />
	 * <strong>NOTE:</strong> There is a difference in using {@link Point} versus {@link GeoJsonPoint}. {@link Point}
	 * values are rendered as coordinate pairs in the legacy format and operate upon radians, whereas the
	 * {@link GeoJsonPoint} uses according to its specification {@literal meters} as unit of measure. This may lead to
	 * different results when using a {@link Metrics#NEUTRAL neutral Metric}.
	 *
	 * @param point must not be {@literal null}.
	 * @param metric must not be {@literal null}.
	 * @return new instance of {@link NearQuery}.
	 */
	public static NearQuery near(Point point, Metric metric) {
		return new NearQuery(point, metric);
	}

	/**
	 * Returns the {@link Metric} underlying the actual query. If no metric was set explicitly {@link Metrics#NEUTRAL}
	 * will be returned.
	 *
	 * @return will never be {@literal null}.
	 */
	public Metric getMetric() {
		return metric;
	}

	/**
	 * Configures the maximum number of results to return.
	 *
	 * @param limit
	 * @return
	 * @since 2.2
	 */
	@Contract("_ -> this")
	public NearQuery limit(long limit) {
		this.limit = limit;
		return this;
	}

	/**
	 * Configures the number of results to skip.
	 *
	 * @param skip
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery skip(long skip) {
		this.skip = skip;
		return this;
	}

	/**
	 * Configures the {@link Pageable} to use.
	 *
	 * @param pageable must not be {@literal null}
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery with(Pageable pageable) {

		Assert.notNull(pageable, "Pageable must not be 'null'");
		if (pageable.isPaged()) {
			this.skip = pageable.getOffset();
			this.limit = (long) pageable.getPageSize();
		}
		return this;
	}

	/**
	 * Sets the max distance results shall have from the configured origin. If a {@link Metric} was set before the given
	 * value will be interpreted as being a value in that metric. E.g.
	 *
	 * <pre>
	 * NearQuery query = near(10.0, 20.0, Metrics.KILOMETERS).maxDistance(150);
	 * </pre>
	 *
	 * Will set the maximum distance to 150 kilometers.
	 *
	 * @param maxDistance
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery maxDistance(double maxDistance) {
		return maxDistance(Distance.of(maxDistance, getMetric()));
	}

	/**
	 * Sets the maximum distance supplied in a given metric. Will normalize the distance but not reconfigure the query's
	 * result {@link Metric} if one was configured before.
	 *
	 * @param maxDistance
	 * @param metric must not be {@literal null}.
	 * @return
	 */
	@Contract("_, _ -> this")
	public NearQuery maxDistance(double maxDistance, Metric metric) {

		Assert.notNull(metric, "Metric must not be null");

		return maxDistance(Distance.of(maxDistance, metric));
	}

	/**
	 * Sets the maximum distance to the given {@link Distance}. Will set the returned {@link Metric} to be the one of the
	 * given {@link Distance} if {@link Metric} was {@link Metrics#NEUTRAL} before.
	 *
	 * @param distance must not be {@literal null}.
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery maxDistance(Distance distance) {

		Assert.notNull(distance, "Distance must not be null");

		if (distance.getMetric() != Metrics.NEUTRAL) {
			this.spherical(true);
		}

		if (ObjectUtils.nullSafeEquals(Metrics.NEUTRAL, this.metric)) {
			in(distance.getMetric());
		}

		this.maxDistance = distance;
		return this;
	}

	/**
	 * Sets the minimum distance results shall have from the configured origin. If a {@link Metric} was set before the
	 * given value will be interpreted as being a value in that metric. E.g.
	 *
	 * <pre>
	 * NearQuery query = near(10.0, 20.0, Metrics.KILOMETERS).minDistance(150);
	 * </pre>
	 *
	 * Will set the minimum distance to 150 kilometers.
	 *
	 * @param minDistance
	 * @return
	 * @since 1.7
	 */
	@Contract("_ -> this")
	public NearQuery minDistance(double minDistance) {
		return minDistance(Distance.of(minDistance, getMetric()));
	}

	/**
	 * Sets the minimum distance supplied in a given metric. Will normalize the distance but not reconfigure the query's
	 * result {@link Metric} if one was configured before.
	 *
	 * @param minDistance
	 * @param metric must not be {@literal null}.
	 * @return
	 * @since 1.7
	 */
	@Contract("_, _ -> this")
	public NearQuery minDistance(double minDistance, Metric metric) {

		Assert.notNull(metric, "Metric must not be null");

		return minDistance(Distance.of(minDistance, metric));
	}

	/**
	 * Sets the minimum distance to the given {@link Distance}. Will set the returned {@link Metric} to be the one of the
	 * given {@link Distance} if no {@link Metric} was set before.
	 *
	 * @param distance must not be {@literal null}.
	 * @return
	 * @since 1.7
	 */
	@Contract("_ -> this")
	public NearQuery minDistance(Distance distance) {

		Assert.notNull(distance, "Distance must not be null");

		if (distance.getMetric() != Metrics.NEUTRAL) {
			this.spherical(true);
		}

		if (this.metric == null) {
			in(distance.getMetric());
		}

		this.minDistance = distance;
		return this;
	}

	/**
	 * Returns the maximum {@link Distance}.
	 *
	 * @return
	 */
	public @Nullable Distance getMaxDistance() {
		return this.maxDistance;
	}

	/**
	 * Returns the maximum {@link Distance}.
	 *
	 * @return
	 * @since 1.7
	 */
	public @Nullable Distance getMinDistance() {
		return this.minDistance;
	}

	/**
	 * Configures a {@link CustomMetric} with the given multiplier.
	 *
	 * @param distanceMultiplier
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery distanceMultiplier(double distanceMultiplier) {

		this.metric = new CustomMetric(distanceMultiplier);
		return this;
	}

	/**
	 * Configures whether to return spherical values for the actual distance.
	 *
	 * @param spherical
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery spherical(boolean spherical) {
		this.spherical = spherical;
		return this;
	}

	/**
	 * Returns whether spharical values will be returned.
	 *
	 * @return
	 */
	public boolean isSpherical() {
		return this.spherical;
	}

	/**
	 * Will cause the results' distances being returned in kilometers. Sets {@link #distanceMultiplier(double)} and
	 * {@link #spherical(boolean)} accordingly.
	 *
	 * @return
	 */
	@Contract("-> this")
	public NearQuery inKilometers() {
		return adaptMetric(Metrics.KILOMETERS);
	}

	/**
	 * Will cause the results' distances being returned in miles. Sets {@link #distanceMultiplier(double)} and
	 * {@link #spherical(boolean)} accordingly.
	 *
	 * @return
	 */
	@Contract("-> this")
	public NearQuery inMiles() {
		return adaptMetric(Metrics.MILES);
	}

	/**
	 * Will cause the results' distances being returned in the given metric. Sets {@link #distanceMultiplier(double)}
	 * accordingly as well as {@link #spherical(boolean)} if the given {@link Metric} is not {@link Metrics#NEUTRAL}.
	 *
	 * @param metric the metric the results shall be returned in. Uses {@link Metrics#NEUTRAL} if {@literal null} is
	 *          passed.
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery in(@Nullable Metric metric) {
		return adaptMetric(metric == null ? Metrics.NEUTRAL : metric);
	}

	/**
	 * Configures the given {@link Metric} to be used as base on for this query and recalculate the maximum distance if no
	 * metric was set before.
	 *
	 * @param metric
	 */
	@Contract("_ -> this")
	private NearQuery adaptMetric(Metric metric) {

		if (metric != Metrics.NEUTRAL) {
			spherical(true);
		}

		this.metric = metric;
		return this;
	}

	/**
	 * Adds an actual query to the {@link NearQuery} to restrict the objects considered for the actual near operation.
	 *
	 * @param query must not be {@literal null}.
	 * @return
	 */
	@Contract("_ -> this")
	public NearQuery query(Query query) {

		Assert.notNull(query, "Cannot apply 'null' query on NearQuery");

		this.query = query;
		this.skip = query.getSkip();

		if (query.getLimit() != 0) {
			this.limit = (long) query.getLimit();
		}
		return this;
	}

	/**
	 * @return the number of elements to skip.
	 */
	public @Nullable Long getSkip() {
		return skip;
	}

	/**
	 * Get the {@link Collation} to use along with the {@link #query(Query)}.
	 *
	 * @return the {@link Collation} if set. {@literal null} otherwise.
	 * @since 2.2
	 */
	public @Nullable Collation getCollation() {
		return query != null ? query.getCollation().orElse(null) : null;
	}

	/**
	 * Configures the query to use the given {@link ReadConcern} unless the underlying {@link #query(Query)}
	 * {@link Query#hasReadConcern() specifies} another one.
	 *
	 * @param readConcern must not be {@literal null}.
	 * @return this.
	 * @since 4.1
	 */
	@Contract("_ -> this")
	public NearQuery withReadConcern(ReadConcern readConcern) {

		Assert.notNull(readConcern, "ReadConcern must not be null");
		this.readConcern = readConcern;
		return this;
	}

	/**
	 * Configures the query to use the given {@link ReadPreference} unless the underlying {@link #query(Query)}
	 * {@link Query#hasReadPreference() specifies} another one.
	 *
	 * @param readPreference must not be {@literal null}.
	 * @return this.
	 * @since 4.1
	 */
	@Contract("_ -> this")
	public NearQuery withReadPreference(ReadPreference readPreference) {

		Assert.notNull(readPreference, "ReadPreference must not be null");
		this.readPreference = readPreference;
		return this;
	}

	/**
	 * Get the {@link ReadConcern} to use. Will return the underlying {@link #query(Query) queries}
	 * {@link Query#getReadConcern() ReadConcern} if present or the one defined on the {@link NearQuery#readConcern}
	 * itself.
	 *
	 * @return can be {@literal null} if none set.
	 * @since 4.1
	 * @see ReadConcernAware
	 */
	@Override
	public @Nullable ReadConcern getReadConcern() {

		if (query != null && query.hasReadConcern()) {
			return query.getReadConcern();
		}
		return readConcern;
	}

	/**
	 * Get the {@link ReadPreference} to use. Will return the underlying {@link #query(Query) queries}
	 * {@link Query#getReadPreference() ReadPreference} if present or the one defined on the
	 * {@link NearQuery#readPreference} itself.
	 *
	 * @return can be {@literal null} if none set.
	 * @since 4.1
	 * @see ReadPreferenceAware
	 */
	@Override
	public @Nullable ReadPreference getReadPreference() {

		if (query != null && query.hasReadPreference()) {
			return query.getReadPreference();
		}
		return readPreference;
	}

	/**
	 * Returns the {@link Document} built by the {@link NearQuery}.
	 *
	 * @return
	 */
	public Document toDocument() {

		Document document = new Document();

		if (query != null) {

			document.put("query", query.getQueryObject());
			query.getCollation().ifPresent(collation -> document.append("collation", collation.toDocument()));
		}

		if (maxDistance != null) {
			document.put("maxDistance", getDistanceValueInRadiantsOrMeters(maxDistance));
		}

		if (minDistance != null) {
			document.put("minDistance", getDistanceValueInRadiantsOrMeters(minDistance));
		}

		if (metric != null) {
			document.put("distanceMultiplier", getDistanceMultiplier());
		}

		if (limit != null && limit > 0) {
			document.put("num", limit);
		}

		if (usesGeoJson()) {
			document.put("near", point);
		} else {
			document.put("near", Arrays.asList(point.getX(), point.getY()));
		}

		document.put("spherical", spherical ? spherical : usesGeoJson());

		return document;
	}

	private double getDistanceMultiplier() {
		return usesMetricSystem() ? MetricConversion.getMetersToMetricMultiplier(metric) : metric.getMultiplier();
	}

	private double getDistanceValueInRadiantsOrMeters(Distance distance) {
		return usesMetricSystem() ? MetricConversion.getDistanceInMeters(distance) : distance.getNormalizedValue();
	}

	private boolean usesMetricSystem() {
		return usesGeoJson();
	}

	private boolean usesGeoJson() {
		return point instanceof GeoJsonPoint;
	}

}