-
Notifications
You must be signed in to change notification settings - Fork 3k
Recompile classes annotated with configured annotation when dependency changes #51145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<DotName, Set<DotName>> dependencyToAnnotatedClasses; | ||
|
|
||
| public AnnotationDependentClassesBuildItem(DotName annotationName, | ||
| Map<DotName, Set<DotName>> dependencyToAnnotatedClasses) { | ||
| this.annotationName = annotationName; | ||
| this.dependencyToAnnotatedClasses = dependencyToAnnotatedClasses; | ||
| } | ||
|
|
||
| public DotName getAnnotationName() { | ||
| return annotationName; | ||
| } | ||
|
|
||
| public Map<DotName, Set<DotName>> getDependencyToAnnotatedClasses() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not immediately clear to me which way the dependency points, so javadoc would help explain it here. |
||
| return dependencyToAnnotatedClasses; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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). | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not clear to me. I think that what this list does is:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If… this is indeed what this does? 😅
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, so I was wrong, and this is not what it does, reading the description below. What this does is:
|
||
| */ | ||
| Optional<Set<String>> recompileAnnotations(); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any better suggestions for the config name?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets discuss this when the javadoc is clearer, I'm sure the name will follow.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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". | ||
| * </p> | ||
| * Only classes annotated with an annotation configured in {@link AnnotationDependentClassesConfig} are discovered. | ||
| * </p> | ||
| * 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. | ||
| * </p> | ||
| * 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. | ||
| * </p> | ||
| * 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. | ||
| * </p> | ||
| * | ||
| * 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. | ||
| * </p> | ||
| * 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<AnnotationDependentClassesBuildItem> 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<DotName> recompileAnnotationNames = resolveConfiguredAnnotationNames(config.recompileAnnotations().get(), | ||
| combinedIndexBuildItem.getComputingIndex()); | ||
|
|
||
| List<AnnotationDependentClassesBuildItem> 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<DotName> resolveConfiguredAnnotationNames(Set<String> 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<DotName> 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<AnnotationInstance> annotations = index.getAnnotations(supportedAnnotationName); | ||
| Map<DotName, Set<DotName>> 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<DotName> referencedTypes = collectAllReferencedTypeNames(annotatedClass, index); | ||
|
|
||
| Set<DotName> 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<DotName> collectAllReferencedTypeNames(ClassInfo startingClass, IndexView index) { | ||
| Set<DotName> visited = new HashSet<>(); | ||
| ArrayDeque<DotName> stack = new ArrayDeque<>(); | ||
| stack.add(startingClass.name()); | ||
|
|
||
| Set<DotName> 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()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will likely not work with generics. Consider these types: class Base extends Top<Bar> {
}
class Top<T> {
public T property;
}By going up the type hierarchy with |
||
|
|
||
| for (ClassInfo knownDirectSubclass : index.getKnownDirectSubclasses(currentClassName)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I did not realise that subtypes also had to be factored in. I guess the suggested javadoc above should reflect that. |
||
| 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<DotName> 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<String, List<SomeType>> | ||
| 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<DotName> collectedTypes, DotName startingPoint, IndexView index, | ||
| DotName annotatedClassName) { | ||
| ArrayDeque<DotName> 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure why that is different to the "referenced 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<AnnotationDependentClassesBuildItem> 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<DotName, Set<DotName>> dependencyToAnnotatedClasses = new HashMap<>(); | ||
| for (AnnotationDependentClassesBuildItem buildItem : annotationDependentClassesBuildItems) { | ||
| for (Map.Entry<DotName, Set<DotName>> 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Javadoc on build items please :)