Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
378 changes: 189 additions & 189 deletions mvnw.cmd

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package ai.timefold.solver.service.definition.api;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Collections;
import java.util.List;
import java.util.Map;

Expand All @@ -12,6 +11,7 @@
import ai.timefold.solver.service.definition.internal.descriptor.OutputMetricDescriptor;
import ai.timefold.solver.service.definition.internal.descriptor.UISupport;
import ai.timefold.solver.service.definition.internal.descriptor.VisualizationPageDescriptor;
import ai.timefold.solver.service.definition.internal.descriptor.usage.UniqueResource;

public class ModelDescriptor {

Expand Down Expand Up @@ -41,23 +41,23 @@ public class ModelDescriptor {

protected String imageRefNative;

protected Map<String, String> environment = new HashMap<>();
protected Map<String, String> environment = Collections.emptyMap();

protected List<String> features = new ArrayList<>();
protected List<String> features = Collections.emptyList();

protected List<ConstraintGroupDescriptor> constraintGroupDescriptors = new ArrayList<>();
protected List<ConstraintGroupDescriptor> constraintGroupDescriptors = Collections.emptyList();

protected ModelConfigDescriptor modelConfigDescriptor;

protected List<InputMetricDescriptor> inputMetricDescriptors = new ArrayList<>();
protected List<InputMetricDescriptor> inputMetricDescriptors = Collections.emptyList();

protected List<OutputMetricDescriptor> outputMetricDescriptors = new ArrayList<>();
protected List<OutputMetricDescriptor> outputMetricDescriptors = Collections.emptyList();

protected Integer maxThreadCount;

protected String logoUrl;

protected List<String> images = new ArrayList<>();
protected List<String> images = Collections.emptyList();

protected TrialConfig trialConfig;

Expand All @@ -67,10 +67,12 @@ public class ModelDescriptor {

protected DocumentationDescriptor documentationDescriptor;

protected List<VisualizationPageDescriptor> visualizationPages = new ArrayList<>();
protected List<VisualizationPageDescriptor> visualizationPages = Collections.emptyList();

protected boolean requiresMap = false;

protected List<UniqueResource> uniqueResources = Collections.emptyList();

/**
* @return Unique id, composed by model id and version. Ex: employee-scheduling_v1
*/
Expand Down Expand Up @@ -278,8 +280,16 @@ public void setRequiresMap(boolean requiresMap) {
this.requiresMap = requiresMap;
}

public List<UniqueResource> getUniqueResources() {
return uniqueResources;
}

public void setUniqueResources(List<UniqueResource> uniqueResources) {
this.uniqueResources = uniqueResources;
}

public static ModelDescriptor from(ModelDescriptor descriptor) {
ModelDescriptor copy = new ModelDescriptor();
var copy = new ModelDescriptor();
copy.setId(descriptor.getId());
copy.setModel(descriptor.getModel());
copy.setName(descriptor.getName());
Expand All @@ -303,6 +313,7 @@ public static ModelDescriptor from(ModelDescriptor descriptor) {
copy.setDocumentationDescriptor(descriptor.getDocumentationDescriptor());
copy.setVisualizationPages(descriptor.getVisualizationPages());
copy.setRequiresMap(descriptor.isRequiresMap());
copy.setUniqueResources(descriptor.getUniqueResources());
return copy;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ai.timefold.solver.service.definition.internal.descriptor.usage;

import org.jspecify.annotations.NullMarked;

/**
* Represents a resource type and a JSONPath expression to the ID field of the respective resource in the input payload.
*/
@NullMarked
public record UniqueResource(String resourceType, String resourceIdJsonPath) {

public UniqueResource {
if (resourceType == null || resourceType.trim().isEmpty()) {
throw new IllegalArgumentException("resourceType must not be empty");
}
if (resourceIdJsonPath == null || resourceIdJsonPath.trim().isEmpty()) {
throw new IllegalArgumentException("resourceIdJsonPath must not be empty");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ai.timefold.solver.service.definition.preview.api.usage;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Points to an identifier of a trackable resource for the purpose of tracking the complexity of the dataset.
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.CLASS)
public @interface UniqueResourceId {

/**
* Identifies the resource type to scope the uniqueness of the resource IDs.
*/
String type();
Comment thread
triceo marked this conversation as resolved.
Resolved
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
import ai.timefold.solver.service.quarkus.deployment.builditem.SingleOpenApiDocumentBuildItem;
import ai.timefold.solver.service.quarkus.deployment.config.VisualizationPagesConfig;
import ai.timefold.solver.service.quarkus.deployment.descriptor.DefaultConstraintGroupDescriptorFactory;
import ai.timefold.solver.service.quarkus.deployment.descriptor.ResourceIdPathResolver;
import ai.timefold.solver.service.quarkus.deployment.openapi.SchemaPostProcessor;
import ai.timefold.solver.service.quarkus.deployment.openapi.SchemaUtils;

Expand Down Expand Up @@ -581,6 +582,9 @@
outputMetricsFromClassInfo(combinedIndex.getIndex(), modelComponentsBuildItem.getModelOutputMetrics());
descriptor.setOutputMetricDescriptors(metrics);

descriptor.setUniqueResources(
ResourceIdPathResolver.resolve(combinedIndex.getIndex(), modelComponentsBuildItem.getModelInput()));

// check model specific data
String modelMaturityLevel =
config.getOptionalValue(MODEL_MATURITY_LEVEL_PROPERTY, String.class)
Expand Down Expand Up @@ -1159,7 +1163,7 @@
List<String> includedPrefixes = List.of("og-");
// copy model images and logo
Path imagesPath = Paths.get(outputDirectory.getParent().toString(), "model-images");

List<String> images = new ArrayList<>();
if (Files.exists(imagesPath)) {
String apiPath = "/api/info/models/" + descriptor.getId() + "/model-images/";
try (Stream<Path> stream = Files.list(imagesPath)) {
Expand All @@ -1176,15 +1180,16 @@
} else if (fileName.equals("logo.png")) {
descriptor.setLogoUrl(apiPath + fileName);
} else {
descriptor.getImages().add(apiPath + fileName);
images.add(apiPath + fileName);
}
} catch (IOException e) {
LOG.error("Error processing image: " + fileName, e);
}

});
}
Collections.sort(descriptor.getImages());
Collections.sort(images);

Check warning on line 1191 in service/quarkus/deployment/src/main/java/ai/timefold/solver/service/quarkus/deployment/TimefoldModelDescriptorProcessor.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this "Collections.sort()" with "List.sort()".

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaF-NBE1WEUsQlV0ZKob&open=AaF-NBE1WEUsQlV0ZKob&pullRequest=2779
descriptor.setImages(images);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package ai.timefold.solver.service.quarkus.deployment.descriptor;

import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import ai.timefold.solver.service.definition.internal.descriptor.usage.UniqueResource;
import ai.timefold.solver.service.definition.preview.api.usage.UniqueResourceId;

import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.FieldInfo;
import org.jboss.jandex.IndexView;
import org.jboss.jandex.Type;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
* Finds the fields annotated with {@link UniqueResourceId} in the object structure of the model input
* and creates a JSON Path expression to each of them.
* <p>
* Multiple fields may share the same {@link UniqueResourceId#type()};
* the IDs must then be unique across all the fields of that type.
* <p>
* The resolver walks the fields of the indexed classes.
* It goes into the elements of arrays, collections ({@code [*]}) and the values of maps ({@code .*}).
* It honors {@code @JsonProperty} names and skips static, transient and {@code @JsonIgnore} fields.
*/
public final class ResourceIdPathResolver {

private static final DotName RESOURCE_ID = DotName.createSimple(UniqueResourceId.class);
private static final String RESOURCE_ID_TYPE_PARAM = "type";
private static final DotName JSON_PROPERTY = DotName.createSimple(JsonProperty.class);
private static final DotName JSON_IGNORE = DotName.createSimple(JsonIgnore.class);
private static final DotName OBJECT = DotName.createSimple(Object.class);
private static final String ROOT = "$";

private ResourceIdPathResolver() {
throw new UnsupportedOperationException();
}

/**
* @param index never null
* @param modelInput never null
* @return never null, one {@link UniqueResource} for each field annotated with {@link UniqueResourceId}
* reachable from the model input, sorted by the resource type and the path;
* empty if no such field exists
* @throws IllegalStateException if a {@link UniqueResourceId#type()} is blank
*/
public static List<UniqueResource> resolve(IndexView index, ClassInfo modelInput) {
var resourceList = new ArrayList<UniqueResource>();
visitClass(index, modelInput, ROOT, new HashSet<>(), resourceList);
resourceList.sort(Comparator.comparing(UniqueResource::resourceType));
return List.copyOf(resourceList);
}

private static void visitClass(IndexView index, ClassInfo classInfo, String path, Set<DotName> visitingSet,
List<UniqueResource> resourceList) {
if (!visitingSet.add(classInfo.name())) {
// Cycle in the object structure.
return;
}
var currentClass = classInfo;
while (currentClass != null) {
for (var field : currentClass.fields()) {
if (isIgnored(field)) {
continue;
}
var fieldPath = path + "." + jsonName(field);
Comment thread
rsynek marked this conversation as resolved.
Resolved
if (field.hasDeclaredAnnotation(RESOURCE_ID)) {
resourceList.add(new UniqueResource(resourceType(field), fieldPath));
} else {
visitType(index, field.type(), fieldPath, visitingSet, resourceList);
}
}
var superName = currentClass.superName();
currentClass = superName == null || superName.equals(OBJECT) ? null : index.getClassByName(superName);
}
visitingSet.remove(classInfo.name());
}

private static void visitType(IndexView index, Type type, String path, Set<DotName> visitingSet,
List<UniqueResource> resourceList) {
switch (type.kind()) {
case ARRAY -> visitType(index, type.asArrayType().componentType(), path + "[*]", visitingSet, resourceList);
case PARAMETERIZED_TYPE -> {
var argumentList = type.asParameterizedType().arguments();
if (isAssignableTo(type.name(), Collection.class) && argumentList.size() == 1) {
visitType(index, argumentList.get(0), path + "[*]", visitingSet, resourceList);
} else if (isAssignableTo(type.name(), Map.class) && argumentList.size() == 2) {
visitType(index, argumentList.get(1), path + ".*", visitingSet, resourceList);
} else {
visitClassByName(index, type.name(), path, visitingSet, resourceList);
Comment thread
rsynek marked this conversation as resolved.
Resolved
}
}
case CLASS -> visitClassByName(index, type.name(), path, visitingSet, resourceList);
case WILDCARD_TYPE -> {
var upperBound = type.asWildcardType().extendsBound();
if (upperBound != null) {
visitType(index, upperBound, path, visitingSet, resourceList);
}
}
default -> {
// Primitives are already handled by the visitClass method; type variables and wildcards have no fields to visit.
}
}
}

private static void visitClassByName(IndexView index, DotName className, String path, Set<DotName> visitingSet,
List<UniqueResource> resourceList) {
var classInfo = index.getClassByName(className);
if (classInfo != null) {
visitClass(index, classInfo, path, visitingSet, resourceList);
}
}

private static boolean isAssignableTo(DotName className, Class<?> superType) {
try {
var clazz = Class.forName(className.toString(), false, Thread.currentThread().getContextClassLoader());
return superType.isAssignableFrom(clazz);
} catch (ClassNotFoundException e) {
return false;
}
}

private static boolean isIgnored(FieldInfo field) {
if (Modifier.isStatic(field.flags()) || Modifier.isTransient(field.flags())) {
return true;
}
var jsonIgnore = field.declaredAnnotation(JSON_IGNORE);
if (jsonIgnore == null) {
return false;
}
var value = jsonIgnore.value();
return value == null || value.asBoolean();
}

private static String resourceType(FieldInfo field) {
var typeValue = field.declaredAnnotation(RESOURCE_ID).value(RESOURCE_ID_TYPE_PARAM);
if (typeValue == null || typeValue.asString().trim().isBlank()) {
throw new IllegalStateException("The field (%s) of the class (%s) is annotated with @%s with a blank %s."
.formatted(field.name(), field.declaringClass().name(), UniqueResourceId.class.getSimpleName(),
RESOURCE_ID_TYPE_PARAM));
}
return typeValue.asString();
}

private static String jsonName(FieldInfo field) {
var jsonProperty = field.declaredAnnotation(JSON_PROPERTY);
Comment thread
rsynek marked this conversation as resolved.
Resolved
if (jsonProperty != null) {
var value = jsonProperty.value();
if (value != null && !value.asString().isEmpty()) {
return value.asString();
}
}
return field.name();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package ai.timefold.solver.service.quarkus.deployment;

import static org.assertj.core.api.Assertions.assertThat;

import java.io.File;
import java.io.IOException;

import jakarta.inject.Inject;

import ai.timefold.solver.service.definition.api.ModelDescriptor;
import ai.timefold.solver.service.definition.internal.descriptor.usage.UniqueResource;
import ai.timefold.solver.service.quarkus.deployment.testdata.uniqueresource.TestdataConstraintProvider;
import ai.timefold.solver.service.quarkus.deployment.testdata.uniqueresource.TestdataRest;
import ai.timefold.solver.service.quarkus.deployment.testdata.uniqueresource.TestdataSolution;
import ai.timefold.solver.service.quarkus.deployment.testdata.uniqueresource.TestdataVehicle;
import ai.timefold.solver.service.quarkus.deployment.testdata.uniqueresource.TestdataVisit;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkus.test.QuarkusExtensionTest;

class ModelDescriptorUniqueResourcesTest {

@RegisterExtension
static final QuarkusExtensionTest config = ExtensionTestUtil.createDeploymentWithMandatoryConfig(TestdataVehicle.class,
TestdataVisit.class, TestdataSolution.class, TestdataConstraintProvider.class, TestdataRest.class);

@Inject
ObjectMapper objectMapper;

@Test
void uniqueResourcesDerivedFromResourceIdAnnotations() throws IOException {
var descriptor = new File("target/timefold/timefold-model-descriptor.json");

var modelDescriptor = objectMapper.readValue(descriptor, ModelDescriptor.class);
assertThat(modelDescriptor.getUniqueResources()).containsExactlyInAnyOrder(
new UniqueResource("vehicle", "$.vehicles[*].id"),
new UniqueResource("visit", "$.visits[*].id"),
new UniqueResource("visit", "$.vehicles[*].visits[*].id"));
Comment thread
rsynek marked this conversation as resolved.
Resolved
}
}
Loading
Loading