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}