Skip to content

Commit 4b57022

Browse files
feat: improve error message when a declarative supplier is missing in Quarkus (#1687)
Does not affect Spring since Spring does not generate it own member accessors.
1 parent d2b2406 commit 4b57022

File tree

16 files changed

+711
-21
lines changed

16 files changed

+711
-21
lines changed

core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ The member (%s) on the entity class (%s) is a declarative shadow variable, but t
5151
var method = ReflectionHelper.getDeclaredMethod(variableMemberAccessor.getDeclaringClass(), methodName);
5252

5353
if (method == null) {
54-
throw new IllegalArgumentException("Could not find method named %s on the class %s. Maybe you misspelled it?"
55-
.formatted(methodName, variableMemberAccessor.getDeclaringClass().getSimpleName()));
54+
throw new IllegalArgumentException("""
55+
@%s (%s) defines a supplierMethod (%s) that does not exist inside its declaring class (%s).
56+
Maybe you misspelled the supplierMethod name?"""
57+
.formatted(ShadowVariable.class.getSimpleName(), variableName, methodName,
58+
variableMemberAccessor.getDeclaringClass().getCanonicalName()));
5659
}
5760

5861
var shadowVariableUpdater = method.getAnnotation(ShadowSources.class);

core/src/test/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptorTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import ai.timefold.solver.core.testdomain.chained.TestdataChainedSolution;
2525
import ai.timefold.solver.core.testdomain.collection.TestdataArrayBasedSolution;
2626
import ai.timefold.solver.core.testdomain.collection.TestdataSetBasedSolution;
27+
import ai.timefold.solver.core.testdomain.declarative.missing.TestdataDeclarativeMissingSupplierSolution;
2728
import ai.timefold.solver.core.testdomain.immutable.enumeration.TestdataEnumSolution;
2829
import ai.timefold.solver.core.testdomain.immutable.record.TestdataRecordSolution;
2930
import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.childnot.TestdataOnlyBaseAnnotatedChildEntity;
@@ -671,4 +672,13 @@ void testBadChainedAndListModel() {
671672
.hasMessageContaining("on a single planning entity")
672673
.hasMessageContaining("is not supported");
673674
}
675+
676+
@Test
677+
void missingDeclarativeSupplierMethod() {
678+
assertThatCode(TestdataDeclarativeMissingSupplierSolution::buildSolutionDescriptor)
679+
.hasMessageContainingAll("@ShadowVariable (endTime)",
680+
"supplierMethod (calculateEndTime) that does not exist",
681+
"inside its declaring class (ai.timefold.solver.core.testdomain.declarative.missing.TestdataDeclarativeMissingSupplierValue).",
682+
"Maybe you misspelled the supplierMethod name?");
683+
}
674684
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package ai.timefold.solver.core.testdomain.declarative.missing;
2+
3+
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
4+
import ai.timefold.solver.core.api.domain.variable.PlanningVariable;
5+
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
6+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowSources;
7+
8+
@PlanningEntity
9+
public class TestdataDeclarativeMissingSupplierEntity {
10+
String id;
11+
@PlanningVariable
12+
TestdataDeclarativeMissingSupplierValue value;
13+
14+
@ShadowVariable(supplierName = "updateDurationInDays")
15+
long durationInDays;
16+
17+
public TestdataDeclarativeMissingSupplierEntity(String id, TestdataDeclarativeMissingSupplierValue value) {
18+
this.id = id;
19+
this.value = value;
20+
}
21+
22+
public String getId() {
23+
return id;
24+
}
25+
26+
public void setId(String id) {
27+
this.id = id;
28+
}
29+
30+
public TestdataDeclarativeMissingSupplierValue getValue() {
31+
return value;
32+
}
33+
34+
public void setValue(TestdataDeclarativeMissingSupplierValue value) {
35+
this.value = value;
36+
}
37+
38+
@ShadowSources("value")
39+
public long updateDurationInDays() {
40+
if (value != null) {
41+
return value.getDuration().toDays();
42+
}
43+
return 0;
44+
}
45+
46+
public long getDurationInDays() {
47+
return durationInDays;
48+
}
49+
50+
@Override
51+
public String toString() {
52+
return "TestdataDeclarativeMissingSupplierEntity{" +
53+
"id=" + id +
54+
", value=" + value +
55+
'}';
56+
}
57+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package ai.timefold.solver.core.testdomain.declarative.missing;
2+
3+
import java.util.EnumSet;
4+
import java.util.List;
5+
6+
import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty;
7+
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
8+
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
9+
import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider;
10+
import ai.timefold.solver.core.api.score.buildin.hardsoft.HardSoftScore;
11+
import ai.timefold.solver.core.config.solver.PreviewFeature;
12+
import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
13+
14+
@PlanningSolution
15+
public class TestdataDeclarativeMissingSupplierSolution {
16+
17+
public static SolutionDescriptor<TestdataDeclarativeMissingSupplierSolution> buildSolutionDescriptor() {
18+
return SolutionDescriptor.buildSolutionDescriptor(EnumSet.of(PreviewFeature.DECLARATIVE_SHADOW_VARIABLES),
19+
TestdataDeclarativeMissingSupplierSolution.class, TestdataDeclarativeMissingSupplierEntity.class,
20+
TestdataDeclarativeMissingSupplierValue.class);
21+
}
22+
23+
@PlanningEntityCollectionProperty
24+
List<TestdataDeclarativeMissingSupplierEntity> entities;
25+
26+
@PlanningEntityCollectionProperty
27+
@ValueRangeProvider
28+
List<TestdataDeclarativeMissingSupplierValue> values;
29+
30+
@PlanningScore
31+
HardSoftScore score;
32+
33+
public TestdataDeclarativeMissingSupplierSolution() {
34+
}
35+
36+
public TestdataDeclarativeMissingSupplierSolution(List<TestdataDeclarativeMissingSupplierEntity> entities,
37+
List<TestdataDeclarativeMissingSupplierValue> values) {
38+
this.values = values;
39+
this.entities = entities;
40+
}
41+
42+
public List<TestdataDeclarativeMissingSupplierValue> getValues() {
43+
return values;
44+
}
45+
46+
public void setValues(List<TestdataDeclarativeMissingSupplierValue> values) {
47+
this.values = values;
48+
}
49+
50+
public List<TestdataDeclarativeMissingSupplierEntity> getEntities() {
51+
return entities;
52+
}
53+
54+
public void setEntities(
55+
List<TestdataDeclarativeMissingSupplierEntity> entities) {
56+
this.entities = entities;
57+
}
58+
59+
public HardSoftScore getScore() {
60+
return score;
61+
}
62+
63+
public void setScore(HardSoftScore score) {
64+
this.score = score;
65+
}
66+
67+
@Override
68+
public String toString() {
69+
return "TestdataDeclarativeMissingSupplierSolution{" +
70+
"entities=" + entities +
71+
", values=" + values +
72+
", score=" + score +
73+
'}';
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package ai.timefold.solver.core.testdomain.declarative.missing;
2+
3+
import java.time.Duration;
4+
import java.time.LocalDateTime;
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
import ai.timefold.solver.core.api.domain.entity.PlanningEntity;
9+
import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable;
10+
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
11+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowSources;
12+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowVariableLooped;
13+
14+
@PlanningEntity
15+
public class TestdataDeclarativeMissingSupplierValue {
16+
public static final LocalDateTime DEFAULT_TIME =
17+
LocalDateTime.of(2025, 4, 29, 18, 40, 0);
18+
19+
String id;
20+
21+
@ShadowVariable(supplierName = "calculateStartTime")
22+
LocalDateTime startTime;
23+
24+
@ShadowVariable(supplierName = "calculateEndTime")
25+
LocalDateTime endTime;
26+
27+
@ShadowVariableLooped
28+
boolean isInvalid;
29+
30+
@InverseRelationShadowVariable(sourceVariableName = "value")
31+
List<TestdataDeclarativeMissingSupplierEntity> entityList = new ArrayList<>();
32+
33+
Duration duration;
34+
35+
public TestdataDeclarativeMissingSupplierValue() {
36+
}
37+
38+
public TestdataDeclarativeMissingSupplierValue(String id, Duration duration) {
39+
this.id = id;
40+
this.duration = duration;
41+
}
42+
43+
public List<TestdataDeclarativeMissingSupplierEntity> getEntityList() {
44+
return entityList;
45+
}
46+
47+
public void setEntityList(List<TestdataDeclarativeMissingSupplierEntity> entityList) {
48+
this.entityList = entityList;
49+
}
50+
51+
public LocalDateTime getStartTime() {
52+
return startTime;
53+
}
54+
55+
public void setStartTime(LocalDateTime startTime) {
56+
this.startTime = startTime;
57+
}
58+
59+
@ShadowSources({ "entityList" })
60+
public LocalDateTime calculateStartTime() {
61+
LocalDateTime readyTime = DEFAULT_TIME.plusDays(10);
62+
if (!entityList.isEmpty()) {
63+
readyTime = DEFAULT_TIME.plusDays(entityList.size());
64+
}
65+
return readyTime;
66+
}
67+
68+
public LocalDateTime getEndTime() {
69+
return endTime;
70+
}
71+
72+
public void setEndTime(LocalDateTime endTime) {
73+
this.endTime = endTime;
74+
}
75+
76+
public Duration getDuration() {
77+
return duration;
78+
}
79+
80+
public void setDuration(Duration duration) {
81+
this.duration = duration;
82+
}
83+
84+
public boolean isInvalid() {
85+
return isInvalid;
86+
}
87+
88+
public void setInvalid(boolean invalid) {
89+
isInvalid = invalid;
90+
}
91+
92+
@Override
93+
public String toString() {
94+
return id + "{" +
95+
"endTime=" + endTime +
96+
"]}";
97+
}
98+
}

quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import ai.timefold.solver.core.api.domain.solution.PlanningScore;
3131
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
3232
import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty;
33+
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
3334
import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
3435
import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator;
3536
import ai.timefold.solver.core.api.score.stream.ConstraintMetaModel;
@@ -1006,33 +1007,20 @@ private GeneratedGizmoClasses generateDomainAccessors(Map<String, SolverConfig>
10061007
});
10071008

10081009
for (var annotatedMember : membersToGeneratedAccessorsForCollection) {
1010+
ClassInfo classInfo = null;
1011+
String memberName = null;
10091012
switch (annotatedMember.target().kind()) {
10101013
case FIELD -> {
10111014
var fieldInfo = annotatedMember.target().asField();
1012-
var classInfo = fieldInfo.declaringClass();
1015+
classInfo = fieldInfo.declaringClass();
1016+
memberName = fieldInfo.name();
10131017
buildFieldAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput,
10141018
classInfo, fieldInfo, transformers);
1015-
if (annotatedMember.name().equals(DotNames.CASCADING_UPDATE_SHADOW_VARIABLE)) {
1016-
// The source method name also must be included
1017-
// targetMethodName is a required field and is always present
1018-
var targetMethodName = annotatedMember.value("targetMethodName").asString();
1019-
var methodInfo = classInfo.method(targetMethodName);
1020-
buildMethodAccessor(null, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput,
1021-
classInfo, methodInfo, false, transformers);
1022-
} else if (annotatedMember.name().equals(DotNames.SHADOW_VARIABLE)
1023-
&& annotatedMember.value("supplierName") != null) {
1024-
// The source method name also must be included
1025-
var targetMethodName = annotatedMember.value("supplierName")
1026-
.asString();
1027-
var methodInfo = classInfo.method(targetMethodName);
1028-
buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer,
1029-
classOutput,
1030-
classInfo, methodInfo, true, transformers);
1031-
}
10321019
}
10331020
case METHOD -> {
10341021
var methodInfo = annotatedMember.target().asMethod();
1035-
var classInfo = methodInfo.declaringClass();
1022+
classInfo = methodInfo.declaringClass();
1023+
memberName = methodInfo.name();
10361024
buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput,
10371025
classInfo, methodInfo, true, transformers);
10381026
}
@@ -1041,6 +1029,30 @@ private GeneratedGizmoClasses generateDomainAccessors(Map<String, SolverConfig>
10411029
"The member (%s) is not on a field or method.".formatted(annotatedMember));
10421030
}
10431031
}
1032+
if (annotatedMember.name().equals(DotNames.CASCADING_UPDATE_SHADOW_VARIABLE)) {
1033+
// The source method name also must be included
1034+
// targetMethodName is a required field and is always present
1035+
var targetMethodName = annotatedMember.value("targetMethodName").asString();
1036+
var methodInfo = classInfo.method(targetMethodName);
1037+
buildMethodAccessor(null, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput,
1038+
classInfo, methodInfo, false, transformers);
1039+
} else if (annotatedMember.name().equals(DotNames.SHADOW_VARIABLE)
1040+
&& annotatedMember.value("supplierName") != null) {
1041+
// The source method name also must be included
1042+
var targetMethodName = annotatedMember.value("supplierName")
1043+
.asString();
1044+
var methodInfo = classInfo.method(targetMethodName);
1045+
if (methodInfo == null) {
1046+
throw new IllegalArgumentException("""
1047+
@%s (%s) defines a supplierMethod (%s) that does not exist inside its declaring class (%s).
1048+
Maybe you misspelled the supplierMethod name?"""
1049+
.formatted(ShadowVariable.class.getSimpleName(), memberName, targetMethodName,
1050+
classInfo.name().toString()));
1051+
}
1052+
buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer,
1053+
classOutput,
1054+
classInfo, methodInfo, true, transformers);
1055+
}
10441056
}
10451057
// The ConstraintWeightOverrides field is not annotated, but it needs a member accessor
10461058
var solutionClassInstance = planningSolutionAnnotationInstanceCollection.iterator().next();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
2+
package ai.timefold.solver.quarkus;
3+
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.junit.jupiter.api.Assertions.fail;
6+
7+
import ai.timefold.solver.quarkus.testdomain.suppliervariable.missing.TestdataQuarkusDeclarativeMissingSupplierEasyScoreCalculator;
8+
import ai.timefold.solver.quarkus.testdomain.suppliervariable.missing.TestdataQuarkusDeclarativeMissingSupplierEntity;
9+
import ai.timefold.solver.quarkus.testdomain.suppliervariable.missing.TestdataQuarkusDeclarativeMissingSupplierSolution;
10+
import ai.timefold.solver.quarkus.testdomain.suppliervariable.missing.TestdataQuarkusDeclarativeMissingSupplierValue;
11+
12+
import org.jboss.shrinkwrap.api.ShrinkWrap;
13+
import org.jboss.shrinkwrap.api.spec.JavaArchive;
14+
import org.junit.jupiter.api.Test;
15+
import org.junit.jupiter.api.extension.RegisterExtension;
16+
17+
import io.quarkus.test.QuarkusUnitTest;
18+
19+
class TimefoldProcessorMissingSupplierForDeclarativeVariableTest {
20+
21+
// Empty classes
22+
@RegisterExtension
23+
static final QuarkusUnitTest config1 = new QuarkusUnitTest()
24+
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class).addClasses(
25+
TestdataQuarkusDeclarativeMissingSupplierSolution.class,
26+
TestdataQuarkusDeclarativeMissingSupplierEntity.class,
27+
TestdataQuarkusDeclarativeMissingSupplierValue.class,
28+
TestdataQuarkusDeclarativeMissingSupplierEasyScoreCalculator.class))
29+
.assertException(t -> assertThat(t)
30+
.isInstanceOf(IllegalArgumentException.class)
31+
.hasMessageContainingAll(
32+
"@ShadowVariable (endTime)",
33+
"supplierMethod (calculateEndTime) that does not exist",
34+
"inside its declaring class (ai.timefold.solver.quarkus.testdomain.suppliervariable.missing.TestdataQuarkusDeclarativeMissingSupplierValue).",
35+
"Maybe you misspelled the supplierMethod name?"));
36+
37+
@Test
38+
void test() {
39+
fail("Should not call this method.");
40+
}
41+
}

0 commit comments

Comments
 (0)