diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java index f0924907a6..31a9b728ff 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java @@ -7,6 +7,8 @@ import ai.timefold.solver.service.definition.api.domain.ModelRequest; import ai.timefold.solver.service.definition.api.domain.ModelResponse; +import org.jspecify.annotations.NullMarked; + /** * The convertor of: * + *

+ * All method parameters and return values are non-null unless explicitly marked otherwise. + * Absence of a previous model output is expressed with an empty {@link Optional}, not a null reference. * * @param The solver model concrete {@link ai.timefold.solver.core.api.domain.solution.PlanningScore} type. * @param The type of the model input part of the {@link ModelRequest}. @@ -24,6 +29,7 @@ * by Timefold solver. * @param The type of the model output part of the {@link ModelResponse}. */ +@NullMarked public non-sealed interface ModelConvertor, ModelInput_ extends ModelInput, ModelConfigurationOverrides_ extends ModelConfigOverrides, SolverModel_ extends SolverModel, ModelOutput_ extends ModelOutput> extends ModelConvertorBase { @@ -35,15 +41,21 @@ public non-sealed interface ModelConvertor, ModelIn * the solving process. In such cases, {@code lastModelOutput} represents the last stored output before the failure, * allowing the method to restore or reuse relevant state as needed. * - * @param modelInput the model input part of the request, containing the data to be converted - * @param modelConfig the model configuration, including any overrides to be applied + * @param modelInput the model input part of the request, containing the data to be converted; never null + * @param modelConfig the model configuration, including any overrides to be applied; never null * @param lastModelOutput an {@link Optional} containing the last stored model output before a failure, or empty if not - * recovering - * @return the solver model instance to be used by the Timefold solver + * recovering; never null + * @return the solver model instance to be used by the Timefold solver; never null */ SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig modelConfig, Optional lastModelOutput); + /** + * Converts the given {@link SolverModel_} into a {@link ModelOutput_}. + * + * @param solverModel the planning solution produced by the solver; never null + * @return the model output; never null + */ ModelOutput_ toModelOutput(SolverModel_ solverModel); /** @@ -54,9 +66,9 @@ SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig * The updated {@link ModelInput_} can be used as a basis for new datasets by applying external changes. * - * @param modelInput The model input to be updated. - * @param modelOutput The model output containing changes to be applied to the model input. - * @return Reference to the updated {@link ModelInput_}. + * @param modelInput The model input to be updated; never null + * @param modelOutput The model output containing changes to be applied to the model input; never null + * @return Reference to the updated {@link ModelInput_}; never null */ ModelInput_ applyOutputToInput(ModelInput_ modelInput, ModelOutput_ modelOutput); } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java new file mode 100644 index 0000000000..52982dd1e4 --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java @@ -0,0 +1,14 @@ +package ai.timefold.solver.service.definition.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +class ModelConvertorNullabilityTest { + + @Test + void modelConvertorIsNullMarked() { + assertThat(ModelConvertor.class.getAnnotation(NullMarked.class)).isNotNull(); + } +} diff --git a/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java b/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java new file mode 100644 index 0000000000..594b3151f3 --- /dev/null +++ b/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java @@ -0,0 +1,86 @@ +package ai.timefold.solver.service.quarkus.deployment.defaults; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; + +import java.util.Optional; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.service.definition.api.ModelInput; +import ai.timefold.solver.service.definition.api.ModelOutput; +import ai.timefold.solver.service.definition.api.SolverModel; +import ai.timefold.solver.service.definition.api.domain.ModelConfig; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AbstractTrivialModelConvertorTest { + + private AbstractTrivialModelConvertor convertor; + private TestdataModel model; + private ModelConfig modelConfig; + + @BeforeEach + void setUp() { + convertor = new AbstractTrivialModelConvertor<>() { + }; + model = new TestdataModel(); + modelConfig = ModelConfig.empty(); + } + + @Test + void toSolverModelRejectsNullArguments() { + assertThatNullPointerException() + .isThrownBy(() -> convertor.toSolverModel(null, modelConfig, Optional.empty())) + .withMessage("modelInput"); + assertThatNullPointerException() + .isThrownBy(() -> convertor.toSolverModel(model, null, Optional.empty())) + .withMessage("modelConfig"); + assertThatNullPointerException() + .isThrownBy(() -> convertor.toSolverModel(model, modelConfig, null)) + .withMessage("lastModelOutput"); + } + + @Test + void toModelOutputRejectsNullSolverModel() { + assertThatNullPointerException() + .isThrownBy(() -> convertor.toModelOutput(null)) + .withMessage("solverModel"); + } + + @Test + void applyOutputToInputRejectsNullArguments() { + assertThatNullPointerException() + .isThrownBy(() -> convertor.applyOutputToInput(null, model)) + .withMessage("modelInput"); + assertThatNullPointerException() + .isThrownBy(() -> convertor.applyOutputToInput(model, null)) + .withMessage("modelOutput"); + } + + @Test + void applyOutputToInputReturnsModelOutputWhenTypesMatch() { + TestdataModel modelOutput = new TestdataModel(); + + assertThat(convertor.applyOutputToInput(model, modelOutput)).isSameAs(modelOutput); + } + + @Test + void toSolverModelUsesEmptyOptionalWhenNoPreviousOutput() { + assertThat(convertor.toSolverModel(model, modelConfig, Optional.empty())).isSameAs(model); + } + + private static final class TestdataModel implements ModelInput, ModelOutput, SolverModel { + + @Override + public SimpleScore getScore() { + return null; + } + + @Override + public ConstraintWeightOverrides getConstraintWeightOverrides() { + return null; + } + } +} diff --git a/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java b/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java index 5d5b906363..e2c9e44e62 100644 --- a/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java +++ b/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.quarkus.deployment.defaults; +import java.util.Objects; import java.util.Optional; import ai.timefold.solver.core.api.score.Score; @@ -12,6 +13,8 @@ import ai.timefold.solver.service.definition.api.domain.ModelRequest; import ai.timefold.solver.service.definition.api.domain.ModelResponse; +import org.jspecify.annotations.NullMarked; + /** * Default model convertor for the trivial case when: *