StringSpecifications.java

package expresspecs;

import static expresspecs.BasicSpecifications.unrestricted;
import static expresspecs.SQLUtils.escapeLike;

import java.util.Collection;
import java.util.List;
import java.util.Locale;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.domain.Specification;

import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import lombok.experimental.UtilityClass;

/**
 * Predicate factories for String-based JPA Specifications, including partial matches and case-insensitive equality.
 *
 * <p>Most of the methods return an {@linkplain BasicSpecifications#unrestricted() unrestricted specification} when the
 * filter value is {@code null}, blank, or an empty collection, making them safe to use directly from optional
 * query parameters without null-checking at the call site.
 */
@UtilityClass
public class StringSpecifications {

	static final char ESCAPE_CHAR = '\\';


	/**
	 * Creates a specification that matches entities where the specified property equals
	 * {@code aValue}, ignoring case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param aValue       Value to compare against.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> equalsIgnoreCase(String propertyPath, String aValue) {
		return equalsIgnoreCase(PropertyPath.from(propertyPath), aValue);
	}

	/**
	 * Creates a specification that matches entities where the specified property equals
	 * {@code aValue}, ignoring case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param aValue       Value to compare against.
	 */
	public static <T> @NonNull Specification<T> equalsIgnoreCase(PropertyPath propertyPath, String aValue) {
		if (aValue == null) {
			return unrestricted();
		}

		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.equal(cb.lower(path), aValue.toLowerCase(Locale.ROOT));
		};
	}

	/**
	 * Creates a specification that matches entities where the specified string property is empty:
	 * either {@code null} or an empty string ({@code ""}).
	 *
	 * <p>Useful for schemas that store missing values as either {@code null} or empty string.
	 * Produces the SQL predicate {@code (column IS NULL OR column = '')}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to a string attribute.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> isNullOrEmpty(String propertyPath) {
		return isNullOrEmpty(PropertyPath.from(propertyPath));
	}

	/**
	 * Creates a specification that matches entities where the specified string property is empty:
	 * either {@code null} or an empty string ({@code ""}).
	 *
	 * <p>Useful for schemas that store missing values as either {@code null} or empty string.
	 * Produces the SQL predicate {@code (column IS NULL OR column = '')}.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to a string attribute.
	 */
	public static <T> @NonNull Specification<T> isNullOrEmpty(PropertyPath propertyPath) {
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.or(cb.isNull(path), cb.equal(path, ""));
		};
	}

	/**
	 * Creates a specification that matches entities where the specified string property is not empty:
	 * neither {@code null} nor an empty string ({@code ""}).
	 *
	 * <p>This is the logical complement of {@link #isNullOrEmpty(String)}.
	 * Produces the SQL predicate {@code (column IS NOT NULL AND column <> '')}.
	 *
	 * <p><strong>Oracle compatibility note:</strong> This predicate does not behave correctly on Oracle.
	 * Oracle coerces {@code ''} to {@code NULL} at storage time, so the {@code column <> ''} term
	 * binds as {@code column <> NULL}, which is UNKNOWN under SQL three-valued logic. This causes
	 * every row to be excluded, even rows with real values. On Oracle, use
	 * {@link BasicSpecifications#notNull(String)} instead: since Oracle cannot store an empty string,
	 * a non-null value is guaranteed to be non-empty.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to a string attribute.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> isNotNullOrEmpty(String propertyPath) {
		return isNotNullOrEmpty(PropertyPath.from(propertyPath));
	}

	/**
	 * Creates a specification that matches entities where the specified string property is not empty:
	 * neither {@code null} nor an empty string ({@code ""}).
	 *
	 * <p>This is the logical complement of {@link #isNullOrEmpty(PropertyPath)}.
	 * Produces the SQL predicate {@code (column IS NOT NULL AND column <> '')}.
	 *
	 * <p><strong>Oracle compatibility note:</strong> This predicate does not behave correctly on Oracle.
	 * Oracle coerces {@code ''} to {@code NULL} at storage time, so the {@code column <> ''} term
	 * binds as {@code column <> NULL}, which is UNKNOWN under SQL three-valued logic. This causes
	 * every row to be excluded, even rows with real values. On Oracle, use
	 * {@link BasicSpecifications#notNull(PropertyPath)} instead: since Oracle cannot store an empty string,
	 * a non-null value is guaranteed to be non-empty.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to a string attribute.
	 */
	public static <T> @NonNull Specification<T> isNotNullOrEmpty(PropertyPath propertyPath) {
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.and(cb.isNotNull(path), cb.notEqual(path, ""));
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property contains
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Substring to match within the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> contains(String propertyPath, String value) {
		return contains(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property contains
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Substring to match within the property value.
	 */
	public static <T> @NonNull Specification<T> contains(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value, ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(path, "%" + escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property contains
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Substring to match within the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> containsIgnoreCase(String propertyPath, String value) {
		return containsIgnoreCase(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property contains
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Substring to match within the property value.
	 */
	public static <T> @NonNull Specification<T> containsIgnoreCase(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value.toLowerCase(Locale.ROOT), ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(cb.lower(path), "%" + escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property does not
	 * contain {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Substring to exclude from the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> doesNotContain(String propertyPath, String value) {
		return doesNotContain(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property does not
	 * contain {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Substring to exclude from the property value.
	 */
	public static <T> @NonNull Specification<T> doesNotContain(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value, ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.notLike(path, "%" + escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property does not
	 * contain {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Substring to exclude from the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> doesNotContainIgnoreCase(String propertyPath, String value) {
		return doesNotContainIgnoreCase(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property does not
	 * contain {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Substring to exclude from the property value.
	 */
	public static <T> @NonNull Specification<T> doesNotContainIgnoreCase(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value.toLowerCase(Locale.ROOT), ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.notLike(cb.lower(path), "%" + escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property contains at least
	 * one of the values in {@code searchTerms}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param searchTerms  Candidate substrings for case-insensitive matching.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> containsAnyIgnoreCase(String propertyPath,
			Collection<String> searchTerms) {
		return containsAnyIgnoreCase(PropertyPath.from(propertyPath), searchTerms);
	}

	/**
	 * Creates a specification that matches entities where the specified property contains at least
	 * one of the values in {@code searchTerms}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param searchTerms  Candidate substrings for case-insensitive matching.
	 */
	public static <T> @NonNull Specification<T> containsAnyIgnoreCase(PropertyPath propertyPath,
			Collection<String> searchTerms) {
		if (CollectionUtils.isEmpty(searchTerms)) {
			return unrestricted();
		}

		List<String> escapedTerms = searchTerms.stream()
				.filter(term -> !StringUtils.isEmpty(term))
				.map(term -> escapeLike(term.toLowerCase(Locale.ROOT), ESCAPE_CHAR))
				.toList();

		// Special case: all terms are empty so nothing to match against
		if (escapedTerms.isEmpty()) {
			return unrestricted();
		}

		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			Predicate[] predicates = escapedTerms.stream()
					.map(term -> cb.like(cb.lower(path), "%" + term + "%", ESCAPE_CHAR))
					.toArray(Predicate[]::new);
			return cb.or(predicates);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property starts with
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Prefix to match at the start of the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> startsWith(String propertyPath, String value) {
		return startsWith(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property starts with
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Prefix to match at the start of the property value.
	 */
	public static <T> @NonNull Specification<T> startsWith(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value, ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(path, escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property starts with
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Prefix to match at the start of the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> startsWithIgnoreCase(String propertyPath, String value) {
		return startsWithIgnoreCase(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property starts with
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Prefix to match at the start of the property value.
	 */
	public static <T> @NonNull Specification<T> startsWithIgnoreCase(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value.toLowerCase(Locale.ROOT), ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(cb.lower(path), escapedValue + "%", ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property ends with
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Suffix to match at the end of the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> endsWith(String propertyPath, String value) {
		return endsWith(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property ends with
	 * {@code value}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Suffix to match at the end of the property value.
	 */
	public static <T> @NonNull Specification<T> endsWith(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value, ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(path, "%" + escapedValue, ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property ends with
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param value        Suffix to match at the end of the property value.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> endsWithIgnoreCase(String propertyPath, String value) {
		return endsWithIgnoreCase(PropertyPath.from(propertyPath), value);
	}

	/**
	 * Creates a specification that matches entities where the specified property ends with
	 * {@code value}. Matching ignores case.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param value        Suffix to match at the end of the property value.
	 */
	public static <T> @NonNull Specification<T> endsWithIgnoreCase(PropertyPath propertyPath, String value) {
		if (StringUtils.isEmpty(value)) {
			return unrestricted();
		}

		String escapedValue = escapeLike(value.toLowerCase(Locale.ROOT), ESCAPE_CHAR);
		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			return cb.like(cb.lower(path), "%" + escapedValue, ESCAPE_CHAR);
		};
	}

	/**
	 * Creates a specification that matches entities where the specified property contains at least
	 * one of the values in {@code searchTerms}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Dot-delimited property path to compare.
	 * @param searchTerms  Candidate substrings for case-sensitive matching.
	 * @see PropertyPath#from(String)
	 */
	public static <T> @NonNull Specification<T> containsAny(String propertyPath, Collection<String> searchTerms) {
		return containsAny(PropertyPath.from(propertyPath), searchTerms);
	}

	/**
	 * Creates a specification that matches entities where the specified property contains at least
	 * one of the values in {@code searchTerms}. Matching is case-sensitive.
	 *
	 * @param <T>          The entity type being queried.
	 * @param propertyPath Resolved property path to compare.
	 * @param searchTerms  Candidate substrings for case-sensitive matching.
	 */
	public static <T> @NonNull Specification<T> containsAny(PropertyPath propertyPath, Collection<String> searchTerms) {
		if (CollectionUtils.isEmpty(searchTerms)) {
			return unrestricted();
		}

		List<String> escapedTerms = searchTerms.stream()
				.filter(term -> !StringUtils.isEmpty(term))
				.map(term -> escapeLike(term, ESCAPE_CHAR))
				.toList();

		// Special case: all terms are empty so nothing to match against
		if (escapedTerms.isEmpty()) {
			return unrestricted();
		}

		return (root, query, cb) -> {
			Path<String> path = propertyPath.asPath(root);
			Predicate[] predicates = escapedTerms.stream()
					.map(term -> cb.like(path, "%" + term + "%", ESCAPE_CHAR))
					.toArray(Predicate[]::new);
			return cb.or(predicates);
		};
	}

}