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 extends Annotation> 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 extends Annotation> 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 ext
") the classpath or modulepath must contain io.quarkus.gizmo:gizmo.\n" +
"Maybe add a dependency to io.quarkus.gizmo:gizmo.");
}
- return GizmoMemberAccessorImplementor.createAccessorFor(member, annotationClass, gizmoClassLoader);
+ return GizmoMemberAccessorImplementor.createAccessorFor(member, annotationClass, returnTypeRequired, gizmoClassLoader);
}
private GizmoMemberAccessorFactory() {
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementor.java
index fba609f0d2..5e0b8ae705 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementor.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/gizmo/GizmoMemberAccessorImplementor.java
@@ -86,8 +86,10 @@ private static Class extends AbstractGizmoMemberAccessor> 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 extends AbstractGizmoMemberAccessor> getCorrectSuperclass
* in which case no Gizmo code will be generated.
*/
static MemberAccessor createAccessorFor(Member member, Class extends Annotation> 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 extends Annotation> annotationClass) {
+ private static void assertIsGoodMethod(MethodDescriptor method, boolean returnTypeRequired,
+ Class extends Annotation> 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 extends Annotation> annotationClass) {
+public record GizmoMemberInfo(GizmoMemberDescriptor descriptor, boolean returnTypeRequired,
+ Class extends Annotation> 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 TestdataCascadingBaseEntity extends TestdataMultipleCascadingBaseValue>, ? 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 extends Annotation>) 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 extends Annotation>) Class.forName(annotationInstance.name().toString(), false,
- Thread.currentThread().getContextClassLoader()));
+ Class extends Annotation> annotationClass = null;
+ if (requiredReturnType || annotationInstance != null) {
+ annotationClass = (Class extends Annotation>) 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;