diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesBuildItem.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesBuildItem.java new file mode 100644 index 0000000000000..e53b22895944c --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesBuildItem.java @@ -0,0 +1,29 @@ +package io.quarkus.deployment.dev; + +import java.util.Map; +import java.util.Set; + +import org.jboss.jandex.DotName; + +import io.quarkus.builder.item.MultiBuildItem; + +public final class AnnotationDependentClassesBuildItem extends MultiBuildItem { + + private final DotName annotationName; + + private final Map> dependencyToAnnotatedClasses; + + public AnnotationDependentClassesBuildItem(DotName annotationName, + Map> dependencyToAnnotatedClasses) { + this.annotationName = annotationName; + this.dependencyToAnnotatedClasses = dependencyToAnnotatedClasses; + } + + public DotName getAnnotationName() { + return annotationName; + } + + public Map> getDependencyToAnnotatedClasses() { + return dependencyToAnnotatedClasses; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesConfig.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesConfig.java new file mode 100644 index 0000000000000..6810bd3d2f4ec --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesConfig.java @@ -0,0 +1,19 @@ +package io.quarkus.deployment.dev; + +import java.util.Optional; +import java.util.Set; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; + +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +@ConfigMapping(prefix = "quarkus.dev") +public interface AnnotationDependentClassesConfig { + + /** + * FQDNs of annotations that trigger automatic recompilation of annotated classes when their dependencies change + * during dev mode. This is useful for annotation processors that generate code based on these classes (e.g. Mapstruct). + */ + Optional> recompileAnnotations(); +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessor.java new file mode 100644 index 0000000000000..a037a56f89261 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessor.java @@ -0,0 +1,335 @@ +package io.quarkus.deployment.dev; + +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.FieldInfo; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.Type; +import org.jboss.jandex.TypeVariable; +import org.jboss.jandex.WildcardType; +import org.jboss.logging.Logger; + +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Produce; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; + +/** + * Processor which figures out annotation marked recompilation dependencies. This is needed to solve the problem of "how to + * recompile classes generated by annotation processor, where the generated class accesses another type in the users code". + *

+ * Only classes annotated with an annotation configured in {@link AnnotationDependentClassesConfig} are discovered. + *

+ * From these annotated classes, the set of all directly referenced types is collected. This includes all fields, method return + * types, and method parameters type of any visibility of the full class hierarchy. + *

+ * Then based on the set of directly referenced types, another set of indirectly referenced types is discovered. This includes + * all the public, protected, and package private fields, method return types, and method parameters types of the current type + * and upwards in the hierarchy. Package private and protected (which is also package private) member are only included, if they + * are declared on a class in the same package as the annotated class. This step is addition repeated for each additional public + * referenced type, until every direct or public type was visited once. + *

+ * Result is a Mapping of the referenced type to the annotated class. Note that no chaining information between the referenced + * types is kept. Since for the recompilation to take place we just need to resolve later on from the dependency to the + * annotated class. This discovery is repeated for all the configured annotations. + *

+ * + * A consolidation step takes place, which resolves inner classes to their outer classes. Inner classes and outer classes share + * source file (at least in java). For our goal of recompilation we therefore only need to outer class. + *

+ * After resolving outer classes, all the mappings of referenced type to annotated class are combined into one mapping, which is + * then given to the {@link RuntimeUpdatesProcessor#setRecompilationDependencies(Map)}. + */ +public class AnnotationDependentClassesProcessor { + + private static final Logger LOGGER = Logger.getLogger(AnnotationDependentClassesProcessor.class); + + @BuildStep + public List discoverAnnotationDependentClasses(AnnotationDependentClassesConfig config, + CombinedIndexBuildItem combinedIndexBuildItem) { + + if (config.recompileAnnotations().isEmpty()) { + return Collections.emptyList(); + } + + // Sometimes the annotation itself is not in a jandex index, but that is fine as long as we can find it + Set recompileAnnotationNames = resolveConfiguredAnnotationNames(config.recompileAnnotations().get(), + combinedIndexBuildItem.getComputingIndex()); + + List result = new ArrayList<>(); + for (DotName recompileAnnotationName : recompileAnnotationNames) { + // the classes the annotation is applied on have to be in our index though. + + AnnotationDependentClassesBuildItem annotationDependentClassesBuildItem = determineDependencies( + combinedIndexBuildItem.getIndex(), recompileAnnotationName); + + if (annotationDependentClassesBuildItem != null) { + result.add(annotationDependentClassesBuildItem); + } + } + + return result; + } + + private Set resolveConfiguredAnnotationNames(Set recompileAnnotations, IndexView index) { + // quick check and warn if the annotation type even exists + // warning is enough, we just optionally want to determine additional classes to recompile based on the configured recompileAnnotations + Set result = new HashSet<>(); + for (String recompileAnnotation : recompileAnnotations) { + + ClassInfo classByName = index.getClassByName(recompileAnnotation); + if (classByName == null) { + LOGGER.warnf(""" + Configured recompile annotation type %s not found.\ + Won't automatically recompile annotated classes when dependent classes change.""", + recompileAnnotation); + continue; + } + + if (!classByName.isAnnotation()) { + LOGGER.warnf(""" + Configured recompile annotation type %s is not an annotation class.""", + recompileAnnotation); + continue; + } + + result.add(classByName.name()); + } + + return result; + } + + private AnnotationDependentClassesBuildItem determineDependencies(IndexView index, DotName supportedAnnotationName) { + Collection annotations = index.getAnnotations(supportedAnnotationName); + Map> dependencyToAnnotatedClasses = new HashMap<>(); + for (AnnotationInstance annotation : annotations) { + ClassInfo annotatedClass; + try { + annotatedClass = annotation.target().asClass(); + } catch (Exception exception) { + LOGGER.warnf(exception, "Annotation %s is not placed on a class. Target %s instead. Skipping.", + supportedAnnotationName, annotation.target()); + continue; + } + + Set referencedTypes = collectAllReferencedTypeNames(annotatedClass, index); + + Set dependencies = new HashSet<>(); + for (DotName referencedType : referencedTypes) { + collectVisibleTypeNames(dependencies, referencedType, index, annotatedClass.name()); + } + + for (DotName dependency : dependencies) { + dependencyToAnnotatedClasses.computeIfAbsent(dependency, k -> new HashSet<>()).add(annotatedClass.name()); + } + } + + if (dependencyToAnnotatedClasses.isEmpty()) { + return null; + } + + return new AnnotationDependentClassesBuildItem(supportedAnnotationName, dependencyToAnnotatedClasses); + } + + private Set collectAllReferencedTypeNames(ClassInfo startingClass, IndexView index) { + Set visited = new HashSet<>(); + ArrayDeque stack = new ArrayDeque<>(); + stack.add(startingClass.name()); + + Set referencedTypeNames = new HashSet<>(); + while (!stack.isEmpty()) { + DotName currentClassName = stack.poll(); + if (!visited.add(currentClassName)) { + continue; + } + if (currentClassName.equals(DotName.OBJECT_NAME)) { + continue; + } + + ClassInfo classInfo = index.getClassByName(currentClassName); + if (classInfo == null) { + continue; + } + + // search up and down the inheritance chain + stack.add(classInfo.superClassType().name()); + stack.addAll(classInfo.interfaceNames()); + + for (ClassInfo knownDirectSubclass : index.getKnownDirectSubclasses(currentClassName)) { + stack.add(knownDirectSubclass.name()); + } + for (ClassInfo knownDirectImplementation : index.getKnownDirectImplementations(currentClassName)) { + stack.add(knownDirectImplementation.name()); + } + + // Collect types of any fields in the inheritance chain of the annotated class + for (FieldInfo field : classInfo.fields()) { + if (!field.isSynthetic()) { + extractTypeNames(field.type(), referencedTypeNames); + } + } + + // Collect types of any methods in the inheritance chain of the annotated class + for (MethodInfo method : classInfo.methods()) { + if (!method.isSynthetic()) { + extractTypeNames(method.returnType(), referencedTypeNames); + for (MethodParameterInfo parameter : method.parameters()) { + extractTypeNames(parameter.type(), referencedTypeNames); + } + } + } + } + + return referencedTypeNames; + } + + private void extractTypeNames(Type type, Collection names) { + switch (type.kind()) { + case CLASS -> names.add(type.name()); + case PARAMETERIZED_TYPE -> { + names.add(type.name()); + ParameterizedType parameterizedType = type.asParameterizedType(); + for (Type argument : parameterizedType.arguments()) { + // useful for nested generics, e.g. Map> + extractTypeNames(argument, names); + } + } + case WILDCARD_TYPE -> { + names.add(type.name()); + WildcardType wildcardType = type.asWildcardType(); + if (!ClassType.OBJECT_TYPE.equals(wildcardType.extendsBound())) { + extractTypeNames(wildcardType.extendsBound(), names); + } else if (wildcardType.superBound() != null) { + extractTypeNames(wildcardType.superBound(), names); + } + } + case TYPE_VARIABLE -> { + names.add(type.name()); + TypeVariable typeVariable = type.asTypeVariable(); + for (Type bound : typeVariable.bounds()) { + extractTypeNames(bound, names); + } + } + case ARRAY -> extractTypeNames(type.asArrayType().constituent(), names); + } + } + + private void collectVisibleTypeNames(Set collectedTypes, DotName startingPoint, IndexView index, + DotName annotatedClassName) { + ArrayDeque stack = new ArrayDeque<>(); + stack.add(startingPoint); + + while (!stack.isEmpty()) { + DotName currentClassName = stack.poll(); + if (!collectedTypes.add(currentClassName)) { + // already know about this property type + continue; + } + if (currentClassName.equals(DotName.OBJECT_NAME)) { + continue; + } + + ClassInfo classInfo = index.getClassByName(currentClassName); + if (classInfo == null) { + continue; + } + + // only search upwards. The annotated class should only contain references to the public types it can see, i.e. our own and our parents public types + stack.add(classInfo.superClassType().name()); + stack.addAll(classInfo.interfaceNames()); + + for (FieldInfo field : classInfo.fields()) { + if (isVisibleForAnnotatedClass(field.flags(), classInfo, annotatedClassName) && !field.isSynthetic()) { + extractTypeNames(field.type(), stack); + } + } + + for (MethodInfo method : classInfo.methods()) { + if (isVisibleForAnnotatedClass(method.flags(), classInfo, annotatedClassName) && !method.isSynthetic()) { + extractTypeNames(method.returnType(), stack); + for (MethodParameterInfo parameter : method.parameters()) { + extractTypeNames(parameter.type(), stack); + } + } + } + } + } + + private boolean isVisibleForAnnotatedClass(short flags, ClassInfo declaringClass, DotName annotatedClassName) { + if (Modifier.isPublic(flags)) { + return true; + } + + boolean isProtected = Modifier.isProtected(flags); + boolean isPackagePrivate = !isProtected && !Modifier.isPrivate(flags); + if (isProtected || isPackagePrivate) { + return declaringClass.name().packagePrefix().equals(annotatedClassName.packagePrefix()); + } + + return false; + } + + @BuildStep + @Produce(ServiceStartBuildItem.class) + public void consolidateRecompilationDependencies(CombinedIndexBuildItem combinedIndexBuildItem, + List annotationDependentClassesBuildItems) { + + // Cleanup and combine all the dependencyToAnnotatedClasses maps: + // - Resolve inner classes to their top-level class names + // - Remove entries where the class is not in the index + + Map> dependencyToAnnotatedClasses = new HashMap<>(); + for (AnnotationDependentClassesBuildItem buildItem : annotationDependentClassesBuildItems) { + for (Map.Entry> entry : buildItem.getDependencyToAnnotatedClasses().entrySet()) { + + DotName dependency = resolveOutermostClassName(entry.getKey(), combinedIndexBuildItem.getIndex()); + if (dependency == null) { + continue; + } + + for (DotName annotatedClass : entry.getValue()) { + annotatedClass = resolveOutermostClassName(annotatedClass, combinedIndexBuildItem.getIndex()); + if (annotatedClass == null) { + continue; + } + + dependencyToAnnotatedClasses.computeIfAbsent(dependency, k -> new HashSet<>()).add(annotatedClass); + } + } + } + + if (RuntimeUpdatesProcessor.INSTANCE != null) { + RuntimeUpdatesProcessor.INSTANCE.setRecompilationDependencies(dependencyToAnnotatedClasses); + } + } + + private DotName resolveOutermostClassName(DotName name, IndexView index) { + + ClassInfo classInfo = index.getClassByName(name); + if (classInfo == null) { + return null; + } + + if (classInfo.nestingType() != ClassInfo.NestingType.TOP_LEVEL) { + return resolveOutermostClassName(classInfo.enclosingClassAlways(), index); + } + + return name; + } +} diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java index c4ba5228fcb18..3e8b0169976b4 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/RuntimeUpdatesProcessor.java @@ -2,7 +2,6 @@ import static java.util.Arrays.asList; import static java.util.Collections.singletonList; -import static java.util.stream.Collectors.groupingBy; import java.io.ByteArrayInputStream; import java.io.Closeable; @@ -21,9 +20,11 @@ import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -52,6 +53,7 @@ import java.util.stream.Stream; import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; import org.jboss.jandex.Index; import org.jboss.jandex.IndexView; import org.jboss.jandex.Indexer; @@ -104,6 +106,7 @@ public class RuntimeUpdatesProcessor implements HotReplacementContext, Closeable private final TimestampSet main = new TimestampSet(); private final TimestampSet test = new TimestampSet(); final Map sourceFileTimestamps = new ConcurrentHashMap<>(); + private Map> recompilationDependencies = new HashMap<>(); private final List preScanSteps = new CopyOnWriteArrayList<>(); private final List postRestartSteps = new CopyOnWriteArrayList<>(); @@ -709,6 +712,24 @@ ClassScanResult checkForChangedTestClasses(boolean firstScan) { return ret; } + private void collectRecompilationTargets(DotName changedDependency, Set annotatedClassesToRecompile) { + Deque toResolve = new ArrayDeque<>(); + toResolve.add(changedDependency); + while (!toResolve.isEmpty()) { + DotName currentDependency = toResolve.poll(); + + Set annotatedClasses = recompilationDependencies.get(currentDependency); + + if (annotatedClasses != null) { + for (DotName annotatedClass : annotatedClasses) { + if (annotatedClassesToRecompile.add(annotatedClass)) { + toResolve.add(annotatedClass); + } + } + } + } + } + /** * A first scan is considered done when we have visited all modules at least once. * This is useful in two ways. @@ -720,33 +741,91 @@ ClassScanResult checkForChangedClasses(QuarkusCompiler compiler, Function cuf, boolean firstScan, TimestampSet timestampSet, boolean compilingTests) { ClassScanResult classScanResult = new ClassScanResult(); - boolean ignoreFirstScanChanges = firstScan; + + record RecompilableLocationsBySourcePath(Path sourcePath, Set changedFiles, Set changedDependencies) { + } + record ChangeDetectionResult(DevModeContext.ModuleInfo moduleInfo, + List changedLocations) { + } + List changeDetectionResults = new ArrayList<>(); + Set annotatedClassesToRecompile = new HashSet<>(); for (DevModeContext.ModuleInfo module : context.getAllModules()) { - final List moduleChangedSourceFilePaths = new ArrayList<>(); + ChangeDetectionResult changeDetectionResult = new ChangeDetectionResult(module, new ArrayList<>()); for (Path sourcePath : cuf.apply(module).getSourcePaths()) { if (!Files.exists(sourcePath)) { continue; } + final Set changedSourceFiles; try (final Stream sourcesStream = Files.walk(sourcePath)) { changedSourceFiles = sourcesStream .parallel() .filter(p -> matchingHandledExtension(p).isPresent() - && sourceFileWasRecentModified(p, ignoreFirstScanChanges, firstScan)) + && sourceFileWasRecentModified(p, firstScan, firstScan)) .map(Path::toFile) //Needing a concurrent Set, not many standard options: .collect(Collectors.toCollection(ConcurrentSkipListSet::new)); } catch (IOException e) { throw new RuntimeException(e); } + if (!changedSourceFiles.isEmpty()) { + RecompilableLocationsBySourcePath recompilableLocationsBySourcePath = new RecompilableLocationsBySourcePath( + sourcePath, changedSourceFiles, new HashSet<>()); + changeDetectionResult.changedLocations().add(recompilableLocationsBySourcePath); + + for (File changedSourceFile : changedSourceFiles) { + String changedDependency = convertFileToClassname(sourcePath, changedSourceFile); + + collectRecompilationTargets(DotName.createSimple(changedDependency), annotatedClassesToRecompile); + } + } + } + changeDetectionResults.add(changeDetectionResult); + } + + for (DotName annotatedClass : annotatedClassesToRecompile) { + String partialRelativePath = annotatedClass.toString('/'); + + boolean found = false; + for (ChangeDetectionResult changeDetectionResult : changeDetectionResults) { + for (RecompilableLocationsBySourcePath recompilableLocationsBySourcePath : changeDetectionResult + .changedLocations()) { + for (String extension : compiler.allHandledExtensions()) { + Path resolved = recompilableLocationsBySourcePath.sourcePath().resolve(partialRelativePath + extension); + + if (Files.exists(resolved)) { + recompilableLocationsBySourcePath.changedDependencies().add(resolved.toFile()); + found = true; + break; + } + } + + if (found) { + break; + } + } + + if (found) { + break; + } + } + } + + for (ChangeDetectionResult changeDetectionResult : changeDetectionResults) { + final List moduleChangedSourceFilePaths = new ArrayList<>(); + for (RecompilableLocationsBySourcePath recompilableLocationsBySourcePath : changeDetectionResult + .changedLocations()) { + Path sourcePath = recompilableLocationsBySourcePath.sourcePath(); + Set changedSourceFiles = recompilableLocationsBySourcePath.changedFiles(); + if (!changedSourceFiles.isEmpty() || !recompilableLocationsBySourcePath.changedDependencies().isEmpty()) { classScanResult.compilationHappened = true; //so this is pretty yuck, but on a lot of systems a write is actually a truncate + write //its possible we see the truncated file timestamp, then the write updates the timestamp //which will then re-trigger continuous testing/live reload - //the empty fine does not normally cause issues as by the time we actually compile it the write + //the empty file does not normally cause issues as by the time we actually compile it the write //has completed (but the old timestamp is used) for (File i : changedSourceFiles) { if (i.length() == 0) { @@ -761,6 +840,7 @@ && sourceFileWasRecentModified(p, ignoreFirstScanChanges, firstScan)) } } } + Map compileTimestamps = new HashMap<>(); //now we record the timestamps as they are before the compile phase @@ -769,12 +849,18 @@ && sourceFileWasRecentModified(p, ignoreFirstScanChanges, firstScan)) } for (;;) { try { - final Set changedPaths = changedSourceFiles.stream() - .map(File::toPath) - .collect(Collectors.toSet()); + Map> changedFilesByExtension = new HashMap<>(); + Set changedPaths = new HashSet<>(); + Stream.concat(changedSourceFiles.stream(), + recompilableLocationsBySourcePath.changedDependencies.stream()).forEach(file -> { + changedPaths.add(file.toPath()); + + Set files = changedFilesByExtension.computeIfAbsent(this.getFileExtension(file), + k -> new HashSet<>()); + files.add(file); + }); moduleChangedSourceFilePaths.addAll(changedPaths); - compiler.compile(sourcePath.toString(), changedSourceFiles.stream() - .collect(groupingBy(this::getFileExtension, Collectors.toSet()))); + compiler.compile(sourcePath.toString(), changedFilesByExtension); compileProblem = null; if (compilingTests) { testCompileProblem = null; @@ -812,12 +898,10 @@ && sourceFileWasRecentModified(p, ignoreFirstScanChanges, firstScan)) sourceFileTimestamps.put(entry.getKey().toPath(), entry.getValue()); } } - } - - checkForClassFilesChangesInModule(module, moduleChangedSourceFilePaths, ignoreFirstScanChanges, classScanResult, + checkForClassFilesChangesInModule(changeDetectionResult.moduleInfo(), moduleChangedSourceFilePaths, + firstScan, classScanResult, cuf, timestampSet); - } return classScanResult; @@ -922,6 +1006,19 @@ private String getFileExtension(File file) { return name.substring(lastIndexOf); } + // convert a filename to a class name with package + private String convertFileToClassname(Path sourcePath, File file) { + String className = sourcePath.relativize(file.toPath()) + .toString(); + className = className.replace(File.separatorChar, '.'); + + int lastIndexOf = className.lastIndexOf('.'); + if (lastIndexOf > 0) { + className = className.substring(0, lastIndexOf); + } + return className; + } + Set checkForFileChange() { return checkForFileChange(DevModeContext.ModuleInfo::getMain, main); } @@ -1155,6 +1252,11 @@ public RuntimeUpdatesProcessor setDisableInstrumentationForIndexPredicate( return this; } + RuntimeUpdatesProcessor setRecompilationDependencies(Map> dependencyToAnnotatedClasses) { + this.recompilationDependencies = dependencyToAnnotatedClasses; + return this; + } + public RuntimeUpdatesProcessor setWatchedFilePaths(Map watchedFilePaths, List, Boolean>> watchedFilePredicates, boolean isTest) { if (isTest) { diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessorTest.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessorTest.java new file mode 100644 index 0000000000000..f1d6e88c5b099 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/AnnotationDependentClassesProcessorTest.java @@ -0,0 +1,543 @@ +package io.quarkus.deployment.dev; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.jboss.jandex.DotName; +import org.jboss.jandex.Index; +import org.jboss.jandex.Indexer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.APMarker; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.Address; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.Contact; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.ContactMapper; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.ContactMapperImpl; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.ContactMapperMultipleAnnotation; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.DefaultEmailCreator; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.Email; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.MapperHelper; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.MapperHelperImpl; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.Marker1; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.Marker2; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.ModelBase; +import io.quarkus.deployment.dev.annotation_dependent_classes.model.PackagePrivateData; +import io.quarkus.deployment.index.IndexWrapper; +import io.quarkus.deployment.index.PersistentClassIndex; + +class AnnotationDependentClassesProcessorTest { + private AnnotationDependentClassesProcessor processor; + + @BeforeEach + void setup() { + processor = new AnnotationDependentClassesProcessor(); + RuntimeUpdatesProcessor.INSTANCE = new TestRuntimeUpdatesProcessor(); + } + + @Test + void testNoAnnotations() { + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(Optional::empty, null); + + assertEquals(0, annotationDependentClassesBuildItems.size()); + } + + @Test + void fullTest() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + Index index = buildIndex(Address.class, APMarker.class, Contact.class, ContactMapper.class, ContactMapperImpl.class, + DefaultEmailCreator.class, Email.class, MapperHelper.class, MapperHelperImpl.class, ModelBase.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + + AnnotationDependentClassesBuildItem annotationDependentClassesBuildItem = annotationDependentClassesBuildItems.get(0); + assertEquals(DotName.createSimple(APMarker.class), annotationDependentClassesBuildItem.getAnnotationName()); + + Map> dependencies = annotationDependentClassesBuildItem + .getDependencyToAnnotatedClasses(); + assertEquals(14, dependencies.size()); + assertAffectedClasses(dependencies, ModelBase.class, ContactMapper.class); + assertAffectedClasses(dependencies, DefaultEmailCreator.class, ContactMapper.class); + assertAffectedClasses(dependencies, Address.class, ContactMapper.class); + assertAffectedClasses(dependencies, Email.class, DefaultEmailCreator.class, ContactMapper.class); + assertAffectedClasses(dependencies, Contact.class, ContactMapper.class); + assertAffectedClasses(dependencies, MapperHelper.class, ContactMapper.class); + assertAffectedClasses(dependencies, MapperHelperImpl.class, ContactMapper.class); + assertAffectedClasses(dependencies, Address.LocalizationInfo.class, ContactMapper.class); + } + + /** + * Test that circular dependencies between annotated classes do not cause infinite loops. + */ + @Test + void testCyclicAPMarkedClasses() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + Index index = buildIndex(CyclicApMarked1.class, CyclicApMarked2.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(3, dependencies.size()); + assertAffectedClasses(dependencies, CyclicApMarked1.class, CyclicApMarked2.class); + assertAffectedClasses(dependencies, CyclicApMarked2.class, CyclicApMarked1.class); + } + + @APMarker + class CyclicApMarked1 { + private CyclicApMarked2 cyclicApMarked2; + } + + @APMarker + class CyclicApMarked2 { + private CyclicApMarked1 cyclicApMarked1; + } + + @Test + void testAPMarkedAnnotationRecomputed() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + Index index = buildIndex(Address.class, ContactMapper.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(0, annotationDependentClassesBuildItems.size()); + + combinedIndexBuildItem = new CombinedIndexBuildItem(index, + new IndexWrapper(index, APMarker.class.getClassLoader(), new PersistentClassIndex())); + annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + } + + @Test + void testConfiguredAnnotationIsInRealityAClass() throws IOException { + + class D { + } + class C extends D { + } + class B extends C { + } + @APMarker + record A(B b) { + } + + Index index = buildIndex(D.class, C.class, B.class, A.class, APMarker.class); + + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(A.class.getName())); + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(0, annotationDependentClassesBuildItems.size()); + } + + @Test + void testNonClassAnnotated() throws IOException { + class A { + @APMarker + String field; + + @APMarker + void method() { + } + } + + Index index = buildIndex(A.class, APMarker.class); + + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(0, annotationDependentClassesBuildItems.size()); + } + + @Test + void testInheritanceOfReferencedPublicType() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + class D { + } + class C extends D { + } + class B extends C { + } + @APMarker + record A(B b) { + } + + Index index = buildIndex(D.class, C.class, A.class, B.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(5, dependencies.size()); + assertAffectedClasses(dependencies, B.class, A.class); + assertAffectedClasses(dependencies, C.class, A.class); + assertAffectedClasses(dependencies, D.class, A.class); + } + + @Test + void testGenericParameterizedType() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + record D() { + } + record C() { + } + record B() { + } + + @APMarker + record A(List bs, Map> map) { + } + + Index index = buildIndex(D.class, C.class, B.class, A.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(8, dependencies.size()); + assertAffectedClasses(dependencies, D.class, A.class); + assertAffectedClasses(dependencies, C.class, A.class); + assertAffectedClasses(dependencies, B.class, A.class); + } + + @Test + void testWildcardType() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + class D { + } + class C { + } + class B { + } + + @APMarker + record A(List bs, Map map) { + } + + Index index = buildIndex(D.class, C.class, B.class, A.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(7, dependencies.size()); + assertAffectedClasses(dependencies, D.class, A.class); + assertAffectedClasses(dependencies, C.class, A.class); + assertAffectedClasses(dependencies, B.class, A.class); + } + + @Test + void testTypeVariable() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + class B { + } + + @APMarker + class A { + public void helper(T variable) { + } + } + + Index index = buildIndex(B.class, A.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(2, dependencies.size()); + assertAffectedClasses(dependencies, B.class, A.class); + } + + @Test + void testArrayType() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + class C { + } + class B { + } + + @APMarker + class A { + public void helper(T[] array, C[] array2) { + } + } + + Index index = buildIndex(C.class, A.class, B.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + assertEquals(3, dependencies.size()); + assertFalse(dependencies.containsKey(DotName.createSimple(B[].class.getName()))); + assertFalse(dependencies.containsKey(DotName.createSimple(C[].class.getName()))); + assertAffectedClasses(dependencies, B.class, A.class); + assertAffectedClasses(dependencies, C.class, A.class); + } + + @Test + void testPublicTypeCollectionVisibilityCheck() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + class F { + } + class E { + } + class D { + } + class C { + } + class B { + private C c; + D d; + protected E e; + public F f; + } + + class B2 { + private C help1() { + return null; + } + + D help2() { + return null; + } + + protected E help3() { + return null; + } + + public F help4() { + return null; + } + } + + class B3 { + private void help1(C c) { + } + + void help2(D d) { + } + + protected void help3(E e) { + } + + public void help4(F f) { + } + } + + @APMarker + class A { + B b; + + PackagePrivateData packagePrivateData; + } + + @APMarker + class A2 { + B2 b2; + } + + @APMarker + class A3 { + B3 b3; + } + + Index index = buildIndex(A.class, A2.class, A3.class, APMarker.class, B.class, B2.class, B3.class, C.class, D.class, + E.class, F.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + + // C is private, should not be included + assertFalse(dependencies.containsKey(DotName.createSimple(C.class))); + // Address and Contact are in different packages compared to the annoated classes, and should not be included + assertFalse(dependencies.containsKey(DotName.createSimple(Address.class))); + assertFalse(dependencies.containsKey(DotName.createSimple(Contact.class))); + // F is public + // E is protected, and can see package private + // D is package private + assertAffectedClasses(dependencies, F.class, A.class, A2.class, A3.class); + assertAffectedClasses(dependencies, E.class, A.class, A2.class, A3.class); + assertAffectedClasses(dependencies, D.class, A.class, A2.class, A3.class); + } + + @Test + void testReferencedTypeInheritanceNotEvaluated() throws IOException { + AnnotationDependentClassesConfig config = () -> Optional.of(Set.of(APMarker.class.getName())); + + class D { + } + + class B extends D implements TestInheritanceOfReferencedTypesC { + } + + @APMarker + class A { + TestInheritanceOfReferencedTypesC c; + + D d; + } + + Index index = buildIndex(TestInheritanceOfReferencedTypesC.class, A.class, B.class, APMarker.class); + + CombinedIndexBuildItem combinedIndexBuildItem = new CombinedIndexBuildItem(index, index); + List annotationDependentClassesBuildItems = processor + .discoverAnnotationDependentClasses(config, combinedIndexBuildItem); + + assertEquals(1, annotationDependentClassesBuildItems.size()); + Map> dependencies = annotationDependentClassesBuildItems.get(0) + .getDependencyToAnnotatedClasses(); + + assertFalse(dependencies.containsKey(DotName.createSimple(B.class.getName()))); + assertAffectedClasses(dependencies, TestInheritanceOfReferencedTypesC.class, A.class); + assertAffectedClasses(dependencies, D.class, A.class); + } + + interface TestInheritanceOfReferencedTypesC { + } + + @Test + void testConsolidateRecompilationDependencies() throws IOException { + Index index = buildIndex(Contact.class, ContactMapper.class, MapperHelper.class, Address.class, + Address.LocalizationInfo.class, Address.LocalizationInfo.LocalizationInfo2.class, + Address.LocalizationInfo.LocalizationInfo2.LocalizationInfo3.class); + + AnnotationDependentClassesBuildItem b1 = new AnnotationDependentClassesBuildItem(DotName.createSimple("APMarker1"), + Map.of(DotName.createSimple(Contact.class), Set.of(DotName.createSimple(ContactMapper.class)))); + AnnotationDependentClassesBuildItem b2 = new AnnotationDependentClassesBuildItem(DotName.createSimple("APMarker2"), + Map.of(DotName.createSimple(Address.LocalizationInfo.LocalizationInfo2.LocalizationInfo3.class), + Set.of(DotName.createSimple(MapperHelper.class)))); + + processor.consolidateRecompilationDependencies(new CombinedIndexBuildItem(index, index), List.of(b1, b2)); + + Map> recompilationDependencies = ((TestRuntimeUpdatesProcessor) RuntimeUpdatesProcessor.INSTANCE).recompilationDependencies; + + assertEquals(2, recompilationDependencies.size()); + + assertAffectedClasses(recompilationDependencies, Contact.class, ContactMapper.class); + + // Tests that inner class is dropped, and only outer class passed. + assertAffectedClasses(recompilationDependencies, Address.class, MapperHelper.class); + } + + @Test + void testConsolidationOfMultipleAnnotations() throws IOException { + + AnnotationDependentClassesConfig config = () -> Optional.of( + Set.of(Marker1.class.getName(), Marker2.class.getName(), APMarker.class.getName())); + + Index index = buildIndex(Address.class, ContactMapper.class, ContactMapperMultipleAnnotation.class, APMarker.class, + Marker1.class, Marker2.class); + + List buildItems = processor + .discoverAnnotationDependentClasses(config, new CombinedIndexBuildItem(index, index)); + + // 3 builditems, 2 are Address -> ContactMapperMultipleAnnotation, the other one is Address -> ContactMapper + assertEquals(3, buildItems.size()); + for (AnnotationDependentClassesBuildItem buildItem : buildItems) { + if (buildItem.getAnnotationName().equals(DotName.createSimple(APMarker.class.getName()))) { + assertAffectedClasses(buildItem.getDependencyToAnnotatedClasses(), Address.class, ContactMapper.class); + } else { + assertAffectedClasses(buildItem.getDependencyToAnnotatedClasses(), Address.class, + ContactMapperMultipleAnnotation.class); + } + } + + processor.consolidateRecompilationDependencies(new CombinedIndexBuildItem(index, index), buildItems); + + Map> recompilationDependencies = ((TestRuntimeUpdatesProcessor) RuntimeUpdatesProcessor.INSTANCE).recompilationDependencies; + + // In the end only one entry in the resulting dependency map + // Address -> ContactMapperMultipleAnnotation, ContactMapper + assertEquals(1, recompilationDependencies.size()); + assertAffectedClasses(recompilationDependencies, Address.class, ContactMapperMultipleAnnotation.class, + ContactMapper.class); + } + + private void assertAffectedClasses(Map> dependencies, Class clazz, + Class... affectedClasses) { + DotName className = DotName.createSimple(clazz.getName()); + assertTrue(dependencies.containsKey(className)); + assertEquals(affectedClasses.length, dependencies.get(className).size()); + for (Class affectedClass : affectedClasses) { + DotName affectedClassName = DotName.createSimple(affectedClass.getName()); + assertTrue(dependencies.get(className).contains(affectedClassName)); + } + } + + private Index buildIndex(Class... classes) throws IOException { + assertTrue(classes.length > 0); + + Indexer indexer = new Indexer(); + + for (Class clazz : classes) { + indexer.indexClass(clazz); + } + + return indexer.complete(); + } + + private static class TestRuntimeUpdatesProcessor extends RuntimeUpdatesProcessor { + + private Map> recompilationDependencies = new ConcurrentHashMap<>(); + + public TestRuntimeUpdatesProcessor() { + super(null, null, null, null, null, null, null, null, null); + } + + public RuntimeUpdatesProcessor setRecompilationDependencies(Map> recompilationDependencies) { + this.recompilationDependencies = recompilationDependencies; + return this; + } + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/APMarker.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/APMarker.java new file mode 100644 index 0000000000000..aa1093851e11a --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/APMarker.java @@ -0,0 +1,4 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public @interface APMarker { +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Address.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Address.java new file mode 100644 index 0000000000000..b500d81269010 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Address.java @@ -0,0 +1,38 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +import java.util.List; + +public class Address extends ModelBase { + private String city; + + private String streetName; + + public LocalizationInfo localizationInfo; + + private List contacts; + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public List getContacts() { + return contacts; + } + + public void setContacts(List contacts) { + this.contacts = contacts; + } + + public static class LocalizationInfo { + + public static class LocalizationInfo2 { + public static class LocalizationInfo3 { + + } + } + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Contact.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Contact.java new file mode 100644 index 0000000000000..c7857c7d7fe01 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Contact.java @@ -0,0 +1,23 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public class Contact extends ModelBase { + private String name; + + private Email email; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Email getEmail() { + return email; + } + + public void setEmail(Email email) { + this.email = email; + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapper.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapper.java new file mode 100644 index 0000000000000..d184ad6028b96 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapper.java @@ -0,0 +1,6 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +@APMarker +public interface ContactMapper { + void mapToData(Address contact); +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperImpl.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperImpl.java new file mode 100644 index 0000000000000..8d8ccfd67a349 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperImpl.java @@ -0,0 +1,11 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public class ContactMapperImpl implements ContactMapper { + private MapperHelperImpl mapperHelper = new MapperHelperImpl(); + + private DefaultEmailCreator defaultEmailCreator; + + @Override + public void mapToData(Address contact) { + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperMultipleAnnotation.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperMultipleAnnotation.java new file mode 100644 index 0000000000000..2002e8a50dcaa --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ContactMapperMultipleAnnotation.java @@ -0,0 +1,6 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +@Marker1 +@Marker2 +public interface ContactMapperMultipleAnnotation extends ContactMapper { +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/DefaultEmailCreator.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/DefaultEmailCreator.java new file mode 100644 index 0000000000000..b5e840a6c40ee --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/DefaultEmailCreator.java @@ -0,0 +1,6 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +@APMarker +public interface DefaultEmailCreator { + Email createDefault(); +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Email.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Email.java new file mode 100644 index 0000000000000..179987bfae6c1 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Email.java @@ -0,0 +1,14 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +// embeddable, with validation etc +public class Email { + private String email; + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelper.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelper.java new file mode 100644 index 0000000000000..030eaa21495c3 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelper.java @@ -0,0 +1,13 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +import java.util.Calendar; +import java.util.Date; + +public abstract class MapperHelper { + Calendar mapToCalendar(Date date) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + + return calendar; + } +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelperImpl.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelperImpl.java new file mode 100644 index 0000000000000..f7001f7f28056 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/MapperHelperImpl.java @@ -0,0 +1,4 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public class MapperHelperImpl extends MapperHelper { +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker1.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker1.java new file mode 100644 index 0000000000000..9ebdf837f6612 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker1.java @@ -0,0 +1,4 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public @interface Marker1 { +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker2.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker2.java new file mode 100644 index 0000000000000..431ae9a933ff2 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/Marker2.java @@ -0,0 +1,4 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public @interface Marker2 { +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ModelBase.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ModelBase.java new file mode 100644 index 0000000000000..0e0d7b89a6fee --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/ModelBase.java @@ -0,0 +1,6 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +// base class that every model in the system should implement +public class ModelBase { + public Long id; +} diff --git a/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/PackagePrivateData.java b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/PackagePrivateData.java new file mode 100644 index 0000000000000..5a2dde4140b26 --- /dev/null +++ b/core/deployment/src/test/java/io/quarkus/deployment/dev/annotation_dependent_classes/model/PackagePrivateData.java @@ -0,0 +1,20 @@ +package io.quarkus.deployment.dev.annotation_dependent_classes.model; + +public class PackagePrivateData { + Address address; + protected Contact contact; + + Address help2Address() { + return null; + } + + protected Contact help3Contact() { + return null; + } + + void help2(Address d) { + } + + protected void help3(Contact e) { + } +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressData.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressData.java new file mode 100644 index 0000000000000..d149c96c68ea3 --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressData.java @@ -0,0 +1,7 @@ +package io.quarkus.test.reload; + +public class AddressData { + public String name1; + + public ContactData contactData; +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressMapper.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressMapper.java new file mode 100644 index 0000000000000..903f19b969fb5 --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AddressMapper.java @@ -0,0 +1,12 @@ +package io.quarkus.test.reload; + +import jakarta.enterprise.context.ApplicationScoped; + +@AnnotationProcessorMarker +@ApplicationScoped +public class AddressMapper { + + public String map(AddressData address) { + return address.name1; + } +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadDevModeTest.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadDevModeTest.java new file mode 100644 index 0000000000000..f6fc4b403330f --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadDevModeTest.java @@ -0,0 +1,59 @@ +package io.quarkus.test.reload; + +import static org.hamcrest.Matchers.is; + +import java.util.logging.LogRecord; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusDevModeTest; +import io.restassured.RestAssured; + +class AnnotationDependentReloadDevModeTest { + + @RegisterExtension + static final QuarkusDevModeTest TEST = new QuarkusDevModeTest() + .withApplicationRoot((jar) -> jar + .addClass(AnnotationProcessorMarker.class) + .addClass(AddressData.class) + .addClass(ContactData.class) + .addClass(AddressMapper.class) + .addClass(AnnotationDependentReloadResource.class) + .addAsResource(new StringAsset( + """ + quarkus.dev.recompile-annotations=io.quarkus.test.reload.AnnotationProcessorMarker + quarkus.grpc.dev-mode.force-server-start=false + """), + "application.properties")) + .setLogRecordPredicate(r -> true); + + // mostly meant as smoke test to ensure the recompile-annotations has any effect + // more detailed testing is done as Unit Tests in AnnotationDependentClassesProcessorTest + @Test + void testDependencyChangeTriggersAnnotatedClassRecompilation() { + RestAssured.get("/annotation-dependent-reload/test").then().body(is("hello")); + + // just a file change to make quarkus hot reload on next rest call + TEST.modifySourceFile(ContactData.class, oldSource -> oldSource.replace( + "}", + "public String email;}")); + + RestAssured.get("/annotation-dependent-reload/test").then().body(is("hello")); + + // ContactData -> AddressMapper recompile + // but not AdressData + // since AdressData is not annotated + boolean found = false; + for (LogRecord logRecord : TEST.getLogRecords()) { + if (logRecord.getLoggerName().equals("io.quarkus.deployment.dev.RuntimeUpdatesProcessor") + && (logRecord.getParameters()[0].equals("AddressMapper.class, ContactData.class") + || logRecord.getParameters()[0].equals("ContactData.class, AddressMapper.class"))) { + found = true; + } + } + Assertions.assertTrue(found, "Did not find a log record from RuntimeUpdatesProcessor for AddressMapper class"); + } +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadResource.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadResource.java new file mode 100644 index 0000000000000..fc94b7d0eb941 --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationDependentReloadResource.java @@ -0,0 +1,23 @@ +package io.quarkus.test.reload; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@ApplicationScoped +@Path("/annotation-dependent-reload") +public class AnnotationDependentReloadResource { + + @Inject + AddressMapper addressMapper; + + @GET + @Path("/test") + @Produces(MediaType.TEXT_PLAIN) + public String test() { + return "hello"; + } +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationProcessorMarker.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationProcessorMarker.java new file mode 100644 index 0000000000000..c805e9b61f3b3 --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/AnnotationProcessorMarker.java @@ -0,0 +1,11 @@ +package io.quarkus.test.reload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface AnnotationProcessorMarker { +} diff --git a/integration-tests/devmode/src/test/java/io/quarkus/test/reload/ContactData.java b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/ContactData.java new file mode 100644 index 0000000000000..6b383dbb4c270 --- /dev/null +++ b/integration-tests/devmode/src/test/java/io/quarkus/test/reload/ContactData.java @@ -0,0 +1,4 @@ +package io.quarkus.test.reload; + +public class ContactData { +}