From 992260c8fc9c8a313f970cb99f8f4f3890b21e33 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 5 Nov 2026 16:38:21 -0500 Subject: [PATCH 1/4] Make annotated lifecycle methods entity-only (plan item 4.14) An @EntityListeners class now implements the per-event listener interfaces only. Mapping rejects one that declares @PrePersist/@PostPersist/@PreLoad/@PostLoad methods or implements none of the interfaces. Its instances are created when the model is set up and called through TypedListeners, so EntityListenerAdapter handles just the entity's own methods (OnEntityListenerAdapter is folded in). The new AnnotatedListenerMethodMigration recipe, part of UpgradeToMorphia30, makes such a class implement each event's interface with a method that calls its annotated methods, and marks what it can't migrate. --- .../main/java/dev/morphia/EntityListener.java | 4 +- .../morphia/annotations/EntityListeners.java | 5 +- .../dev/morphia/annotations/PostLoad.java | 3 + .../dev/morphia/annotations/PostPersist.java | 3 + .../java/dev/morphia/annotations/PreLoad.java | 3 + .../dev/morphia/annotations/PrePersist.java | 3 + .../mapping/codec/pojo/EntityModel.java | 41 +- .../lifecycle/EntityListenerAdapter.java | 130 ++-- .../lifecycle/OnEntityListenerAdapter.java | 36 - core/src/main/resources/sofia.properties | 3 +- .../parser/generator/TestGeneration.java | 4 +- .../dev/morphia/critter/sources/Example.java | 4 +- .../codec/pojo/TestLifecycleStreaming.java | 3 +- .../java/dev/morphia/test/TestDatastore.java | 18 +- .../test/lifecycle/TestTypedListeners.java | 61 +- .../lifecycle/invalid/InvalidListeners.java | 67 ++ .../morphia/test/models/ExampleListener.java | 14 + design/mapping-game-plan.md | 13 +- .../AnnotatedListenerMethodMigration.java | 483 +++++++++++++ .../lifecycle/EntityListenerMigration.java | 6 +- .../resources/META-INF/rewrite/rewrite.yml | 1 + .../AnnotatedListenerMethodMigrationTest.java | 645 ++++++++++++++++++ 22 files changed, 1404 insertions(+), 146 deletions(-) delete mode 100644 core/src/main/java/dev/morphia/mapping/lifecycle/OnEntityListenerAdapter.java create mode 100644 core/src/test/java/dev/morphia/test/lifecycle/invalid/InvalidListeners.java create mode 100644 core/src/test/java/dev/morphia/test/models/ExampleListener.java create mode 100644 rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java create mode 100644 rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java diff --git a/annotations/src/main/java/dev/morphia/EntityListener.java b/annotations/src/main/java/dev/morphia/EntityListener.java index 5ee83f25fcb..e5fe6fa9cec 100644 --- a/annotations/src/main/java/dev/morphia/EntityListener.java +++ b/annotations/src/main/java/dev/morphia/EntityListener.java @@ -3,8 +3,8 @@ /** * Marks a lifecycle listener. A listener implements one or more of the per-event interfaces, {@code PrePersistListener}, * {@code PostPersistListener}, {@code PreLoadListener} and {@code PostLoadListener}, and Morphia calls it for those events - * alone. A class listed in {@link dev.morphia.annotations.EntityListeners @EntityListeners} may instead, or as well, declare - * methods annotated with a lifecycle event annotation. + * alone. A class listed in {@link dev.morphia.annotations.EntityListeners @EntityListeners} must implement at least one of them; + * only an entity's own methods may be annotated with a lifecycle event annotation. * * @param the type of the entities the listener accepts */ diff --git a/annotations/src/main/java/dev/morphia/annotations/EntityListeners.java b/annotations/src/main/java/dev/morphia/annotations/EntityListeners.java index c3f38c32b84..ff0ea20a59e 100644 --- a/annotations/src/main/java/dev/morphia/annotations/EntityListeners.java +++ b/annotations/src/main/java/dev/morphia/annotations/EntityListeners.java @@ -10,7 +10,10 @@ import dev.morphia.EntityListener; /** - * Specifies other classes to participate in the @Entity's lifecycle + * Specifies other classes to participate in the @Entity's lifecycle. Each class implements one or more of the per-event listener + * interfaces, such as {@code PrePersistListener}, and has a no-argument constructor. Morphia creates an instance of each when it + * maps the entity. A listed class may not declare methods annotated with a lifecycle event annotation: only an entity's own + * methods can be. */ @Documented @Inherited diff --git a/annotations/src/main/java/dev/morphia/annotations/PostLoad.java b/annotations/src/main/java/dev/morphia/annotations/PostLoad.java index 05638b5af2f..5ce1cae2a80 100644 --- a/annotations/src/main/java/dev/morphia/annotations/PostLoad.java +++ b/annotations/src/main/java/dev/morphia/annotations/PostLoad.java @@ -11,6 +11,9 @@ * Called after the data has been loaded into the java object. This is a good place to perform * initialization and sanity-checking of the object. *

+ * Only an entity's own methods may carry this annotation. An {@link EntityListeners @EntityListeners} class implements the + * event's listener interface instead. + *

* Methods with this annotation may optionally take a parameter of type {@link org.bson.Document Document} * and/or a parameter of type Datastore. If both parameters are used, * the order is unimportant. diff --git a/annotations/src/main/java/dev/morphia/annotations/PostPersist.java b/annotations/src/main/java/dev/morphia/annotations/PostPersist.java index b8dd2a1776a..aea893fde31 100644 --- a/annotations/src/main/java/dev/morphia/annotations/PostPersist.java +++ b/annotations/src/main/java/dev/morphia/annotations/PostPersist.java @@ -11,6 +11,9 @@ * Called after the data has been persisted from the java object. Here you can alter the * BSON Document before it is saved. *

+ * Only an entity's own methods may carry this annotation. An {@link EntityListeners @EntityListeners} class implements the + * event's listener interface instead. + *

* Methods with this annotation may optionally take a parameter of type {@link org.bson.Document Document} * and/or a parameter of type Datastore. If both parameters are used, the * order is unimportant. For typical usage, the method should take a Document parameter. diff --git a/annotations/src/main/java/dev/morphia/annotations/PreLoad.java b/annotations/src/main/java/dev/morphia/annotations/PreLoad.java index eb232154f5a..b9378b2b369 100644 --- a/annotations/src/main/java/dev/morphia/annotations/PreLoad.java +++ b/annotations/src/main/java/dev/morphia/annotations/PreLoad.java @@ -11,6 +11,9 @@ * Called before the data has been loaded into the object. Here you can alter the raw BSON Document from the * datastore prior to object initialization. *

+ * Only an entity's own methods may carry this annotation. An {@link EntityListeners @EntityListeners} class implements the + * event's listener interface instead. + *

* Methods with this annotation may optionally take a parameter of type {@link org.bson.Document Document} * and/or a parameter of type Datastore. If both parameters are used, the * order is unimportant. For typical usage, the method should take a Document parameter. diff --git a/annotations/src/main/java/dev/morphia/annotations/PrePersist.java b/annotations/src/main/java/dev/morphia/annotations/PrePersist.java index ce16bcfd939..169756f6ad7 100644 --- a/annotations/src/main/java/dev/morphia/annotations/PrePersist.java +++ b/annotations/src/main/java/dev/morphia/annotations/PrePersist.java @@ -11,6 +11,9 @@ * Called before the data has been persisted to the datastore (before mapping is done). * Here you can alter your class members prior to being saved. *

+ * Only an entity's own methods may carry this annotation. An {@link EntityListeners @EntityListeners} class implements the + * event's listener interface instead. + *

* Methods with this annotation may optionally take a parameter of type * Datastore. This is the Datastore that * data will be persisted to. diff --git a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java index f3fd59e042b..9709b965ad9 100644 --- a/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java +++ b/core/src/main/java/dev/morphia/mapping/codec/pojo/EntityModel.java @@ -49,7 +49,6 @@ import dev.morphia.mapping.conventions.MorphiaConvention; import dev.morphia.mapping.conventions.MorphiaDefaultsConvention; import dev.morphia.mapping.lifecycle.EntityListenerAdapter; -import dev.morphia.mapping.lifecycle.OnEntityListenerAdapter; import dev.morphia.mapping.lifecycle.TypedListeners; import dev.morphia.sofia.Sofia; @@ -98,7 +97,11 @@ public class EntityModel { private EntityModel superClass; private PropertyModel idProperty; private PropertyModel versionProperty; - private final List listeners = new ArrayList<>(); + // the entity's own lifecycle methods, or null when it has none + @Nullable + private EntityListenerAdapter ownListener; + // instances of its @EntityListeners classes + private final List> listeners = new ArrayList<>(); // bit i set when a listener handles Mapper.LIFECYCLE_ANNOTATIONS[i]; worked out when the listeners are set up private int lifecycleEvents; // bit i set when a listener's method for Mapper.LIFECYCLE_ANNOTATIONS[i] takes the Document @@ -257,8 +260,11 @@ public void annotation(Annotation annotation) { public void callLifecycleMethods(Class event, Object entity, @Nullable Document document, MorphiaDatastore datastore) { if (hasLifecycle(event)) { - for (EntityListenerAdapter listener : listeners) { - listener.invoke(event, entity, document, datastore); + for (EntityListener listener : listeners) { + TypedListeners.invoke(listener, event, entity, document, datastore); + } + if (ownListener != null) { + ownListener.invoke(event, entity, document, datastore); } } for (EntityListener listener : datastore.getMapper().getGlobalListeners()) { @@ -603,35 +609,40 @@ public boolean lifecycleUsesDocument(Class type) { } /** - * Populates the {@code listeners} list from {@code @EntityListeners} and on-entity lifecycle methods. + * Creates the instances of the {@code @EntityListeners} classes and finds the entity's own lifecycle methods. * Subclasses that bypass the normal {@link MappingUtil} pipeline (e.g. {@code CritterEntityModel}) must * call this after their properties have been set up. + * + * @throws MappingException if an {@code @EntityListeners} class can't be created, implements none of the per-event + * listener interfaces, or declares annotated lifecycle methods */ protected void initializeListeners() { final EntityListeners entityLisAnn = getAnnotation(EntityListeners.class); if (entityLisAnn != null) { - for (Class aClass : entityLisAnn.value()) { - listeners.add(new EntityListenerAdapter(aClass)); + for (Class> type : entityLisAnn.value()) { + listeners.add(EntityListenerAdapter.create(type)); } } - - OnEntityListenerAdapter adapter = OnEntityListenerAdapter.listen(getType()); - if (adapter != null) { - listeners.add(adapter); - } + ownListener = EntityListenerAdapter.listen(getType()); int events = 0; int documents = 0; for (int i = 0; i < LIFECYCLE_ANNOTATIONS.size(); i++) { Class event = LIFECYCLE_ANNOTATIONS.get(i); - for (EntityListenerAdapter listener : listeners) { - if (listener.handles(event)) { + for (EntityListener listener : listeners) { + if (TypedListeners.handles(listener.getClass(), event, getType())) { events |= 1 << i; - if (listener.usesDocument(event, getType())) { + if (TypedListeners.usesDocument(listener.getClass(), event, getType())) { documents |= 1 << i; } } } + if (ownListener != null && ownListener.handles(event)) { + events |= 1 << i; + if (ownListener.usesDocument(event, getType())) { + documents |= 1 << i; + } + } } lifecycleEvents = events; documentEvents = documents; diff --git a/core/src/main/java/dev/morphia/mapping/lifecycle/EntityListenerAdapter.java b/core/src/main/java/dev/morphia/mapping/lifecycle/EntityListenerAdapter.java index 1e7acc53945..82872669f71 100644 --- a/core/src/main/java/dev/morphia/mapping/lifecycle/EntityListenerAdapter.java +++ b/core/src/main/java/dev/morphia/mapping/lifecycle/EntityListenerAdapter.java @@ -2,6 +2,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; @@ -21,49 +22,87 @@ import org.bson.Document; import static dev.morphia.mapping.Mapper.LIFECYCLE_ANNOTATIONS; +import static java.lang.String.format; /** - * Calls the lifecycle methods of an {@code @EntityListeners} class, or of an entity: its methods annotated with a lifecycle - * event annotation, and those of any per-event listener interface it implements. It implements {@link EntityListener} only so - * it can be named in {@code @EntityListeners}. + * Calls an entity's own lifecycle methods: its methods annotated with a lifecycle event annotation, and those of any per-event + * listener interface it implements. Also creates the instances of {@code @EntityListeners} classes, which implement the + * interfaces only. * * @hidden * @since 2.4 * @morphia.internal */ @MorphiaInternal -public class EntityListenerAdapter implements EntityListener { +public final class EntityListenerAdapter { private final Map, List> callbacks = new HashMap<>(); - private final Class listenerType; - // whether it implements a per-event listener interface - private final boolean typed; - private Object listener; + private final Class entityType; - /** - * @param listenerType the {@code @EntityListeners} class or the entity - * @throws MappingException if it implements two listener interfaces for one event - */ - public EntityListenerAdapter(Class listenerType) { + private EntityListenerAdapter(Class entityType) { for (Class annotationClass : LIFECYCLE_ANNOTATIONS) { callbacks.put(annotationClass, new ArrayList<>()); } - mapAnnotationsToMethods(listenerType); - this.listenerType = listenerType; + mapAnnotationsToMethods(entityType); + this.entityType = entityType; + } + + /** + * @param entityType the entity + * @return the adapter for the entity's own lifecycle methods, or null when it has none + * @throws MappingException if it implements two listener interfaces for one event + */ + @Nullable + public static EntityListenerAdapter listen(Class entityType) { + EntityListenerAdapter adapter = new EntityListenerAdapter(entityType); // checked here, when the entity is mapped: annotated methods would otherwise short-circuit every check - typed = TypedListeners.handlesAny(listenerType); + boolean typed = TypedListeners.handlesAny(entityType); + return typed || adapter.hasCallbacks() ? adapter : null; + } + + /** + * @param listenerType an {@code @EntityListeners} class + * @return a new instance of it + * @throws MappingException if it implements none of the per-event listener interfaces, or two for one event, declares + * annotated lifecycle methods, or has no no-argument constructor or can't be created with it + */ + public static EntityListener create(Class> listenerType) { + for (Method method : getDeclaredAndInheritedMethods(listenerType)) { + for (Class annotationClass : LIFECYCLE_ANNOTATIONS) { + if (method.isAnnotationPresent(annotationClass)) { + throw new MappingException(Sofia.annotatedListenerMethod(listenerType.getName(), method.getName(), + annotationClass.getSimpleName())); + } + } + } + if (!TypedListeners.handlesAny(listenerType)) { + throw new MappingException(Sofia.noLifecycleMethods(listenerType.getName())); + } + Constructor> constructor; + try { + constructor = listenerType.getDeclaredConstructor(); + } catch (NoSuchMethodException e) { + throw new MappingException(Sofia.noargConstructorNotFound(listenerType), e); + } + try { + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (ReflectiveOperationException e) { + Throwable cause = e instanceof InvocationTargetException target && target.getCause() != null ? target.getCause() : e; + throw new MappingException(Sofia.cannotInstantiate(listenerType.getName(), cause.getMessage()), cause); + } } /** * @param event the lifecycle event annotation - * @return true if the listener handles the event, with an annotated method or its per-event listener interface + * @return true if the entity handles the event, with an annotated method or its per-event listener interface */ public boolean handles(@NonNull Class event) { - return !callbacks.get(event).isEmpty() || TypedListeners.handles(listenerType, event); + return !callbacks.get(event).isEmpty() || TypedListeners.handles(entityType, event); } /** * @param event the lifecycle event annotation - * @param entityType the type of the entity the listener is for + * @param entityType the type of the entity the model is for: the declaring entity or a subtype * @return true if a method for the event takes the {@code Document}: an annotated method with a {@code Document} * parameter, or the event's listener interface's method if it accepts every entity of the type. One that * accepts only a subtype doesn't count: a codec checks the subtype's own needs once it knows the entity is one @@ -74,23 +113,20 @@ public boolean usesDocument(@NonNull Class event, Class return true; } } - return TypedListeners.usesDocument(listenerType, event, entityType); + return TypedListeners.usesDocument(this.entityType, event, entityType); } - /** - * @return true if any lifecycle method was found - */ - boolean hasCallbacks() { + private boolean hasCallbacks() { for (List list : callbacks.values()) { if (!list.isEmpty()) { return true; } } - return typed; + return false; } /** - * Calls the listener's annotated methods for the event, then its per-event listener interface's method. + * Calls the entity's annotated methods for the event, then its per-event listener interface's method. * * @param event the lifecycle event annotation * @param entity the entity @@ -100,48 +136,24 @@ boolean hasCallbacks() { public void invoke(Class event, Object entity, @Nullable Document document, Datastore datastore) { for (Callback callback : callbacks.get(event)) { try { - callback.method.invoke(target(entity), callback.arguments(entity, document, datastore)); + callback.method.invoke(entity, callback.arguments(entity, document, datastore)); } catch (IllegalArgumentException | ReflectiveOperationException e) { throw failure(callback.method, entity, e); } } - if (TypedListeners.handles(listenerType, event)) { - TypedListeners.invoke(target(entity), event, entity, document, datastore); + if (TypedListeners.handles(entityType, event)) { + TypedListeners.invoke(entity, event, entity, document, datastore); } } - /** - * @param entity the entity the event is for - * @return the object to call the lifecycle methods on: an instance of the listener type - */ - protected Object target(Object entity) { - return getListener(); - } - - /** - * @param method the lifecycle method that failed - * @param entity the entity the event is for - * @param e the failure - * @return the exception to throw - */ - protected RuntimeException failure(Method method, Object entity, Exception e) { - return e instanceof IllegalArgumentException illegal ? illegal : new RuntimeException(e); - } - - Object getListener() { - if (listener == null) { - try { - Constructor constructor = listenerType.getDeclaredConstructor(); - constructor.setAccessible(true); - listener = constructor.newInstance(); - } catch (ReflectiveOperationException e) { - throw new MappingException(Sofia.noargConstructorNotFound(listenerType), e); - } + private static RuntimeException failure(Method method, Object entity, Exception e) { + if (e instanceof IllegalArgumentException && "object is not an instance of declaring class".equals(e.getMessage())) { + return new RuntimeException(format("%s is not an instance of the method's type: %s", + entity.getClass().getName(), method.getDeclaringClass().getName()), e); } - return listener; + return new RuntimeException("Failed to invoke method: " + method, e); } - @NonNull private void mapAnnotationsToMethods(Class type) { for (Method method : getDeclaredAndInheritedMethods(type)) { for (Class annotationClass : LIFECYCLE_ANNOTATIONS) { @@ -198,7 +210,7 @@ Object[] arguments(Object entity, @Nullable Document document, Datastore datasto } } - private List getDeclaredAndInheritedMethods(Class type) { + private static List getDeclaredAndInheritedMethods(Class type) { final List methods = new ArrayList<>(); if (type == Object.class) { return methods; diff --git a/core/src/main/java/dev/morphia/mapping/lifecycle/OnEntityListenerAdapter.java b/core/src/main/java/dev/morphia/mapping/lifecycle/OnEntityListenerAdapter.java deleted file mode 100644 index e81b7c71536..00000000000 --- a/core/src/main/java/dev/morphia/mapping/lifecycle/OnEntityListenerAdapter.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.morphia.mapping.lifecycle; - -import java.lang.reflect.Method; - -import com.mongodb.lang.Nullable; - -import static java.lang.String.format; - -public class OnEntityListenerAdapter extends EntityListenerAdapter { - private OnEntityListenerAdapter(Class listenerType) { - super(listenerType); - } - - @Nullable - public static OnEntityListenerAdapter listen(Class type) { - OnEntityListenerAdapter adapter = new OnEntityListenerAdapter(type); - return adapter.hasCallbacks() ? adapter : null; - } - - /** - * The lifecycle methods are the entity's own. - */ - @Override - protected Object target(Object entity) { - return entity; - } - - @Override - protected RuntimeException failure(Method method, Object entity, Exception e) { - if (e instanceof IllegalArgumentException && "object is not an instance of declaring class".equals(e.getMessage())) { - return new RuntimeException(format("%s is not an instance of the method's type: %s", - entity.getClass().getName(), method.getDeclaringClass().getName()), e); - } - return new RuntimeException("Failed to invoke method: " + method, e); - } -} diff --git a/core/src/main/resources/sofia.properties b/core/src/main/resources/sofia.properties index e8e50214993..094f345ec92 100644 --- a/core/src/main/resources/sofia.properties +++ b/core/src/main/resources/sofia.properties @@ -1,6 +1,7 @@ aggregation.failed=Failed to execute the aggregation pipeline: {0} aggregation.collection.name=Aggregation options must have a collection named. aggregation.terminated=This pipeline already contains a {0} and so can not contain subsequent stages. +annotated.listener.method=The @EntityListeners class {0} declares {1}() annotated with @{2}. Only an entity''s own methods can be annotated: implement the event''s listener interface, such as PostLoadListener, instead. at.least.one.update.required=At least one update operation is required. at.least.one.sort.required=At least one sort is required. bad.shard.keys=Unknown properties used as shard keys: {0} @@ -40,7 +41,7 @@ lifecycle.noargs=A type with lifecycle events must have a no-arg constructor: {0 listener.both.interfaces=The listener {0} implements both {1} and {2}, which handle the same event. Implement only one of them. listener.class.not.found=Could not load the configured listener class ''{0}''. listener.not.created=Could not create the configured listener ''{0}'': {1} -no.lifecycle.methods=The configured listener {0} implements none of PrePersistListener, PostPersistListener, PostPersistDocumentListener, PreLoadListener, PostLoadListener or PostLoadDocumentListener. +no.lifecycle.methods=The listener {0} implements none of PrePersistListener, PostPersistListener, PostPersistDocumentListener, PreLoadListener, PostLoadListener or PostLoadDocumentListener. logged.query=logged query: {0} @debug[once].mapping.package=Mapping package ''{0}'' mismatched.field.on.external.type=Mapped field ''{0}'' on ''{1}'' does not match any fields on ''{2}''. diff --git a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java index 6415bef8137..2cdfe1d393b 100644 --- a/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java +++ b/core/src/test/java/dev/morphia/critter/parser/generator/TestGeneration.java @@ -44,7 +44,7 @@ import dev.morphia.mapping.codec.pojo.EntityModel; import dev.morphia.mapping.codec.pojo.PropertyModel; import dev.morphia.mapping.codec.pojo.TypeData; -import dev.morphia.mapping.lifecycle.EntityListenerAdapter; +import dev.morphia.test.models.ExampleListener; import org.bson.codecs.pojo.PropertyAccessor; import org.bson.types.ObjectId; @@ -354,7 +354,7 @@ private static void assertRoundTrip(EntityModel model, Object entity, String pro } private void validate(EntityModel model) { - Assertions.assertEquals(EntityListenersBuilder.entityListenersBuilder().value(EntityListenerAdapter.class).build(), + Assertions.assertEquals(EntityListenersBuilder.entityListenersBuilder().value(ExampleListener.class).build(), model.getAnnotation(EntityListeners.class)); Assertions.assertEquals(EntityBuilder.entityBuilder().value("examples").build(), model.getAnnotation(Entity.class)); Assertions.assertEquals(IndexesBuilder.indexesBuilder() diff --git a/core/src/test/java/dev/morphia/critter/sources/Example.java b/core/src/test/java/dev/morphia/critter/sources/Example.java index 7f8aebf1940..a2ab9c4e30f 100644 --- a/core/src/test/java/dev/morphia/critter/sources/Example.java +++ b/core/src/test/java/dev/morphia/critter/sources/Example.java @@ -18,12 +18,12 @@ import dev.morphia.annotations.Property; import dev.morphia.annotations.Reference; import dev.morphia.annotations.Transient; -import dev.morphia.mapping.lifecycle.EntityListenerAdapter; +import dev.morphia.test.models.ExampleListener; import org.bson.types.ObjectId; @Entity("examples") -@EntityListeners(EntityListenerAdapter.class) +@EntityListeners(ExampleListener.class) @Indexes(@Index(fields = @Field(value = "name", weight = 42), options = @IndexOptions(partialFilter = "partial filter", collation = @Collation(caseFirst = CollationCaseFirst.LOWER)))) public class Example { @Id diff --git a/core/src/test/java/dev/morphia/mapping/codec/pojo/TestLifecycleStreaming.java b/core/src/test/java/dev/morphia/mapping/codec/pojo/TestLifecycleStreaming.java index 9b49869d066..52100ecf2b0 100644 --- a/core/src/test/java/dev/morphia/mapping/codec/pojo/TestLifecycleStreaming.java +++ b/core/src/test/java/dev/morphia/mapping/codec/pojo/TestLifecycleStreaming.java @@ -369,7 +369,8 @@ void anEntityListenersClassForASubtypeLeavesItsSiblingsStreaming(MapperType mapp datastore.getMapper().map(Figure.class, Ring.class, Block.class); Assertions.assertFalse(((LifecycleDecoder) codec(datastore, Figure.class).getDecoder()).needsDocument()); - Assertions.assertFalse(((LifecycleDecoder) codec(datastore, Block.class).getDecoder()).needsDocument()); + // the listener accepts no blocks, so they have no lifecycle at all + Assertions.assertFalse(codec(datastore, Block.class).getDecoder() instanceof LifecycleDecoder); Assertions.assertTrue(((LifecycleDecoder) codec(datastore, Ring.class).getDecoder()).needsDocument()); } } diff --git a/core/src/test/java/dev/morphia/test/TestDatastore.java b/core/src/test/java/dev/morphia/test/TestDatastore.java index ff1af0ef91a..40757810d68 100644 --- a/core/src/test/java/dev/morphia/test/TestDatastore.java +++ b/core/src/test/java/dev/morphia/test/TestDatastore.java @@ -16,12 +16,12 @@ import dev.morphia.Datastore; import dev.morphia.DeleteOptions; -import dev.morphia.EntityListener; import dev.morphia.InsertManyOptions; import dev.morphia.InsertOneOptions; import dev.morphia.MissingIdException; import dev.morphia.ModifyOptions; import dev.morphia.MorphiaDatastore; +import dev.morphia.PrePersistListener; import dev.morphia.UpdateOptions; import dev.morphia.annotations.Entity; import dev.morphia.annotations.EntityListeners; @@ -592,24 +592,16 @@ public void testUpdateWithCollation() { .next().loginCount); } - private static class LifecycleListener implements EntityListener { + private static class LifecycleListener implements PrePersistListener { private static boolean prePersist; private static boolean prePersistWithEntity; private static boolean foundDatastore; - @PrePersist - void prePersist(Datastore datastore) { + @Override + public void prePersist(LifecycleTestObj obj, Datastore datastore) { foundDatastore = datastore != null; prePersist = true; - } - - @PrePersist - void prePersist(LifecycleTestObj obj) { - if (obj == null) { - throw new RuntimeException(); - } - prePersistWithEntity = true; - + prePersistWithEntity = obj != null; } } diff --git a/core/src/test/java/dev/morphia/test/lifecycle/TestTypedListeners.java b/core/src/test/java/dev/morphia/test/lifecycle/TestTypedListeners.java index 3863a9ea58a..90ec4271c15 100644 --- a/core/src/test/java/dev/morphia/test/lifecycle/TestTypedListeners.java +++ b/core/src/test/java/dev/morphia/test/lifecycle/TestTypedListeners.java @@ -20,7 +20,9 @@ import dev.morphia.config.ManualMorphiaConfig; import dev.morphia.config.MorphiaConfig; import dev.morphia.mapping.MapperType; +import dev.morphia.mapping.MappingException; import dev.morphia.mapping.lifecycle.TypedListeners; +import dev.morphia.test.lifecycle.invalid.InvalidListeners; import org.bson.BsonBinaryReader; import org.bson.BsonBinaryWriter; @@ -97,8 +99,55 @@ void entityListenersClassesCanImplementTheInterfaces(MapperType mapperType) { decode(codec, encode(codec, new Listened())); - // the annotated method and the interface's share one instance of the listener - Assertions.assertEquals(List.of("annotated:1", "typed:2", "postLoad"), CALLS); + Assertions.assertEquals(List.of("created", "typed:1", "postLoad"), CALLS); + } + } + + @ParameterizedTest + @EnumSource(MapperType.class) + void entityListenersClassesCantDeclareAnnotatedMethods(MapperType mapperType) { + try (MongoClient client = MongoClients.create("mongodb://localhost:1")) { + MorphiaDatastore datastore = datastore(client, mapperType); + + MappingException e = Assertions.assertThrows(MappingException.class, + () -> datastore.getMapper().map(InvalidListeners.AnnotatedListened.class)); + Assertions.assertTrue(e.getMessage().contains(InvalidListeners.Annotated.class.getName() + " declares annotated()"), + e.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(MapperType.class) + void entityListenersClassesMustImplementAnInterface(MapperType mapperType) { + try (MongoClient client = MongoClients.create("mongodb://localhost:1")) { + MorphiaDatastore datastore = datastore(client, mapperType); + + MappingException e = Assertions.assertThrows(MappingException.class, + () -> datastore.getMapper().map(InvalidListeners.MarkerListened.class)); + Assertions.assertTrue(e.getMessage().contains(InvalidListeners.MarkerOnly.class.getName()), e.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(MapperType.class) + void anEntityListenersClassThatFailsToBeCreatedReportsWhy(MapperType mapperType) { + try (MongoClient client = MongoClients.create("mongodb://localhost:1")) { + MorphiaDatastore datastore = datastore(client, mapperType); + + MappingException e = Assertions.assertThrows(MappingException.class, + () -> datastore.getMapper().map(InvalidListeners.FailingListened.class)); + Assertions.assertTrue(e.getMessage().contains("no listener today"), e.getMessage()); + } + } + + @ParameterizedTest + @EnumSource(MapperType.class) + void entityListenersClassesAreCreatedWhenTheEntityIsMapped(MapperType mapperType) { + try (MongoClient client = MongoClients.create("mongodb://localhost:1")) { + MorphiaDatastore datastore = datastore(client, mapperType); + datastore.getMapper().map(Listened.class); + + Assertions.assertEquals(List.of("created"), CALLS); } } @@ -234,15 +283,11 @@ public void prePersist(Plain entity, Datastore datastore) { } } - /** - * Has both an annotated method and an interface's. - */ public static class Counting implements PrePersistListener, PostLoadListener { private int count; - @PrePersist - void annotated(Listened entity) { - CALLS.add("annotated:" + ++count); + Counting() { + CALLS.add("created"); } @Override diff --git a/core/src/test/java/dev/morphia/test/lifecycle/invalid/InvalidListeners.java b/core/src/test/java/dev/morphia/test/lifecycle/invalid/InvalidListeners.java new file mode 100644 index 00000000000..36c389dbe2d --- /dev/null +++ b/core/src/test/java/dev/morphia/test/lifecycle/invalid/InvalidListeners.java @@ -0,0 +1,67 @@ +package dev.morphia.test.lifecycle.invalid; + +import dev.morphia.Datastore; +import dev.morphia.EntityListener; +import dev.morphia.PostLoadListener; +import dev.morphia.annotations.Entity; +import dev.morphia.annotations.EntityListeners; +import dev.morphia.annotations.Id; +import dev.morphia.annotations.PrePersist; + +import org.bson.types.ObjectId; + +/** + * Entities whose {@code @EntityListeners} classes fail to map. They're kept out of {@code dev.morphia.test.lifecycle}, which + * other tests scan. + */ +public final class InvalidListeners { + private InvalidListeners() { + } + + @Entity("invalid_listeners_annotated") + @EntityListeners(Annotated.class) + public static class AnnotatedListened { + @Id + ObjectId id; + } + + @Entity("invalid_listeners_marker") + @EntityListeners(MarkerOnly.class) + public static class MarkerListened { + @Id + ObjectId id; + } + + /** + * Has an annotated method, which only an entity may have, as well as an interface's. + */ + public static class Annotated implements PostLoadListener { + @PrePersist + void annotated(AnnotatedListened entity) { + } + + @Override + public void postLoad(AnnotatedListened entity, Datastore datastore) { + } + } + + public static class MarkerOnly implements EntityListener { + } + + @Entity("invalid_listeners_failing") + @EntityListeners(Failing.class) + public static class FailingListened { + @Id + ObjectId id; + } + + public static class Failing implements PostLoadListener { + public Failing() { + throw new IllegalStateException("no listener today"); + } + + @Override + public void postLoad(FailingListened entity, Datastore datastore) { + } + } +} diff --git a/core/src/test/java/dev/morphia/test/models/ExampleListener.java b/core/src/test/java/dev/morphia/test/models/ExampleListener.java new file mode 100644 index 00000000000..8372f420952 --- /dev/null +++ b/core/src/test/java/dev/morphia/test/models/ExampleListener.java @@ -0,0 +1,14 @@ +package dev.morphia.test.models; + +import dev.morphia.Datastore; +import dev.morphia.PostLoadListener; + +/** + * The critter generator tests' {@code @EntityListeners} class. It lives outside {@code dev.morphia.critter}, which the critter + * class loader loads its own copies of, so the annotation the generated model reads names the same class. + */ +public class ExampleListener implements PostLoadListener { + @Override + public void postLoad(Object entity, Datastore datastore) { + } +} diff --git a/design/mapping-game-plan.md b/design/mapping-game-plan.md index 3efe30e5376..a3e6703e664 100644 --- a/design/mapping-game-plan.md +++ b/design/mapping-game-plan.md @@ -442,8 +442,15 @@ harness passes, and both mappers agree. - Fix the `CritterEntityModel` javadoc and `design/GeneratedCode.adoc` item 2, which recommends the constant-getter approach that C1 shows the JIT can't optimize. -### 4.14 Annotated lifecycle methods become entity-only (follow-up to #4432) -Decide once #4431 has landed and the listener code has settled, based on what's left in `EntityListenerAdapter`. +### ~~4.14 Annotated lifecycle methods become entity-only (follow-up to #4432)~~ +Decided after 4.2: removed outright in 3.0 rather than deprecated. With 4.2's `PostPersistDocumentListener` and +`PostLoadDocumentListener`, every annotated method has an interface to move to, so the cost below no longer applies, and the +new `AnnotatedListenerMethodMigration` recipe moves them: the class implements each event's interface, whose method calls the +annotated methods. Mapping rejects an `@EntityListeners` class that declares annotated methods or implements no interface. +`EntityListenerAdapter` now handles only the entity's own methods; `@EntityListeners` instances are created when the model is +set up and called through `TypedListeners`. + +The original notes: - `@EntityListeners` classes would implement the per-event interfaces only. Their annotated methods are deprecated first, not removed. `EntityModel.callLifecycleMethods` then has three paths: the entity's own annotated methods (reflective, `OnEntityListenerAdapter`), and `@EntityListeners` instances and global listeners (both @@ -511,7 +518,7 @@ Only start after Phases 3 and 4 have shipped and been re-measured. 6. 3.1 → 3.9, with JMH deltas 7. 3.10–3.15 startup, lifecycle and codec quick wins; 3.16 primitive arrays (independent) 8. 4.1 sessions share the mapper → #4432 listener interfaces → #4431 configured listeners → 4.2 lifecycle - streaming; 4.14 entity-only annotated methods once #4431 settles + streaming; ~~4.14 entity-only annotated methods~~ 9. 4.4 generic resolver 10. Phase 5 A11 builder/annotation utility → 4.5 parity test → 4.6 shared rules → 4.7 plain models → 4.8 divergences → 4.9–4.11 → 4.12 AOT guard + 4.13 API surface (**before 3.0 GA**) diff --git a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java new file mode 100644 index 00000000000..ce071278f7e --- /dev/null +++ b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java @@ -0,0 +1,483 @@ +package dev.morphia.rewrite.recipes.lifecycle; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.Nullable; +import org.openrewrite.Cursor; +import org.openrewrite.ExecutionContext; +import org.openrewrite.NlsRewrite.Description; +import org.openrewrite.NlsRewrite.DisplayName; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.Tree; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.java.AnnotationMatcher; +import org.openrewrite.java.ImplementInterface; +import org.openrewrite.java.JavaIsoVisitor; +import org.openrewrite.java.JavaParser; +import org.openrewrite.java.JavaTemplate; +import org.openrewrite.java.RemoveAnnotationVisitor; +import org.openrewrite.java.tree.Expression; +import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JContainer; +import org.openrewrite.java.tree.JRightPadded; +import org.openrewrite.java.tree.JavaType; +import org.openrewrite.java.tree.Space; +import org.openrewrite.java.tree.Statement; +import org.openrewrite.java.tree.TypeTree; +import org.openrewrite.java.tree.TypeUtils; +import org.openrewrite.marker.Markers; +import org.openrewrite.marker.SearchResult; + +import static dev.morphia.rewrite.recipes.RewriteUtils.runtimeClasspathPaths; + +/** + * Moves the annotated lifecycle methods of {@code @EntityListeners} classes onto the per-event listener interfaces. Only an + * entity's own methods may be annotated in 3.0. The class implements each event's interface, whose method calls the class's + * annotated methods for the event in the order they're declared, and the annotations are removed. + */ +public class AnnotatedListenerMethodMigration extends ScanningRecipe { + private static final String ENTITY_LISTENER = "dev.morphia.EntityListener"; + private static final String ENTITY_LISTENERS = "dev.morphia.annotations.EntityListeners"; + private static final String DATASTORE = "dev.morphia.Datastore"; + private static final String DOCUMENT = "org.bson.Document"; + private static final List ENTITY_ANNOTATIONS = List.of("dev.morphia.annotations.Entity", + "dev.morphia.annotations.Embedded", "dev.morphia.annotations.ExternalEntity"); + private static final Map EVENTS = new LinkedHashMap<>(); + + static { + for (Event event : List.of( + new Event("PrePersist", "prePersist", "PrePersistListener", null), + new Event("PostPersist", "postPersist", "PostPersistListener", "PostPersistDocumentListener"), + new Event("PreLoad", "preLoad", "PreLoadListener", "PreLoadListener"), + new Event("PostLoad", "postLoad", "PostLoadListener", "PostLoadDocumentListener"))) { + EVENTS.put("dev.morphia.annotations." + event.annotation(), event); + } + } + + /** Creates a new instance. */ + public AnnotatedListenerMethodMigration() { + } + + @NotNull + @Override + public @DisplayName String getDisplayName() { + return "Move the annotated methods of @EntityListeners classes to the listener interfaces"; + } + + @NotNull + @Override + public @Description String getDescription() { + return "Only an entity's own methods may be annotated with @PrePersist, @PostPersist, @PreLoad or @PostLoad. A class " + + "listed in @EntityListeners implements the event's listener interface instead, whose method calls the class's " + + "annotated methods. Methods that can't be called that way are marked."; + } + + @NotNull + @Override + public Accumulator getInitialValue(@NotNull ExecutionContext ctx) { + return new Accumulator(); + } + + @NotNull + @Override + public TreeVisitor getScanner(@NotNull Accumulator accumulator) { + return new JavaIsoVisitor<>() { + @Override + public J.Annotation visitAnnotation(J.Annotation annotation, ExecutionContext ctx) { + if (TypeUtils.isOfClassType(annotation.getType(), ENTITY_LISTENERS)) { + new JavaIsoVisitor>() { + @Override + public J.FieldAccess visitFieldAccess(J.FieldAccess fieldAccess, Set listed) { + JavaType.FullyQualified type = TypeUtils.asFullyQualified(fieldAccess.getTarget().getType()); + if (fieldAccess.getSimpleName().equals("class") && type != null) { + listed.add(type.getFullyQualifiedName()); + } + return fieldAccess; + } + }.visit(annotation, accumulator.listed); + } + return super.visitAnnotation(annotation, ctx); + } + + @Override + public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext ctx) { + if (!events(method).isEmpty() && !method.hasModifier(J.Modifier.Type.Static) && method.getMethodType() != null) { + accumulator.annotated.add(method.getMethodType().getDeclaringType().getFullyQualifiedName()); + } + return method; + } + }; + } + + @NotNull + @Override + public TreeVisitor getVisitor(@NotNull Accumulator accumulator) { + return new Migrator(accumulator); + } + + /** + * The classes listed in {@code @EntityListeners}, and the classes that ever declared an annotated lifecycle method. + */ + static final class Accumulator { + private final Set listed = new HashSet<>(); + private final Set annotated = new HashSet<>(); + } + + private static final class Migrator extends JavaIsoVisitor { + private final Accumulator accumulator; + + Migrator(Accumulator accumulator) { + this.accumulator = accumulator; + } + + @Override + public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) { + J.ClassDeclaration result = super.visitClassDeclaration(classDecl, ctx); + JavaType.FullyQualified type = result.getType(); + if (type == null || result.getKind() == J.ClassDeclaration.Kind.Type.Interface || EntityListenerMigration.marked(result) + || isEntity(result) || !(accumulator.listed.contains(type.getFullyQualifiedName()) + || TypeUtils.isAssignableTo(ENTITY_LISTENER, type))) { + return result; + } + Map> methods = annotatedMethods(result); + if (methods.isEmpty()) { + return result; + } + String inherited = listenerSuperclass(type); + if (inherited != null) { + return SearchResult.found(result, ("extends %s, which has lifecycle methods of its own, and only an entity's own " + + "annotated methods are called now: implement the per-event listener interfaces by hand, calling the " + + "superclass's").formatted(inherited)); + } + JavaType entityType = entityType(type, methods); + if (entityType == null) { + return SearchResult.found(result, "its lifecycle methods take unrelated entity types, and only an entity's own " + + "annotated methods are called now: implement the per-event listener interfaces by hand"); + } + String entityName = name(entityType); + + Map problems = new LinkedHashMap<>(); + // the events each method moves to an interface for + Map> moved = new HashMap<>(); + List interfaces = new ArrayList<>(); + List generated = new ArrayList<>(); + for (Map.Entry> entry : methods.entrySet()) { + Event event = entry.getKey(); + List eventMethods = entry.getValue(); + List calls = new ArrayList<>(); + boolean document = event.annotation().equals("PreLoad"); + for (J.MethodDeclaration method : eventMethods) { + Call call = call(method, event, entityType); + if (call.problem() != null) { + problems.put(method, call.problem()); + } else { + calls.add(call); + document |= call.document(); + } + } + if (calls.isEmpty()) { + continue; + } + String listener = document ? event.documentListener() : event.listener(); + int arity = document ? 3 : 2; + J.MethodDeclaration existing = declared(result, event.method(), arity); + // either of the event's interfaces, or a method that implements one: a class may implement only one of them + J.MethodDeclaration other = declared(result, event.method(), document ? 2 : 3); + if (existing == null && (other != null || implementsEvent(result, event))) { + calls.forEach(call -> problems.put(call.method(), ("the class already implements %s() for this event, so this " + + "method can't be called from %s's: call it from there by hand").formatted(event.method(), listener))); + continue; + } + if (existing != null) { + if (calls.size() == 1 && calls.get(0).method() == existing && existing.hasModifier(J.Modifier.Type.Public) + && calls.get(0).code().equals("%s(entity, %sdatastore)".formatted(event.method(), + document ? "document, " : "")) + && TypeUtils.isOfType(existing.getMethodType().getParameterTypes().get(0), entityType)) { + // already the interface's method + moved.computeIfAbsent(existing, m -> new ArrayList<>()).add(event); + interfaces.add(listener); + } else { + calls.forEach(call -> problems.put(call.method(), ("the class already declares %s() with %d parameters, so " + + "this method can't be called from %s's: call it from there by hand") + .formatted(event.method(), arity, listener))); + } + continue; + } + calls.forEach(call -> moved.computeIfAbsent(call.method(), m -> new ArrayList<>()).add(event)); + interfaces.add(listener); + StringBuilder code = new StringBuilder("@Override\npublic void %s(%s entity, %sDatastore datastore) {\n" + .formatted(event.method(), entityName, document ? "Document document, " : "")); + calls.forEach(call -> code.append(call.code()).append(";\n")); + generated.add(code.append("}").toString()); + } + + J.ClassDeclaration updated = result.withBody(result.getBody().withStatements(ListUtils.map( + result.getBody().getStatements(), statement -> { + if (statement instanceof J.MethodDeclaration method) { + J.MethodDeclaration migrated = removeAnnotations(method, moved.getOrDefault(method, List.of()), ctx); + return problems.containsKey(method) ? SearchResult.found(migrated, problems.get(method)) : migrated; + } + return statement; + }))); + if (interfaces.isEmpty()) { + return updated; + } + updated = implement(updated, interfaces, entityType, ctx); + for (String code : generated) { + updated = JavaTemplate.builder(code) + .imports(DATASTORE, DOCUMENT) + .javaParser(JavaParser.fromJavaVersion().classpath(runtimeClasspathPaths())) + .build() + .apply(new Cursor(getCursor().getParentOrThrow(), updated), + updated.getBody().getCoordinates().lastStatement()); + } + maybeAddImport(DATASTORE, false); + if (generated.stream().anyMatch(code -> code.contains("Document"))) { + maybeAddImport(DOCUMENT, false); + } + interfaces.forEach(listener -> maybeAddImport("dev.morphia." + listener, false)); + if (entityType instanceof JavaType.FullyQualified fullyQualified) { + maybeAddImport(fullyQualified.getFullyQualifiedName(), false); + } + EVENTS.values().forEach(event -> maybeRemoveImport("dev.morphia.annotations." + event.annotation())); + return updated; + } + + /** + * Adds the interfaces, in place of a directly implemented {@code EntityListener}. + */ + private J.ClassDeclaration implement(J.ClassDeclaration classDecl, List interfaces, JavaType entityType, + ExecutionContext ctx) { + J.ClassDeclaration result = classDecl; + if (result.getImplements() != null) { + List> kept = ListUtils.map(result.getPadding().getImplements().getPadding().getElements(), + padded -> TypeUtils.isOfClassType(padded.getElement().getType(), ENTITY_LISTENER) ? null : padded); + if (kept.size() != result.getImplements().size()) { + maybeRemoveImport(ENTITY_LISTENER); + result = result.getPadding().withImplements(kept.isEmpty() ? null + : JContainer.build(result.getPadding().getImplements().getBefore(), kept, Markers.EMPTY)); + } + } + for (String listener : interfaces) { + J.Identifier argument = new J.Identifier(Tree.randomId(), Space.EMPTY, Markers.EMPTY, List.of(), name(entityType), + entityType, null); + result = (J.ClassDeclaration) new ImplementInterface(result, + JavaType.ShallowClass.build("dev.morphia." + listener), List. of(argument)) + .visitNonNull(result, ctx, getCursor().getParentOrThrow()); + } + return result; + } + + private J.MethodDeclaration removeAnnotations(J.MethodDeclaration method, List events, ExecutionContext ctx) { + J.MethodDeclaration result = method; + for (Event event : events) { + result = (J.MethodDeclaration) new RemoveAnnotationVisitor( + new AnnotationMatcher("@dev.morphia.annotations." + event.annotation())) + .visitNonNull(result, ctx, getCursor()); + } + return result; + } + + /** + * @return the first superclass that declares an annotated lifecycle method or is a listener, whose methods a generated + * one would override or leave uncalled; or null + */ + private @Nullable String listenerSuperclass(JavaType.FullyQualified type) { + for (JavaType.FullyQualified parent = type.getSupertype(); parent != null + && !parent.getFullyQualifiedName().equals("java.lang.Object"); parent = parent.getSupertype()) { + if (accumulator.annotated.contains(parent.getFullyQualifiedName()) + || TypeUtils.isAssignableTo(ENTITY_LISTENER, parent) + || parent.getMethods().stream().anyMatch(method -> method.getAnnotations().stream() + .anyMatch(annotation -> EVENTS.containsKey(annotation.getFullyQualifiedName())))) { + return parent.getFullyQualifiedName(); + } + } + return null; + } + } + + /** + * @return the class's annotated lifecycle methods by event, each event's in the order they're declared + */ + private static Map> annotatedMethods(J.ClassDeclaration classDecl) { + Map> methods = new LinkedHashMap<>(); + for (Statement statement : classDecl.getBody().getStatements()) { + if (statement instanceof J.MethodDeclaration method && !EntityListenerMigration.marked(method) + && !method.hasModifier(J.Modifier.Type.Static)) { + events(method).forEach(event -> methods.computeIfAbsent(event, e -> new ArrayList<>()).add(method)); + } + } + return methods; + } + + /** + * @return the events the method is annotated for: one method may handle several + */ + private static List events(J.MethodDeclaration method) { + List events = new ArrayList<>(); + for (J.Annotation annotation : method.getLeadingAnnotations()) { + JavaType.FullyQualified type = TypeUtils.asFullyQualified(annotation.getType()); + if (type != null && EVENTS.containsKey(type.getFullyQualifiedName())) { + events.add(EVENTS.get(type.getFullyQualifiedName())); + } + } + return events; + } + + /** + * The interfaces' type argument: the class's {@code EntityListener} type argument if it has one, otherwise the most + * specific of the methods' entity parameter types, or {@code Object} when they take no entity. + * + * @return the type, or null if the methods' entity types are unrelated or can't be named + */ + private static @Nullable JavaType entityType(JavaType.FullyQualified type, Map> methods) { + JavaType argument = EntityListenerMigration.listenerArgument(type, Map.of(), new HashSet<>()); + if (argument != null && !(argument instanceof JavaType.GenericTypeVariable variable && variable.getName().equals("?"))) { + return nameable(argument) ? argument : null; + } + List candidates = new ArrayList<>(); + methods.values().forEach(list -> list.forEach(method -> { + JavaType.Method methodType = method.getMethodType(); + if (methodType != null) { + methodType.getParameterTypes().stream() + .filter(parameter -> !isDatastore(parameter) && !isDocument(parameter)) + .forEach(candidates::add); + } + })); + if (candidates.isEmpty()) { + return JavaType.ShallowClass.build("java.lang.Object"); + } + for (JavaType candidate : candidates) { + if (nameable(candidate) && candidates.stream().allMatch(other -> TypeUtils.isAssignableTo(other, candidate))) { + return candidate; + } + } + return null; + } + + /** + * Works out how the interface's method calls an annotated method. + */ + private static Call call(J.MethodDeclaration method, Event event, JavaType entityType) { + JavaType.Method methodType = method.getMethodType(); + if (methodType == null) { + return Call.problem(method, "its type isn't known: call it from %s by hand".formatted(event.listener())); + } + for (JavaType thrown : methodType.getThrownExceptions()) { + if (!TypeUtils.isAssignableTo("java.lang.RuntimeException", thrown) && !TypeUtils.isAssignableTo("java.lang.Error", thrown)) { + return Call.problem(method, ("it throws a checked exception, which %s's method can't: call it from there by hand") + .formatted(event.listener())); + } + } + List arguments = new ArrayList<>(); + boolean document = false; + List parameters = method.getParameters(); + for (int i = 0; i < methodType.getParameterTypes().size(); i++) { + JavaType parameter = methodType.getParameterTypes().get(i); + if (isDatastore(parameter)) { + arguments.add("datastore"); + } else if (isDocument(parameter)) { + if (event.documentListener() != null) { + arguments.add("document"); + document = true; + } else if (parameters.get(i) instanceof J.VariableDeclarations declaration + && EntityListenerMigration.uses(method, declaration)) { + return Call.problem(method, "PrePersistListener takes no Document: the one this method was given was always " + + "empty, as the entity isn't encoded yet. To change what's saved, implement PostPersistDocumentListener"); + } else { + arguments.add("new Document()"); + } + } else if (accepts(parameter, entityType)) { + arguments.add("entity"); + } else { + return Call.problem(method, ("%s's method can't pass its %s entity to this method's %s parameter: call it from " + + "there by hand").formatted(event.listener(), name(entityType), name(parameter))); + } + } + return new Call(method, "%s(%s)".formatted(method.getSimpleName(), String.join(", ", arguments)), document, null); + } + + private static boolean accepts(JavaType parameter, JavaType entityType) { + if (TypeUtils.isOfClassType(parameter, "java.lang.Object")) { + return true; + } + if (entityType instanceof JavaType.GenericTypeVariable variable) { + if (parameter instanceof JavaType.GenericTypeVariable other && other.getName().equals(variable.getName())) { + return true; + } + return variable.getBounds().stream().anyMatch(bound -> TypeUtils.isAssignableTo(parameter, bound)); + } + return TypeUtils.isAssignableTo(parameter, entityType); + } + + private static J.@Nullable MethodDeclaration declared(J.ClassDeclaration classDecl, String name, int arity) { + for (Statement statement : classDecl.getBody().getStatements()) { + if (statement instanceof J.MethodDeclaration method && method.getSimpleName().equals(name) + && method.getParameters().stream().filter(parameter -> !(parameter instanceof J.Empty)).count() == arity) { + return method; + } + } + return null; + } + + private static boolean implementsEvent(J.ClassDeclaration classDecl, Event event) { + return classDecl.getImplements() != null && classDecl.getImplements().stream().anyMatch(type -> Stream + .of(event.listener(), event.documentListener()) + .anyMatch(listener -> listener != null && TypeUtils.isOfClassType(type.getType(), "dev.morphia." + listener))); + } + + private static boolean isEntity(J.ClassDeclaration classDecl) { + return classDecl.getLeadingAnnotations().stream() + .anyMatch(annotation -> ENTITY_ANNOTATIONS.stream().anyMatch(name -> TypeUtils.isOfClassType(annotation.getType(), name))); + } + + private static boolean isDatastore(JavaType type) { + return TypeUtils.isOfClassType(type, DATASTORE); + } + + private static boolean isDocument(JavaType type) { + return TypeUtils.isOfClassType(type, DOCUMENT); + } + + private static boolean nameable(JavaType type) { + return type instanceof JavaType.GenericTypeVariable + || type instanceof JavaType.Class c && !(type instanceof JavaType.Parameterized) && c.getTypeParameters().isEmpty(); + } + + private static String name(JavaType type) { + if (type instanceof JavaType.GenericTypeVariable variable) { + return variable.getName(); + } else if (type instanceof JavaType.FullyQualified fullyQualified) { + return fullyQualified.getClassName(); + } + return type.toString(); + } + + /** + * A call from an interface's method to an annotated method, or why there can't be one. + * + * @param document true if it passes the {@code Document} + */ + private record Call(J.MethodDeclaration method, String code, boolean document, @Nullable String problem) { + static Call problem(J.MethodDeclaration method, String problem) { + return new Call(method, "", false, problem); + } + } + + /** + * A lifecycle event. + * + * @param documentListener the interface whose method takes the {@code Document}, or null if there's none + */ + private record Event(String annotation, String method, String listener, @Nullable String documentListener) { + } +} diff --git a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/EntityListenerMigration.java b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/EntityListenerMigration.java index d85ef4645cd..62be000a4e4 100644 --- a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/EntityListenerMigration.java +++ b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/EntityListenerMigration.java @@ -628,7 +628,7 @@ private static boolean droppable(Expression argument) { * Resolves the {@code EntityListener} type argument a type inherits, substituting type variables along the way: for * {@code Listener implements Audit} and {@code Audit extends EntityListener}, it's {@code Listener}'s {@code T}. */ - private static @Nullable JavaType listenerArgument(@Nullable JavaType type, Map bindings, Set seen) { + static @Nullable JavaType listenerArgument(@Nullable JavaType type, Map bindings, Set seen) { JavaType.FullyQualified raw; Map resolved = new HashMap<>(); if (type instanceof JavaType.Parameterized parameterized) { @@ -699,11 +699,11 @@ private static boolean toSuper(J.MethodInvocation invocation, String name) { && invocation.getSimpleName().equals(name); } - private static boolean marked(J method) { + static boolean marked(J method) { return method.getMarkers().findFirst(SearchResult.class).isPresent(); } - private static boolean uses(J.MethodDeclaration method, J.VariableDeclarations parameter) { + static boolean uses(J.MethodDeclaration method, J.VariableDeclarations parameter) { if (method.getBody() == null) { return false; } diff --git a/rewrite/src/main/resources/META-INF/rewrite/rewrite.yml b/rewrite/src/main/resources/META-INF/rewrite/rewrite.yml index 9a73c3053b7..2a1f31ee0c3 100644 --- a/rewrite/src/main/resources/META-INF/rewrite/rewrite.yml +++ b/rewrite/src/main/resources/META-INF/rewrite/rewrite.yml @@ -12,6 +12,7 @@ recipeList: oldFullyQualifiedTypeName: dev.morphia.EntityInterceptor newFullyQualifiedTypeName: dev.morphia.EntityListener - dev.morphia.rewrite.recipes.lifecycle.EntityListenerMigration + - dev.morphia.rewrite.recipes.lifecycle.AnnotatedListenerMethodMigration - org.openrewrite.java.ChangeType: oldFullyQualifiedTypeName: dev.morphia.DatastoreImpl newFullyQualifiedTypeName: dev.morphia.MorphiaDatastore diff --git a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java new file mode 100644 index 00000000000..81d692b0f5c --- /dev/null +++ b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java @@ -0,0 +1,645 @@ +package dev.morphia.rewrite.recipes.test.lifecycle; + +import dev.morphia.rewrite.recipes.lifecycle.AnnotatedListenerMethodMigration; +import dev.morphia.rewrite.recipes.lifecycle.EntityListenerMigration; +import dev.morphia.rewrite.recipes.test.MorphiaRewriteTest; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.openrewrite.Recipe; +import org.openrewrite.test.RecipeSpec; +import org.openrewrite.test.TypeValidation; + +import static org.openrewrite.java.Assertions.java; + +class AnnotatedListenerMethodMigrationTest extends MorphiaRewriteTest { + //language=java + private static final String ORDER = """ + package app; + + import dev.morphia.annotations.Entity; + import dev.morphia.annotations.EntityListeners; + import dev.morphia.annotations.Id; + + @Entity + @EntityListeners(Stamping.class) + public class Order { + @Id + String id; + } + """; + + @NotNull + @Override + protected Recipe getRecipe() { + return new AnnotatedListenerMethodMigration(); + } + + @Override + public void defaults(RecipeSpec spec) { + super.defaults(spec); + // the new interfaces aren't on the pre-3.0 classpath the sources are parsed against + spec.afterTypeValidationOptions(TypeValidation.none()); + } + + @Test + void movesTheMethodsOfAListedClassToTheInterfaces() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostLoad; + import dev.morphia.annotations.PrePersist; + import org.bson.Document; + + public class Stamping { + @PrePersist + void stamp(Order order) { + System.out.println(order); + } + + @PostLoad + void check(Document document, Order order, Datastore datastore) { + System.out.println(document); + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadDocumentListener; + import dev.morphia.PrePersistListener; + import org.bson.Document; + + public class Stamping implements PrePersistListener, PostLoadDocumentListener { + void stamp(Order order) { + System.out.println(order); + } + + void check(Document document, Order order, Datastore datastore) { + System.out.println(document); + } + + @Override + public void prePersist(Order entity, Datastore datastore) { + stamp(entity); + } + + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + check(document, entity, datastore); + } + } + """)); + } + + @Test + void callsEachMethodForAnEventInOrder() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + void first() { + } + + @PostLoad + void postLoad(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadListener; + + public class Stamping implements PostLoadListener { + void first() { + } + + void postLoad(Order order) { + } + + @Override + public void postLoad(Order entity, Datastore datastore) { + first(); + postLoad(entity); + } + } + """)); + } + + @Test + void replacesTheEntityListenerInterfaceAndUsesItsTypeArgument() { + rewriteRun(java( + //language=java + """ + package app; + + import dev.morphia.EntityListener; + import dev.morphia.annotations.PreLoad; + import org.bson.Document; + + public class Cleaning implements EntityListener { + @PreLoad + void clean(Document document) { + document.remove("legacy"); + } + + @Override + public boolean hasAnnotation(Class type) { + return true; + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PreLoadListener; + import org.bson.Document; + + public class Cleaning implements PreLoadListener { + void clean(Document document) { + document.remove("legacy"); + } + + @Override + public boolean hasAnnotation(Class type) { + return true; + } + + @Override + public void preLoad(Object entity, Document document, Datastore datastore) { + clean(document); + } + } + """)); + } + + @Test + void reusesAMethodThatIsAlreadyTheInterfacesMethod() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostPersist; + + public class Stamping { + @PostPersist + public void postPersist(Order order, Datastore datastore) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostPersistListener; + + public class Stamping implements PostPersistListener { + public void postPersist(Order order, Datastore datastore) { + } + } + """)); + } + + @Test + void passesAnEmptyDocumentToAPrePersistMethodThatIgnoresIt() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + import org.bson.Document; + + public class Stamping { + @PrePersist + void stamp(Order order, Document document) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PrePersistListener; + import org.bson.Document; + + public class Stamping implements PrePersistListener { + void stamp(Order order, Document document) { + } + + @Override + public void prePersist(Order entity, Datastore datastore) { + stamp(entity, new Document()); + } + } + """)); + } + + @Test + void flagsAPrePersistMethodThatUsesTheDocument() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + import org.bson.Document; + + public class Stamping { + @PrePersist + void stamp(Order order, Document document) { + document.put("stamp", 1); + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + import org.bson.Document; + + public class Stamping { + /*~~(PrePersistListener takes no Document: the one this method was given was always empty, as the entity isn't encoded yet. To change what's saved, implement PostPersistDocumentListener)~~>*/@PrePersist + void stamp(Order order, Document document) { + document.put("stamp", 1); + } + } + """)); + } + + @Test + void leavesAnEntitysOwnMethods() { + rewriteRun(java( + //language=java + """ + package app; + + import dev.morphia.annotations.Entity; + import dev.morphia.annotations.EntityListeners; + import dev.morphia.annotations.Id; + import dev.morphia.annotations.PrePersist; + + @Entity + @EntityListeners(Order.class) + public class Order { + @Id + String id; + + @PrePersist + void stamp() { + } + } + """)); + } + + @Test + void leavesAClassThatIsntAListener() { + rewriteRun(java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + + public class Base { + @PrePersist + void stamp() { + } + } + """)); + } + + @Test + void flagsAListenerThatInheritsAnnotatedMethods() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + + public class Base { + @PrePersist + void stamp() { + } + } + """), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping extends Base { + @PostLoad + void check() { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + /*~~(extends app.Base, which has lifecycle methods of its own, and only an entity's own annotated methods are called now: implement the per-event listener interfaces by hand, calling the superclass's)~~>*/public class Stamping extends Base { + @PostLoad + void check() { + } + } + """)); + } + + @Test + void flagsMethodsThatTakeUnrelatedEntityTypes() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + import dev.morphia.annotations.PrePersist; + + public class Stamping { + @PrePersist + void stamp(Order order) { + } + + @PostLoad + void check(String other) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + import dev.morphia.annotations.PrePersist; + + /*~~(its lifecycle methods take unrelated entity types, and only an entity's own annotated methods are called now: implement the per-event listener interfaces by hand)~~>*/public class Stamping { + @PrePersist + void stamp(Order order) { + } + + @PostLoad + void check(String other) { + } + } + """)); + } + + @Test + void flagsAMethodThatThrowsACheckedException() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + void check(Order order) throws java.io.IOException { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping { + /*~~(it throws a checked exception, which PostLoadListener's method can't: call it from there by hand)~~>*/@PostLoad + void check(Order order) throws java.io.IOException { + } + } + """)); + } + + @Test + void runsAfterTheEntityListenerMigration() { + rewriteRun(spec -> spec.recipes(new EntityListenerMigration(), new AnnotatedListenerMethodMigration()), + java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.EntityListener; + import dev.morphia.annotations.PrePersist; + import org.bson.Document; + + import java.lang.annotation.Annotation; + + public class Stamping implements EntityListener { + @Override + public boolean hasAnnotation(Class type) { + return true; + } + + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + System.out.println(entity); + } + + @PrePersist + void stamp(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadListener; + import dev.morphia.PrePersistListener; + + public class Stamping implements PostLoadListener, PrePersistListener { + @Override + public void postLoad(Order entity, Datastore datastore) { + System.out.println(entity); + } + + void stamp(Order order) { + } + + @Override + public void prePersist(Order entity, Datastore datastore) { + stamp(entity); + } + } + """)); + } + + @Test + void flagsASubclassOfAListener() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.EntityListener; + + public class Base implements EntityListener { + @Override + public boolean hasAnnotation(Class type) { + return false; + } + } + """), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + + public class Stamping extends Base { + @PrePersist + void stamp(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + + /*~~(extends app.Base, which has lifecycle methods of its own, and only an entity's own annotated methods are called now: implement the per-event listener interfaces by hand, calling the superclass's)~~>*/public class Stamping extends Base { + @PrePersist + void stamp(Order order) { + } + } + """)); + } + + @Test + void flagsAMethodWhoseEventTheClassAlreadyImplements() { + rewriteRun(spec -> spec.recipes(new EntityListenerMigration(), new AnnotatedListenerMethodMigration()), + java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.EntityListener; + import dev.morphia.annotations.PostLoad; + import org.bson.Document; + + public class Stamping implements EntityListener { + @Override + public boolean hasAnnotation(Class type) { + return true; + } + + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + System.out.println(document); + } + + @PostLoad + void check(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadDocumentListener; + import dev.morphia.annotations.PostLoad; + import org.bson.Document; + + public class Stamping implements PostLoadDocumentListener { + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + System.out.println(document); + } + + /*~~(the class already implements postLoad() for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + void check(Order order) { + } + } + """)); + } + + @Test + void callsAMethodFromEachEventItsAnnotatedFor() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + import dev.morphia.annotations.PrePersist; + + public class Stamping { + @PrePersist + @PostLoad + void touch(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadListener; + import dev.morphia.PrePersistListener; + + public class Stamping implements PrePersistListener, PostLoadListener { + void touch(Order order) { + } + + @Override + public void prePersist(Order entity, Datastore datastore) { + touch(entity); + } + + @Override + public void postLoad(Order entity, Datastore datastore) { + touch(entity); + } + } + """)); + } +} From 2caf24d3c8dac0941ca4a4c75b527cf4adf80f4c Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 5 Nov 2026 16:47:12 -0500 Subject: [PATCH 2/4] Mark more listener cases the recipe can't migrate, and keep the annotated overload The recipe reuses a method as the interface's only if it returns void and the class doesn't implement the event's other interface, recognizes interfaces through subinterfaces and superclasses, marks a listener that only inherits annotated methods from a class that isn't a listener, and casts the entity to the annotated method's parameter type so an overload taking the entity's own type isn't called. --- .../AnnotatedListenerMethodMigration.java | 57 ++++-- .../AnnotatedListenerMethodMigrationTest.java | 171 ++++++++++++++++++ 2 files changed, 217 insertions(+), 11 deletions(-) diff --git a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java index ce071278f7e..9c151850442 100644 --- a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java +++ b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java @@ -150,7 +150,11 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex } Map> methods = annotatedMethods(result); if (methods.isEmpty()) { - return result; + String annotated = annotatedSuperclass(type); + return annotated == null ? result + : SearchResult.found(result, ("inherits annotated lifecycle methods from %s, " + + "which isn't a listener, and only an entity's own annotated methods are called now: implement the " + + "per-event listener interfaces by hand").formatted(annotated)); } String inherited = listenerSuperclass(type); if (inherited != null) { @@ -192,13 +196,16 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex J.MethodDeclaration existing = declared(result, event.method(), arity); // either of the event's interfaces, or a method that implements one: a class may implement only one of them J.MethodDeclaration other = declared(result, event.method(), document ? 2 : 3); - if (existing == null && (other != null || implementsEvent(result, event))) { + if (existing == null && (other != null || implementsEvent(result, type, event, null)) + || existing != null && implementsEvent(result, type, event, listener)) { calls.forEach(call -> problems.put(call.method(), ("the class already implements %s() for this event, so this " + "method can't be called from %s's: call it from there by hand").formatted(event.method(), listener))); continue; } if (existing != null) { if (calls.size() == 1 && calls.get(0).method() == existing && existing.hasModifier(J.Modifier.Type.Public) + && existing.getMethodType() != null + && existing.getMethodType().getReturnType() == JavaType.Primitive.Void && calls.get(0).code().equals("%s(entity, %sdatastore)".formatted(event.method(), document ? "document, " : "")) && TypeUtils.isOfType(existing.getMethodType().getParameterTypes().get(0), entityType)) { @@ -287,6 +294,27 @@ private J.MethodDeclaration removeAnnotations(J.MethodDeclaration method, List method.getAnnotations().stream() + .anyMatch(annotation -> EVENTS.containsKey(annotation.getFullyQualifiedName()))); + } + /** * @return the first superclass that declares an annotated lifecycle method or is a listener, whose methods a generated * one would override or leave uncalled; or null @@ -294,10 +322,7 @@ private J.MethodDeclaration removeAnnotations(J.MethodDeclaration method, List method.getAnnotations().stream() - .anyMatch(annotation -> EVENTS.containsKey(annotation.getFullyQualifiedName())))) { + if (declaresAnnotated(parent) || TypeUtils.isAssignableTo(ENTITY_LISTENER, parent)) { return parent.getFullyQualifiedName(); } } @@ -397,7 +422,8 @@ private static Call call(J.MethodDeclaration method, Event event, JavaType entit arguments.add("new Document()"); } } else if (accepts(parameter, entityType)) { - arguments.add("entity"); + // cast to the parameter's type, so an overload taking the entity's own type isn't called instead + arguments.add(TypeUtils.isOfType(parameter, entityType) ? "entity" : "(%s) entity".formatted(name(parameter))); } else { return Call.problem(method, ("%s's method can't pass its %s entity to this method's %s parameter: call it from " + "there by hand").formatted(event.listener(), name(entityType), name(parameter))); @@ -429,10 +455,19 @@ private static boolean accepts(JavaType parameter, JavaType entityType) { return null; } - private static boolean implementsEvent(J.ClassDeclaration classDecl, Event event) { - return classDecl.getImplements() != null && classDecl.getImplements().stream().anyMatch(type -> Stream - .of(event.listener(), event.documentListener()) - .anyMatch(listener -> listener != null && TypeUtils.isOfClassType(type.getType(), "dev.morphia." + listener))); + /** + * @param except an interface to leave out, or null + * @return true if the class implements one of the event's interfaces, directly, through a subinterface or a superclass, + * or as rewritten by an earlier recipe in this run + */ + private static boolean implementsEvent(J.ClassDeclaration classDecl, JavaType.FullyQualified type, Event event, + @Nullable String except) { + return Stream.of(event.listener(), event.documentListener()) + .filter(listener -> listener != null && !listener.equals(except)) + .map(listener -> "dev.morphia." + listener) + .anyMatch(listener -> TypeUtils.isAssignableTo(listener, type) || classDecl.getImplements() != null + && classDecl.getImplements().stream().anyMatch(implemented -> TypeUtils.isAssignableTo(listener, + implemented.getType()))); } private static boolean isEntity(J.ClassDeclaration classDecl) { diff --git a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java index 81d692b0f5c..b6cb26926bd 100644 --- a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java +++ b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java @@ -642,4 +642,175 @@ public void postLoad(Order entity, Datastore datastore) { } """)); } + + @Test + void doesNotReuseAMethodThatReturnsAValue() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + public int postLoad(Order order, Datastore datastore) { + return 1; + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostLoad; + + public class Stamping { + /*~~(the class already declares postLoad() with 2 parameters, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + public int postLoad(Order order, Datastore datastore) { + return 1; + } + } + """)); + } + + @Test + void flagsAListenerThatOnlyInheritsAnnotatedMethods() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PrePersist; + + public class Base { + @PrePersist + void stamp() { + } + } + """), + java( + //language=java + """ + package app; + + public class Stamping extends Base { + } + """, + //language=java + """ + package app; + + /*~~(inherits annotated lifecycle methods from app.Base, which isn't a listener, and only an entity's own annotated methods are called now: implement the per-event listener interfaces by hand)~~>*/public class Stamping extends Base { + } + """)); + } + + @Test + void doesNotReuseAMethodWhenTheClassImplementsTheOtherInterface() { + rewriteRun(spec -> spec.recipes(new EntityListenerMigration(), new AnnotatedListenerMethodMigration()), + java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.EntityListener; + import dev.morphia.annotations.PostLoad; + import org.bson.Document; + + public class Stamping implements EntityListener { + @Override + public boolean hasAnnotation(Class type) { + return true; + } + + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + System.out.println(document); + } + + @PostLoad + public void postLoad(Order order, Datastore datastore) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadDocumentListener; + import dev.morphia.annotations.PostLoad; + import org.bson.Document; + + public class Stamping implements PostLoadDocumentListener { + @Override + public void postLoad(Order entity, Document document, Datastore datastore) { + System.out.println(document); + } + + /*~~(the class already implements postLoad() for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + public void postLoad(Order order, Datastore datastore) { + } + } + """)); + } + + @Test + void castsTheEntityToKeepTheAnnotatedOverload() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.EntityListener; + import dev.morphia.annotations.PostLoad; + + public class Stamping implements EntityListener { + @PostLoad + void touch(Object entity) { + } + + void touch(Order order) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadListener; + + public class Stamping implements PostLoadListener { + void touch(Object entity) { + } + + void touch(Order order) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + + @Override + public void postLoad(Order entity, Datastore datastore) { + touch((Object) entity); + } + } + """)); + } } From fb45133f88b4a73fd8f424976cb5222be821f300 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 5 Nov 2026 16:57:38 -0500 Subject: [PATCH 3/4] Mark inherited event methods, generic reuse and method-scoped entity types; import cast types The recipe marks an annotated method when a superclass already has the event's interface method, when the method that would be reused is generic, or when its entity parameter is the method's own type variable, and imports the type a cast names. --- .../AnnotatedListenerMethodMigration.java | 47 ++++- .../AnnotatedListenerMethodMigrationTest.java | 171 +++++++++++++++++- 2 files changed, 210 insertions(+), 8 deletions(-) diff --git a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java index 9c151850442..61f38190a1f 100644 --- a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java +++ b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java @@ -196,9 +196,9 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex J.MethodDeclaration existing = declared(result, event.method(), arity); // either of the event's interfaces, or a method that implements one: a class may implement only one of them J.MethodDeclaration other = declared(result, event.method(), document ? 2 : 3); - if (existing == null && (other != null || implementsEvent(result, type, event, null)) + if (inherits(type, event.method()) || existing == null && (other != null || implementsEvent(result, type, event, null)) || existing != null && implementsEvent(result, type, event, listener)) { - calls.forEach(call -> problems.put(call.method(), ("the class already implements %s() for this event, so this " + calls.forEach(call -> problems.put(call.method(), ("the class already has a %s() method for this event, so this " + "method can't be called from %s's: call it from there by hand").formatted(event.method(), listener))); continue; } @@ -206,6 +206,7 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex if (calls.size() == 1 && calls.get(0).method() == existing && existing.hasModifier(J.Modifier.Type.Public) && existing.getMethodType() != null && existing.getMethodType().getReturnType() == JavaType.Primitive.Void + && (existing.getTypeParameters() == null || existing.getTypeParameters().isEmpty()) && calls.get(0).code().equals("%s(entity, %sdatastore)".formatted(event.method(), document ? "document, " : "")) && TypeUtils.isOfType(existing.getMethodType().getParameterTypes().get(0), entityType)) { @@ -220,6 +221,7 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex continue; } calls.forEach(call -> moved.computeIfAbsent(call.method(), m -> new ArrayList<>()).add(event)); + calls.forEach(call -> call.imports().forEach(name -> maybeAddImport(name, false))); interfaces.add(listener); StringBuilder code = new StringBuilder("@Override\npublic void %s(%s entity, %sDatastore datastore) {\n" .formatted(event.method(), entityName, document ? "Document document, " : "")); @@ -374,7 +376,7 @@ private static List events(J.MethodDeclaration method) { JavaType.Method methodType = method.getMethodType(); if (methodType != null) { methodType.getParameterTypes().stream() - .filter(parameter -> !isDatastore(parameter) && !isDocument(parameter)) + .filter(parameter -> !isDatastore(parameter) && !isDocument(parameter) && !methodScoped(method, parameter)) .forEach(candidates::add); } })); @@ -404,6 +406,7 @@ private static Call call(J.MethodDeclaration method, Event event, JavaType entit } } List arguments = new ArrayList<>(); + List imports = new ArrayList<>(); boolean document = false; List parameters = method.getParameters(); for (int i = 0; i < methodType.getParameterTypes().size(); i++) { @@ -421,7 +424,13 @@ private static Call call(J.MethodDeclaration method, Event event, JavaType entit } else { arguments.add("new Document()"); } + } else if (methodScoped(method, parameter)) { + return Call.problem(method, ("its entity parameter's type is the method's own type variable, which %s's method " + + "can't name: call it from there by hand").formatted(event.listener())); } else if (accepts(parameter, entityType)) { + if (!TypeUtils.isOfType(parameter, entityType) && parameter instanceof JavaType.FullyQualified cast) { + imports.add(cast.getFullyQualifiedName()); + } // cast to the parameter's type, so an overload taking the entity's own type isn't called instead arguments.add(TypeUtils.isOfType(parameter, entityType) ? "entity" : "(%s) entity".formatted(name(parameter))); } else { @@ -429,7 +438,7 @@ private static Call call(J.MethodDeclaration method, Event event, JavaType entit + "there by hand").formatted(event.listener(), name(entityType), name(parameter))); } } - return new Call(method, "%s(%s)".formatted(method.getSimpleName(), String.join(", ", arguments)), document, null); + return new Call(method, "%s(%s)".formatted(method.getSimpleName(), String.join(", ", arguments)), document, imports, null); } private static boolean accepts(JavaType parameter, JavaType entityType) { @@ -470,6 +479,30 @@ private static boolean implementsEvent(J.ClassDeclaration classDecl, JavaType.Fu implemented.getType()))); } + /** + * @return true if a superclass declares a method with the name and the arity of one of the event's interface methods, + * which a generated method would override or clash with + */ + private static boolean inherits(JavaType.FullyQualified type, String name) { + for (JavaType.FullyQualified parent = type.getSupertype(); parent != null + && !parent.getFullyQualifiedName().equals("java.lang.Object"); parent = parent.getSupertype()) { + if (parent.getMethods().stream().anyMatch(method -> method.getName().equals(name) + && (method.getParameterTypes().size() == 2 || method.getParameterTypes().size() == 3))) { + return true; + } + } + return false; + } + + /** + * @return true if the type is one of the method's own type variables, which can't be named outside it + */ + private static boolean methodScoped(J.MethodDeclaration method, JavaType type) { + return type instanceof JavaType.GenericTypeVariable variable && method.getTypeParameters() != null + && method.getTypeParameters().stream().anyMatch(parameter -> parameter.getName() instanceof J.Identifier name + && name.getSimpleName().equals(variable.getName())); + } + private static boolean isEntity(J.ClassDeclaration classDecl) { return classDecl.getLeadingAnnotations().stream() .anyMatch(annotation -> ENTITY_ANNOTATIONS.stream().anyMatch(name -> TypeUtils.isOfClassType(annotation.getType(), name))); @@ -501,10 +534,12 @@ private static String name(JavaType type) { * A call from an interface's method to an annotated method, or why there can't be one. * * @param document true if it passes the {@code Document} + * @param imports the types its casts name */ - private record Call(J.MethodDeclaration method, String code, boolean document, @Nullable String problem) { + private record Call(J.MethodDeclaration method, String code, boolean document, List imports, + @Nullable String problem) { static Call problem(J.MethodDeclaration method, String problem) { - return new Call(method, "", false, problem); + return new Call(method, "", false, List.of(), problem); } } diff --git a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java index b6cb26926bd..582b91b7965 100644 --- a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java +++ b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java @@ -593,7 +593,7 @@ public void postLoad(Order entity, Document document, Datastore datastore) { System.out.println(document); } - /*~~(the class already implements postLoad() for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + /*~~(the class already has a postLoad() method for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad void check(Order order) { } } @@ -755,7 +755,7 @@ public void postLoad(Order entity, Document document, Datastore datastore) { System.out.println(document); } - /*~~(the class already implements postLoad() for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + /*~~(the class already has a postLoad() method for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad public void postLoad(Order order, Datastore datastore) { } } @@ -813,4 +813,171 @@ public void postLoad(Order entity, Datastore datastore) { } """)); } + + @Test + void flagsAMethodWhoseEventMethodIsInherited() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + + public class Base { + public final void postLoad(Order order, Datastore datastore) { + } + } + """), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping extends Base { + @PostLoad + void stamp(Order order) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping extends Base { + /*~~(the class already has a postLoad() method for this event, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + void stamp(Order order) { + } + } + """)); + } + + @Test + void doesNotReuseAGenericMethod() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + public void postLoad(Order order, Datastore datastore) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.annotations.PostLoad; + + public class Stamping { + /*~~(the class already declares postLoad() with 2 parameters, so this method can't be called from PostLoadListener's: call it from there by hand)~~>*/@PostLoad + public void postLoad(Order order, Datastore datastore) { + } + } + """)); + } + + @Test + void flagsAMethodWhoseEntityIsItsOwnTypeVariable() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + void touch(T entity) { + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping { + /*~~(its entity parameter's type is the method's own type variable, which PostLoadListener's method can't name: call it from there by hand)~~>*/@PostLoad + void touch(T entity) { + } + } + """)); + } + + @Test + void importsTheTypeACastNames() { + rewriteRun(java( + //language=java + """ + package app; + + import dev.morphia.annotations.Entity; + import dev.morphia.annotations.EntityListeners; + import dev.morphia.annotations.Id; + + @Entity + @EntityListeners(Stamping.class) + public class Order implements java.io.Serializable { + @Id + String id; + } + """), + java( + //language=java + """ + package app; + + import dev.morphia.EntityListener; + import dev.morphia.annotations.PostLoad; + + public class Stamping implements EntityListener { + @PostLoad + void touch(java.io.Serializable entity) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + } + """, + //language=java + """ + package app; + + import dev.morphia.Datastore; + import dev.morphia.PostLoadListener; + + import java.io.Serializable; + + public class Stamping implements PostLoadListener { + void touch(Serializable entity) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + + @Override + public void postLoad(Order entity, Datastore datastore) { + touch((Serializable) entity); + } + } + """)); + } } From ded892416ae6839ff87e70b689a01318eae95800 Mon Sep 17 00:00:00 2001 From: evanchooly Date: Thu, 5 Nov 2026 17:06:16 -0500 Subject: [PATCH 4/4] Mark listeners whose generated methods would hit a name clash or a raw EntityListener The recipe marks a class when a type the generated code names by its simple name is hidden by another of that name in the file, or when the class implements an interface that inherits EntityListener raw or with another type argument. --- .../AnnotatedListenerMethodMigration.java | 71 +++++++++++++- .../AnnotatedListenerMethodMigrationTest.java | 97 +++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java index 61f38190a1f..7ef96f6fb9a 100644 --- a/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java +++ b/rewrite/src/main/java/dev/morphia/rewrite/recipes/lifecycle/AnnotatedListenerMethodMigration.java @@ -174,6 +174,7 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex Map> moved = new HashMap<>(); List interfaces = new ArrayList<>(); List generated = new ArrayList<>(); + List castTypes = new ArrayList<>(); for (Map.Entry> entry : methods.entrySet()) { Event event = entry.getKey(); List eventMethods = entry.getValue(); @@ -221,7 +222,7 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex continue; } calls.forEach(call -> moved.computeIfAbsent(call.method(), m -> new ArrayList<>()).add(event)); - calls.forEach(call -> call.imports().forEach(name -> maybeAddImport(name, false))); + calls.forEach(call -> castTypes.addAll(call.imports())); interfaces.add(listener); StringBuilder code = new StringBuilder("@Override\npublic void %s(%s entity, %sDatastore datastore) {\n" .formatted(event.method(), entityName, document ? "Document document, " : "")); @@ -229,6 +230,19 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex generated.add(code.append("}").toString()); } + if (!interfaces.isEmpty()) { + String clash = clash(entityType, castTypes, generated.stream().anyMatch(code -> code.contains("Document"))); + if (clash != null) { + return SearchResult.found(result, ("the generated listener methods would name %s, which another type of that " + + "name in this file hides: implement the per-event listener interfaces by hand").formatted(clash)); + } + String inheritedListener = rawOrOtherListener(result, entityType); + if (inheritedListener != null) { + return SearchResult.found(result, ("implements %s, which is an EntityListener with another type argument, or " + + "none, than the event interfaces would have: implement them by hand").formatted(inheritedListener)); + } + } + castTypes.forEach(name -> maybeAddImport(name, false)); J.ClassDeclaration updated = result.withBody(result.getBody().withStatements(ListUtils.map( result.getBody().getStatements(), statement -> { if (statement instanceof J.MethodDeclaration method) { @@ -261,6 +275,38 @@ public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, Ex return updated; } + /** + * @return the first type the generated code would name by a simple name that the file, or the generated code itself, + * uses for another type; or null + */ + private @Nullable String clash(JavaType entityType, List castTypes, boolean document) { + Map names = new HashMap<>(); + J.CompilationUnit cu = getCursor().firstEnclosing(J.CompilationUnit.class); + if (cu != null) { + for (J.Import anImport : cu.getImports()) { + if (!anImport.isStatic() && !anImport.getQualid().getSimpleName().equals("*")) { + names.put(anImport.getQualid().getSimpleName(), anImport.getTypeName()); + } + } + } + List used = new ArrayList<>(castTypes); + used.add(DATASTORE); + if (document) { + used.add(DOCUMENT); + } + if (entityType instanceof JavaType.FullyQualified fullyQualified) { + used.add(fullyQualified.getFullyQualifiedName()); + } + for (String name : used) { + String simple = name.substring(name.lastIndexOf('.') + 1); + String other = names.putIfAbsent(simple, name); + if (other != null && !other.equals(name)) { + return name; + } + } + return null; + } + /** * Adds the interfaces, in place of a directly implemented {@code EntityListener}. */ @@ -503,6 +549,29 @@ private static boolean methodScoped(J.MethodDeclaration method, JavaType type) { && name.getSimpleName().equals(variable.getName())); } + /** + * @return an implemented type, other than {@code EntityListener} itself, that inherits {@code EntityListener} raw or with a + * type argument other than the entity type, so the class couldn't also implement the parameterized event + * interfaces; or null + */ + private static @Nullable String rawOrOtherListener(J.ClassDeclaration classDecl, JavaType entityType) { + if (classDecl.getImplements() == null) { + return null; + } + for (TypeTree implemented : classDecl.getImplements()) { + JavaType type = implemented.getType(); + if (type == null || TypeUtils.isOfClassType(type, ENTITY_LISTENER) || !TypeUtils.isAssignableTo(ENTITY_LISTENER, type)) { + continue; + } + JavaType argument = EntityListenerMigration.listenerArgument(type, Map.of(), new HashSet<>()); + if (argument == null || !TypeUtils.isOfType(argument, entityType)) { + JavaType.FullyQualified named = TypeUtils.asFullyQualified(type); + return named != null ? named.getFullyQualifiedName() : type.toString(); + } + } + return null; + } + private static boolean isEntity(J.ClassDeclaration classDecl) { return classDecl.getLeadingAnnotations().stream() .anyMatch(annotation -> ENTITY_ANNOTATIONS.stream().anyMatch(name -> TypeUtils.isOfClassType(annotation.getType(), name))); diff --git a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java index 582b91b7965..4a38d9b00c7 100644 --- a/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java +++ b/rewrite/src/test/java/dev/morphia/rewrite/recipes/test/lifecycle/AnnotatedListenerMethodMigrationTest.java @@ -980,4 +980,101 @@ public void postLoad(Order entity, Datastore datastore) { } """)); } + + @Test + void flagsAnEntityTypeNamedLikeTheDocument() { + rewriteRun(java( + //language=java + """ + package com.acme; + + import dev.morphia.annotations.Entity; + import dev.morphia.annotations.EntityListeners; + import dev.morphia.annotations.Id; + + @Entity + @EntityListeners(app.Stamping.class) + public class Document { + @Id + String id; + } + """), + java( + //language=java + """ + package app; + + import com.acme.Document; + import dev.morphia.annotations.PostLoad; + + public class Stamping { + @PostLoad + void check(Document entity, org.bson.Document document) { + } + } + """, + //language=java + """ + package app; + + import com.acme.Document; + import dev.morphia.annotations.PostLoad; + + /*~~(the generated listener methods would name org.bson.Document, which another type of that name in this file hides: implement the per-event listener interfaces by hand)~~>*/public class Stamping { + @PostLoad + void check(Document entity, org.bson.Document document) { + } + } + """)); + } + + @Test + void flagsAClassThatInheritsTheRawEntityListener() { + rewriteRun(java(ORDER), + java( + //language=java + """ + package app; + + import dev.morphia.EntityListener; + + public interface Audit extends EntityListener { + } + """), + java( + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + public class Stamping implements Audit { + @PostLoad + void check(Order order) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + } + """, + //language=java + """ + package app; + + import dev.morphia.annotations.PostLoad; + + /*~~(implements app.Audit, which is an EntityListener with another type argument, or none, than the event interfaces would have: implement them by hand)~~>*/public class Stamping implements Audit { + @PostLoad + void check(Order order) { + } + + @Override + public boolean hasAnnotation(Class type) { + return false; + } + } + """)); + } }