Skip to content

Commit 3a5cb11

Browse files
authored
chore: add cloning fail fast check (#2132)
1 parent 8f37ec3 commit 3a5cb11

4 files changed

Lines changed: 172 additions & 0 deletions

File tree

core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningUtils.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import ai.timefold.solver.core.api.domain.common.PlanningId;
3333
import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone;
3434
import ai.timefold.solver.core.api.domain.variable.PlanningListVariable;
35+
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
3536
import ai.timefold.solver.core.api.score.Score;
3637
import ai.timefold.solver.core.impl.domain.common.ReflectionHelper;
3738
import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
@@ -83,6 +84,22 @@ public static boolean isFieldDeepCloned(SolutionDescriptor<?> solutionDescriptor
8384
if (isImmutable(fieldType)) {
8485
return false;
8586
} else {
87+
// Problem facts
88+
// assigned to basic variables that enable deep cloning must also enable deep cloning for the fact type.
89+
// Otherwise, the solver might fail to recognize that an assigned value belongs to a value range.
90+
if (isFieldAPlanningBasicVariable(field, owningClass) && isFieldADeepCloneProperty(field, owningClass)
91+
&& !isClassDeepCloned(solutionDescriptor, field.getType())) {
92+
throw new IllegalStateException("""
93+
The field (%s) of class (%s) is configured to be deep-cloned,
94+
but its type (%s) is not deep-cloned.
95+
Maybe remove the @%s annotation from the field?
96+
Maybe annotate the type (%s) with @%s?"""
97+
.formatted(field.getName(), owningClass.getCanonicalName(),
98+
field.getType().getCanonicalName(),
99+
DeepPlanningClone.class.getSimpleName(),
100+
field.getType().getCanonicalName(),
101+
DeepPlanningClone.class.getSimpleName()));
102+
}
86103
return needsDeepClone(solutionDescriptor, field, owningClass);
87104
}
88105

@@ -207,6 +224,15 @@ private static boolean isFieldAPlanningListVariable(Field field, Class<?> owning
207224
}
208225
}
209226

227+
private static boolean isFieldAPlanningBasicVariable(Field field, Class<?> owningClass) {
228+
if (!field.isAnnotationPresent(PlanningVariable.class)) {
229+
Method getterMethod = ReflectionHelper.getGetterMethod(owningClass, field.getName());
230+
return getterMethod != null && getterMethod.isAnnotationPresent(PlanningVariable.class);
231+
} else {
232+
return true;
233+
}
234+
}
235+
210236
private DeepCloningUtils() {
211237
// No external instances.
212238
}

core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/AbstractSolutionClonerTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static ai.timefold.solver.core.testutil.PlannerAssert.assertCode;
44
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
56
import static org.assertj.core.api.SoftAssertions.assertSoftly;
67

78
import java.time.Duration;
@@ -32,6 +33,7 @@
3233
import ai.timefold.solver.core.testdomain.clone.deepcloning.TestdataVariousTypes;
3334
import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningEntity;
3435
import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningSolution;
36+
import ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid.TestdataInvalidEntityProvidingSolution;
3537
import ai.timefold.solver.core.testdomain.collection.TestdataArrayBasedEntity;
3638
import ai.timefold.solver.core.testdomain.collection.TestdataArrayBasedSolution;
3739
import ai.timefold.solver.core.testdomain.collection.TestdataEntityCollectionPropertyEntity;
@@ -1041,6 +1043,21 @@ private void assertDeepCloningEntityClone(TestdataFieldAnnotatedDeepCloningEntit
10411043

10421044
}
10431045

1046+
@Test
1047+
void failDeepCloneRequiredTypeAnnotation() {
1048+
var solutionDescriptor = TestdataInvalidEntityProvidingSolution.buildSolutionDescriptor();
1049+
var original = TestdataInvalidEntityProvidingSolution.generateSolution();
1050+
assertThatThrownBy(() -> {
1051+
var cloner = createSolutionCloner(solutionDescriptor);
1052+
cloner.cloneSolution(original);
1053+
}).hasMessageContaining(
1054+
"The field (value) of class (ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid.TestdataInvalidEntityProvidingEntity) is configured to be deep-cloned")
1055+
.hasMessageContaining("but its type (ai.timefold.solver.core.testdomain.TestdataValue) is not deep-cloned")
1056+
.hasMessageContaining("Maybe remove the @DeepPlanningClone annotation from the field?")
1057+
.hasMessageContaining(
1058+
"Maybe annotate the type (ai.timefold.solver.core.testdomain.TestdataValue) with @DeepPlanningClone?");
1059+
}
1060+
10441061
private static class MaxStackFrameFinder {
10451062
int maxStackFrames = 0;
10461063

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid;
2+
3+
import java.util.List;
4+
5+
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
6+
import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone;
7+
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
8+
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
9+
import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor;
10+
import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor;
11+
import ai.timefold.solver.core.testdomain.TestdataObject;
12+
import ai.timefold.solver.core.testdomain.TestdataValue;
13+
14+
@PlanningEntity
15+
public class TestdataInvalidEntityProvidingEntity extends TestdataObject {
16+
17+
public static EntityDescriptor<TestdataInvalidEntityProvidingSolution> buildEntityDescriptor() {
18+
return TestdataInvalidEntityProvidingSolution.buildSolutionDescriptor()
19+
.findEntityDescriptorOrFail(TestdataInvalidEntityProvidingEntity.class);
20+
}
21+
22+
public static GenuineVariableDescriptor<TestdataInvalidEntityProvidingSolution> buildVariableDescriptorForValue() {
23+
return buildEntityDescriptor().getGenuineVariableDescriptor("value");
24+
}
25+
26+
@ValueRangeProvider(id = "valueRange")
27+
private List<TestdataValue> valueRange;
28+
29+
@DeepPlanningClone
30+
private TestdataValue value; // TestdataValue is not deep-cloned, and the cloning logic should fail-fast
31+
32+
public TestdataInvalidEntityProvidingEntity() {
33+
// Required for cloning
34+
}
35+
36+
public TestdataInvalidEntityProvidingEntity(String code, List<TestdataValue> valueRange) {
37+
this(code, valueRange, null);
38+
}
39+
40+
public TestdataInvalidEntityProvidingEntity(String code, List<TestdataValue> valueRange, TestdataValue value) {
41+
super(code);
42+
this.valueRange = valueRange;
43+
this.value = value;
44+
}
45+
46+
@PlanningVariable(valueRangeProviderRefs = "valueRange")
47+
public TestdataValue getValue() {
48+
return value;
49+
}
50+
51+
public void setValue(TestdataValue value) {
52+
this.value = value;
53+
}
54+
55+
public List<TestdataValue> getValueRange() {
56+
return valueRange;
57+
}
58+
59+
public void setValueRange(List<TestdataValue> valueRange) {
60+
this.valueRange = valueRange;
61+
}
62+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package ai.timefold.solver.core.testdomain.clone.deepcloning.field.invalid;
2+
3+
import java.util.List;
4+
5+
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
6+
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
7+
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
8+
import ai.timefold.solver.core.api.score.SimpleScore;
9+
import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
10+
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
11+
import ai.timefold.solver.core.testdomain.TestdataObject;
12+
import ai.timefold.solver.core.testdomain.TestdataValue;
13+
14+
@PlanningSolution
15+
public class TestdataInvalidEntityProvidingSolution extends TestdataObject {
16+
17+
public static SolutionDescriptor<TestdataInvalidEntityProvidingSolution> buildSolutionDescriptor() {
18+
return SolutionDescriptor.buildSolutionDescriptor(TestdataInvalidEntityProvidingSolution.class,
19+
TestdataInvalidEntityProvidingEntity.class);
20+
}
21+
22+
public static PlanningSolutionMetaModel<TestdataInvalidEntityProvidingSolution> buildMetaModel() {
23+
return buildSolutionDescriptor().getMetaModel();
24+
}
25+
26+
public static TestdataInvalidEntityProvidingSolution generateSolution() {
27+
var solution = new TestdataInvalidEntityProvidingSolution("s1");
28+
var value1 = new TestdataValue("1");
29+
var value2 = new TestdataValue("2");
30+
var entity1 = new TestdataInvalidEntityProvidingEntity("1", List.of(value1, value2));
31+
entity1.setValue(value1);
32+
var entity2 = new TestdataInvalidEntityProvidingEntity("2", List.of(value1, value2));
33+
entity2.setValue(value2);
34+
solution.setEntityList(List.of(entity1, entity2));
35+
return solution;
36+
}
37+
38+
private List<TestdataInvalidEntityProvidingEntity> entityList;
39+
40+
private SimpleScore score;
41+
42+
public TestdataInvalidEntityProvidingSolution() {
43+
// Required for cloning
44+
}
45+
46+
public TestdataInvalidEntityProvidingSolution(String code) {
47+
super(code);
48+
}
49+
50+
@PlanningEntityCollectionProperty
51+
public List<TestdataInvalidEntityProvidingEntity> getEntityList() {
52+
return entityList;
53+
}
54+
55+
public void setEntityList(List<TestdataInvalidEntityProvidingEntity> entityList) {
56+
this.entityList = entityList;
57+
}
58+
59+
@PlanningScore
60+
public SimpleScore getScore() {
61+
return score;
62+
}
63+
64+
public void setScore(SimpleScore score) {
65+
this.score = score;
66+
}
67+
}

0 commit comments

Comments
 (0)