From 12cb7126458525be0584dd5b6944d2a61df131e4 Mon Sep 17 00:00:00 2001 From: fred Date: Thu, 19 Feb 2026 12:29:17 -0300 Subject: [PATCH] feat: add cloning fail fast check --- .../solution/cloner/DeepCloningUtils.java | 26 +++++++ .../cloner/AbstractSolutionClonerTest.java | 17 +++++ .../TestdataInvalidEntityProvidingEntity.java | 62 +++++++++++++++++ ...estdataInvalidEntityProvidingSolution.java | 67 +++++++++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingEntity.java create mode 100644 core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingSolution.java diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningUtils.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningUtils.java index 64b6c3cebb..4cbcc4e6bf 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningUtils.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningUtils.java @@ -32,6 +32,7 @@ import ai.timefold.solver.core.api.domain.common.PlanningId; import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone; import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; @@ -83,6 +84,22 @@ public static boolean isFieldDeepCloned(SolutionDescriptor solutionDescriptor if (isImmutable(fieldType)) { return false; } else { + // Problem facts + // assigned to basic variables that enable deep cloning must also enable deep cloning for the fact type. + // Otherwise, the solver might fail to recognize that an assigned value belongs to a value range. + if (isFieldAPlanningBasicVariable(field, owningClass) && isFieldADeepCloneProperty(field, owningClass) + && !isClassDeepCloned(solutionDescriptor, field.getType())) { + throw new IllegalStateException(""" + The field (%s) of class (%s) is configured to be deep-cloned, + but its type (%s) is not deep-cloned. + Maybe remove the @%s annotation from the field? + Maybe annotate the type (%s) with @%s?""" + .formatted(field.getName(), owningClass.getCanonicalName(), + field.getType().getCanonicalName(), + DeepPlanningClone.class.getSimpleName(), + field.getType().getCanonicalName(), + DeepPlanningClone.class.getSimpleName())); + } return needsDeepClone(solutionDescriptor, field, owningClass); } @@ -207,6 +224,15 @@ private static boolean isFieldAPlanningListVariable(Field field, Class owning } } + private static boolean isFieldAPlanningBasicVariable(Field field, Class owningClass) { + if (!field.isAnnotationPresent(PlanningVariable.class)) { + Method getterMethod = ReflectionHelper.getGetterMethod(owningClass, field.getName()); + return getterMethod != null && getterMethod.isAnnotationPresent(PlanningVariable.class); + } else { + return true; + } + } + private DeepCloningUtils() { // No external instances. } diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java index dae9f0d883..542e929279 100644 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java @@ -2,6 +2,7 @@ import static ai.timefold.solver.core.testutil.PlannerAssert.assertCode; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import java.time.Duration; @@ -32,6 +33,7 @@ import ai.timefold.solver.core.testdomain.clone.deepcloning.TestdataVariousTypes; import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningEntity; import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningSolution; +import ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid.TestdataInvalidEntityProvidingSolution; import ai.timefold.solver.core.testdomain.collection.TestdataArrayBasedEntity; import ai.timefold.solver.core.testdomain.collection.TestdataArrayBasedSolution; import ai.timefold.solver.core.testdomain.collection.TestdataEntityCollectionPropertyEntity; @@ -1041,6 +1043,21 @@ private void assertDeepCloningEntityClone(TestdataFieldAnnotatedDeepCloningEntit } + @Test + void failDeepCloneRequiredTypeAnnotation() { + var solutionDescriptor = TestdataInvalidEntityProvidingSolution.buildSolutionDescriptor(); + var original = TestdataInvalidEntityProvidingSolution.generateSolution(); + assertThatThrownBy(() -> { + var cloner = createSolutionCloner(solutionDescriptor); + cloner.cloneSolution(original); + }).hasMessageContaining( + "The field (value) of class (ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid.TestdataInvalidEntityProvidingEntity) is configured to be deep-cloned") + .hasMessageContaining("but its type (ai.timefold.solver.core.testdomain.TestdataValue) is not deep-cloned") + .hasMessageContaining("Maybe remove the @DeepPlanningClone annotation from the field?") + .hasMessageContaining( + "Maybe annotate the type (ai.timefold.solver.core.testdomain.TestdataValue) with @DeepPlanningClone?"); + } + private static class MaxStackFrameFinder { int maxStackFrames = 0; diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingEntity.java b/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingEntity.java new file mode 100644 index 0000000000..4ad6f43d1a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingEntity.java @@ -0,0 +1,62 @@ +package ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.testdomain.TestdataObject; +import ai.timefold.solver.core.testdomain.TestdataValue; + +@PlanningEntity +public class TestdataInvalidEntityProvidingEntity extends TestdataObject { + + public static EntityDescriptor buildEntityDescriptor() { + return TestdataInvalidEntityProvidingSolution.buildSolutionDescriptor() + .findEntityDescriptorOrFail(TestdataInvalidEntityProvidingEntity.class); + } + + public static GenuineVariableDescriptor buildVariableDescriptorForValue() { + return buildEntityDescriptor().getGenuineVariableDescriptor("value"); + } + + @ValueRangeProvider(id = "valueRange") + private List valueRange; + + @DeepPlanningClone + private TestdataValue value; // TestdataValue is not deep-cloned, and the cloning logic should fail-fast + + public TestdataInvalidEntityProvidingEntity() { + // Required for cloning + } + + public TestdataInvalidEntityProvidingEntity(String code, List valueRange) { + this(code, valueRange, null); + } + + public TestdataInvalidEntityProvidingEntity(String code, List valueRange, TestdataValue value) { + super(code); + this.valueRange = valueRange; + this.value = value; + } + + @PlanningVariable(valueRangeProviderRefs = "valueRange") + public TestdataValue getValue() { + return value; + } + + public void setValue(TestdataValue value) { + this.value = value; + } + + public List getValueRange() { + return valueRange; + } + + public void setValueRange(List valueRange) { + this.valueRange = valueRange; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingSolution.java b/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingSolution.java new file mode 100644 index 0000000000..5914d8949c --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/testdomain/clone/deepcloning/field/invalid/TestdataInvalidEntityProvidingSolution.java @@ -0,0 +1,67 @@ +package ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid; + +import java.util.List; + +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.score.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel; +import ai.timefold.solver.core.testdomain.TestdataObject; +import ai.timefold.solver.core.testdomain.TestdataValue; + +@PlanningSolution +public class TestdataInvalidEntityProvidingSolution extends TestdataObject { + + public static SolutionDescriptor buildSolutionDescriptor() { + return SolutionDescriptor.buildSolutionDescriptor(TestdataInvalidEntityProvidingSolution.class, + TestdataInvalidEntityProvidingEntity.class); + } + + public static PlanningSolutionMetaModel buildMetaModel() { + return buildSolutionDescriptor().getMetaModel(); + } + + public static TestdataInvalidEntityProvidingSolution generateSolution() { + var solution = new TestdataInvalidEntityProvidingSolution("s1"); + var value1 = new TestdataValue("1"); + var value2 = new TestdataValue("2"); + var entity1 = new TestdataInvalidEntityProvidingEntity("1", List.of(value1, value2)); + entity1.setValue(value1); + var entity2 = new TestdataInvalidEntityProvidingEntity("2", List.of(value1, value2)); + entity2.setValue(value2); + solution.setEntityList(List.of(entity1, entity2)); + return solution; + } + + private List entityList; + + private SimpleScore score; + + public TestdataInvalidEntityProvidingSolution() { + // Required for cloning + } + + public TestdataInvalidEntityProvidingSolution(String code) { + super(code); + } + + @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; + } +}