ca.uhn.fhir.test.utilities.jpa.JpaModelScannerAndVerifier Maven / Gradle / Ivy
/*-
* #%L
* HAPI FHIR Test Utilities
* %%
* Copyright (C) 2014 - 2024 Smile CDR, Inc.
* %%
* 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
*
* http://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.
* #L%
*/
package ca.uhn.fhir.test.utilities.jpa;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.util.ClasspathUtil;
import com.google.common.base.Ascii;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.reflect.ClassPath;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.Embedded;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.SequenceGenerator;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.Size;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.Subselect;
import org.hibernate.validator.constraints.Length;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.assertj.core.api.Assertions.assertThat;
/**
* This class is only used at build-time. It scans the various Hibernate entity classes
* and enforces various rules (appropriate table names, no duplicate names, etc.)
*/
public class JpaModelScannerAndVerifier {
public static final int MAX_COL_LENGTH = 4000;
private static final int MAX_LENGTH = 30;
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(JpaModelScannerAndVerifier.class);
/**
* We will keep track of all the index names and the columns they
* refer to.
* ---
* H2 automatically adds Indexes to ForeignKey constraints.
* This *does not happen* in other databases.
* ---
* But because we should be indexing foreign keys, we have to explicitly
* add an index to a foreign key.
* But if we give it a new name, SchemaMigrationTest will complain about an extra
* index that doesn't exist in H2.
* ---
* tl;dr
* Due to the quirks of supported DBs, we must have index names that duplicate their
* foreign key constraint names.
* So we'll be keeping a list of them here.
*/
private static final Multimap ourIndexNameToColumn = HashMultimap.create();
private static Set ourReservedWords;
public JpaModelScannerAndVerifier() {
super();
}
/**
* This is really only useful for unit tests, do not call otherwise
*/
@SuppressWarnings("UnstableApiUsage")
public void scanEntities(String... thePackageNames) throws IOException, ClassNotFoundException {
try (InputStream is = ClasspathUtil.loadResourceAsStream("/mysql-reserved-words.txt")) {
String contents = IOUtils.toString(is, Constants.CHARSET_UTF8);
String[] words = contents.split("\\n");
ourReservedWords = Arrays.stream(words)
.filter(StringUtils::isNotBlank)
.map(Ascii::toUpperCase)
.collect(Collectors.toSet());
}
for (String packageName : thePackageNames) {
ImmutableSet classes = ClassPath.from(JpaModelScannerAndVerifier.class.getClassLoader()).getTopLevelClassesRecursive(packageName);
Set names = new HashSet<>();
if (classes.size() <= 1) {
throw new InternalErrorException(Msg.code(1623) + "Found no classes");
}
for (ClassPath.ClassInfo classInfo : classes) {
Class> clazz = Class.forName(classInfo.getName());
Entity entity = clazz.getAnnotation(Entity.class);
Embeddable embeddable = clazz.getAnnotation(Embeddable.class);
if (entity == null && embeddable == null) {
continue;
}
scanClass(names, clazz);
}
}
}
private void scanClass(Set theNames, Class> theClazz) {
Map columnNameToLength = new HashMap<>();
scanClassOrSuperclass(theNames, theClazz, false, columnNameToLength);
Table table = theClazz.getAnnotation(Table.class);
if (table != null) {
// This is the length for MySQL per https://dev.mysql.com/doc/refman/8.0/en/innodb-limits.html
// No idea why 3072. what a weird limit but I'm sure they have their reason.
int maxIndexLength = 3072;
for (UniqueConstraint nextIndex : table.uniqueConstraints()) {
int indexLength = calculateIndexLength(nextIndex.columnNames(), columnNameToLength, nextIndex.name());
if (indexLength > maxIndexLength) {
throw new IllegalStateException(Msg.code(1624) + "Index '" + nextIndex.name() + "' is too long. Length is " + indexLength + " and must not exceed " + maxIndexLength + " which is the maximum MySQL length");
}
}
}
}
private void scanClassOrSuperclass(Set theNames, Class> theClazz, boolean theIsSuperClass, Map columnNameToLength) {
ourLog.info("Scanning: {}", theClazz.getSimpleName());
Subselect subselect = theClazz.getAnnotation(Subselect.class);
boolean isView = (subselect != null);
scan(theClazz, theNames, theIsSuperClass, isView);
boolean foundId = false;
for (Field nextField : theClazz.getDeclaredFields()) {
if (Modifier.isStatic(nextField.getModifiers())) {
continue;
}
ourLog.info(" * Scanning field: {}", nextField.getName());
scan(nextField, theNames, theIsSuperClass, isView);
Id id = nextField.getAnnotation(Id.class);
boolean isId = id != null;
if (isId) {
Validate.isTrue(!foundId, "Multiple fields annotated with @Id");
foundId = true;
if (Long.class.equals(nextField.getType())) {
GeneratedValue generatedValue = nextField.getAnnotation(GeneratedValue.class);
if (generatedValue != null) {
Validate.notBlank(generatedValue.generator(), "Field has no @GeneratedValue.generator(): %s", nextField);
assertNotADuplicateName(generatedValue.generator(), theNames);
assertEqualsForIdGenerator(nextField, generatedValue.strategy(), GenerationType.AUTO);
GenericGenerator genericGenerator = nextField.getAnnotation(GenericGenerator.class);
SequenceGenerator sequenceGenerator = nextField.getAnnotation(SequenceGenerator.class);
Validate.isTrue(sequenceGenerator != null ^ genericGenerator != null);
if (genericGenerator != null) {
assertEqualsForIdGenerator(nextField, "ca.uhn.fhir.jpa.model.dialect.HapiSequenceStyleGenerator", genericGenerator.type().getName());
assertEqualsForIdGenerator(nextField, "native", genericGenerator.strategy());
assertEqualsForIdGenerator(nextField, generatedValue.generator(), genericGenerator.name());
} else {
Validate.notNull(sequenceGenerator);
assertEqualsForIdGenerator(nextField, generatedValue.generator(), sequenceGenerator.name());
assertEqualsForIdGenerator(nextField, generatedValue.generator(), sequenceGenerator.sequenceName());
}
}
}
}
boolean isTransient = nextField.getAnnotation(Transient.class) != null;
if (!isTransient) {
boolean hasColumn = nextField.getAnnotation(Column.class) != null;
boolean hasJoinColumn = nextField.getAnnotation(JoinColumn.class) != null;
boolean hasEmbeddedId = nextField.getAnnotation(EmbeddedId.class) != null;
boolean hasEmbedded = nextField.getAnnotation(Embedded.class) != null;
OneToMany oneToMany = nextField.getAnnotation(OneToMany.class);
OneToOne oneToOne = nextField.getAnnotation(OneToOne.class);
boolean isOtherSideOfOneToManyMapping = oneToMany != null && isNotBlank(oneToMany.mappedBy());
boolean isOtherSideOfOneToOneMapping = oneToOne != null && isNotBlank(oneToOne.mappedBy());
boolean isField = nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField.class) != null;
isField |= nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField.class) != null;
isField |= nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.ScaledNumberField.class) != null;
Validate.isTrue(
hasEmbedded ||
hasColumn ||
hasJoinColumn ||
isOtherSideOfOneToManyMapping ||
isOtherSideOfOneToOneMapping ||
hasEmbeddedId ||
isField, "Non-transient has no @Column or @JoinColumn or @EmbeddedId: " + nextField);
int columnLength = 16;
String columnName = null;
boolean nullable = false;
if (hasColumn) {
Column column = nextField.getAnnotation(Column.class);
columnName = column.name();
columnLength = column.length();
nullable = column.nullable();
}
if (hasJoinColumn) {
JoinColumn joinColumn = nextField.getAnnotation(JoinColumn.class);
columnName = joinColumn.name();
nullable = joinColumn.nullable();
}
if (isId) {
nullable = false;
}
if (nullable && !isView) {
Validate.isTrue(!nextField.getType().isPrimitive(), "Field [%s] has a nullable primitive type: %s", nextField.getName(), nextField.getType());
}
if (columnName != null) {
if (nextField.getType().isAssignableFrom(String.class)) {
// MySQL treats each char as the max possible byte count in UTF-8 for its calculations
columnLength = columnLength * 4;
}
columnNameToLength.put(columnName, columnLength);
}
}
}
for (Class> innerClass : theClazz.getDeclaredClasses()) {
Embeddable embeddable = innerClass.getAnnotation(Embeddable.class);
if (embeddable != null) {
scanClassOrSuperclass(theNames, innerClass, false, columnNameToLength);
}
}
if (theClazz.getSuperclass().equals(Object.class)) {
return;
}
scanClassOrSuperclass(theNames, theClazz.getSuperclass(), true, columnNameToLength);
}
private void scan(AnnotatedElement theAnnotatedElement, Set theNames, boolean theIsSuperClass, boolean theIsView) {
Table table = theAnnotatedElement.getAnnotation(Table.class);
if (table != null) {
// Banned name because we already used it once
ArrayList bannedNames = Lists.newArrayList("CDR_USER_2FA", "TRM_VALUESET_CODE");
Validate.isTrue(!bannedNames.contains(table.name().toUpperCase()));
Validate.isTrue(table.name().toUpperCase().equals(table.name()));
assertNotADuplicateName(table.name(), theNames);
for (UniqueConstraint nextConstraint : table.uniqueConstraints()) {
assertNotADuplicateName(nextConstraint.name(), theNames);
Validate.isTrue(nextConstraint.name().startsWith("IDX_"), nextConstraint.name() + " must start with IDX_");
}
for (Index nextConstraint : table.indexes()) {
assertNotADuplicateName(nextConstraint.name(), theNames);
Validate.isTrue(nextConstraint.name().startsWith("IDX_") || nextConstraint.name().startsWith("FK_"),
nextConstraint.name() + " must start with IDX_ or FK_ (last one when indexing a FK column)");
// add the index names to the collection of allowable duplicate fk names
String[] cols = nextConstraint.columnList().split(",");
for (String col : cols) {
ourIndexNameToColumn.put(nextConstraint.name(), col);
}
}
}
JoinColumn joinColumn = theAnnotatedElement.getAnnotation(JoinColumn.class);
if (joinColumn != null) {
String columnName = joinColumn.name();
validateColumnName(columnName, theAnnotatedElement);
assertNotADuplicateName(columnName, null);
ForeignKey fk = joinColumn.foreignKey();
if (theIsSuperClass) {
Validate.isTrue(isBlank(fk.name()), "Foreign key on " + theAnnotatedElement + " has a name() and should not as it is a superclass");
} else {
Validate.notNull(fk);
Validate.isTrue(isNotBlank(fk.name()), "Foreign key on " + theAnnotatedElement + " has no name()");
// Validate FK naming.
// temporarily allow two hibernate legacy sp fk names until we fix them
List legacySPHibernateFKNames = Arrays.asList(
"FKC97MPK37OKWU8QVTCEG2NH9VN", "FKGXSREUTYMMFJUWDSWV3Y887DO");
Validate.isTrue(fk.name().startsWith("FK_") || legacySPHibernateFKNames.contains(fk.name()),
"Foreign key " + fk.name() + " on " + theAnnotatedElement + " must start with FK_");
if (ourIndexNameToColumn.containsKey(fk.name())) {
// this foreign key has the same name as an existing index
// let's make sure it's on the same column
Collection columns = ourIndexNameToColumn.get(fk.name());
assertThat(columns.contains(columnName)).as(String.format("Foreign key %s duplicates index name, but column %s is not part of the index!", fk.name(), columnName)).isTrue();
} else {
// verify it's not a duplicate
assertNotADuplicateName(fk.name(), theNames);
}
}
}
Column column = theAnnotatedElement.getAnnotation(Column.class);
if (column != null) {
String columnName = column.name();
validateColumnName(columnName, theAnnotatedElement);
assertNotADuplicateName(columnName, null);
Validate.isTrue(column.unique() == false, "Should not use unique attribute on column (use named @UniqueConstraint instead) on " + theAnnotatedElement);
boolean hasLob = theAnnotatedElement.getAnnotation(Lob.class) != null;
Field field = (Field) theAnnotatedElement;
/*
* For string columns, we want to make sure that an explicit max
* length is always specified, and that this max is always sensible.
* Unfortunately there is no way to differentiate between "explicitly
* set to 255" and "just using the default of 255" so we have banned
* the exact length of 255.
*/
if (field.getType().equals(String.class)) {
if (!hasLob) {
if (!theIsView && column.length() == 255) {
throw new IllegalStateException(Msg.code(1626) + "Field does not have an explicit maximum length specified: " + field);
}
}
Size size = theAnnotatedElement.getAnnotation(Size.class);
if (size != null) {
if (size.max() > MAX_COL_LENGTH) {
throw new IllegalStateException(Msg.code(1628) + "Field is too long: " + field);
}
}
Length length = theAnnotatedElement.getAnnotation(Length.class);
if (length != null) {
if (length.max() > MAX_COL_LENGTH) {
throw new IllegalStateException(Msg.code(1629) + "Field is too long: " + field);
}
}
}
}
}
private void validateColumnName(String theColumnName, AnnotatedElement theElement) {
if (!theColumnName.equals(theColumnName.toUpperCase())) {
throw new IllegalArgumentException(Msg.code(1630) + "Column name must be all upper case: " + theColumnName + " found on " + theElement);
}
if (ourReservedWords.contains(theColumnName)) {
throw new IllegalArgumentException(Msg.code(1631) + "Column name is a reserved word: " + theColumnName + " found on " + theElement);
}
if (theColumnName.startsWith("_")) {
throw new IllegalArgumentException(Msg.code(2272) + "Column name "+ theColumnName +" starts with an '_' (underscore). This is not permitted for oracle field names. Found on " + theElement);
}
}
private static int calculateIndexLength(String[] theColumnNames, Map theColumnNameToLength, String theIndexName) {
int retVal = 0;
for (String nextName : theColumnNames) {
Integer nextLength = theColumnNameToLength.get(nextName);
if (nextLength == null) {
throw new IllegalStateException(Msg.code(1625) + "Index '" + theIndexName + "' references unknown column: " + nextName);
}
retVal += nextLength;
}
return retVal;
}
private static void assertEqualsForIdGenerator(Field theSource, Object theExpectedGenerator, Object theActualGenerator) {
Validate.isTrue(theExpectedGenerator.equals(theActualGenerator), "Value " + theActualGenerator + " doesn't match expected " + theExpectedGenerator + " for ID generator on " + theSource);
}
private static void assertNotADuplicateName(String theName, Set theNames) {
if (isBlank(theName)) {
return;
}
Validate.isTrue(theName.length() <= MAX_LENGTH, "Identifier \"" + theName + "\" is " + theName.length() + " chars long");
if (theNames != null) {
Validate.isTrue(theNames.add(theName), "Duplicate name: " + theName);
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy