All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.omnifaces.persistence.JPA Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2021 OmniFaces
 *
 * 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.omnifaces.persistence;

import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.toList;
import static org.omnifaces.persistence.Database.POSTGRESQL;
import static org.omnifaces.persistence.Provider.HIBERNATE;
import static org.omnifaces.utils.stream.Collectors.toMap;
import static org.omnifaces.utils.stream.Streams.stream;

import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Member;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.OffsetTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.enterprise.inject.Typed;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.NoResultException;
import jakarta.persistence.NonUniqueResultException;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.ValidationMode;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.Bindable;
import jakarta.persistence.metamodel.CollectionAttribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.ListAttribute;
import jakarta.persistence.metamodel.MapAttribute;
import jakarta.persistence.metamodel.Metamodel;
import jakarta.persistence.metamodel.PluralAttribute;
import jakarta.persistence.metamodel.SetAttribute;
import jakarta.persistence.metamodel.SingularAttribute;

import org.omnifaces.persistence.criteria.Numeric;

/**
 * JPA utilities.
 */
@Typed
public final class JPA {

	// Public constants -------------------------------------------------------------------------------------------------------------------

	public static final String QUERY_HINT_LOAD_GRAPH = "jakarta.persistence.loadgraph";
	public static final String QUERY_HINT_FETCH_GRAPH = "jakarta.persistence.fetchgraph";
	public static final String QUERY_HINT_CACHE_STORE_MODE = "jakarta.persistence.cache.storeMode"; // USE | BYPASS | REFRESH
	public static final String QUERY_HINT_CACHE_RETRIEVE_MODE = "jakarta.persistence.cache.retrieveMode"; // USE | BYPASS
	public static final String PROPERTY_VALIDATION_MODE = "jakarta.persistence.validation.mode"; // AUTO | CALLBACK | NONE


	// Constructors -----------------------------------------------------------------------------------------------------------------------

	private JPA() {
		throw new AssertionError();
	}


	// Configuration utils ----------------------------------------------------------------------------------------------------------------

	/**
	 * Returns the currently configured bean validation mode for given entity manager.
	 * This consults the {@value #PROPERTY_VALIDATION_MODE} property in persistence.xml.
	 * @param entityManager The involved entity manager.
	 * @return The currently configured bean validation mode.
	 */
	public static ValidationMode getValidationMode(EntityManager entityManager) {
		Object validationMode = entityManager.getEntityManagerFactory().getProperties().get(PROPERTY_VALIDATION_MODE);
		return validationMode != null ? ValidationMode.valueOf(validationMode.toString().toUpperCase()) : ValidationMode.AUTO;
	}


	// Query utils ------------------------------------------------------------------------------------------------------------------------

	/**
	 * Returns single result of given typed query as {@link Optional}.
	 * @param  The generic result type.
	 * @param typedQuery The involved typed query.
	 * @return Single result of given typed query as {@link Optional}.
	 * @throws NonUniqueResultException When there is no unique result.
	 */
	public static  Optional getOptionalSingleResult(TypedQuery typedQuery) {
		return ofNullable(getSingleResultOrNull(typedQuery));
	}

	/**
	 * Returns single result of given query as {@link Optional}.
	 * @param  The expected result type.
	 * @param query The involved query.
	 * @return Single result of given query as {@link Optional}.
	 * @throws NonUniqueResultException When there is no unique result.
	 * @throws ClassCastException When T is of wrong type.
	 */
	public static  Optional getOptionalSingleResult(Query query) {
		return ofNullable(getSingleResultOrNull(query));
	}

	/**
	 * Returns single result of given typed query, or null if there is none.
	 * @param  The generic result type.
	 * @param typedQuery The involved typed query.
	 * @return Single result of given typed query, or null if there is none.
	 * @throws NonUniqueResultException When there is no unique result.
	 */
	public static  T getSingleResultOrNull(TypedQuery typedQuery) {
		try {
			return typedQuery.getSingleResult();
		}
		catch (NoResultException e) {
			return null;
		}
	}

	/**
	 * Returns single result of given query, or null if there is none.
	 * @param  The expected result type.
	 * @param query The involved query.
	 * @return Single result of given query, or null if there is none.
	 * @throws NonUniqueResultException When there is no unique result.
	 * @throws ClassCastException When T is of wrong type.
	 */
	@SuppressWarnings("unchecked")
	public static  T getSingleResultOrNull(Query query) {
		try {
			return (T) query.getSingleResult();
		}
		catch (NoResultException e) {
			return null;
		}
	}

	/**
	 * Returns first result of given typed query as {@link Optional}.
	 * The difference with {@link #getOptionalSingleResult(TypedQuery)} is that it doesn't throw {@link NonUniqueResultException} when there are multiple matches.
	 * @param  The generic result type.
	 * @param typedQuery The involved typed query.
	 * @return First result of given typed query as {@link Optional}.
	 */
	public static  Optional getOptionalFirstResult(TypedQuery typedQuery) {
		typedQuery.setMaxResults(1);
		return typedQuery.getResultList().stream().findFirst();
	}

	/**
	 * Returns first result of given query as {@link Optional}.
	 * The difference with {@link #getOptionalSingleResult(Query)} is that it doesn't throw {@link NonUniqueResultException} when there are multiple matches.
	 * @param  The expected result type.
	 * @param query The involved query.
	 * @return First result of given query as {@link Optional}.
	 * @throws ClassCastException When T is of wrong type.
	 */
	@SuppressWarnings("unchecked")
	public static  Optional getOptionalFirstResult(Query query) {
		query.setMaxResults(1);
		return query.getResultList().stream().findFirst();
	}

	/**
	 * Returns first result of given typed query, or null if there is none.
	 * The difference with {@link #getSingleResultOrNull(TypedQuery)} is that it doesn't throw {@link NonUniqueResultException} when there are multiple matches.
	 * @param  The generic result type.
	 * @param typedQuery The involved typed query.
	 * @return First result of given typed query, or null if there is none.
	 */
	public static  T getFirstResultOrNull(TypedQuery typedQuery) {
		return getOptionalFirstResult(typedQuery).orElse(null);
	}

	/**
	 * Returns first result of given query, or null if there is none.
	 * The difference with {@link #getSingleResultOrNull(Query)} is that it doesn't throw {@link NonUniqueResultException} when there are multiple matches.
	 * @param  The expected result type.
	 * @param query The involved query.
	 * @return First result of given query, or null if there is none.
	 * @throws ClassCastException When T is of wrong type.
	 */
	@SuppressWarnings("unchecked")
	public static  T getFirstResultOrNull(Query query) {
		return (T) getOptionalFirstResult(query).orElse(null);
	}

	/**
	 * Returns the result list of given typed query as a map mapped by the given key mapper.
	 * @param  The generic map key type.
	 * @param  The generic result type, also map value type.
	 * @param typedQuery The involved typed query.
	 * @param keyMapper The key mapper.
	 * @return The result list of given typed query as a map mapped by the given key mapper.
	 */
	public static  Map getResultMap(TypedQuery typedQuery, Function keyMapper) {
		return typedQuery.getResultList().stream().collect(toMap(keyMapper));
	}

	/**
	 * Returns the result list of given typed query as a map mapped by the given key and value mappers.
	 * @param  The generic map key type.
	 * @param  The generic result type.
	 * @param  The generic map value type.
	 * @param typedQuery The involved typed query.
	 * @param keyMapper The key mapper.
	 * @param valueMapper The value mapper.
	 * @return The result list of given typed query as a map mapped by the given key and value mappers.
	 */
	public static  Map getResultMap(TypedQuery typedQuery, Function keyMapper, Function valueMapper) {
		return typedQuery.getResultList().stream().collect(Collectors.toMap(keyMapper, valueMapper));
	}


	// Entity utils -----------------------------------------------------------------------------------------------------------------------

	/**
	 * Returns count of all foreign key references to entity of given entity type with given ID of given identifier type.
	 * This is particularly useful in case you intend to check if the given entity is still referenced elsewhere in database.
	 * @param  The generic result type.
	 * @param  The generic identifier type.
	 * @param entityManager The involved entity manager.
	 * @param entityType Entity type.
	 * @param identifierType Identifier type.
	 * @param id Entity ID.
	 * @return Count of all foreign key references to entity of given entity type with given ID of given identifier type.
	 */
	public static  long countForeignKeyReferences(EntityManager entityManager, Class entityType, Class identifierType, I id) {
		Metamodel metamodel = entityManager.getMetamodel();
		SingularAttribute idAttribute = metamodel.entity(entityType).getId(identifierType);
		return metamodel.getEntities().stream()
			.flatMap(entity -> getAttributesOfType(entity, entityType))
			.distinct()
			.mapToLong(attribute -> countReferencesTo(entityManager, attribute, idAttribute, id))
			.sum();
	}

	private static  Stream> getAttributesOfType(EntityType entity, Class entityType) {
		return entity.getAttributes().stream()
			.filter(attribute -> entityType.equals(getJavaType(attribute)))
			.map(attribute -> attribute);
	}

	private static  Class getJavaType(Attribute attribute) {
		return (attribute instanceof PluralAttribute)
			? ((PluralAttribute) attribute).getElementType().getJavaType()
			: attribute.getJavaType();
	}

	@SuppressWarnings("unchecked")
	private static  Long countReferencesTo(EntityManager entityManager, Attribute attribute, SingularAttribute idAttribute, I id) {
		CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
		CriteriaQuery query = criteriaBuilder.createQuery(Long.class);
		Root root = query.from(attribute.getDeclaringType().getJavaType());
		Join join;

		if (attribute instanceof SingularAttribute) {
			join = root.join((SingularAttribute) attribute);
		}
		else if (attribute instanceof ListAttribute) {
			join = root.join((ListAttribute) attribute);
		}
		else if (attribute instanceof SetAttribute) {
			join = root.join((SetAttribute) attribute);
		}
		else if (attribute instanceof MapAttribute) {
			join = root.join((MapAttribute) attribute);
		}
		else if (attribute instanceof CollectionAttribute) {
			join = root.join((CollectionAttribute) attribute);
		}
		else {
			return 0L; // Unknown attribute type, just return 0.
		}

		query.select(criteriaBuilder.count(root)).where(criteriaBuilder.equal(join.get(idAttribute), id));
		return entityManager.createQuery(query).getSingleResult();
	}


	// Criteria utils ---------------------------------------------------------------------------------------------------------------------

	/**
	 * Returns a SQL CONCAT(...) of given expressions or strings.
	 * @param builder The involved criteria builder.
	 * @param expressionsOrStrings Expressions or Strings.
	 * @return A SQL CONCAT(...) of given expressions or strings.
	 * @throws IllegalArgumentException When there are less than 2 expressions or strings. There's no point of concat then.
	 */
	public static Expression concat(CriteriaBuilder builder, Object... expressionsOrStrings) {
		if (expressionsOrStrings.length < 2) {
			throw new IllegalArgumentException("There must be at least 2 expressions or strings");
		}

		List> expressions = stream(expressionsOrStrings).map(expressionOrString -> {
			if (expressionOrString instanceof Expression) {
				return castAsString(builder, (Expression) expressionOrString);
			}
			else {
				return builder.literal(expressionOrString);
			}
		}).collect(toList());

		return builder.function("CONCAT", String.class, expressions.toArray(new Expression[expressions.size()]));
	}

	/**
	 * Returns a new expression wherein given expression is cast as String.
	 * This covers known problems with certain providers and/or databases.
	 * @param builder The involved criteria builder.
	 * @param expression Expression to be cast as String.
	 * @return A new expression wherein given expression is cast as String.
	 */
	@SuppressWarnings("unchecked")
	public static Expression castAsString(CriteriaBuilder builder, Expression expression) {
		if (Provider.is(HIBERNATE)) {
			return expression.as(String.class);
		}

		// EclipseLink and OpenJPA have a broken Expression#as() implementation, need to delegate to DB specific function.

		// PostgreSQL is quite strict in string casting, it has to be performed explicitly.
		if (Database.is(POSTGRESQL)) {
			String pattern = null;

			if (Numeric.is(expression.getJavaType())) {
				pattern = "FM999999999999999999"; // NOTE: Amount of digits matches amount of Long.MAX_VALUE digits minus one.
			}
			else if (LocalDate.class.isAssignableFrom(expression.getJavaType())) {
				pattern = "YYYY-MM-DD";
			}
			else if (LocalTime.class.isAssignableFrom(expression.getJavaType())) {
				pattern = "HH24:MI:SS";
			}
			else if (LocalDateTime.class.isAssignableFrom(expression.getJavaType())) {
				pattern = "YYYY-MM-DD'T'HH24:MI:SS'Z'"; // NOTE: PostgreSQL uses ISO_INSTANT instead of ISO_LOCAL_DATE_TIME.
			}
			else if (OffsetTime.class.isAssignableFrom(expression.getJavaType())) {
				pattern = "HH24:MI:SS-OF"; // TODO: PostgreSQL doc says it's not supported?
			}
			else if (OffsetDateTime.class.isAssignableFrom(expression.getJavaType())) {
				pattern = "YYYY-MM-DD'T'HH24:MI:SS-OF";
			}

			if (pattern != null) {
				return builder.function("TO_CHAR", String.class, expression, builder.literal(pattern));
			}
		}

		// H2 and MySQL are more lenient in this, they can do implicit casting with sane defaults, so no custom function call necessary.
		return (Expression) expression;
	}

	/**
	 * Returns whether given path is {@link Enumerated} by {@link EnumType#ORDINAL}.
	 * @param path Path of interest.
	 * @return Whether given path is {@link Enumerated} by {@link EnumType#ORDINAL}.
	 */
	public static boolean isEnumeratedByOrdinal(Path path) {
		Bindable model = path.getModel();

		if (model instanceof Attribute) {
			Member member = ((Attribute) model).getJavaMember();

			if (member instanceof AnnotatedElement) {
				Enumerated enumerated = ((AnnotatedElement) member).getAnnotation(Enumerated.class);

				if (enumerated != null) {
					return enumerated.value() == EnumType.ORDINAL;
				}
			}
		}

		return false;
	}

}