001package ca.uhn.fhir.test.utilities.jpa; 002 003/*- 004 * #%L 005 * HAPI FHIR Test Utilities 006 * %% 007 * Copyright (C) 2014 - 2023 Smile CDR, Inc. 008 * %% 009 * Licensed under the Apache License, Version 2.0 (the "License"); 010 * you may not use this file except in compliance with the License. 011 * You may obtain a copy of the License at 012 * 013 * http://www.apache.org/licenses/LICENSE-2.0 014 * 015 * Unless required by applicable law or agreed to in writing, software 016 * distributed under the License is distributed on an "AS IS" BASIS, 017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 018 * See the License for the specific language governing permissions and 019 * limitations under the License. 020 * #L% 021 */ 022 023import ca.uhn.fhir.i18n.Msg; 024import ca.uhn.fhir.rest.api.Constants; 025import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 026import ca.uhn.fhir.util.ClasspathUtil; 027import com.google.common.base.Ascii; 028import com.google.common.collect.ImmutableSet; 029import com.google.common.collect.Lists; 030import com.google.common.collect.Sets; 031import com.google.common.reflect.ClassPath; 032import org.apache.commons.io.IOUtils; 033import org.apache.commons.lang3.StringUtils; 034import org.apache.commons.lang3.Validate; 035import org.hibernate.annotations.GenericGenerator; 036import org.hibernate.annotations.Subselect; 037import org.hibernate.validator.constraints.Length; 038 039import javax.persistence.Column; 040import javax.persistence.Embeddable; 041import javax.persistence.Embedded; 042import javax.persistence.EmbeddedId; 043import javax.persistence.Entity; 044import javax.persistence.ForeignKey; 045import javax.persistence.GeneratedValue; 046import javax.persistence.GenerationType; 047import javax.persistence.Id; 048import javax.persistence.Index; 049import javax.persistence.JoinColumn; 050import javax.persistence.Lob; 051import javax.persistence.OneToMany; 052import javax.persistence.OneToOne; 053import javax.persistence.SequenceGenerator; 054import javax.persistence.Table; 055import javax.persistence.Transient; 056import javax.persistence.UniqueConstraint; 057import javax.validation.constraints.Size; 058import java.io.IOException; 059import java.io.InputStream; 060import java.lang.reflect.AnnotatedElement; 061import java.lang.reflect.Field; 062import java.lang.reflect.Modifier; 063import java.util.ArrayList; 064import java.util.Arrays; 065import java.util.HashMap; 066import java.util.HashSet; 067import java.util.List; 068import java.util.Map; 069import java.util.Set; 070import java.util.stream.Collectors; 071 072import static org.apache.commons.lang3.StringUtils.isBlank; 073import static org.apache.commons.lang3.StringUtils.isNotBlank; 074 075/** 076 * This class is only used at build-time. It scans the various Hibernate entity classes 077 * and enforces various rules (appropriate table names, no duplicate names, etc.) 078 */ 079public class JpaModelScannerAndVerifier { 080 081 public static final int MAX_COL_LENGTH = 4000; 082 private static final int MAX_LENGTH = 30; 083 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(JpaModelScannerAndVerifier.class); 084 // Exceptions set because H2 sets indexes for FKs automatically so this index had to be called as the target FK field 085 // it is indexing to avoid SchemaMigrationTest to complain about the extra index (which doesn't exist in H2) 086 private static final Set<String> duplicateNameValidationExceptionList = Sets.newHashSet( 087 "FK_CONCEPTPROP_CONCEPT", 088 "FK_CONCEPTDESIG_CONCEPT", 089 "FK_TERM_CONCEPTPC_CHILD", 090 "FK_TERM_CONCEPTPC_PARENT", 091 "FK_TRM_VALUESET_CONCEPT_PID", 092 "FK_SEARCHINC_SEARCH" 093 ); 094 private static Set<String> ourReservedWords; 095 public JpaModelScannerAndVerifier() { 096 super(); 097 } 098 099 100 /** 101 * This is really only useful for unit tests, do not call otherwise 102 */ 103 @SuppressWarnings("UnstableApiUsage") 104 public void scanEntities(String... thePackageNames) throws IOException, ClassNotFoundException { 105 106 try (InputStream is = ClasspathUtil.loadResourceAsStream("/mysql-reserved-words.txt")) { 107 String contents = IOUtils.toString(is, Constants.CHARSET_UTF8); 108 String[] words = contents.split("\\n"); 109 ourReservedWords = Arrays.stream(words) 110 .filter(StringUtils::isNotBlank) 111 .map(Ascii::toUpperCase) 112 .collect(Collectors.toSet()); 113 } 114 115 for (String packageName : thePackageNames) { 116 ImmutableSet<ClassPath.ClassInfo> classes = ClassPath.from(JpaModelScannerAndVerifier.class.getClassLoader()).getTopLevelClassesRecursive(packageName); 117 Set<String> names = new HashSet<>(); 118 119 if (classes.size() <= 1) { 120 throw new InternalErrorException(Msg.code(1623) + "Found no classes"); 121 } 122 123 for (ClassPath.ClassInfo classInfo : classes) { 124 Class<?> clazz = Class.forName(classInfo.getName()); 125 Entity entity = clazz.getAnnotation(Entity.class); 126 Embeddable embeddable = clazz.getAnnotation(Embeddable.class); 127 if (entity == null && embeddable == null) { 128 continue; 129 } 130 131 scanClass(names, clazz); 132 133 } 134 } 135 } 136 137 private void scanClass(Set<String> theNames, Class<?> theClazz) { 138 Map<String, Integer> columnNameToLength = new HashMap<>(); 139 140 scanClassOrSuperclass(theNames, theClazz, false, columnNameToLength); 141 142 Table table = theClazz.getAnnotation(Table.class); 143 if (table != null) { 144 145 // This is the length for MySQL per https://dev.mysql.com/doc/refman/8.0/en/innodb-limits.html 146 // No idea why 3072. what a weird limit but I'm sure they have their reason. 147 int maxIndexLength = 3072; 148 149 for (UniqueConstraint nextIndex : table.uniqueConstraints()) { 150 int indexLength = calculateIndexLength(nextIndex.columnNames(), columnNameToLength, nextIndex.name()); 151 if (indexLength > maxIndexLength) { 152 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"); 153 } 154 } 155 156 } 157 158 } 159 160 private void scanClassOrSuperclass(Set<String> theNames, Class<?> theClazz, boolean theIsSuperClass, Map<String, Integer> columnNameToLength) { 161 ourLog.info("Scanning: {}", theClazz.getSimpleName()); 162 163 Subselect subselect = theClazz.getAnnotation(Subselect.class); 164 boolean isView = (subselect != null); 165 166 scan(theClazz, theNames, theIsSuperClass, isView); 167 168 boolean foundId = false; 169 for (Field nextField : theClazz.getDeclaredFields()) { 170 if (Modifier.isStatic(nextField.getModifiers())) { 171 continue; 172 } 173 174 ourLog.info(" * Scanning field: {}", nextField.getName()); 175 scan(nextField, theNames, theIsSuperClass, isView); 176 177 Id id = nextField.getAnnotation(Id.class); 178 if (id != null) { 179 Validate.isTrue(!foundId, "Multiple fields annotated with @Id"); 180 foundId = true; 181 182 if (Long.class.equals(nextField.getType())) { 183 184 GeneratedValue generatedValue = nextField.getAnnotation(GeneratedValue.class); 185 if (generatedValue != null) { 186 Validate.notBlank(generatedValue.generator(), "Field has no @GeneratedValue.generator(): %s", nextField); 187 assertNotADuplicateName(generatedValue.generator(), theNames); 188 assertEquals(generatedValue.strategy(), GenerationType.AUTO); 189 190 GenericGenerator genericGenerator = nextField.getAnnotation(GenericGenerator.class); 191 SequenceGenerator sequenceGenerator = nextField.getAnnotation(SequenceGenerator.class); 192 Validate.isTrue(sequenceGenerator != null ^ genericGenerator != null); 193 194 if (genericGenerator != null) { 195 assertEquals("ca.uhn.fhir.jpa.model.dialect.HapiSequenceStyleGenerator", genericGenerator.strategy()); 196 assertEquals(generatedValue.generator(), genericGenerator.name()); 197 } else { 198 Validate.notNull(sequenceGenerator); 199 assertEquals(generatedValue.generator(), sequenceGenerator.name()); 200 assertEquals(generatedValue.generator(), sequenceGenerator.sequenceName()); 201 } 202 } 203 } 204 205 } 206 207 boolean isTransient = nextField.getAnnotation(Transient.class) != null; 208 if (!isTransient) { 209 boolean hasColumn = nextField.getAnnotation(Column.class) != null; 210 boolean hasJoinColumn = nextField.getAnnotation(JoinColumn.class) != null; 211 boolean hasEmbeddedId = nextField.getAnnotation(EmbeddedId.class) != null; 212 boolean hasEmbedded = nextField.getAnnotation(Embedded.class) != null; 213 OneToMany oneToMany = nextField.getAnnotation(OneToMany.class); 214 OneToOne oneToOne = nextField.getAnnotation(OneToOne.class); 215 boolean isOtherSideOfOneToManyMapping = oneToMany != null && isNotBlank(oneToMany.mappedBy()); 216 boolean isOtherSideOfOneToOneMapping = oneToOne != null && isNotBlank(oneToOne.mappedBy()); 217 boolean isField = nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.FullTextField.class) != null; 218 isField |= nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.GenericField.class) != null; 219 isField |= nextField.getAnnotation(org.hibernate.search.mapper.pojo.mapping.definition.annotation.ScaledNumberField.class) != null; 220 Validate.isTrue( 221 hasEmbedded || 222 hasColumn || 223 hasJoinColumn || 224 isOtherSideOfOneToManyMapping || 225 isOtherSideOfOneToOneMapping || 226 hasEmbeddedId || 227 isField, "Non-transient has no @Column or @JoinColumn or @EmbeddedId: " + nextField); 228 229 int columnLength = 16; 230 String columnName = null; 231 if (hasColumn) { 232 columnName = nextField.getAnnotation(Column.class).name(); 233 columnLength = nextField.getAnnotation(Column.class).length(); 234 } 235 if (hasJoinColumn) { 236 columnName = nextField.getAnnotation(JoinColumn.class).name(); 237 } 238 239 if (columnName != null) { 240 if (nextField.getType().isAssignableFrom(String.class)) { 241 // MySQL treats each char as the max possible byte count in UTF-8 for its calculations 242 columnLength = columnLength * 4; 243 } 244 245 columnNameToLength.put(columnName, columnLength); 246 } 247 248 } 249 250 251 } 252 253 for (Class<?> innerClass : theClazz.getDeclaredClasses()) { 254 Embeddable embeddable = innerClass.getAnnotation(Embeddable.class); 255 if (embeddable != null) { 256 scanClassOrSuperclass(theNames, innerClass, false, columnNameToLength); 257 } 258 259 } 260 261 if (theClazz.getSuperclass().equals(Object.class)) { 262 return; 263 } 264 265 scanClassOrSuperclass(theNames, theClazz.getSuperclass(), true, columnNameToLength); 266 } 267 268 private void scan(AnnotatedElement theAnnotatedElement, Set<String> theNames, boolean theIsSuperClass, boolean theIsView) { 269 Table table = theAnnotatedElement.getAnnotation(Table.class); 270 if (table != null) { 271 272 // Banned name because we already used it once 273 ArrayList<String> bannedNames = Lists.newArrayList("CDR_USER_2FA", "TRM_VALUESET_CODE"); 274 Validate.isTrue(!bannedNames.contains(table.name().toUpperCase())); 275 276 Validate.isTrue(table.name().toUpperCase().equals(table.name())); 277 278 assertNotADuplicateName(table.name(), theNames); 279 for (UniqueConstraint nextConstraint : table.uniqueConstraints()) { 280 assertNotADuplicateName(nextConstraint.name(), theNames); 281 Validate.isTrue(nextConstraint.name().startsWith("IDX_"), nextConstraint.name() + " must start with IDX_"); 282 } 283 for (Index nextConstraint : table.indexes()) { 284 assertNotADuplicateName(nextConstraint.name(), theNames); 285 Validate.isTrue(nextConstraint.name().startsWith("IDX_") || nextConstraint.name().startsWith("FK_"), 286 nextConstraint.name() + " must start with IDX_ or FK_ (last one when indexing a FK column)"); 287 } 288 } 289 290 JoinColumn joinColumn = theAnnotatedElement.getAnnotation(JoinColumn.class); 291 if (joinColumn != null) { 292 String columnName = joinColumn.name(); 293 validateColumnName(columnName, theAnnotatedElement); 294 295 assertNotADuplicateName(columnName, null); 296 ForeignKey fk = joinColumn.foreignKey(); 297 if (theIsSuperClass) { 298 Validate.isTrue(isBlank(fk.name()), "Foreign key on " + theAnnotatedElement + " has a name() and should not as it is a superclass"); 299 } else { 300 Validate.notNull(fk); 301 Validate.isTrue(isNotBlank(fk.name()), "Foreign key on " + theAnnotatedElement + " has no name()"); 302 303 // Validate FK naming. 304 // temporarily allow two hibernate legacy sp fk names until we fix them 305 List<String> legacySPHibernateFKNames = Arrays.asList( 306 "FKC97MPK37OKWU8QVTCEG2NH9VN", "FKGXSREUTYMMFJUWDSWV3Y887DO"); 307 Validate.isTrue(fk.name().startsWith("FK_") || legacySPHibernateFKNames.contains(fk.name()), 308 "Foreign key " + fk.name() + " on " + theAnnotatedElement + " must start with FK_"); 309 310 if (!duplicateNameValidationExceptionList.contains(fk.name())) { 311 assertNotADuplicateName(fk.name(), theNames); 312 } 313 } 314 } 315 316 Column column = theAnnotatedElement.getAnnotation(Column.class); 317 if (column != null) { 318 String columnName = column.name(); 319 validateColumnName(columnName, theAnnotatedElement); 320 321 assertNotADuplicateName(columnName, null); 322 Validate.isTrue(column.unique() == false, "Should not use unique attribute on column (use named @UniqueConstraint instead) on " + theAnnotatedElement); 323 324 boolean hasLob = theAnnotatedElement.getAnnotation(Lob.class) != null; 325 Field field = (Field) theAnnotatedElement; 326 327 /* 328 * For string columns, we want to make sure that an explicit max 329 * length is always specified, and that this max is always sensible. 330 * Unfortunately there is no way to differentiate between "explicitly 331 * set to 255" and "just using the default of 255" so we have banned 332 * the exact length of 255. 333 */ 334 if (field.getType().equals(String.class)) { 335 if (!hasLob) { 336 if (!theIsView && column.length() == 255) { 337 throw new IllegalStateException(Msg.code(1626) + "Field does not have an explicit maximum length specified: " + field); 338 } 339 if (column.length() > MAX_COL_LENGTH) { 340 throw new IllegalStateException(Msg.code(1627) + "Field is too long: " + field); 341 } 342 } 343 344 Size size = theAnnotatedElement.getAnnotation(Size.class); 345 if (size != null) { 346 if (size.max() > MAX_COL_LENGTH) { 347 throw new IllegalStateException(Msg.code(1628) + "Field is too long: " + field); 348 } 349 } 350 351 Length length = theAnnotatedElement.getAnnotation(Length.class); 352 if (length != null) { 353 if (length.max() > MAX_COL_LENGTH) { 354 throw new IllegalStateException(Msg.code(1629) + "Field is too long: " + field); 355 } 356 } 357 } 358 359 } 360 361 } 362 363 private void validateColumnName(String theColumnName, AnnotatedElement theElement) { 364 if (!theColumnName.equals(theColumnName.toUpperCase())) { 365 throw new IllegalArgumentException(Msg.code(1630) + "Column name must be all upper case: " + theColumnName + " found on " + theElement); 366 } 367 if (ourReservedWords.contains(theColumnName)) { 368 throw new IllegalArgumentException(Msg.code(1631) + "Column name is a reserved word: " + theColumnName + " found on " + theElement); 369 } 370 if (theColumnName.startsWith("_")) { 371 throw new IllegalArgumentException(Msg.code(2272) + "Column name "+ theColumnName +" starts with an '_' (underscore). This is not permitted for oracle field names. Found on " + theElement); 372 } 373 } 374 375 private static int calculateIndexLength(String[] theColumnNames, Map<String, Integer> theColumnNameToLength, String theIndexName) { 376 int retVal = 0; 377 for (String nextName : theColumnNames) { 378 Integer nextLength = theColumnNameToLength.get(nextName); 379 if (nextLength == null) { 380 throw new IllegalStateException(Msg.code(1625) + "Index '" + theIndexName + "' references unknown column: " + nextName); 381 } 382 retVal += nextLength; 383 } 384 return retVal; 385 } 386 387 private static void assertEquals(Object theGenerator, Object theName) { 388 Validate.isTrue(theGenerator.equals(theName)); 389 } 390 391 private static void assertNotADuplicateName(String theName, Set<String> theNames) { 392 if (isBlank(theName)) { 393 return; 394 } 395 Validate.isTrue(theName.length() <= MAX_LENGTH, "Identifier \"" + theName + "\" is " + theName.length() + " chars long"); 396 if (theNames != null) { 397 Validate.isTrue(theNames.add(theName), "Duplicate name: " + theName); 398 } 399 } 400 401}