Repository navigation
feat: report unique resources #2779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
triceo
merged 7 commits into
TimefoldAI:main
from
rsynek:feat/json-path-resource-reporting
Nov 3, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c3a4f50
feat: report unique resources
rsynek 05d058f
address review comments
rsynek d6bf7ef
address review comments
rsynek 217efdc
move UniqueResourceId to preview.api package
rsynek 99ad48c
normalize line endings in mvnw.cmd
rsynek f15e706
support multi-dimensional arrays
rsynek a331792
use context classloader
rsynek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
19 changes: 19 additions & 0 deletions
19
.../java/ai/timefold/solver/service/definition/internal/descriptor/usage/UniqueResource.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
| } | ||
| } |
19 changes: 19 additions & 0 deletions
19
...c/main/java/ai/timefold/solver/service/definition/preview/api/usage/UniqueResourceId.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
164 changes: 164 additions & 0 deletions
164
...java/ai/timefold/solver/service/quarkus/deployment/descriptor/ResourceIdPathResolver.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
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); | ||
|
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); | ||
|
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(); | ||
| } | ||
| } | ||
44 changes: 44 additions & 0 deletions
44
...ava/ai/timefold/solver/service/quarkus/deployment/ModelDescriptorUniqueResourcesTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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")); | ||
|
rsynek marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.