Fields.java

/*
 * Copyright 2013-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.aggregation;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.jspecify.annotations.Nullable;
import org.springframework.data.mongodb.core.mapping.FieldName;
import org.springframework.lang.Contract;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

/**
 * Value object to capture a list of {@link Field} instances.
 *
 * @author Oliver Gierke
 * @author Thomas Darimont
 * @since 1.3
 */
public final class Fields implements Iterable<Field> {

	private static final String AMBIGUOUS_EXCEPTION = "Found two fields both using '%s' as name: %s and %s; Please "
			+ "customize your field definitions to get to unique field names";

	public static final String UNDERSCORE_ID = FieldName.ID.name();
	public static final String UNDERSCORE_ID_REF = "$_id";

	private final List<Field> fields;

	/**
	 * Creates a new {@link Fields} instance from the given {@link Fields}.
	 *
	 * @param fields must not be {@literal null} or empty.
	 * @return
	 */
	public static Fields from(Field... fields) {

		Assert.notNull(fields, "Fields must not be null");
		return new Fields(Arrays.asList(fields));
	}

	/**
	 * Creates a new {@link Fields} instance for {@link Field}s with the given names.
	 *
	 * @param names must not be {@literal null}.
	 * @return
	 */
	public static Fields fields(String... names) {

		Assert.notNull(names, "Field names must not be null");

		List<Field> fields = new ArrayList<>();

		for (String name : names) {
			fields.add(field(name));
		}

		return new Fields(fields);
	}

	/**
	 * Creates a {@link Field} with the given name.
	 *
	 * @param name must not be {@literal null} or empty.
	 * @return
	 */
	public static Field field(String name) {
		return new AggregationField(name);
	}

	/**
	 * Creates a {@link Field} with the given {@code name} and {@code target}.
	 * <p>
	 * The {@code target} is the name of the backing document field that will be aliased with {@code name}.
	 *
	 * @param name
	 * @param target must not be {@literal null} or empty
	 * @return
	 */
	public static Field field(String name, String target) {
		Assert.hasText(target, "Target must not be null or empty");
		return new AggregationField(name, target);
	}

	/**
	 * Creates a new {@link Fields} instance using the given {@link Field}s.
	 *
	 * @param fields must not be {@literal null}.
	 */
	private Fields(List<Field> fields) {

		Assert.notNull(fields, "Fields must not be null");

		this.fields = verify(fields);
	}

	private static List<Field> verify(List<Field> fields) {

		Map<String, Field> reference = new HashMap<>();

		for (Field field : fields) {

			String name = field.getName();
			Field found = reference.get(name);

			if (found != null) {
				throw new IllegalArgumentException(String.format(AMBIGUOUS_EXCEPTION, name, found, field));
			}

			reference.put(name, field);
		}

		return fields;
	}

	private Fields(Fields existing, Field tail) {

		this.fields = new ArrayList<>(existing.fields.size() + 1);
		this.fields.addAll(existing.fields);
		this.fields.add(tail);
	}

	/**
	 * Creates a new {@link Fields} instance with a new {@link Field} of the given name added.
	 *
	 * @param name must not be {@literal null}.
	 * @return
	 */
	@Contract("_ -> new")
	public Fields and(String name) {
		return and(new AggregationField(name));
	}

	@Contract("_, _ -> new")
	public Fields and(String name, String target) {
		return and(new AggregationField(name, target));
	}

	@Contract("_ -> new")
	public Fields and(Field field) {
		return new Fields(this, field);
	}

	public Fields and(Fields fields) {

		Fields result = this;

		for (Field field : fields) {
			result = result.and(field);
		}

		return result;
	}

	public int size() {
		return fields.size();
	}

	public @Nullable Field getField(String name) {

		for (Field field : fields) {
			if (field.getName().equals(name)) {
				return field;
			}
		}

		return null;
	}

	@Override
	public Iterator<Field> iterator() {
		return fields.iterator();
	}

	/**
	 * @return
	 * @since 1.10
	 */
	public List<Field> asList() {
		return Collections.unmodifiableList(fields);
	}

	/**
	 * Value object to encapsulate a field in an aggregation operation.
	 *
	 * @author Oliver Gierke
	 */
	static class AggregationField implements Field {

		private final String raw;
		private final String name;
		private final @Nullable String target;

		/**
		 * Creates an aggregation field with the given {@code name}.
		 *
		 * @see AggregationField#AggregationField(String, String).
		 * @param name must not be {@literal null} or empty
		 */
		public AggregationField(String name) {
			this(name, null);
		}

		/**
		 * Creates an aggregation field with the given {@code name} and {@code target}.
		 * <p>
		 * The {@code name} serves as an alias for the actual backing document field denoted by {@code target}. If no target
		 * is set explicitly, the name will be used as target.
		 *
		 * @param name must not be {@literal null} or empty
		 * @param target
		 */
		public AggregationField(String name, @Nullable String target) {

			raw = name;
			String nameToSet = name != null ? cleanUp(name) : null;
			String targetToSet = target != null ? cleanUp(target) : null;

			Assert.hasText(nameToSet, "AggregationField name must not be null or empty");

			if (target == null && name.contains(".")) {
				this.name = nameToSet.substring(nameToSet.indexOf('.') + 1);
				this.target = nameToSet;
			} else {
				this.name = nameToSet;
				this.target = targetToSet;
			}
		}

		private static String cleanUp(String source) {

			if (AggregationVariable.isVariable(source)) {
				return source;
			}

			int dollarIndex = source.lastIndexOf('$');
			return dollarIndex == -1 ? source : source.substring(dollarIndex + 1);
		}

		@Override
		public String getName() {
			return name;
		}

		@Override
		public String getTarget() {

			if (isLocalVar() || pointsToDBRefId()) {
				return this.getRaw();
			}

			return StringUtils.hasText(this.target) ? this.target : this.name;
		}

		@Override
		public boolean isAliased() {
			return !getName().equals(getTarget());
		}

		@Override
		public boolean isInternal() {
			return getRaw().endsWith("$$this") || getRaw().endsWith("$$value");
		}

		/**
		 * @return {@literal true} in case the field name starts with {@code $$}.
		 * @since 1.10
		 */
		public boolean isLocalVar() {
			return raw.startsWith("$$") && !raw.startsWith("$$$");
		}

		protected boolean pointsToDBRefId() { // see https://jira.mongodb.org/browse/SERVER-14466
			return raw.endsWith(".$id");
		}

		/**
		 * @return
		 * @since 1.10
		 */
		public String getRaw() {
			return raw;
		}

		@Override
		public String toString() {
			return String.format("AggregationField - name: %s, target: %s", name, target);
		}

		@Override
		public boolean equals(@Nullable Object obj) {

			if (this == obj) {
				return true;
			}

			if (!(obj instanceof AggregationField field)) {
				return false;
			}

			return this.name.equals(field.name) && ObjectUtils.nullSafeEquals(this.target, field.target);
		}

		@Override
		public int hashCode() {

			int result = 17;

			result += 31 * name.hashCode();
			result += 31 * ObjectUtils.nullSafeHashCode(target);

			return result;
		}
	}
}