diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/variable/CascadingUpdateShadowVariable.java b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/CascadingUpdateShadowVariable.java new file mode 100644 index 0000000000..062e2b2b9b --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/variable/CascadingUpdateShadowVariable.java @@ -0,0 +1,54 @@ +package ai.timefold.solver.core.api.domain.variable; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Specifies that field may be updated by the target method when one or more source variables change. + *

+ * Automatically cascades change events to {@link NextElementShadowVariable} of a {@link PlanningListVariable}. + *

+ * Important: it must only change the shadow variable(s) for which it's configured. + * It can be applied to multiple fields to modify different shadow variables. + * It should never change a genuine variable or a problem fact. + * It can change its shadow variable(s) on multiple entity instances + * (for example: an arrivalTime change affects all trailing entities too). + */ +@Target({ FIELD }) +@Retention(RUNTIME) +@Repeatable(CascadingUpdateShadowVariable.List.class) +public @interface CascadingUpdateShadowVariable { + + /** + * The source variable name. + * + * @return never null, a genuine or shadow variable name + */ + String sourceVariableName(); + + /** + * The target method element. + *

+ * Important: the method must be non-static and should not include any parameters. + * There are no restrictions regarding the method visibility. + * There is no restriction on the method's return type, + * but if it returns a value, it will be ignored and will not impact the listener's execution. + * + * @return method name of the source host element which will update the shadow variable + */ + String targetMethodName(); + + /** + * Defines several {@link ShadowVariable} annotations on the same element. + */ + @Target({ FIELD }) + @Retention(RUNTIME) + @interface List { + + CascadingUpdateShadowVariable[] value(); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactory.java index dd4b89cb2e..fb4cf08dcf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactory.java @@ -49,6 +49,7 @@ public static MemberAccessor buildMemberAccessor(Member member, MemberAccessorTy Class annotationClass, DomainAccessType domainAccessType, ClassLoader classLoader) { return switch (domainAccessType) { case GIZMO -> GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, annotationClass, + memberAccessorType != MemberAccessorType.REGULAR_METHOD, (GizmoClassLoader) Objects.requireNonNull(classLoader)); case REFLECTION -> buildReflectiveMemberAccessor(member, memberAccessorType, annotationClass); }; @@ -82,6 +83,9 @@ private static MemberAccessor buildReflectiveMemberAccessor(Member member, Membe } memberAccessor = new ReflectionBeanPropertyMemberAccessor(method, getterOnly); break; + case REGULAR_METHOD: + memberAccessor = new ReflectionMethodMemberAccessor(method, false); + break; default: throw new IllegalStateException("The memberAccessorType (%s) is not implemented." .formatted(memberAccessorType)); @@ -163,6 +167,7 @@ public GizmoClassLoader getGizmoClassLoader() { public enum MemberAccessorType { FIELD_OR_READ_METHOD, FIELD_OR_GETTER_METHOD, - FIELD_OR_GETTER_METHOD_WITH_SETTER + FIELD_OR_GETTER_METHOD_WITH_SETTER, + REGULAR_METHOD } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/ReflectionMethodMemberAccessor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/ReflectionMethodMemberAccessor.java index 610b789c7d..135abcfe25 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/ReflectionMethodMemberAccessor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/ReflectionMethodMemberAccessor.java @@ -19,6 +19,10 @@ public final class ReflectionMethodMemberAccessor extends AbstractMemberAccessor private final MethodHandle methodHandle; public ReflectionMethodMemberAccessor(Method readMethod) { + this(readMethod, true); + } + + public ReflectionMethodMemberAccessor(Method readMethod, boolean returnTypeRequired) { this.readMethod = readMethod; this.returnType = readMethod.getReturnType(); this.methodName = readMethod.getName(); @@ -37,7 +41,7 @@ public ReflectionMethodMemberAccessor(Method readMethod) { throw new IllegalArgumentException("The readMethod (" + readMethod + ") must not have any parameters (" + Arrays.toString(readMethod.getParameterTypes()) + ")."); } - if (readMethod.getReturnType() == void.class) { + if (returnTypeRequired && readMethod.getReturnType() == void.class) { throw new IllegalArgumentException("The readMethod (" + readMethod + ") must have a return type (" + readMethod.getReturnType() + ")."); } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactory.java index 5b247d10d1..7a8f535f22 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactory.java @@ -33,7 +33,7 @@ public static String getGeneratedClassName(Member member) { * @return never null */ public static MemberAccessor buildGizmoMemberAccessor(Member member, Class annotationClass, - GizmoClassLoader gizmoClassLoader) { + boolean returnTypeRequired, GizmoClassLoader gizmoClassLoader) { try { // Check if Gizmo on the classpath by verifying we can access one of its classes Class.forName("io.quarkus.gizmo.ClassCreator", false, @@ -44,7 +44,7 @@ public static MemberAccessor buildGizmoMemberAccessor(Member member, Class getCorrectSuperclass * Creates a MemberAccessor for a given member, generating * the MemberAccessor bytecode if required * - * @param member The member to generate a MemberAccessor for. - * @param annotationClass The annotation it was annotated with (used for error reporting), or null. + * @param member The member to generate a MemberAccessor for + * @param annotationClass The annotation it was annotated with (used for + * error reporting) + * @param returnTypeRequired A flag that indicates if the return type is required or optional * @param gizmoClassLoader never null * @return A new MemberAccessor that uses Gizmo generated bytecode. * Will generate the bytecode the first type it is called @@ -95,14 +97,15 @@ private static Class getCorrectSuperclass * in which case no Gizmo code will be generated. */ static MemberAccessor createAccessorFor(Member member, Class annotationClass, - GizmoClassLoader gizmoClassLoader) { + boolean returnTypeRequired, GizmoClassLoader gizmoClassLoader) { String className = GizmoMemberAccessorFactory.getGeneratedClassName(member); if (gizmoClassLoader.hasBytecodeFor(className)) { return createInstance(className, gizmoClassLoader); } final MutableReference classBytecodeHolder = new MutableReference<>(null); ClassOutput classOutput = (path, byteCode) -> classBytecodeHolder.setValue(byteCode); - GizmoMemberInfo memberInfo = new GizmoMemberInfo(new GizmoMemberDescriptor(member), annotationClass); + GizmoMemberInfo memberInfo = + new GizmoMemberInfo(new GizmoMemberDescriptor(member), returnTypeRequired, annotationClass); defineAccessorFor(className, classOutput, memberInfo); byte[] classBytecode = classBytecodeHolder.getValue(); @@ -160,12 +163,17 @@ private static void createConstructor(ClassCreator classCreator, GizmoMemberInfo Method.class, String.class, Class[].class), declaringClass, name, methodCreator.newArray(Class.class, 0)); - ResultHandle type = - methodCreator.invokeVirtualMethod( - MethodDescriptor.ofMethod(Method.class, "getGenericReturnType", Type.class), - method); - methodCreator.writeInstanceField(FieldDescriptor.of(classCreator.getClassName(), GENERIC_TYPE_FIELD, Type.class), - thisObj, type); + if (memberInfo.returnTypeRequired()) { + // We create a field to store the result, only if the called method has a return type. + // Otherwise, we will only execute it + ResultHandle type = + methodCreator.invokeVirtualMethod( + MethodDescriptor.ofMethod(Method.class, "getGenericReturnType", Type.class), + method); + methodCreator.writeInstanceField( + FieldDescriptor.of(classCreator.getClassName(), GENERIC_TYPE_FIELD, Type.class), + thisObj, type); + } methodCreator.writeInstanceField( FieldDescriptor.of(classCreator.getClassName(), ANNOTATED_ELEMENT_FIELD, AnnotatedElement.class), thisObj, method); @@ -194,8 +202,9 @@ private static void createGetDeclaringClass(ClassCreator classCreator, GizmoMemb * Asserts method is a getter or read method * * @param method Method to assert is getter or read + * @param returnTypeRequired Flag used to check method return type */ - private static void assertIsGoodMethod(MethodDescriptor method) { + private static void assertIsGoodMethod(MethodDescriptor method, boolean returnTypeRequired) { // V = void return type // Z = primitive boolean return type String methodName = method.getName(); @@ -218,7 +227,7 @@ Maybe rename the method (get%s)?""" } } else { // must be a read method - if (method.getReturnType().equals("V")) { + if (returnTypeRequired && method.getReturnType().equals("V")) { throw new IllegalStateException("The readMethod (%s) must have a non-void return type." .formatted(methodName)); } @@ -229,9 +238,11 @@ Maybe rename the method (get%s)?""" * Asserts method is a getter or read method * * @param method Method to assert is getter or read + * @param returnTypeRequired Flag used to check method return type * @param annotationClass Used in exception message */ - private static void assertIsGoodMethod(MethodDescriptor method, Class annotationClass) { + private static void assertIsGoodMethod(MethodDescriptor method, boolean returnTypeRequired, + Class annotationClass) { // V = void return type // Z = primitive boolean return type String methodName = method.getName(); @@ -256,8 +267,8 @@ Maybe rename the method (get%s)?""" methodName.substring(2))); } } else { - // must be a read method - if (method.getReturnType().equals("V")) { + // must be a read method and return a result only if returnTypeRequired is true + if (returnTypeRequired && method.getReturnType().equals("V")) { throw new IllegalStateException("The readMethod (%s) with a %s annotation must have a non-void return type." .formatted(methodName, annotationClass.getSimpleName())); } @@ -279,13 +290,14 @@ Maybe rename the method (get%s)?""" private static void createGetName(ClassCreator classCreator, GizmoMemberInfo memberInfo) { MethodCreator methodCreator = getMethodCreator(classCreator, String.class, "getName"); - // If it is a method, assert that it has the required properties. + // If it is a method, assert that it has the required + // properties memberInfo.descriptor().whenIsMethod(method -> { var annotationClass = memberInfo.annotationClass(); if (annotationClass == null) { - assertIsGoodMethod(method); + assertIsGoodMethod(method, memberInfo.returnTypeRequired()); } else { - assertIsGoodMethod(method, annotationClass); + assertIsGoodMethod(method, memberInfo.returnTypeRequired(), annotationClass); } }); @@ -343,7 +355,7 @@ private static void createGetGenericType(ClassCreator classCreator) { * } * * - * For a method + * For a method with returning type * *

      * Object executeGetter(Object bean) {
@@ -351,6 +363,15 @@ private static void createGetGenericType(ClassCreator classCreator) {
      * }
      * 
* + * For a method without returning type + * + *
+     * Object executeGetter(Object bean) {
+     *     ((DeclaringClass) bean).method();
+     *     return null;
+     * }
+     * 
+ * * The member MUST be public if not called in Quarkus * (i.e. we don't delegate to the field getter/setter). * In Quarkus, we generate simple getter/setter for the @@ -359,7 +380,13 @@ private static void createGetGenericType(ClassCreator classCreator) { private static void createExecuteGetter(ClassCreator classCreator, GizmoMemberInfo memberInfo) { MethodCreator methodCreator = getMethodCreator(classCreator, Object.class, "executeGetter", Object.class); ResultHandle bean = methodCreator.getMethodParam(0); - methodCreator.returnValue(memberInfo.descriptor().readMemberValue(methodCreator, bean)); + if (memberInfo.returnTypeRequired()) { + methodCreator.returnValue(memberInfo.descriptor().readMemberValue(methodCreator, bean)); + } else { + memberInfo.descriptor().readMemberValue(methodCreator, bean); + // Returns null as the called method has no return type + methodCreator.returnNull(); + } } /** diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberInfo.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberInfo.java index 42db75d8e4..caaf2b5ca2 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberInfo.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberInfo.java @@ -4,8 +4,10 @@ /** * @param descriptor never null + * @param returnTypeRequired true if the method return type is required * @param annotationClass null if not annotated */ -public record GizmoMemberInfo(GizmoMemberDescriptor descriptor, Class annotationClass) { +public record GizmoMemberInfo(GizmoMemberDescriptor descriptor, boolean returnTypeRequired, + Class annotationClass) { } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java index 7481920f65..a374f1491d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -26,6 +27,7 @@ import ai.timefold.solver.core.api.domain.valuerange.ValueRange; import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; import ai.timefold.solver.core.api.domain.variable.AnchorShadowVariable; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; import ai.timefold.solver.core.api.domain.variable.CustomShadowVariable; import ai.timefold.solver.core.api.domain.variable.IndexShadowVariable; import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; @@ -45,6 +47,7 @@ import ai.timefold.solver.core.impl.domain.solution.descriptor.ProblemScaleTracker; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.domain.variable.anchor.AnchorShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.cascade.CascadingUpdateShadowVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.custom.CustomShadowVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.custom.LegacyCustomShadowVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.custom.PiggybackShadowVariableDescriptor; @@ -85,7 +88,9 @@ public class EntityDescriptor { ShadowVariable.class, ShadowVariable.List.class, PiggybackShadowVariable.class, - CustomShadowVariable.class }; + CustomShadowVariable.class, + CascadingUpdateShadowVariable.class, + CascadingUpdateShadowVariable.List.class }; private static final Logger LOGGER = LoggerFactory.getLogger(EntityDescriptor.class); @@ -104,6 +109,7 @@ public class EntityDescriptor { // Only declared variable descriptors, excludes inherited variable descriptors private Map> declaredGenuineVariableDescriptorMap; private Map> declaredShadowVariableDescriptorMap; + private Map> declaredCascadingUpdateShadowVariableDecriptorMap; private List> declaredPinEntityFilterList; private List> inheritedEntityDescriptorList; @@ -196,6 +202,7 @@ public void processAnnotations(DescriptorPolicy descriptorPolicy) { processEntityAnnotations(descriptorPolicy); declaredGenuineVariableDescriptorMap = new LinkedHashMap<>(); declaredShadowVariableDescriptorMap = new LinkedHashMap<>(); + declaredCascadingUpdateShadowVariableDecriptorMap = new HashMap<>(); declaredPinEntityFilterList = new ArrayList<>(2); // Only iterate declared fields and methods, not inherited members, to avoid registering the same one twice var memberList = ConfigUtils.getDeclaredMembers(entityClass); @@ -283,7 +290,9 @@ private void processPlanningVariableAnnotation(MutableInt variableDescriptorCoun if (variableAnnotationClass.equals(CustomShadowVariable.class) || variableAnnotationClass.equals(ShadowVariable.class) || variableAnnotationClass.equals(ShadowVariable.List.class) - || variableAnnotationClass.equals(PiggybackShadowVariable.class)) { + || variableAnnotationClass.equals(PiggybackShadowVariable.class) + || variableAnnotationClass.equals(CascadingUpdateShadowVariable.class) + || variableAnnotationClass.equals(CascadingUpdateShadowVariable.List.class)) { memberAccessorType = FIELD_OR_GETTER_METHOD; } else { memberAccessorType = FIELD_OR_GETTER_METHOD_WITH_SETTER; @@ -350,6 +359,27 @@ The entityClass (%s) has a @%s annotated member (%s) that has an unsupported typ || variableAnnotationClass.equals(ShadowVariable.List.class)) { var variableDescriptor = new CustomShadowVariableDescriptor<>(nextVariableDescriptorOrdinal, this, memberAccessor); declaredShadowVariableDescriptorMap.put(memberName, variableDescriptor); + } else if (variableAnnotationClass.equals(CascadingUpdateShadowVariable.class) + || variableAnnotationClass.equals(CascadingUpdateShadowVariable.List.class)) { + var variableDescriptor = + new CascadingUpdateShadowVariableDescriptor<>(nextVariableDescriptorOrdinal, this, memberAccessor); + declaredShadowVariableDescriptorMap.put(memberName, variableDescriptor); + if (declaredCascadingUpdateShadowVariableDecriptorMap.containsKey(variableDescriptor.getTargetMethodName())) { + // If the target method is already set, + // it means that multiple fields define the cascading shadow variable + // and point to the same target method. + // As a result, only one listener will be created for the related target method, + // which will include all sources from all fields. + // This specific shadow variable will not be notifiable, + // and no listener will be created from CascadingUpdateVariableListenerDescriptor#buildVariableListeners. + variableDescriptor.setNotifiable(false); + declaredCascadingUpdateShadowVariableDecriptorMap.get(variableDescriptor.getTargetMethodName()) + .addTargetVariable(this, memberAccessor); + } else { + // The first shadow variable read is notifiable and will generate a listener. + declaredCascadingUpdateShadowVariableDecriptorMap.put(variableDescriptor.getTargetMethodName(), + variableDescriptor); + } } else if (variableAnnotationClass.equals(PiggybackShadowVariable.class)) { var variableDescriptor = new PiggybackShadowVariableDescriptor<>(nextVariableDescriptorOrdinal, this, memberAccessor); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCascadingUpdateShadowVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCascadingUpdateShadowVariableListener.java new file mode 100644 index 0000000000..46905b3527 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCascadingUpdateShadowVariableListener.java @@ -0,0 +1,65 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.variable.VariableListener; +import ai.timefold.solver.core.api.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; + +/** + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public abstract class AbstractCascadingUpdateShadowVariableListener implements VariableListener { + + final List> targetVariableDescriptorList; + final MemberAccessor targetMethod; + + AbstractCascadingUpdateShadowVariableListener(List> targetVariableDescriptorList, + MemberAccessor targetMethod) { + this.targetVariableDescriptorList = targetVariableDescriptorList; + this.targetMethod = targetMethod; + } + + abstract boolean execute(ScoreDirector scoreDirector, Object entity); + + abstract Object getNextElement(Object entity); + + @Override + public void beforeVariableChanged(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + @Override + public void afterVariableChanged(ScoreDirector scoreDirector, Object entity) { + var currentEntity = entity; + while (currentEntity != null) { + if (!execute(scoreDirector, currentEntity)) { + break; + } + currentEntity = getNextElement(currentEntity); + } + } + + @Override + public void beforeEntityAdded(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + @Override + public void afterEntityAdded(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + @Override + public void beforeEntityRemoved(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + @Override + public void afterEntityRemoved(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCollectionAbstractCascadingUpdateShadowVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCollectionAbstractCascadingUpdateShadowVariableListener.java new file mode 100644 index 0000000000..0b8e2a1f8d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractCollectionAbstractCascadingUpdateShadowVariableListener.java @@ -0,0 +1,44 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; + +/** + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public abstract class AbstractCollectionAbstractCascadingUpdateShadowVariableListener + extends AbstractCascadingUpdateShadowVariableListener { + + protected AbstractCollectionAbstractCascadingUpdateShadowVariableListener( + List> targetVariableDescriptorList, + MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + } + + @Override + boolean execute(ScoreDirector scoreDirector, Object entity) { + var oldValueList = new ArrayList<>(targetVariableDescriptorList.size()); + for (VariableDescriptor targetVariableDescriptor : targetVariableDescriptorList) { + scoreDirector.beforeVariableChanged(entity, targetVariableDescriptor.getVariableName()); + oldValueList.add(targetVariableDescriptor.getValue(entity)); + } + targetMethod.executeGetter(entity); + var newValueList = new ArrayList<>(targetVariableDescriptorList.size()); + var hasChange = false; + for (int i = 0; i < targetVariableDescriptorList.size(); i++) { + var targetVariableDescriptor = targetVariableDescriptorList.get(i); + newValueList.add(targetVariableDescriptor.getValue(entity)); + scoreDirector.afterVariableChanged(entity, targetVariableDescriptor.getVariableName()); + if (!hasChange && !Objects.equals(oldValueList.get(i), newValueList.get(i))) { + hasChange = true; + } + } + return hasChange; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractSingleAbstractCascadingUpdateShadowVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractSingleAbstractCascadingUpdateShadowVariableListener.java new file mode 100644 index 0000000000..9c31123916 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/AbstractSingleAbstractCascadingUpdateShadowVariableListener.java @@ -0,0 +1,35 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; +import java.util.Objects; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; + +/** + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public abstract class AbstractSingleAbstractCascadingUpdateShadowVariableListener + extends AbstractCascadingUpdateShadowVariableListener { + + private final VariableDescriptor targetVariableDescriptor; + + AbstractSingleAbstractCascadingUpdateShadowVariableListener( + List> targetVariableDescriptorList, + MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + this.targetVariableDescriptor = targetVariableDescriptorList.get(0); + } + + @Override + boolean execute(ScoreDirector scoreDirector, Object entity) { + var oldValue = targetVariableDescriptor.getValue(entity); + scoreDirector.beforeVariableChanged(entity, targetVariableDescriptor.getVariableName()); + targetMethod.executeGetter(entity); + var newValue = targetVariableDescriptor.getValue(entity); + scoreDirector.afterVariableChanged(entity, targetVariableDescriptor.getVariableName()); + return !Objects.equals(oldValue, newValue); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java new file mode 100644 index 0000000000..636987827a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java @@ -0,0 +1,206 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import static java.util.stream.Collectors.joining; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import ai.timefold.solver.core.api.domain.variable.AbstractVariableListener; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorFactory; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.policy.DescriptorPolicy; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.listener.VariableListenerWithSources; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementVariableDemand; +import ai.timefold.solver.core.impl.domain.variable.supply.Demand; +import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; + +public final class CascadingUpdateShadowVariableDescriptor extends ShadowVariableDescriptor { + + private final List> targetVariables; + private ListVariableDescriptor sourceListVariable; + private final List> targetVariableDescriptorList = new ArrayList<>(); + private final Set> sourceShadowVariableDescriptorSet = new HashSet<>(); + private ShadowVariableDescriptor nextElementShadowVariableDescriptor; + private MemberAccessor targetMethod; + // This flag defines if the planning variable generates a listener, which will be notified later by the event system + private boolean notifiable = true; + + public CascadingUpdateShadowVariableDescriptor(int ordinal, EntityDescriptor entityDescriptor, + MemberAccessor variableMemberAccessor) { + super(ordinal, entityDescriptor, variableMemberAccessor); + targetVariables = new ArrayList<>(); + addTargetVariable(entityDescriptor, variableMemberAccessor); + } + + public void addTargetVariable(EntityDescriptor entityDescriptor, + MemberAccessor variableMemberAccessor) { + targetVariables.add(new TargetVariable<>(entityDescriptor, variableMemberAccessor)); + } + + public void setNotifiable(boolean notifiable) { + this.notifiable = notifiable; + } + + private List getDeclaredListeners(MemberAccessor variableMemberAccessor) { + var declaredListenerList = Arrays.asList(variableMemberAccessor + .getDeclaredAnnotationsByType(CascadingUpdateShadowVariable.class)); + var targetMethodList = declaredListenerList.stream() + .map(CascadingUpdateShadowVariable::targetMethodName) + .distinct() + .toList(); + if (targetMethodList.size() > 1) { + throw new IllegalArgumentException( + """ + The entityClass (%s) has multiple @%s in the annotated property (%s), and there are distinct targetMethodName values [%s]. + Maybe update targetMethodName to use same method in the field %s.""" + .formatted(entityDescriptor.getEntityClass(), + CascadingUpdateShadowVariable.class.getSimpleName(), + variableMemberAccessor.getName(), + String.join(", ", targetMethodList), + variableMemberAccessor.getName())); + } + return declaredListenerList; + } + + public String getTargetMethodName() { + return getDeclaredListeners(variableMemberAccessor).get(0).targetMethodName(); + } + + @Override + public void processAnnotations(DescriptorPolicy descriptorPolicy) { + // Do nothing + } + + @Override + public void linkVariableDescriptors(DescriptorPolicy descriptorPolicy) { + for (TargetVariable targetVariable : targetVariables) { + var declaredListenerList = + getDeclaredListeners(targetVariable.variableMemberAccessor()); + for (var listener : declaredListenerList) { + linkVariableDescriptorToSource(listener); + } + targetVariableDescriptorList.add(targetVariable.entityDescriptor() + .getShadowVariableDescriptor(targetVariable.variableMemberAccessor().getName())); + } + + // Currently, only one list variable is supported per design. + // So, we assume that only one list variable can be found in the available entities, or we fail fast otherwise. + var listVariableDescriptorList = entityDescriptor.getSolutionDescriptor().getEntityDescriptors().stream() + .flatMap(e -> e.getGenuineVariableDescriptorList().stream()) + .filter(VariableDescriptor::isListVariable) + .toList(); + if (listVariableDescriptorList.size() > 1) { + throw new IllegalArgumentException( + "The listener @%s does not support models with multiple planning list variables [%s].".formatted( + CascadingUpdateShadowVariable.class.getSimpleName(), + listVariableDescriptorList.stream().map( + v -> v.getEntityDescriptor().getEntityClass().getSimpleName() + "::" + v.getVariableName()) + .collect(joining(", ")))); + } + sourceListVariable = (ListVariableDescriptor) listVariableDescriptorList.get(0); + + nextElementShadowVariableDescriptor = entityDescriptor.getShadowVariableDescriptors().stream() + .filter(variableDescriptor -> NextElementShadowVariableDescriptor.class + .isAssignableFrom(variableDescriptor.getClass())) + .findFirst() + .orElse(null); + + var targetMethodName = getTargetMethodName(); + var sourceMethodMember = ConfigUtils.getDeclaredMembers(entityDescriptor.getEntityClass()) + .stream() + .filter(member -> member.getName().equals(targetMethodName)) + .findFirst() + .orElse(null); + if (sourceMethodMember == null) { + throw new IllegalArgumentException( + "The entityClass (%s) has an @%s annotated property (%s), but the method \"%s\" cannot be found." + .formatted(entityDescriptor.getEntityClass(), + CascadingUpdateShadowVariable.class.getSimpleName(), + variableMemberAccessor.getName(), + targetMethodName)); + } + targetMethod = descriptorPolicy.getMemberAccessorFactory().buildAndCacheMemberAccessor(sourceMethodMember, + MemberAccessorFactory.MemberAccessorType.REGULAR_METHOD, null, descriptorPolicy.getDomainAccessType()); + } + + public void linkVariableDescriptorToSource(CascadingUpdateShadowVariable listener) { + var sourceDescriptor = entityDescriptor.getShadowVariableDescriptor(listener.sourceVariableName()); + if (sourceDescriptor == null) { + throw new IllegalArgumentException( + """ + The entityClass (%s) has an @%s annotated property (%s), but the shadow variable "%s" cannot be found. + Maybe update sourceVariableName to an existing shadow variable in the entity %s.""" + .formatted(entityDescriptor.getEntityClass(), + CascadingUpdateShadowVariable.class.getSimpleName(), + variableMemberAccessor.getName(), + listener.sourceVariableName(), + entityDescriptor.getEntityClass())); + } + if (sourceShadowVariableDescriptorSet.add(sourceDescriptor)) { + sourceDescriptor.registerSinkVariableDescriptor(this); + } + } + + @Override + public List> getSourceVariableDescriptorList() { + return List.copyOf(sourceShadowVariableDescriptorSet); + } + + @Override + public Collection> getVariableListenerClasses() { + return Collections.singleton(CollectionCascadingUpdateShadowVariableListener.class); + } + + @Override + public Demand getProvidedDemand() { + throw new UnsupportedOperationException("Cascade update element shadow variable cannot be demanded."); + } + + @Override + public Iterable> buildVariableListeners(SupplyManager supplyManager) { + // There are use cases where the shadow variable is applied to different fields + // and relies on the same method to update their values. + // Therefore, only one listener will be generated when multiple descriptors use the same method, + //and the notifiable flag won't be enabled in such cases. + if (notifiable) { + AbstractCascadingUpdateShadowVariableListener listener; + if (nextElementShadowVariableDescriptor != null) { + if (targetVariableDescriptorList.size() == 1) { + listener = new SingleCascadingUpdateShadowVariableListener<>(targetVariableDescriptorList, + nextElementShadowVariableDescriptor, targetMethod); + } else { + listener = new CollectionCascadingUpdateShadowVariableListener<>(targetVariableDescriptorList, + nextElementShadowVariableDescriptor, targetMethod); + } + } else { + if (targetVariableDescriptorList.size() == 1) { + listener = new SingleCascadingUpdateShadowVariableWithSupplyListener<>(targetVariableDescriptorList, + supplyManager.demand(new NextElementVariableDemand<>(sourceListVariable)), targetMethod); + } else { + listener = new CollectionCascadingUpdateShadowVariableWithSupplyListener<>(targetVariableDescriptorList, + supplyManager.demand(new NextElementVariableDemand<>(sourceListVariable)), targetMethod); + } + } + return Collections.singleton(new VariableListenerWithSources<>(listener, getSourceVariableDescriptorList())); + } else { + return Collections.emptyList(); + } + } + + private record TargetVariable(EntityDescriptor entityDescriptor, + MemberAccessor variableMemberAccessor) { + + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListener.java new file mode 100644 index 0000000000..f7b7705f3d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListener.java @@ -0,0 +1,33 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; + +/** + * The primary listener relies on the user-defined next-element shadow variable + * to fetch the next element of a given planning value. + * + * The listener might update multiple shadow variables since the targetVariableDescriptorList contains various fields. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public class CollectionCascadingUpdateShadowVariableListener + extends AbstractCollectionAbstractCascadingUpdateShadowVariableListener { + + private final ShadowVariableDescriptor nextElementShadowVariableDescriptor; + + public CollectionCascadingUpdateShadowVariableListener(List> targetVariableDescriptorList, + ShadowVariableDescriptor nextElementShadowVariableDescriptor, MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + this.nextElementShadowVariableDescriptor = nextElementShadowVariableDescriptor; + } + + @Override + Object getNextElement(Object entity) { + return nextElementShadowVariableDescriptor.getValue(entity); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableWithSupplyListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableWithSupplyListener.java new file mode 100644 index 0000000000..53194f09d9 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableWithSupplyListener.java @@ -0,0 +1,34 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementVariableSupply; + +/** + * Alternative to {@link CollectionCascadingUpdateShadowVariableListener} when there is no user-defined + * {@link ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable}. + * + * The listener might update multiple shadow variables since the targetVariableDescriptorList contains various fields. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public class CollectionCascadingUpdateShadowVariableWithSupplyListener + extends AbstractCollectionAbstractCascadingUpdateShadowVariableListener { + + private final NextElementVariableSupply nextElementVariableSupply; + + public CollectionCascadingUpdateShadowVariableWithSupplyListener( + List> targetVariableDescriptorList, + NextElementVariableSupply nextElementVariableSupply, MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + this.nextElementVariableSupply = nextElementVariableSupply; + } + + @Override + Object getNextElement(Object entity) { + return nextElementVariableSupply.getNext(entity); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListener.java new file mode 100644 index 0000000000..b7c7732547 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListener.java @@ -0,0 +1,33 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; + +/** + * The primary listener relies on the user-defined next-element shadow variable + * to fetch the next element of a given planning value. + * + * The listener might update only one shadow variables since the targetVariableDescriptorList contains a single field. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public class SingleCascadingUpdateShadowVariableListener + extends AbstractSingleAbstractCascadingUpdateShadowVariableListener { + + private final ShadowVariableDescriptor nextElementShadowVariableDescriptor; + + public SingleCascadingUpdateShadowVariableListener(List> targetVariableDescriptorList, + ShadowVariableDescriptor nextElementShadowVariableDescriptor, MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + this.nextElementShadowVariableDescriptor = nextElementShadowVariableDescriptor; + } + + @Override + Object getNextElement(Object entity) { + return nextElementShadowVariableDescriptor.getValue(entity); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListener.java new file mode 100644 index 0000000000..084b741621 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListener.java @@ -0,0 +1,34 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementVariableSupply; + +/** + * Alternative to {@link CollectionCascadingUpdateShadowVariableListener} when there is no user-defined + * {@link ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable}. + * + * The listener might update only one shadow variables since the targetVariableDescriptorList contains a single field. + * + * @param the solution type, the class with the {@link PlanningSolution} annotation + */ +public class SingleCascadingUpdateShadowVariableWithSupplyListener + extends AbstractSingleAbstractCascadingUpdateShadowVariableListener { + + private final NextElementVariableSupply nextElementVariableSupply; + + public SingleCascadingUpdateShadowVariableWithSupplyListener( + List> targetVariableDescriptorList, + NextElementVariableSupply nextElementVariableSupply, MemberAccessor targetMethod) { + super(targetVariableDescriptorList, targetMethod); + this.nextElementVariableSupply = nextElementVariableSupply; + } + + @Override + Object getNextElement(Object entity) { + return nextElementVariableSupply.getNext(entity); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupply.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupply.java new file mode 100644 index 0000000000..32761dc99d --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupply.java @@ -0,0 +1,176 @@ +package ai.timefold.solver.core.impl.domain.variable.nextprev; + +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +import ai.timefold.solver.core.api.domain.variable.ListVariableListener; +import ai.timefold.solver.core.api.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.listener.SourcedVariableListener; +import ai.timefold.solver.core.impl.heuristic.selector.list.NextPreviousInList; + +/** + * Alternative to {@link NextElementVariableListener}. + */ +public class ExternalizedNextElementVariableSupply implements + SourcedVariableListener, + ListVariableListener, + NextElementVariableSupply { + + private final ListVariableDescriptor sourceListVariableDescriptor; + private Map nextElementListMap = null; + + public ExternalizedNextElementVariableSupply(ListVariableDescriptor sourceListVariableDescriptor) { + this.sourceListVariableDescriptor = sourceListVariableDescriptor; + } + + @Override + public VariableDescriptor getSourceVariableDescriptor() { + return sourceListVariableDescriptor; + } + + @Override + public void resetWorkingSolution(ScoreDirector scoreDirector) { + nextElementListMap = new IdentityHashMap<>(); + sourceListVariableDescriptor.getEntityDescriptor().visitAllEntities(scoreDirector.getWorkingSolution(), + this::insertAll); + } + + @Override + public void close() { + nextElementListMap = null; + } + + @Override + public void beforeEntityAdded(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + @Override + public void afterEntityAdded(ScoreDirector scoreDirector, Object entity) { + insertAll(entity); + } + + @Override + public void beforeListVariableChanged(ScoreDirector scoreDirector, Object entity, int fromIndex, int toIndex) { + List valueList = sourceListVariableDescriptor.getValue(entity).subList(fromIndex, toIndex); + valueList.forEach(this::retract); + } + + @Override + public void afterListVariableChanged(ScoreDirector scoreDirector, Object entity, int fromIndex, int toIndex) { + List valueList = sourceListVariableDescriptor.getValue(entity); + var next = toIndex < valueList.size() ? nextElementListMap.get(valueList.get(toIndex)) : null; + for (int i = toIndex - 1; i >= fromIndex; i--) { + next = insertBefore(valueList.get(i), next != null ? next.getTuple() : null); + } + if (next != null && next.getPrevious() == null && fromIndex > 0) { + // When adding a partial set, they need to be connected (KOpt moves) + var previousElement = nextElementListMap.get(valueList.get(fromIndex - 1)); + next.setPrevious(previousElement); + previousElement.setNext(next); + } + } + + @Override + public void beforeEntityRemoved(ScoreDirector scoreDirector, Object entity) { + List valueList = sourceListVariableDescriptor.getValue(entity); + valueList.forEach(this::retract); + } + + @Override + public void afterListVariableElementUnassigned(ScoreDirector scoreDirector, Object value) { + // Do nothing + } + + @Override + public void afterEntityRemoved(ScoreDirector scoreDirector, Object entity) { + // Do nothing + } + + private void insertAll(Object entity) { + List valueList = sourceListVariableDescriptor.getValue(entity); + NextPreviousInList previous = null; + for (Object value : valueList) { + previous = insertAfter(value, previous != null ? previous.getTuple() : null); + } + } + + private NextPreviousInList insertBefore(Object value, Object nextValue) { + if (nextElementListMap.containsKey(value)) { + throw new IllegalStateException( + "The supply (%s) is corrupted, because the entity (%s) for sourceVariable (%s) cannot be inserted: it was already inserted." + .formatted(this, value, sourceListVariableDescriptor.getVariableName())); + } + var next = nextElementListMap.get(nextValue); + var newElement = new NextPreviousInList(value, next); + if (next != null) { + next.setPrevious(newElement); + } + nextElementListMap.put(value, newElement); + return newElement; + } + + private NextPreviousInList insertAfter(Object value, Object previousValue) { + if (nextElementListMap.containsKey(value)) { + throw new IllegalStateException( + "The supply (%s) is corrupted, because the entity (%s) for sourceVariable (%s) cannot be inserted: it was already inserted." + .formatted(this, value, sourceListVariableDescriptor.getVariableName())); + } + var previous = nextElementListMap.get(previousValue); + NextPreviousInList next = null; + if (previous != null) { + next = previous.getNext(); + } + var newElement = new NextPreviousInList(value, previous, next); + if (previous != null && next != null) { + previous.setNext(newElement); + next.setPrevious(newElement); + } else if (previous != null) { + previous.setNext(newElement); + } + if (next != null) { + next.setPrevious(newElement); + } + nextElementListMap.put(value, newElement); + return newElement; + } + + private void retract(Object value) { + var element = nextElementListMap.remove(value); + if (element == null && !sourceListVariableDescriptor.allowsUnassignedValues()) { + throw new IllegalStateException( + "The supply (%s) is corrupted, because the entity (%s) for sourceVariable (%s) cannot be retracted: it was never inserted." + .formatted(this, value, sourceListVariableDescriptor.getVariableName())); + } + if (element != null) { + var previous = element.getPrevious(); + var next = element.getNext(); + if (previous != null && next != null) { + previous.setNext(next); + next.setPrevious(previous); + } else if (previous != null) { + previous.setNext(null); + } else if (next != null) { + next.setPrevious(null); + } + } + } + + @Override + public Object getNext(Object planningValue) { + var element = nextElementListMap.get(planningValue); + Object next = null; + if (element != null && element.getNext() != null) { + next = element.getNext().getTuple(); + } + return next; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + sourceListVariableDescriptor.getVariableName() + ")"; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementShadowVariableDescriptor.java index 9892ff4ecf..4f76826bfb 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementShadowVariableDescriptor.java @@ -8,6 +8,7 @@ import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.variable.listener.VariableListenerWithSources; +import ai.timefold.solver.core.impl.domain.variable.supply.Demand; import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; public final class NextElementShadowVariableDescriptor @@ -38,4 +39,9 @@ public Iterable> buildVariableListeners(S return new VariableListenerWithSources<>(new NextElementVariableListener<>(this, sourceVariableDescriptor), sourceVariableDescriptor).toCollection(); } + + @Override + public Demand getProvidedDemand() { + return new NextElementVariableDemand<>(sourceVariableDescriptor); + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableDemand.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableDemand.java new file mode 100644 index 0000000000..f2cabf4dec --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableDemand.java @@ -0,0 +1,22 @@ +package ai.timefold.solver.core.impl.domain.variable.nextprev; + +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.supply.AbstractVariableDescriptorBasedDemand; +import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager; + +public final class NextElementVariableDemand + extends AbstractVariableDescriptorBasedDemand> { + + public NextElementVariableDemand(ListVariableDescriptor sourceVariableDescriptor) { + super(sourceVariableDescriptor); + } + + // ************************************************************************ + // Creation method + // ************************************************************************ + + @Override + public ExternalizedNextElementVariableSupply createExternalizedSupply(SupplyManager supplyManager) { + return new ExternalizedNextElementVariableSupply<>((ListVariableDescriptor) variableDescriptor); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableListener.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableListener.java index f4b05457ab..24027abba3 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableListener.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableListener.java @@ -7,7 +7,8 @@ import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; -public class NextElementVariableListener implements ListVariableListener { +public class NextElementVariableListener + implements ListVariableListener, NextElementVariableSupply { protected final NextElementShadowVariableDescriptor shadowVariableDescriptor; protected final ListVariableDescriptor sourceVariableDescriptor; @@ -84,4 +85,9 @@ public void afterListVariableChanged(ScoreDirector scoreDirector, Obj next = element; } } + + @Override + public Object getNext(Object planningValue) { + return shadowVariableDescriptor.getValue(planningValue); + } } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableSupply.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableSupply.java new file mode 100644 index 0000000000..69498a57d0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/NextElementVariableSupply.java @@ -0,0 +1,22 @@ +package ai.timefold.solver.core.impl.domain.variable.nextprev; + +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.domain.variable.supply.Supply; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; + +/** + * Only supported for {@link PlanningListVariable list variables}. + *

+ * To get an instance, demand an {@link NextElementVariableDemand} from {@link InnerScoreDirector#getSupplyManager()}. + */ +public interface NextElementVariableSupply extends Supply { + + /** + * Get next element in the {@link PlanningListVariable list variable} of a given planning value. + * + * @param planningValue never null + * @return {@code next element} assigned to the #planningValue of the list variable, + * or {@code null} when the value is unassigned, or it is the last element + */ + Object getNext(Object planningValue); +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/NextPreviousInList.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/NextPreviousInList.java new file mode 100644 index 0000000000..5f672c6e70 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/list/NextPreviousInList.java @@ -0,0 +1,46 @@ +package ai.timefold.solver.core.impl.heuristic.selector.list; + +/** + * Points to a list variable next and previous elements position specified by an entity. + */ +public class NextPreviousInList { + + private Object tuple; + private NextPreviousInList next; + private NextPreviousInList previous; + + public NextPreviousInList(Object tuple, NextPreviousInList previous, NextPreviousInList next) { + this.tuple = tuple; + this.next = next; + this.previous = previous; + } + + public NextPreviousInList(Object tuple, NextPreviousInList next) { + this.tuple = tuple; + this.next = next; + } + + public Object getTuple() { + return tuple; + } + + public void setTuple(Object tuple) { + this.tuple = tuple; + } + + public NextPreviousInList getNext() { + return next; + } + + public void setNext(NextPreviousInList next) { + this.next = next; + } + + public NextPreviousInList getPrevious() { + return previous; + } + + public void setPrevious(NextPreviousInList previous) { + this.previous = previous; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java index 2c7e82e91c..84f47552d6 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareList.java @@ -2,6 +2,7 @@ import java.util.Iterator; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.function.Consumer; /** @@ -28,6 +29,38 @@ public ElementAwareListEntry add(T tuple) { return entry; } + public ElementAwareListEntry addFirst(T tuple) { + if (first != null) { + ElementAwareListEntry entry = new ElementAwareListEntry<>(this, tuple, null); + first.previous = entry; + entry.next = first; + first = entry; + size++; + return entry; + } else { + return add(tuple); + } + } + + public ElementAwareListEntry addAfter(T tuple, ElementAwareListEntry previous) { + Objects.requireNonNull(previous); + if (first == null || previous == last) { + return add(tuple); + } else { + ElementAwareListEntry entry = new ElementAwareListEntry<>(this, tuple, previous); + ElementAwareListEntry currentNext = previous.next; + if (currentNext != null) { + currentNext.previous = entry; + } else { + last = entry; + } + previous.next = entry; + entry.next = currentNext; + size++; + return entry; + } + } + public void remove(ElementAwareListEntry entry) { if (first == entry) { first = entry.next; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareListEntry.java b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareListEntry.java index aae73d82dc..0a74101b93 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareListEntry.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/util/ElementAwareListEntry.java @@ -19,6 +19,10 @@ public final class ElementAwareListEntry { this.next = null; } + public ElementAwareListEntry previous() { + return previous; + } + public ElementAwareListEntry next() { return next; } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactoryTest.java index ddcfe3d612..a919d75324 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/MemberAccessorFactoryTest.java @@ -12,6 +12,7 @@ import ai.timefold.solver.core.api.domain.solution.ProblemFactProperty; import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberAccessorFactory; +import ai.timefold.solver.core.impl.testdata.domain.TestdataEntity; import ai.timefold.solver.core.impl.testdata.domain.TestdataValue; import ai.timefold.solver.core.impl.testdata.domain.reflect.accessmodifier.TestdataVisibilityModifierSolution; import ai.timefold.solver.core.impl.testdata.domain.reflect.field.TestdataFieldAnnotatedEntity; @@ -96,6 +97,24 @@ void publicProperty() throws NoSuchMethodException { assertThat(memberAccessor.executeGetter(s1)).isEqualTo("secondValue"); } + @Test + void methodReturnVoid() throws NoSuchMethodException { + MemberAccessor memberAccessor = MemberAccessorFactory.buildMemberAccessor(TestdataEntity.class.getMethod("updateValue"), + MemberAccessorFactory.MemberAccessorType.REGULAR_METHOD, null, DomainAccessType.REFLECTION, null); + assertThat(memberAccessor).isInstanceOf(ReflectionMethodMemberAccessor.class); + assertThat(memberAccessor.getName()).isEqualTo("updateValue"); + assertThat(memberAccessor.getType()).isEqualTo(void.class); + assertThat(memberAccessor.getGenericType()).isEqualTo(void.class); + assertThat(memberAccessor.getSpeedNote()).isEqualTo("MethodHandle"); + + TestdataEntity entity = new TestdataEntity(); + TestdataValue value = new TestdataValue("A"); + entity.setValue(value); + + memberAccessor.executeGetter(entity); + assertThat(entity.getValue().getCode()).isEqualTo("A/A"); + } + @Test void shouldUseGeneratedMemberAccessorIfExists() throws NoSuchMethodException { Member member = TestdataVisibilityModifierSolution.class.getDeclaredMethod("getPublicProperty"); diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactoryTest.java index 0b653e1578..3facce1aa7 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactoryTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorFactoryTest.java @@ -36,7 +36,8 @@ void setup() { void testReturnedMemberAccessor() throws NoSuchMethodException { Method member = TestdataEntity.class.getMethod("getValue"); MemberAccessor memberAccessor = - GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, PlanningVariable.class, true, + new GizmoClassLoader()); TestdataEntity entity = new TestdataEntity(); TestdataValue value = new TestdataValue("A"); @@ -71,7 +72,7 @@ public Class loadClass(String name) { }); assertThatCode(() -> { - GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, PlanningVariable.class, true, new GizmoClassLoader()); }).hasMessage("When using the domainAccessType (GIZMO) the classpath or modulepath must contain " + "io.quarkus.gizmo:gizmo.\nMaybe add a dependency to io.quarkus.gizmo:gizmo."); } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementorTest.java index 9220375d1e..4f392e8613 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementorTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementorTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import java.lang.reflect.Member; +import java.lang.reflect.Method; import ai.timefold.solver.core.api.domain.entity.PlanningPin; import ai.timefold.solver.core.api.domain.lookup.PlanningId; @@ -21,7 +22,7 @@ class GizmoMemberAccessorImplementorTest { void testGeneratedMemberAccessorForMethod() throws NoSuchMethodException { Member member = TestdataEntity.class.getMethod("getValue"); MemberAccessor memberAccessor = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, new GizmoClassLoader()); assertThat(memberAccessor.getName()).isEqualTo("value"); assertThat(memberAccessor.getType()).isEqualTo(TestdataValue.class); assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); @@ -43,7 +44,7 @@ void testGeneratedMemberAccessorForMethod() throws NoSuchMethodException { void testGeneratedMemberAccessorForMethodWithoutSetter() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("getId"); MemberAccessor memberAccessor = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningId.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningId.class, true, new GizmoClassLoader()); assertThat(memberAccessor.getName()).isEqualTo("id"); assertThat(memberAccessor.getType()).isEqualTo(String.class); assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); @@ -59,7 +60,7 @@ void testGeneratedMemberAccessorForMethodWithoutSetter() throws NoSuchMethodExce void testGeneratedMemberAccessorForField() throws NoSuchFieldException { Member member = GizmoTestdataEntity.class.getField("value"); MemberAccessor memberAccessor = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, new GizmoClassLoader()); assertThat(memberAccessor.getName()).isEqualTo("value"); assertThat(memberAccessor.getType()).isEqualTo(TestdataValue.class); assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); @@ -81,7 +82,7 @@ void testGeneratedMemberAccessorForField() throws NoSuchFieldException { void testGeneratedMemberAccessorForPrimitiveField() throws NoSuchFieldException { Member member = GizmoTestdataEntity.class.getField("isPinned"); MemberAccessor memberAccessor = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class, true, new GizmoClassLoader()); assertThat(memberAccessor.getName()).isEqualTo("isPinned"); assertThat(memberAccessor.getType()).isEqualTo(boolean.class); assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); @@ -100,18 +101,41 @@ void testGeneratedMemberAccessorSameClass() throws NoSuchMethodException { GizmoClassLoader gizmoClassLoader = new GizmoClassLoader(); Member member = TestdataEntity.class.getMethod("getValue"); MemberAccessor memberAccessor1 = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, gizmoClassLoader); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, gizmoClassLoader); MemberAccessor memberAccessor2 = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, gizmoClassLoader); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, gizmoClassLoader); assertThat(memberAccessor1.getClass()).isEqualTo(memberAccessor2.getClass()); } + @Test + void testGeneratedMemberAccessorReturnVoid() throws NoSuchMethodException { + Method member = TestdataEntity.class.getMethod("updateValue"); + MemberAccessor memberAccessor = + GizmoMemberAccessorFactory.buildGizmoMemberAccessor(member, null, false, + new GizmoClassLoader()); + + TestdataEntity entity = new TestdataEntity(); + TestdataValue value = new TestdataValue("A"); + entity.setValue(value); + + memberAccessor.executeGetter(entity); + assertThat(entity.getValue().getCode()).isEqualTo("A/A"); + + assertThat(memberAccessor.supportSetter()).isFalse(); + assertThat(memberAccessor.getDeclaringClass()).isEqualTo(TestdataEntity.class); + assertThat(memberAccessor.getGenericType()).isNull(); + assertThat(memberAccessor.getType()).isEqualTo(void.class); + assertThat(memberAccessor.getType()).isEqualTo(member.getReturnType()); + assertThat(memberAccessor.getName()).isEqualTo("updateValue"); + assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); + } + @Test void testThrowsWhenGetterMethodHasParameters() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("methodWithParameters", String.class); assertThatCode(() -> { - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, new GizmoClassLoader()); }).hasMessage("The getterMethod (methodWithParameters) with a PlanningVariable annotation " + "must not have any parameters, but has parameters ([Ljava/lang/String;])."); } @@ -120,7 +144,7 @@ void testThrowsWhenGetterMethodHasParameters() throws NoSuchMethodException { void testThrowsWhenGetterMethodReturnVoid() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("getVoid"); assertThatCode(() -> { - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, new GizmoClassLoader()); }).hasMessage("The getterMethod (getVoid) with a PlanningVariable annotation must have a non-void return type."); } @@ -128,7 +152,7 @@ void testThrowsWhenGetterMethodReturnVoid() throws NoSuchMethodException { void testThrowsWhenReadMethodReturnVoid() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("voidMethod"); assertThatCode(() -> { - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, new GizmoClassLoader()); }).hasMessage("The readMethod (voidMethod) with a PlanningVariable annotation must have a non-void return type."); } @@ -136,7 +160,7 @@ void testThrowsWhenReadMethodReturnVoid() throws NoSuchMethodException { void testGeneratedMemberAccessorForBooleanMethod() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("isPinned"); MemberAccessor memberAccessor = - GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class, new GizmoClassLoader()); + GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningPin.class, true, new GizmoClassLoader()); assertThat(memberAccessor.getName()).isEqualTo("pinned"); assertThat(memberAccessor.getType()).isEqualTo(boolean.class); assertThat(memberAccessor.getSpeedNote()).isEqualTo("Fast access with generated bytecode"); @@ -155,7 +179,8 @@ void testGeneratedMemberAccessorForBooleanMethod() throws NoSuchMethodException void testThrowsWhenGetBooleanReturnsNonBoolean() throws NoSuchMethodException { Member member = GizmoTestdataEntity.class.getMethod("isAMethodThatHasABadName"); assertThatCode( - () -> GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, new GizmoClassLoader())) + () -> GizmoMemberAccessorImplementor.createAccessorFor(member, PlanningVariable.class, true, + new GizmoClassLoader())) .hasMessage(""" The getterMethod (isAMethodThatHasABadName) with a PlanningVariable annotation \ must have a primitive boolean return type but returns (L%s;). diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListenerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListenerTest.java new file mode 100644 index 0000000000..94351c886f --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/CollectionCascadingUpdateShadowVariableListenerTest.java @@ -0,0 +1,194 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseSolution; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataMultipleCascadingBaseValue; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.shadow_var.TestdataMultipleCascadingEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.shadow_var.TestdataMultipleCascadingSolution; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.supply.TestdataMultipleCascadingWithSupplyEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.supply.TestdataMultipleCascadingWithSupplySolution; +import ai.timefold.solver.core.impl.testdata.util.PlannerTestUtils; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class CollectionCascadingUpdateShadowVariableListenerTest { + + private GenuineVariableDescriptor generateDescriptor(Type type) { + if (type == Type.WITH_SUPPLY) { + return TestdataMultipleCascadingWithSupplyEntity.buildVariableDescriptorForValueList(); + } else { + return TestdataMultipleCascadingEntity.buildVariableDescriptorForValueList(); + } + } + + private TestdataCascadingBaseSolution, ? extends TestdataMultipleCascadingBaseValue> + generateSolution(Type type, int valueCount, int entityCount) { + if (type == Type.WITH_SUPPLY) { + return TestdataMultipleCascadingWithSupplySolution.generateUninitializedSolution(valueCount, entityCount); + } else { + return TestdataMultipleCascadingSolution.generateUninitializedSolution(valueCount, entityCount); + } + } + + @ParameterizedTest + @EnumSource + void updateAllNextValues(Type type) { + var variableDescriptor = generateDescriptor(type); + + var scoreDirector = (InnerScoreDirector, SimpleScore>) PlannerTestUtils + .mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + var solution = generateSolution(type, 3, 2); + scoreDirector.setWorkingSolution(solution); + + var entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + entity.setValueList(solution.getValueList()); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 3); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(1); + assertThat(entity.getValueList().get(0).getSecondCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(0).getNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(1).getSecondCascadeValue()).isEqualTo(3); + // Called from update next val1, inverse and previous element changes + assertThat(entity.getValueList().get(1).getNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(3); + assertThat(entity.getValueList().get(2).getSecondCascadeValue()).isEqualTo(4); + // Called from update next val2, inverse and previous element changes + assertThat(entity.getValueList().get(2).getNumberOfCalls()).isEqualTo(3); + } + + @ParameterizedTest + @EnumSource + void updateOnlyMiddleValue(Type type) { + var variableDescriptor = generateDescriptor(type); + + var scoreDirector = (InnerScoreDirector, SimpleScore>) PlannerTestUtils + .mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + { // Changing the first shadow var + var solution = generateSolution(type, 3, 2); + solution.getValueList().get(2).setSecondCascadeValue(4); + scoreDirector.setWorkingSolution(solution); + + var entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + solution.getValueList().subList(0, 2).forEach(v -> v.setEntity(entity)); + entity.setValueList(solution.getValueList().subList(0, 2)); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 2); + scoreDirector.triggerVariableListeners(); + solution.getValueList().forEach(TestdataMultipleCascadingBaseValue::reset); + + scoreDirector.beforeListVariableChanged(entity, "valueList", 1, 1); + entity.setValueList( + List.of(solution.getValueList().get(0), solution.getValueList().get(2), solution.getValueList().get(1))); + scoreDirector.afterListVariableChanged(entity, "valueList", 1, 2); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getNumberOfCalls()).isZero(); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + assertThat(entity.getValueList().get(1).getSecondCascadeValue()).isEqualTo(4); + // Called from previous and inverse element change + assertThat(entity.getValueList().get(1).getNumberOfCalls()).isEqualTo(2); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(2).getSecondCascadeValue()).isEqualTo(3); + // Called from update next val + assertThat(entity.getValueList().get(2).getNumberOfCalls()).isEqualTo(2); + } + + { // Changing the second shadow var + var solution = generateSolution(type, 3, 2); + solution.getValueList().get(2).setCascadeValue(3); + scoreDirector.setWorkingSolution(solution); + + var entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + solution.getValueList().subList(0, 2).forEach(v -> v.setEntity(entity)); + entity.setValueList(solution.getValueList().subList(0, 2)); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 2); + scoreDirector.triggerVariableListeners(); + solution.getValueList().forEach(TestdataMultipleCascadingBaseValue::reset); + + scoreDirector.beforeListVariableChanged(entity, "valueList", 1, 1); + entity.setValueList( + List.of(solution.getValueList().get(0), solution.getValueList().get(2), solution.getValueList().get(1))); + scoreDirector.afterListVariableChanged(entity, "valueList", 1, 2); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getNumberOfCalls()).isZero(); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + assertThat(entity.getValueList().get(1).getSecondCascadeValue()).isEqualTo(4); + // Called from previous and inverse element change + assertThat(entity.getValueList().get(1).getNumberOfCalls()).isEqualTo(2); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(2).getSecondCascadeValue()).isEqualTo(3); + // Called from update next val + assertThat(entity.getValueList().get(2).getNumberOfCalls()).isEqualTo(2); + } + } + + @ParameterizedTest + @EnumSource + void stopUpdateNextValues(Type type) { + var variableDescriptor = generateDescriptor(type); + + var scoreDirector = (InnerScoreDirector, SimpleScore>) PlannerTestUtils + .mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + var solution = generateSolution(type, 3, 2); + scoreDirector.setWorkingSolution(solution); + + var entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + solution.getValueList().subList(0, 2).forEach(v -> v.setEntity(entity)); + entity.setValueList(solution.getValueList().subList(0, 2)); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 2); + scoreDirector.triggerVariableListeners(); + solution.getValueList().forEach(TestdataMultipleCascadingBaseValue::reset); + + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + entity.setValueList( + List.of(solution.getValueList().get(2), solution.getValueList().get(0), solution.getValueList().get(1))); + solution.getValueList().get(2).setCascadeValue(3); + solution.getValueList().get(2).setSecondCascadeValue(4); + solution.getValueList().get(0).setCascadeValue(1); + solution.getValueList().get(0).setSecondCascadeValue(2); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 1); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(3); + assertThat(entity.getValueList().get(0).getSecondCascadeValue()).isEqualTo(4); + assertThat(entity.getValueList().get(0).getNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(1); + assertThat(entity.getValueList().get(1).getSecondCascadeValue()).isEqualTo(2); + // Called from update next val1 and previous element change + assertThat(entity.getValueList().get(1).getNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(2).getSecondCascadeValue()).isEqualTo(3); + // Stop on value2 + assertThat(entity.getValueList().get(2).getNumberOfCalls()).isZero(); + } + + enum Type { + WITHOUT_SUPPLY, + WITH_SUPPLY + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListenerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListenerTest.java new file mode 100644 index 0000000000..998a8d5c8b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableListenerTest.java @@ -0,0 +1,137 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingSolution; +import ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongMethod; +import ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource; +import ai.timefold.solver.core.impl.testdata.util.PlannerTestUtils; + +import org.junit.jupiter.api.Test; + +class SingleCascadingUpdateShadowVariableListenerTest { + + @Test + void requiredShadowVariableDependencies() { + assertThatIllegalArgumentException().isThrownBy(TestdataCascadingWrongSource::buildEntityDescriptor) + .withMessageContaining( + "The entityClass (class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource)") + .withMessageContaining("has an @CascadingUpdateShadowVariable annotated property (cascadeValue)") + .withMessageContaining("but the shadow variable \"bad\" cannot be found") + .withMessageContaining( + "Maybe update sourceVariableName to an existing shadow variable in the entity class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource"); + + assertThatIllegalArgumentException().isThrownBy(TestdataCascadingWrongMethod::buildEntityDescriptor) + .withMessageContaining( + "The entityClass (class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongMethod)") + .withMessageContaining("has an @CascadingUpdateShadowVariable annotated property (cascadeValueReturnType)") + .withMessageContaining("but the method \"badUpdateCascadeValueWithReturnType\" cannot be found"); + } + + @Test + void updateAllNextValues() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingSolution solution = TestdataSingleCascadingSolution.generateUninitializedSolution(3, 2); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + entity.setValueList(solution.getValueList()); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 3); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(0).getCascadeValueReturnType()).isEqualTo(3); + assertThat(entity.getValueList().get(0).getSecondNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + // Called from update next val1, inverse and previous element changes + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(1).getCascadeValueReturnType()).isEqualTo(4); + // Called from update next val1, inverse and previous element changes + assertThat(entity.getValueList().get(1).getSecondNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(4); + // Called from update next val2, inverse and previous element changes + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(2).getCascadeValueReturnType()).isEqualTo(5); + // Called from update next val2, inverse and previous element changes + assertThat(entity.getValueList().get(2).getSecondNumberOfCalls()).isEqualTo(3); + } + + @Test + void updateOnlyMiddleValue() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingSolution solution = TestdataSingleCascadingSolution.generateUninitializedSolution(3, 2); + solution.getValueList().get(1).setNext(solution.getValueList().get(2)); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 1, 1); + entity.setValueList(solution.getValueList()); + scoreDirector.afterListVariableChanged(entity, "valueList", 1, 1); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isZero(); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + // Called from previous element change + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(4); + // Called from update next val2 + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isEqualTo(1); + } + + @Test + void stopUpdateNextValues() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingSolution solution = TestdataSingleCascadingSolution.generateUninitializedSolution(3, 2); + solution.getValueList().get(1).setCascadeValue(3); + solution.getValueList().get(1).setEntity(solution.getEntityList().get(0)); + solution.getValueList().get(2).setEntity(solution.getEntityList().get(0)); + solution.getValueList().get(2).setPrevious(solution.getValueList().get(1)); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 1); + entity.setValueList(solution.getValueList()); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 1); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + // Called from update next val1 and previous element change + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(2); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isNull(); + // Stop on value2 + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isZero(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListenerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListenerTest.java new file mode 100644 index 0000000000..553d981d0a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/cascade/SingleCascadingUpdateShadowVariableWithSupplyListenerTest.java @@ -0,0 +1,156 @@ +package ai.timefold.solver.core.impl.domain.variable.cascade; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +import java.util.List; + +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply.TestdataSingleCascadingWithSupplyEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply.TestdataSingleCascadingWithSupplySolution; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply.TestdataSingleCascadingWithSupplyValue; +import ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongMethod; +import ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource; +import ai.timefold.solver.core.impl.testdata.util.PlannerTestUtils; + +import org.junit.jupiter.api.Test; + +class SingleCascadingUpdateShadowVariableWithSupplyListenerTest { + + @Test + void requiredShadowVariableDependencies() { + assertThatIllegalArgumentException().isThrownBy(TestdataCascadingWrongSource::buildEntityDescriptor) + .withMessageContaining( + "The entityClass (class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource)") + .withMessageContaining("has an @CascadingUpdateShadowVariable annotated property (cascadeValue)") + .withMessageContaining("but the shadow variable \"bad\" cannot be found") + .withMessageContaining( + "Maybe update sourceVariableName to an existing shadow variable in the entity class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongSource"); + + assertThatIllegalArgumentException().isThrownBy(TestdataCascadingWrongMethod::buildEntityDescriptor) + .withMessageContaining( + "The entityClass (class ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade.TestdataCascadingWrongMethod)") + .withMessageContaining("has an @CascadingUpdateShadowVariable annotated property (cascadeValueReturnType)") + .withMessageContaining("but the method \"badUpdateCascadeValueWithReturnType\" cannot be found"); + } + + @Test + void updateAllNextValues() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingWithSupplyEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingWithSupplySolution solution = + TestdataSingleCascadingWithSupplySolution.generateUninitializedSolution(3, 2); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingWithSupplyEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + entity.setValueList(solution.getValueList()); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 3); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(2); + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(0).getCascadeValueReturnType()).isEqualTo(3); + assertThat(entity.getValueList().get(0).getSecondNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(3); + // Called from update next val1, inverse and previous element changes + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(1).getCascadeValueReturnType()).isEqualTo(4); + // Called from update next val1, inverse and previous element changes + assertThat(entity.getValueList().get(1).getSecondNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(4); + // Called from update next val2, inverse and previous element changes + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isEqualTo(3); + + assertThat(entity.getValueList().get(2).getCascadeValueReturnType()).isEqualTo(5); + // Called from update next val2, inverse and previous element changes + assertThat(entity.getValueList().get(2).getSecondNumberOfCalls()).isEqualTo(3); + } + + @Test + void updateOnlyMiddleValue() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingWithSupplyEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingWithSupplySolution solution = + TestdataSingleCascadingWithSupplySolution.generateUninitializedSolution(3, 2); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingWithSupplyEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + solution.getValueList().subList(0, 2).forEach(v -> v.setEntity(entity)); + entity.setValueList(solution.getValueList().subList(0, 2)); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 2); + scoreDirector.triggerVariableListeners(); + solution.getValueList().forEach(TestdataSingleCascadingWithSupplyValue::reset); + + scoreDirector.beforeListVariableChanged(entity, "valueList", 1, 1); + entity.setValueList( + List.of(solution.getValueList().get(0), solution.getValueList().get(2), solution.getValueList().get(1))); + scoreDirector.afterListVariableChanged(entity, "valueList", 1, 2); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isZero(); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(4); + // Called from previous and inverse element change + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(2); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(3); + // Called from update next val + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isEqualTo(2); + } + + @Test + void stopUpdateNextValues() { + GenuineVariableDescriptor variableDescriptor = + TestdataSingleCascadingWithSupplyEntity.buildVariableDescriptorForValueList(); + + InnerScoreDirector scoreDirector = + PlannerTestUtils.mockScoreDirector(variableDescriptor.getEntityDescriptor().getSolutionDescriptor()); + + TestdataSingleCascadingWithSupplySolution solution = + TestdataSingleCascadingWithSupplySolution.generateUninitializedSolution(3, 2); + scoreDirector.setWorkingSolution(solution); + + TestdataSingleCascadingWithSupplyEntity entity = solution.getEntityList().get(0); + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + solution.getValueList().subList(0, 2).forEach(v -> v.setEntity(entity)); + entity.setValueList(solution.getValueList().subList(0, 2)); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 2); + scoreDirector.triggerVariableListeners(); + solution.getValueList().forEach(TestdataSingleCascadingWithSupplyValue::reset); + + scoreDirector.beforeListVariableChanged(entity, "valueList", 0, 0); + entity.setValueList( + List.of(solution.getValueList().get(2), solution.getValueList().get(0), solution.getValueList().get(1))); + solution.getValueList().get(2).setCascadeValue(4); + solution.getValueList().get(0).setCascadeValue(2); + scoreDirector.afterListVariableChanged(entity, "valueList", 0, 1); + scoreDirector.triggerVariableListeners(); + + assertThat(entity.getValueList().get(0).getCascadeValue()).isEqualTo(4); + assertThat(entity.getValueList().get(0).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(1).getCascadeValue()).isEqualTo(2); + // Called from update next val1 and previous element change + assertThat(entity.getValueList().get(1).getFirstNumberOfCalls()).isEqualTo(1); + + assertThat(entity.getValueList().get(2).getCascadeValue()).isEqualTo(3); + // Stop on value2 + assertThat(entity.getValueList().get(2).getFirstNumberOfCalls()).isZero(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupplyTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupplyTest.java new file mode 100644 index 0000000000..3c7eeb6e60 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/variable/nextprev/ExternalizedNextElementVariableSupplyTest.java @@ -0,0 +1,150 @@ +package ai.timefold.solver.core.impl.domain.variable.nextprev; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import ai.timefold.solver.core.api.score.director.ScoreDirector; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.list.TestdataListEntity; +import ai.timefold.solver.core.impl.testdata.domain.list.TestdataListSolution; +import ai.timefold.solver.core.impl.testdata.domain.list.TestdataListValue; + +import org.junit.jupiter.api.Test; + +class ExternalizedNextElementVariableSupplyTest { + + @Test + void updateValues() { + ListVariableDescriptor variableDescriptor = + TestdataListEntity.buildVariableDescriptorForValueList(); + ScoreDirector scoreDirector = mock(ScoreDirector.class); + ExternalizedNextElementVariableSupply supply = + new ExternalizedNextElementVariableSupply<>(variableDescriptor); + + TestdataListValue v1 = new TestdataListValue("v1"); + TestdataListValue v2 = new TestdataListValue("v2"); + TestdataListValue v3 = new TestdataListValue("v3"); + TestdataListValue v4 = new TestdataListValue("v4"); + TestdataListValue v5 = new TestdataListValue("v5"); + + TestdataListEntity e1 = new TestdataListEntity("e1", List.of(v1, v2, v3)); + v1.setEntity(e1); + v2.setEntity(e1); + v3.setEntity(e1); + TestdataListEntity e2 = new TestdataListEntity("e2", List.of(v4, v5)); + v4.setEntity(e2); + v5.setEntity(e2); + + TestdataListSolution solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v3, v4, v5)); + + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + supply.resetWorkingSolution(scoreDirector); + + assertThat(supply.getNext(v1)).isSameAs(v2); + assertThat(supply.getNext(v2)).isSameAs(v3); + assertThat(supply.getNext(v3)).isNull(); + + assertThat(supply.getNext(v4)).isSameAs(v5); + assertThat(supply.getNext(v5)).isNull(); + + // Updating first value + supply.beforeListVariableChanged(scoreDirector, e1, 0, 1); + supply.beforeListVariableChanged(scoreDirector, e2, 0, 0); + v1.setEntity(e2); + e2.addValueAt(0, v1); + e1.removeValue(v1); + supply.afterListVariableChanged(scoreDirector, e1, 0, 0); + supply.afterListVariableChanged(scoreDirector, e2, 0, 1); + + assertThat(supply.getNext(v2)).isSameAs(v3); + assertThat(supply.getNext(v3)).isNull(); + assertThat(supply.getNext(v1)).isSameAs(v4); + assertThat(supply.getNext(v4)).isSameAs(v5); + assertThat(supply.getNext(v5)).isNull(); + + // Updating last value + supply.beforeListVariableChanged(scoreDirector, e1, 2, 2); + supply.beforeListVariableChanged(scoreDirector, e2, 0, 1); + v1.setEntity(e1); + e1.addValue(v1); + e2.removeValue(v1); + supply.afterListVariableChanged(scoreDirector, e1, 2, 3); + supply.afterListVariableChanged(scoreDirector, e2, 1, 1); + + assertThat(supply.getNext(v2)).isSameAs(v3); + assertThat(supply.getNext(v3)).isSameAs(v1); + assertThat(supply.getNext(v1)).isNull(); + assertThat(supply.getNext(v4)).isSameAs(v5); + assertThat(supply.getNext(v5)).isNull(); + + // Updating middle value + supply.beforeListVariableChanged(scoreDirector, e1, 3, 3); + supply.beforeListVariableChanged(scoreDirector, e2, 0, 1); + v4.setEntity(e1); + e1.addValueAt(1, v4); + e2.removeValue(v4); + supply.afterListVariableChanged(scoreDirector, e1, 1, 2); + supply.afterListVariableChanged(scoreDirector, e2, 1, 1); + + assertThat(supply.getNext(v2)).isSameAs(v4); + assertThat(supply.getNext(v4)).isSameAs(v3); + + supply.close(); + } + + @Test + void removeEntity() { + ListVariableDescriptor variableDescriptor = + TestdataListEntity.buildVariableDescriptorForValueList(); + ScoreDirector scoreDirector = mock(ScoreDirector.class); + ExternalizedNextElementVariableSupply supply = + new ExternalizedNextElementVariableSupply<>(variableDescriptor); + + TestdataListValue v1 = new TestdataListValue("v1"); + TestdataListValue v2 = new TestdataListValue("v2"); + TestdataListValue v3 = new TestdataListValue("v3"); + TestdataListValue v4 = new TestdataListValue("v4"); + TestdataListValue v5 = new TestdataListValue("v5"); + + TestdataListEntity e1 = new TestdataListEntity("e1", List.of(v1, v2, v3)); + v1.setEntity(e1); + v2.setEntity(e1); + v3.setEntity(e1); + TestdataListEntity e2 = new TestdataListEntity("e2", List.of(v4, v5)); + v4.setEntity(e2); + v5.setEntity(e2); + + TestdataListSolution solution = new TestdataListSolution(); + solution.setEntityList(List.of(e1, e2)); + solution.setValueList(List.of(v1, v2, v3, v4, v5)); + + when(scoreDirector.getWorkingSolution()).thenReturn(solution); + supply.resetWorkingSolution(scoreDirector); + + assertThat(supply.getNext(v1)).isSameAs(v2); + assertThat(supply.getNext(v2)).isSameAs(v3); + assertThat(supply.getNext(v3)).isNull(); + + assertThat(supply.getNext(v4)).isSameAs(v5); + assertThat(supply.getNext(v5)).isNull(); + + // Removing entity + supply.beforeEntityRemoved(scoreDirector, e1); + solution.removeEntity(e1); + supply.afterEntityRemoved(scoreDirector, e1); + + assertThat(supply.getNext(v1)).isNull(); + assertThat(supply.getNext(v2)).isNull(); + assertThat(supply.getNext(v3)).isNull(); + assertThat(supply.getNext(v4)).isSameAs(v5); + assertThat(supply.getNext(v5)).isNull(); + + supply.close(); + } + +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/TestdataEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/TestdataEntity.java index 952fc83e06..d832e8ff55 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/TestdataEntity.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/TestdataEntity.java @@ -44,5 +44,7 @@ public void setValue(TestdataValue value) { // ************************************************************************ // Complex methods // ************************************************************************ - + public void updateValue() { + this.value = new TestdataValue(value.code + "/" + value.code); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseEntity.java new file mode 100644 index 0000000000..5289488152 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseEntity.java @@ -0,0 +1,11 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var; + +import java.util.List; + +public interface TestdataCascadingBaseEntity { + + @SuppressWarnings("rawtypes") + void setValueList(List valueList); + + List getValueList(); +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseSolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseSolution.java new file mode 100644 index 0000000000..fe1090bba2 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataCascadingBaseSolution.java @@ -0,0 +1,10 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var; + +import java.util.List; + +public interface TestdataCascadingBaseSolution { + + List getEntityList(); + + List getValueList(); +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataMultipleCascadingBaseValue.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataMultipleCascadingBaseValue.java new file mode 100644 index 0000000000..4d375f6bab --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/TestdataMultipleCascadingBaseValue.java @@ -0,0 +1,20 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var; + +public interface TestdataMultipleCascadingBaseValue { + + void setEntity(E entity); + + void reset(); + + Integer getValue(); + + Integer getCascadeValue(); + + void setCascadeValue(Integer cascadeValue); + + Integer getSecondCascadeValue(); + + void setSecondCascadeValue(Integer secondCascadeValue); + + int getNumberOfCalls(); +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingEntity.java new file mode 100644 index 0000000000..741414a652 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingEntity.java @@ -0,0 +1,88 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.shadow_var; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.TestdataObject; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseEntity; + +@PlanningEntity +public class TestdataMultipleCascadingEntity extends TestdataObject + implements TestdataCascadingBaseEntity { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataMultipleCascadingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataMultipleCascadingEntity.class); + } + + public static ListVariableDescriptor buildVariableDescriptorForValueList() { + return (ListVariableDescriptor) buildEntityDescriptor() + .getGenuineVariableDescriptor("valueList"); + } + + public static TestdataMultipleCascadingEntity createWithValues(String code, TestdataMultipleCascadingValue... values) { + // Set up shadow variables to preserve consistency. + return new TestdataMultipleCascadingEntity(code, new ArrayList<>(Arrays.asList(values))).setUpShadowVariables(); + } + + TestdataMultipleCascadingEntity setUpShadowVariables() { + if (valueList != null && !valueList.isEmpty()) { + int i = 0; + var previous = valueList.get(i); + var current = valueList.get(i); + while (current != null) { + current.setEntity(this); + current.setPrevious(previous); + if (previous != null) { + previous.setNext(current); + } + previous = current; + current = ++i < valueList.size() ? valueList.get(i) : null; + } + for (var v : valueList) { + v.updateCascadeValue(); + } + } + return this; + } + + @PlanningListVariable(valueRangeProviderRefs = "valueRange") + private List valueList; + + public TestdataMultipleCascadingEntity() { + } + + public TestdataMultipleCascadingEntity(String code) { + super(code); + this.valueList = new LinkedList<>(); + } + + public TestdataMultipleCascadingEntity(String code, List valueList) { + super(code); + this.valueList = valueList; + } + + @Override + @SuppressWarnings("rawtypes") + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @Override + public List getValueList() { + return valueList; + } + + @Override + public String toString() { + return "TestdataMultipleCascadeEntity{" + + "code='" + code + '\'' + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingSolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingSolution.java new file mode 100644 index 0000000000..4f43b6ed42 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingSolution.java @@ -0,0 +1,75 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.shadow_var; + +import java.util.List; +import java.util.stream.IntStream; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseSolution; + +@PlanningSolution +public class TestdataMultipleCascadingSolution + implements TestdataCascadingBaseSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor( + TestdataMultipleCascadingSolution.class, + TestdataMultipleCascadingEntity.class, + TestdataMultipleCascadingValue.class); + } + + public static TestdataMultipleCascadingSolution generateUninitializedSolution(int valueCount, int entityCount) { + return generateSolution(valueCount, entityCount); + } + + private static TestdataMultipleCascadingSolution generateSolution(int valueCount, int entityCount) { + List entityList = IntStream.range(1, entityCount + 1) + .mapToObj(i -> new TestdataMultipleCascadingEntity("Generated Entity " + i)) + .toList(); + List valueList = IntStream.range(1, valueCount + 1) + .mapToObj(TestdataMultipleCascadingValue::new) + .toList(); + TestdataMultipleCascadingSolution solution = new TestdataMultipleCascadingSolution(); + solution.setValueList(valueList); + solution.setEntityList(entityList); + return solution; + } + + private List valueList; + private List entityList; + private SimpleScore score; + + @ValueRangeProvider(id = "valueRange") + @PlanningEntityCollectionProperty + @Override + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @PlanningEntityCollectionProperty + @Override + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + @PlanningScore + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingValue.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingValue.java new file mode 100644 index 0000000000..96679e42cc --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/shadow_var/TestdataMultipleCascadingValue.java @@ -0,0 +1,119 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.shadow_var; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataMultipleCascadingBaseValue; + +@PlanningEntity +public class TestdataMultipleCascadingValue implements TestdataMultipleCascadingBaseValue { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataMultipleCascadingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataMultipleCascadingValue.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataMultipleCascadingEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataMultipleCascadingValue previous; + @NextElementShadowVariable(sourceVariableName = "valueList") + private TestdataMultipleCascadingValue next; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer cascadeValue; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer secondCascadeValue; + private Integer value; + private int numberOfCalls = 0; + + public TestdataMultipleCascadingValue(Integer value) { + this.value = value; + } + + public TestdataMultipleCascadingEntity getEntity() { + return entity; + } + + @Override + public void setEntity(TestdataMultipleCascadingEntity entity) { + this.entity = entity; + } + + public TestdataMultipleCascadingValue getPrevious() { + return previous; + } + + public void setPrevious(TestdataMultipleCascadingValue previous) { + this.previous = previous; + } + + public TestdataMultipleCascadingValue getNext() { + return next; + } + + public void setNext(TestdataMultipleCascadingValue next) { + this.next = next; + } + + @Override + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + @Override + public void setCascadeValue(Integer cascadeValue) { + this.cascadeValue = cascadeValue; + } + + @Override + public Integer getCascadeValue() { + return cascadeValue; + } + + @Override + public Integer getSecondCascadeValue() { + return secondCascadeValue; + } + + @Override + public void setSecondCascadeValue(Integer secondCascadeValue) { + this.secondCascadeValue = secondCascadeValue; + } + + @Override + public int getNumberOfCalls() { + return numberOfCalls; + } + + //---Complex methods---// + public void updateCascadeValue() { + numberOfCalls++; + if (cascadeValue == null) { + cascadeValue = value; + } + if (secondCascadeValue == null || secondCascadeValue != value + 1) { + secondCascadeValue = value + 1; + } + } + + @Override + public void reset() { + numberOfCalls = 0; + } + + @Override + public String toString() { + return "TestdataMultipleCascadeValue{" + + "value=" + value + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyEntity.java new file mode 100644 index 0000000000..217a1a8988 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyEntity.java @@ -0,0 +1,87 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.supply; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.TestdataObject; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseEntity; + +@PlanningEntity +public class TestdataMultipleCascadingWithSupplyEntity extends TestdataObject + implements TestdataCascadingBaseEntity { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataMultipleCascadingWithSupplySolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataMultipleCascadingWithSupplyEntity.class); + } + + public static ListVariableDescriptor buildVariableDescriptorForValueList() { + return (ListVariableDescriptor) buildEntityDescriptor() + .getGenuineVariableDescriptor("valueList"); + } + + public static TestdataMultipleCascadingWithSupplyEntity createWithValues(String code, + TestdataMultipleCascadingWithSupplyValue... values) { + // Set up shadow variables to preserve consistency. + return new TestdataMultipleCascadingWithSupplyEntity(code, new ArrayList<>(Arrays.asList(values))) + .setUpShadowVariables(); + } + + TestdataMultipleCascadingWithSupplyEntity setUpShadowVariables() { + if (valueList != null && !valueList.isEmpty()) { + int i = 0; + var previous = valueList.get(i); + var current = valueList.get(i); + while (current != null) { + current.setEntity(this); + current.setPrevious(previous); + previous = current; + current = ++i < valueList.size() ? valueList.get(i) : null; + } + for (var v : valueList) { + v.updateCascadeValue(); + } + } + return this; + } + + @PlanningListVariable(valueRangeProviderRefs = "valueRange") + private List valueList; + + public TestdataMultipleCascadingWithSupplyEntity() { + } + + public TestdataMultipleCascadingWithSupplyEntity(String code) { + super(code); + this.valueList = new LinkedList<>(); + } + + public TestdataMultipleCascadingWithSupplyEntity(String code, List valueList) { + super(code); + this.valueList = valueList; + } + + @Override + @SuppressWarnings("rawtypes") + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @Override + public List getValueList() { + return valueList; + } + + @Override + public String toString() { + return "TestdataMultipleCascadeEntity{" + + "code='" + code + '\'' + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplySolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplySolution.java new file mode 100644 index 0000000000..5a148dce2b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplySolution.java @@ -0,0 +1,76 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.supply; + +import java.util.List; +import java.util.stream.IntStream; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataCascadingBaseSolution; + +@PlanningSolution +public class TestdataMultipleCascadingWithSupplySolution + implements + TestdataCascadingBaseSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor( + TestdataMultipleCascadingWithSupplySolution.class, + TestdataMultipleCascadingWithSupplyEntity.class, + TestdataMultipleCascadingWithSupplyValue.class); + } + + public static TestdataMultipleCascadingWithSupplySolution generateUninitializedSolution(int valueCount, int entityCount) { + return generateSolution(valueCount, entityCount); + } + + private static TestdataMultipleCascadingWithSupplySolution generateSolution(int valueCount, int entityCount) { + List entityList = IntStream.range(1, entityCount + 1) + .mapToObj(i -> new TestdataMultipleCascadingWithSupplyEntity("Generated Entity " + i)) + .toList(); + List valueList = IntStream.range(1, valueCount + 1) + .mapToObj(TestdataMultipleCascadingWithSupplyValue::new) + .toList(); + TestdataMultipleCascadingWithSupplySolution solution = new TestdataMultipleCascadingWithSupplySolution(); + solution.setValueList(valueList); + solution.setEntityList(entityList); + return solution; + } + + private List valueList; + private List entityList; + private SimpleScore score; + + @ValueRangeProvider(id = "valueRange") + @PlanningEntityCollectionProperty + @Override + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @PlanningEntityCollectionProperty + @Override + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + @PlanningScore + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyValue.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyValue.java new file mode 100644 index 0000000000..18b366e7d2 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/multiple_var/supply/TestdataMultipleCascadingWithSupplyValue.java @@ -0,0 +1,109 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.supply; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.multiple_var.TestdataMultipleCascadingBaseValue; + +@PlanningEntity +public class TestdataMultipleCascadingWithSupplyValue + implements TestdataMultipleCascadingBaseValue { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataMultipleCascadingWithSupplySolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataMultipleCascadingWithSupplyValue.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataMultipleCascadingWithSupplyEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataMultipleCascadingWithSupplyValue previous; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer cascadeValue; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer secondCascadeValue; + private Integer value; + private int numberOfCalls = 0; + + public TestdataMultipleCascadingWithSupplyValue(Integer value) { + this.value = value; + } + + public TestdataMultipleCascadingWithSupplyEntity getEntity() { + return entity; + } + + @Override + public void setEntity(TestdataMultipleCascadingWithSupplyEntity entity) { + this.entity = entity; + } + + public TestdataMultipleCascadingWithSupplyValue getPrevious() { + return previous; + } + + public void setPrevious(TestdataMultipleCascadingWithSupplyValue previous) { + this.previous = previous; + } + + @Override + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + @Override + public void setCascadeValue(Integer cascadeValue) { + this.cascadeValue = cascadeValue; + } + + @Override + public Integer getCascadeValue() { + return cascadeValue; + } + + @Override + public Integer getSecondCascadeValue() { + return secondCascadeValue; + } + + @Override + public void setSecondCascadeValue(Integer secondCascadeValue) { + this.secondCascadeValue = secondCascadeValue; + } + + @Override + public int getNumberOfCalls() { + return numberOfCalls; + } + + //---Complex methods---// + public void updateCascadeValue() { + numberOfCalls++; + if (cascadeValue == null) { + cascadeValue = value; + } + if (secondCascadeValue == null || secondCascadeValue != value + 1) { + secondCascadeValue = value + 1; + } + } + + @Override + public void reset() { + numberOfCalls = 0; + } + + @Override + public String toString() { + return "TestdataMultipleCascadeValue{" + + "value=" + value + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingEntity.java new file mode 100644 index 0000000000..1f5e8bfbf4 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingEntity.java @@ -0,0 +1,81 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.TestdataObject; + +@PlanningEntity +public class TestdataSingleCascadingEntity extends TestdataObject { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataSingleCascadingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataSingleCascadingEntity.class); + } + + public static ListVariableDescriptor buildVariableDescriptorForValueList() { + return (ListVariableDescriptor) buildEntityDescriptor() + .getGenuineVariableDescriptor("valueList"); + } + + public static TestdataSingleCascadingEntity createWithValues(String code, TestdataSingleCascadingValue... values) { + // Set up shadow variables to preserve consistency. + return new TestdataSingleCascadingEntity(code, new ArrayList<>(Arrays.asList(values))).setUpShadowVariables(); + } + + TestdataSingleCascadingEntity setUpShadowVariables() { + if (valueList != null && !valueList.isEmpty()) { + int i = 0; + var previous = valueList.get(i); + var current = valueList.get(i); + while (current != null) { + current.setEntity(this); + current.setPrevious(previous); + if (previous != null) { + previous.setNext(current); + } + previous = current; + current = ++i < valueList.size() ? valueList.get(i) : null; + } + for (var v : valueList) { + v.updateCascadeValue(); + v.updateCascadeValueWithReturnType(); + } + } + return this; + } + + @PlanningListVariable(valueRangeProviderRefs = "valueRange") + private List valueList; + + public TestdataSingleCascadingEntity(String code) { + super(code); + this.valueList = new LinkedList<>(); + } + + public TestdataSingleCascadingEntity(String code, List valueList) { + super(code); + this.valueList = valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + public List getValueList() { + return valueList; + } + + @Override + public String toString() { + return "TestdataCascadeEntity{" + + "code='" + code + '\'' + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingSolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingSolution.java new file mode 100644 index 0000000000..73b60400f6 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingSolution.java @@ -0,0 +1,71 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var; + +import java.util.List; +import java.util.stream.IntStream; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; + +@PlanningSolution +public class TestdataSingleCascadingSolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor( + TestdataSingleCascadingSolution.class, + TestdataSingleCascadingEntity.class, + TestdataSingleCascadingValue.class); + } + + public static TestdataSingleCascadingSolution generateUninitializedSolution(int valueCount, int entityCount) { + return generateSolution(valueCount, entityCount); + } + + private static TestdataSingleCascadingSolution generateSolution(int valueCount, int entityCount) { + List entityList = IntStream.range(1, entityCount + 1) + .mapToObj(i -> new TestdataSingleCascadingEntity("Generated Entity " + i)) + .toList(); + List valueList = IntStream.range(1, valueCount + 1) + .mapToObj(TestdataSingleCascadingValue::new) + .toList(); + TestdataSingleCascadingSolution solution = new TestdataSingleCascadingSolution(); + solution.setValueList(valueList); + solution.setEntityList(entityList); + return solution; + } + + private List valueList; + private List entityList; + private SimpleScore score; + + @ValueRangeProvider(id = "valueRange") + @PlanningEntityCollectionProperty + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @PlanningEntityCollectionProperty + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + @PlanningScore + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingValue.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingValue.java new file mode 100644 index 0000000000..b2baae5c73 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/shadow_var/TestdataSingleCascadingValue.java @@ -0,0 +1,112 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; + +@PlanningEntity +public class TestdataSingleCascadingValue { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataSingleCascadingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataSingleCascadingValue.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingValue previous; + @NextElementShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingValue next; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer cascadeValue; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValueWithReturnType", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValueWithReturnType", sourceVariableName = "previous") + private Integer cascadeValueReturnType; + private Integer value; + private int firstNumberOfCalls = 0; + private int secondNumberOfCalls = 0; + + public TestdataSingleCascadingValue(Integer value) { + this.value = value; + } + + public TestdataSingleCascadingEntity getEntity() { + return entity; + } + + public void setEntity(TestdataSingleCascadingEntity entity) { + this.entity = entity; + } + + public TestdataSingleCascadingValue getPrevious() { + return previous; + } + + public void setPrevious(TestdataSingleCascadingValue previous) { + this.previous = previous; + } + + public TestdataSingleCascadingValue getNext() { + return next; + } + + public void setNext(TestdataSingleCascadingValue next) { + this.next = next; + } + + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + public void setCascadeValue(Integer cascadeValue) { + this.cascadeValue = cascadeValue; + } + + public Integer getCascadeValue() { + return cascadeValue; + } + + public Integer getCascadeValueReturnType() { + return cascadeValueReturnType; + } + + public int getFirstNumberOfCalls() { + return firstNumberOfCalls; + } + + public int getSecondNumberOfCalls() { + return secondNumberOfCalls; + } + + //---Complex methods---// + public void updateCascadeValue() { + firstNumberOfCalls++; + if (cascadeValue == null || cascadeValue != value + 1) { + cascadeValue = value + 1; + } + } + + public Integer updateCascadeValueWithReturnType() { + secondNumberOfCalls++; + if (cascadeValueReturnType == null || cascadeValueReturnType != value + 2) { + cascadeValueReturnType = value + 2; + } + return cascadeValueReturnType; + } + + @Override + public String toString() { + return "TestdataCascadeValue{" + + "value=" + value + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyEntity.java new file mode 100644 index 0000000000..9e00477507 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyEntity.java @@ -0,0 +1,79 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.TestdataObject; + +@PlanningEntity +public class TestdataSingleCascadingWithSupplyEntity extends TestdataObject { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataSingleCascadingWithSupplySolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataSingleCascadingWithSupplyEntity.class); + } + + public static ListVariableDescriptor buildVariableDescriptorForValueList() { + return (ListVariableDescriptor) buildEntityDescriptor() + .getGenuineVariableDescriptor("valueList"); + } + + public static TestdataSingleCascadingWithSupplyEntity createWithValues(String code, + TestdataSingleCascadingWithSupplyValue... values) { + // Set up shadow variables to preserve consistency. + return new TestdataSingleCascadingWithSupplyEntity(code, new ArrayList<>(Arrays.asList(values))).setUpShadowVariables(); + } + + TestdataSingleCascadingWithSupplyEntity setUpShadowVariables() { + if (valueList != null && !valueList.isEmpty()) { + int i = 0; + var previous = valueList.get(i); + var current = valueList.get(i); + while (current != null) { + current.setEntity(this); + current.setPrevious(previous); + previous = current; + current = ++i < valueList.size() ? valueList.get(i) : null; + } + for (var v : valueList) { + v.updateCascadeValue(); + v.updateCascadeValueWithReturnType(); + } + } + return this; + } + + @PlanningListVariable(valueRangeProviderRefs = "valueRange") + private List valueList; + + public TestdataSingleCascadingWithSupplyEntity(String code) { + super(code); + this.valueList = new LinkedList<>(); + } + + public TestdataSingleCascadingWithSupplyEntity(String code, List valueList) { + super(code); + this.valueList = valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + public List getValueList() { + return valueList; + } + + @Override + public String toString() { + return "TestdataCascadeEntity{" + + "code='" + code + '\'' + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplySolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplySolution.java new file mode 100644 index 0000000000..e82ddebbab --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplySolution.java @@ -0,0 +1,71 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply; + +import java.util.List; +import java.util.stream.IntStream; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.buildin.simple.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; + +@PlanningSolution +public class TestdataSingleCascadingWithSupplySolution { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor( + TestdataSingleCascadingWithSupplySolution.class, + TestdataSingleCascadingWithSupplyEntity.class, + TestdataSingleCascadingWithSupplyValue.class); + } + + public static TestdataSingleCascadingWithSupplySolution generateUninitializedSolution(int valueCount, int entityCount) { + return generateSolution(valueCount, entityCount); + } + + private static TestdataSingleCascadingWithSupplySolution generateSolution(int valueCount, int entityCount) { + List entityList = IntStream.range(1, entityCount + 1) + .mapToObj(i -> new TestdataSingleCascadingWithSupplyEntity("Generated Entity " + i)) + .toList(); + List valueList = IntStream.range(1, valueCount + 1) + .mapToObj(TestdataSingleCascadingWithSupplyValue::new) + .toList(); + TestdataSingleCascadingWithSupplySolution solution = new TestdataSingleCascadingWithSupplySolution(); + solution.setValueList(valueList); + solution.setEntityList(entityList); + return solution; + } + + private List valueList; + private List entityList; + private SimpleScore score; + + @ValueRangeProvider(id = "valueRange") + @PlanningEntityCollectionProperty + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + @PlanningEntityCollectionProperty + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + @PlanningScore + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyValue.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyValue.java new file mode 100644 index 0000000000..f5a4084975 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/cascade/single_var/suply/TestdataSingleCascadingWithSupplyValue.java @@ -0,0 +1,106 @@ +package ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.suply; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; + +@PlanningEntity +public class TestdataSingleCascadingWithSupplyValue { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataSingleCascadingWithSupplySolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataSingleCascadingWithSupplyValue.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingWithSupplyEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingWithSupplyValue previous; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer cascadeValue; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValueWithReturnType", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValueWithReturnType", sourceVariableName = "previous") + private Integer cascadeValueReturnType; + private Integer value; + private int firstNumberOfCalls = 0; + private int secondNumberOfCalls = 0; + + public TestdataSingleCascadingWithSupplyValue(Integer value) { + this.value = value; + } + + public TestdataSingleCascadingWithSupplyEntity getEntity() { + return entity; + } + + public void setEntity(TestdataSingleCascadingWithSupplyEntity entity) { + this.entity = entity; + } + + public TestdataSingleCascadingWithSupplyValue getPrevious() { + return previous; + } + + public void setPrevious(TestdataSingleCascadingWithSupplyValue previous) { + this.previous = previous; + } + + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + public void setCascadeValue(Integer cascadeValue) { + this.cascadeValue = cascadeValue; + } + + public Integer getCascadeValue() { + return cascadeValue; + } + + public Integer getCascadeValueReturnType() { + return cascadeValueReturnType; + } + + public int getFirstNumberOfCalls() { + return firstNumberOfCalls; + } + + public int getSecondNumberOfCalls() { + return secondNumberOfCalls; + } + + //---Complex methods---// + public void updateCascadeValue() { + firstNumberOfCalls++; + if (cascadeValue == null || cascadeValue != value + 1) { + cascadeValue = value + 1; + } + } + + public Integer updateCascadeValueWithReturnType() { + secondNumberOfCalls++; + if (cascadeValueReturnType == null || cascadeValueReturnType != value + 2) { + cascadeValueReturnType = value + 2; + } + return cascadeValueReturnType; + } + + public void reset() { + firstNumberOfCalls = 0; + secondNumberOfCalls = 0; + } + + @Override + public String toString() { + return "TestdataCascadeValue{" + + "value=" + value + + '}'; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListEntity.java index b7c850eda5..3325a0173e 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListEntity.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListEntity.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; @@ -52,4 +53,20 @@ public TestdataListEntity(String code, TestdataListValue... values) { public List getValueList() { return valueList; } + + public void addValue(TestdataListValue value) { + addValueAt(valueList.size(), value); + } + + public void addValueAt(int pos, TestdataListValue value) { + List newValueList = new ArrayList<>(valueList); + newValueList.add(pos, value); + this.valueList = newValueList; + } + + public void removeValue(TestdataListValue value) { + this.valueList = valueList.stream() + .filter(v -> !Objects.equals(v, value)) + .toList(); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListSolution.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListSolution.java index 51619e8aac..f6b91103b7 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListSolution.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/TestdataListSolution.java @@ -1,7 +1,6 @@ package ai.timefold.solver.core.impl.testdata.domain.list; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.IntStream; import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; @@ -32,10 +31,10 @@ public static TestdataListSolution generateUninitializedSolution(int valueCount, private static TestdataListSolution generateSolution(int valueCount, int entityCount) { List entityList = IntStream.range(0, entityCount) .mapToObj(i -> new TestdataListEntity("Generated Entity " + i)) - .collect(Collectors.toList()); + .toList(); List valueList = IntStream.range(0, valueCount) .mapToObj(i -> new TestdataListValue("Generated Value " + i)) - .collect(Collectors.toList()); + .toList(); TestdataListSolution solution = new TestdataListSolution(); solution.setValueList(valueList); solution.setEntityList(entityList); @@ -81,4 +80,10 @@ public SimpleScore getScore() { public void setScore(SimpleScore score) { this.score = score; } + + public void removeEntity(TestdataListEntity entity) { + this.entityList = entityList.stream() + .filter(e -> e != entity) + .toList(); + } } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongMethod.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongMethod.java new file mode 100644 index 0000000000..b05d63bbbe --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongMethod.java @@ -0,0 +1,92 @@ +package ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingSolution; + +@PlanningEntity +public class TestdataCascadingWrongMethod { + + public static EntityDescriptor buildEntityDescriptor() { + return SolutionDescriptor + .buildSolutionDescriptor(TestdataSingleCascadingSolution.class, TestdataSingleCascadingEntity.class, + TestdataCascadingWrongMethod.class) + .findEntityDescriptorOrFail(TestdataCascadingWrongMethod.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataCascadingWrongMethod previous; + @NextElementShadowVariable(sourceVariableName = "valueList") + private TestdataCascadingWrongMethod next; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "previous") + private Integer cascadeValue; + @CascadingUpdateShadowVariable(targetMethodName = "badUpdateCascadeValueWithReturnType", sourceVariableName = "entity") + private Integer cascadeValueReturnType; + private Integer value; + + public TestdataCascadingWrongMethod(Integer value) { + this.value = value; + } + + public TestdataSingleCascadingEntity getEntity() { + return entity; + } + + public void setEntity(TestdataSingleCascadingEntity entity) { + this.entity = entity; + } + + public TestdataCascadingWrongMethod getPrevious() { + return previous; + } + + public void setPrevious(TestdataCascadingWrongMethod previous) { + this.previous = previous; + } + + public TestdataCascadingWrongMethod getNext() { + return next; + } + + public void setNext(TestdataCascadingWrongMethod next) { + this.next = next; + } + + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + public Integer getCascadeValue() { + return cascadeValue; + } + + public Integer getCascadeValueReturnType() { + return cascadeValueReturnType; + } + + //---Complex methods---// + public void updateCascadeValue() { + if (value != null) { + value = value + 1; + } + } + + public Integer updateCascadeValueWithReturnType() { + updateCascadeValue(); + cascadeValueReturnType = cascadeValue; + return cascadeValueReturnType; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongSource.java b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongSource.java new file mode 100644 index 0000000000..6ebb5f3dac --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/wrong_cascade/TestdataCascadingWrongSource.java @@ -0,0 +1,80 @@ +package ai.timefold.solver.core.impl.testdata.domain.shadow.wrong_cascade; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingEntity; +import ai.timefold.solver.core.impl.testdata.domain.cascade.single_var.shadow_var.TestdataSingleCascadingSolution; + +@PlanningEntity +public class TestdataCascadingWrongSource { + + public static EntityDescriptor buildEntityDescriptor() { + return SolutionDescriptor + .buildSolutionDescriptor(TestdataSingleCascadingSolution.class, TestdataSingleCascadingEntity.class, + TestdataCascadingWrongSource.class) + .findEntityDescriptorOrFail(TestdataCascadingWrongSource.class); + } + + @InverseRelationShadowVariable(sourceVariableName = "valueList") + private TestdataSingleCascadingEntity entity; + @PreviousElementShadowVariable(sourceVariableName = "valueList") + private TestdataCascadingWrongSource previous; + @NextElementShadowVariable(sourceVariableName = "valueList") + private TestdataCascadingWrongSource next; + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "entity") + @CascadingUpdateShadowVariable(targetMethodName = "updateCascadeValue", sourceVariableName = "bad") + private Integer cascadeValue; + private Integer value; + + public TestdataCascadingWrongSource(Integer value) { + this.value = value; + } + + public TestdataSingleCascadingEntity getEntity() { + return entity; + } + + public void setEntity(TestdataSingleCascadingEntity entity) { + this.entity = entity; + } + + public TestdataCascadingWrongSource getPrevious() { + return previous; + } + + public void setPrevious(TestdataCascadingWrongSource previous) { + this.previous = previous; + } + + public TestdataCascadingWrongSource getNext() { + return next; + } + + public void setNext(TestdataCascadingWrongSource next) { + this.next = next; + } + + public Integer getValue() { + return value; + } + + public void setValue(Integer value) { + this.value = value; + } + + public Integer getCascadeValue() { + return cascadeValue; + } + + //---Complex methods---// + public void updateCascadeValue() { + if (value != null) { + value = value + 1; + } + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java index b5c3815f03..32538b5676 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/util/ElementAwareListTest.java @@ -14,7 +14,7 @@ class ElementAwareListTest { @Test void addRemove() { ElementAwareList tupleList = new ElementAwareList<>(); - assertThat(tupleList.size()).isEqualTo(0); + assertThat(tupleList.size()).isZero(); assertThat(tupleList.first()).isNull(); assertThat(tupleList.last()).isNull(); @@ -44,13 +44,80 @@ void addRemove() { assertThat(tupleList.last()).isEqualTo(entryB); entryB.remove(); - assertThat(tupleList.size()).isEqualTo(0); + assertThat(tupleList.size()).isZero(); assertThat(tupleList.first()).isNull(); assertThat(tupleList.last()).isNull(); } @Test - public void iterator() { + void addFirst() { + ElementAwareList tupleList = new ElementAwareList<>(); + assertThat(tupleList.size()).isZero(); + assertThat(tupleList.first()).isNull(); + assertThat(tupleList.last()).isNull(); + + ElementAwareListEntry entryA = tupleList.add("A"); + ElementAwareListEntry entryB = tupleList.add("B"); + ElementAwareListEntry entryC = tupleList.addFirst("C"); + + assertThat(tupleList.size()).isEqualTo(3); + + assertThat(entryC.next).isEqualTo(entryA); + assertThat(entryA.next).isEqualTo(entryB); + assertThat(entryB.next).isNull(); + + assertThat(entryC.previous).isNull(); + assertThat(entryA.previous).isEqualTo(entryC); + assertThat(entryB.previous).isEqualTo(entryA); + + assertThat(tupleList.first()).isEqualTo(entryC); + assertThat(tupleList.last()).isEqualTo(entryB); + } + + @Test + void addAfter() { + ElementAwareList tupleList = new ElementAwareList<>(); + assertThat(tupleList.size()).isZero(); + assertThat(tupleList.first()).isNull(); + assertThat(tupleList.last()).isNull(); + + ElementAwareListEntry entryA = tupleList.add("A"); + ElementAwareListEntry entryB = tupleList.add("B"); + ElementAwareListEntry entryC = tupleList.addAfter("C", entryA); + + assertThat(tupleList.size()).isEqualTo(3); + + assertThat(entryA.next).isEqualTo(entryC); + assertThat(entryC.next).isEqualTo(entryB); + assertThat(entryB.next).isNull(); + + assertThat(entryA.previous).isNull(); + assertThat(entryC.previous).isEqualTo(entryA); + assertThat(entryB.previous).isEqualTo(entryC); + + assertThat(tupleList.first()).isEqualTo(entryA); + assertThat(tupleList.last()).isEqualTo(entryB); + + ElementAwareListEntry entryD = tupleList.addAfter("D", entryB); + + assertThat(tupleList.size()).isEqualTo(4); + + assertThat(entryA.next).isEqualTo(entryC); + assertThat(entryC.next).isEqualTo(entryB); + assertThat(entryB.next).isEqualTo(entryD); + assertThat(entryD.next).isNull(); + + assertThat(entryA.previous).isNull(); + assertThat(entryC.previous).isEqualTo(entryA); + assertThat(entryB.previous).isEqualTo(entryC); + assertThat(entryD.previous).isEqualTo(entryB); + + assertThat(tupleList.first()).isEqualTo(entryA); + assertThat(tupleList.last()).isEqualTo(entryD); + } + + @Test + void iterator() { // create a list and add some elements ElementAwareList list = new ElementAwareList<>(); SoftAssertions.assertSoftly(softly -> { diff --git a/docs/src/modules/ROOT/pages/using-timefold-solver/modeling-planning-problems.adoc b/docs/src/modules/ROOT/pages/using-timefold-solver/modeling-planning-problems.adoc index 13f3a7f01a..7e2030c940 100644 --- a/docs/src/modules/ROOT/pages/using-timefold-solver/modeling-planning-problems.adoc +++ b/docs/src/modules/ROOT/pages/using-timefold-solver/modeling-planning-problems.adoc @@ -1367,6 +1367,135 @@ public class Customer { public void setNextCustomer(Customer nextCustomer) {...} ---- +[#tailChainVariable] +=== Updating tail chains + +The annotation `@CascadingUpdateShadowVariable` provides a built-in listener that updates a set of connected elements. +Timefold Solver triggers a user-defined logic whenever the a defined source variable changes. +Moreover, it automatically propagates changes to the next elements when the value of the related shadow variable changes. + +The planning entity side has a genuine list variable: + +[source,java] +---- +@PlanningEntity +public class Vehicle { + + @PlanningListVariable + public List getCustomers() { + return customers; + } + + public void setCustomers(List customers) {...} +} +---- + +On the element side: + +[source,java] +---- +@PlanningEntity +public class Customer { + + @PreviousElementShadowVariable(sourceVariableName = "customers") + private Customer previousCustomer; + @NextElementShadowVariable(sourceVariableName = "customers") + private Customer nextCustomer; + @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime", sourceVariableName = "previousCustomer") + private LocalDateTime arrivalTime; + + ... + + public void updateArrivalTime() {...} +---- + +The `targetMethodName` refers to the user-defined logic that updates the annotated shadow variable. +The method must be implemented in the defining entity class, be non-static, and not include any parameters. + +The `sourceVariableName` property is the planning variable's name on the getter's return type, +which will trigger the cascade update listener in case of changes. + +In the previous example, +the cascade update listener calls `updateArrivalTime` whenever `previousCustomer` changes. +It then automatically calls `updateArrivalTime` for the subsequent `nextCustomer` elements +and stops when the `arrivalTime` value does not change after running target method +or when it reaches the end. + +The shadow variable `nextCustomer` is not required, but defining it in the model is recommended: + +[source,java] +---- +@PlanningEntity +public class Customer { + + @PreviousElementShadowVariable(sourceVariableName = "customers") + private Customer previousCustomer; + @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime", sourceVariableName = "previousCustomer") + private LocalDateTime arrivalTime; + + ... + + public void updateArrivalTime() {...} +---- + +It is also possible to define multiple sources per variable: + +[source,java] +---- +@PlanningEntity +public class Customer { + + @InverseRelationShadowVariable(sourceVariableName = "customers") + private Vehicle vehicle; + @PreviousElementShadowVariable(sourceVariableName = "customers") + private Customer previousCustomer; + @NextElementShadowVariable(sourceVariableName = "customers") + private Customer nextCustomer; + @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime", sourceVariableName = "vehicle") + @CascadingUpdateShadowVariable(targetMethodName = "updateArrivalTime", sourceVariableName = "previousCustomer") + private LocalDateTime arrivalTime; + + ... + + public void updateArrivalTime() {...} +---- + +The listener behaves as described previously. +It calls `updateArrivalTime` whenever a `previousCustomer` or `vehicle` changes. +It then propagates the changes to the subsequent elements, +stopping when the method results in no change to the variable, or when it reaches the tail. + +[WARNING] +==== +A user-defined logic can only change shadow variables. +It must never change a genuine planning variable or a problem fact. +==== + +==== Multiple source variables + +If the user-defined logic requires updating multiple shadow variables, +decorate each shadow variable with a separate `@CascadingUpdateShadowVariable` annotation. + +[source,java] +---- +@PlanningEntity +public class Customer { + + @PreviousElementShadowVariable(sourceVariableName = "customers") + private Customer previousCustomer; + @NextElementShadowVariable(sourceVariableName = "customers") + private Customer nextCustomer; + @CascadingUpdateShadowVariable(targetMethodName = "updateWeightAndArrivalTime", sourceVariableName = "previousCustomer") + private LocalDateTime arrivalTime; + @CascadingUpdateShadowVariable(targetMethodName = "updateWeightAndArrivalTime", sourceVariableName = "previousCustomer") + private Integer weightAtVisit; + ... + + public void updateWeightAndArrivalTime() {...} +---- + +Timefold Solver triggers a single listener to run the user-defined logic whenever `previousCustomer` changes. +It stops when both `arrivalTime` and `weightAtVisit` values do not change or when it reaches the end. [#chainedPlanningVariable] == Chained planning variable (TSP, VRP, ...) diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java index 9ad22a1053..c79ed5f1af 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java @@ -17,6 +17,7 @@ import ai.timefold.solver.core.api.domain.solution.ProblemFactProperty; import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; import ai.timefold.solver.core.api.domain.variable.AnchorShadowVariable; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; import ai.timefold.solver.core.api.domain.variable.CustomShadowVariable; import ai.timefold.solver.core.api.domain.variable.IndexShadowVariable; import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; @@ -69,6 +70,8 @@ public final class DotNames { static final DotName PIGGYBACK_SHADOW_VARIABLE = DotName.createSimple(PiggybackShadowVariable.class.getName()); static final DotName PREVIOUS_ELEMENT_SHADOW_VARIABLE = DotName.createSimple(PreviousElementShadowVariable.class.getName()); static final DotName SHADOW_VARIABLE = DotName.createSimple(ShadowVariable.class.getName()); + static final DotName CASCADING_UPDATE_SHADOW_VARIABLE = + DotName.createSimple(CascadingUpdateShadowVariable.class.getName()); // Need to use String since timefold-solver-test is not on the compile classpath static final DotName CONSTRAINT_VERIFIER = @@ -86,6 +89,7 @@ public final class DotNames { PIGGYBACK_SHADOW_VARIABLE, PREVIOUS_ELEMENT_SHADOW_VARIABLE, SHADOW_VARIABLE, + CASCADING_UPDATE_SHADOW_VARIABLE }; static final DotName[] GIZMO_MEMBER_ACCESSOR_ANNOTATIONS = { @@ -110,6 +114,7 @@ public final class DotNames { PIGGYBACK_SHADOW_VARIABLE, PREVIOUS_ELEMENT_SHADOW_VARIABLE, SHADOW_VARIABLE, + CASCADING_UPDATE_SHADOW_VARIABLE }; public enum BeanDefiningAnnotations { diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java index edb91b3313..2659bb18dc 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java @@ -104,7 +104,7 @@ public String generateFieldAccessor(AnnotationInstance annotationInstance, Class Thread.currentThread().getContextClassLoader()); Field fieldMember = declaringClass.getDeclaredField(fieldInfo.name()); GizmoMemberDescriptor member = createMemberDescriptorForField(fieldMember, transformers); - GizmoMemberInfo memberInfo = new GizmoMemberInfo(member, + GizmoMemberInfo memberInfo = new GizmoMemberInfo(member, true, (Class) Class.forName(annotationInstance.name().toString(), false, Thread.currentThread().getContextClassLoader())); String generatedClassName = GizmoMemberAccessorFactory.getGeneratedClassName(fieldMember); @@ -163,7 +163,8 @@ private static Optional getSetterDescriptor(ClassInfo classInf * @param transformers BuildProducer of BytecodeTransformers */ public String generateMethodAccessor(AnnotationInstance annotationInstance, ClassOutput classOutput, - ClassInfo classInfo, MethodInfo methodInfo, BuildProducer transformers) + ClassInfo classInfo, MethodInfo methodInfo, boolean requiredReturnType, + BuildProducer transformers) throws ClassNotFoundException, NoSuchMethodException { Class declaringClass = Class.forName(methodInfo.declaringClass().name().toString(), false, Thread.currentThread().getContextClassLoader()); @@ -185,9 +186,12 @@ public String generateMethodAccessor(AnnotationInstance annotationInstance, Clas member = new GizmoMemberDescriptor(name, newMethodDescriptor, memberDescriptor, declaringClass, setterDescriptor.orElse(null)); } - GizmoMemberInfo memberInfo = new GizmoMemberInfo(member, - (Class) Class.forName(annotationInstance.name().toString(), false, - Thread.currentThread().getContextClassLoader())); + Class annotationClass = null; + if (requiredReturnType || annotationInstance != null) { + annotationClass = (Class) Class.forName(annotationInstance.name().toString(), false, + Thread.currentThread().getContextClassLoader()); + } + GizmoMemberInfo memberInfo = new GizmoMemberInfo(member, requiredReturnType, annotationClass); GizmoMemberAccessorImplementor.defineAccessorFor(generatedClassName, classOutput, memberInfo); return generatedClassName; } diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java index 73089d72f0..3bbd11e3a7 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java @@ -892,13 +892,27 @@ private GeneratedGizmoClasses generateDomainAccessors(Map ClassInfo classInfo = fieldInfo.declaringClass(); buildFieldAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, classInfo, fieldInfo, transformers); + if (annotatedMember.name().equals(DotNames.CASCADING_UPDATE_SHADOW_VARIABLE)) { + // The source method name also must be included + var targetMethodName = annotatedMember.values().stream() + .filter(v -> v.name().equals("targetMethodName")) + .findFirst() + .map(AnnotationValue::asString) + .orElseThrow(() -> new IllegalStateException( + "Fail to generate member accessor of the source method listener (%s) of the class(%s)." + .formatted(DotNames.CASCADING_UPDATE_SHADOW_VARIABLE.local(), + classInfo.name().toString()))); + var methodInfo = classInfo.method(targetMethodName); + buildMethodAccessor(null, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, + classInfo, methodInfo, false, transformers); + } break; } case METHOD: { MethodInfo methodInfo = annotatedMember.target().asMethod(); ClassInfo classInfo = methodInfo.declaringClass(); buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, - classInfo, methodInfo, transformers); + classInfo, methodInfo, true, transformers); break; } default: { @@ -926,7 +940,7 @@ private GeneratedGizmoClasses generateDomainAccessors(Map .orElse(null); if (constraintMethodInfo != null) { buildMethodAccessor(solutionClassInstance, generatedMemberAccessorsClassNameSet, entityEnhancer, - classOutput, solutionClassInfo, constraintMethodInfo, transformers); + classOutput, solutionClassInfo, constraintMethodInfo, true, transformers); } else { buildFieldAccessor(solutionClassInstance, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, solutionClassInfo, constraintFieldInfo, transformers); @@ -961,10 +975,10 @@ private static void buildFieldAccessor(AnnotationInstance annotatedMember, Set generatedMemberAccessorsClassNameSet, GizmoMemberAccessorEntityEnhancer entityEnhancer, ClassOutput classOutput, ClassInfo classInfo, - MethodInfo methodInfo, BuildProducer transformers) { + MethodInfo methodInfo, boolean requiredReturnType, BuildProducer transformers) { try { generatedMemberAccessorsClassNameSet.add(entityEnhancer.generateMethodAccessor(annotatedMember, - classOutput, classInfo, methodInfo, transformers)); + classOutput, classInfo, methodInfo, requiredReturnType, transformers)); } catch (ClassNotFoundException | NoSuchMethodException e) { throw new IllegalStateException( "Failed to generate member accessor for the method (%s) of the class (%s)." diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java index 70c5b93984..c2b1c96a2c 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java @@ -16,6 +16,7 @@ import ai.timefold.solver.core.api.domain.entity.PlanningPin; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; import ai.timefold.solver.core.api.domain.variable.AnchorShadowVariable; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; import ai.timefold.solver.core.api.domain.variable.CustomShadowVariable; import ai.timefold.solver.core.api.domain.variable.IndexShadowVariable; import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; @@ -86,7 +87,8 @@ public class TimefoldSolverAutoConfiguration NextElementShadowVariable.class, PiggybackShadowVariable.class, PreviousElementShadowVariable.class, - ShadowVariable.class + ShadowVariable.class, + CascadingUpdateShadowVariable.class }; private ApplicationContext context; private ClassLoader beanClassLoader;