diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CascadingUpdateShadowBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CascadingUpdateShadowBuilder.java new file mode 100644 index 00000000000..ca8c9467a07 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CascadingUpdateShadowBuilder.java @@ -0,0 +1,16 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.function.Consumer; + +/** + * Builder for configuring a cascading update shadow variable. + * + * @param the solution type + * @param the entity type + */ +public interface CascadingUpdateShadowBuilder { + + CascadingUpdateShadowBuilder updateMethod(Consumer updater); + + CascadingUpdateShadowBuilder sources(String... sourcePaths); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecification.java new file mode 100644 index 00000000000..be89b39f3ae --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecification.java @@ -0,0 +1,100 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; + +/** + * Describes how to clone a planning solution using lambdas. + *

+ * The specification contains a complete cloning recipe: for every cloneable class (solution, entities, + * {@code @DeepPlanningClone} facts), it lists all fields with their getter/setter lambdas and + * a pre-classified {@link DeepCloneDecision} that determines how each field value is handled during cloning. + * + * @param solutionFactory creates a new empty solution instance + * @param solutionProperties all fields on the solution class (including inherited), with clone decisions + * @param cloneableClasses cloning recipes for entities and {@code @DeepPlanningClone} facts, keyed by exact class + * @param entityClasses all entity classes (for runtime entity detection in cloneMap lookups) + * @param deepCloneClasses all {@code @DeepPlanningClone}-annotated classes + * @param customCloner optional custom cloner (null if using lambda-based cloning) + * @param the solution type + */ +public record CloningSpecification( + Supplier solutionFactory, + List solutionProperties, + Map, CloneableClassDescriptor> cloneableClasses, + Set> entityClasses, + Set> deepCloneClasses, + SolutionCloner customCloner) { + + /** + * Describes how to copy a single field during cloning. + * + * @param name the field name (for debugging) + * @param getter reads the field value from an object + * @param setter writes the field value to an object + * @param deepCloneDecision how this field's value should be handled during cloning + * @param cloneTimeValidationMessage if non-null, an error message to throw at clone time + * (used for deferred validation, e.g. {@code @DeepPlanningClone} on a {@code @PlanningVariable} + * whose type is not deep-cloneable) + */ + public record PropertyCopyDescriptor( + String name, + Function getter, + BiConsumer setter, + DeepCloneDecision deepCloneDecision, + String cloneTimeValidationMessage) { + + public PropertyCopyDescriptor( + String name, + Function getter, + BiConsumer setter, + DeepCloneDecision deepCloneDecision) { + this(name, getter, setter, deepCloneDecision, null); + } + } + + /** + * Pre-classified decision for how a field value is handled during cloning. + * Determined at specification-build time so no runtime type inspection is needed. + */ + public enum DeepCloneDecision { + /** Immutable type: direct copy (no cloning needed). */ + SHALLOW, + /** May be an entity or deep-clone type: resolve from cloneMap. */ + RESOLVE_ENTITY_REFERENCE, + /** {@code @DeepPlanningClone} type: always deep clone. */ + ALWAYS_DEEP, + /** Collection needing element resolution/deep-cloning. */ + DEEP_COLLECTION, + /** Map needing key/value resolution/deep-cloning. */ + DEEP_MAP, + /** Array needing element resolution/deep-cloning. */ + DEEP_ARRAY, + /** + * Non-immutable type where the field's declared type is not known to be deep-cloneable, + * but the runtime value's class might be (e.g. a subclass annotated with {@code @DeepPlanningClone}). + * At clone time: check if the value's actual class is deep-cloneable, and deep-clone if so. + * Otherwise, shallow copy. + */ + SHALLOW_OR_DEEP_BY_RUNTIME_TYPE + } + + /** + * Cloning recipe for a single cloneable class (entity or {@code @DeepPlanningClone} fact). + * + * @param clazz the class + * @param factory creates a new empty instance (no-arg constructor) + * @param properties all fields (including inherited), with clone decisions + */ + public record CloneableClassDescriptor( + Class clazz, + Supplier factory, + List properties) { + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecificationBuilder.java new file mode 100644 index 00000000000..a973ef72681 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/CloningSpecificationBuilder.java @@ -0,0 +1,99 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Builder for configuring lambda-based solution cloning. + *

+ * This builder allows users to define a complete cloning recipe: for the solution class, + * every entity class, and any {@code @DeepPlanningClone} fact classes, all fields are declared + * with their getter/setter lambdas and a {@link CloningSpecification.DeepCloneDecision}. + * + * @param the solution type + */ +public interface CloningSpecificationBuilder { + + /** + * Sets the factory that creates new empty solution instances. + */ + CloningSpecificationBuilder solutionFactory(Supplier factory); + + /** + * Declares a shallow-copy property on the solution class. + */ + CloningSpecificationBuilder solutionProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares a property on the solution class with an explicit deep clone decision. + */ + CloningSpecificationBuilder solutionProperty(String name, Function getter, BiConsumer setter, + CloningSpecification.DeepCloneDecision decision); + + /** + * Registers an entity class with its no-arg constructor and property definitions. + */ + CloningSpecificationBuilder entityClass(Class entityClass, Supplier factory, + Consumer> config); + + /** + * Registers an entity class with its no-arg constructor, without additional property definitions. + */ + default CloningSpecificationBuilder entityFactory(Class entityClass, Supplier factory) { + return entityClass(entityClass, factory, e -> { + }); + } + + /** + * Registers a {@code @DeepPlanningClone} fact class with its no-arg constructor and property definitions. + */ + CloningSpecificationBuilder deepCloneFact(Class factClass, Supplier factory, + Consumer> config); + + /** + * Builder for defining properties on a cloneable class (entity or deep-clone fact). + * + * @param the class type + */ + interface CloneableClassBuilder { + + /** + * Declares a shallow-copy property. + */ + CloneableClassBuilder shallowProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares a property that may reference an entity (resolved from cloneMap). + */ + CloneableClassBuilder entityRefProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares a property that should always be deep-cloned. + */ + CloneableClassBuilder deepProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares a collection property that needs element resolution/deep-cloning. + */ + CloneableClassBuilder deepCollectionProperty(String name, Function getter, + BiConsumer setter); + + /** + * Declares a map property that needs key/value resolution/deep-cloning. + */ + CloneableClassBuilder deepMapProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares an array property that needs element resolution/deep-cloning. + */ + CloneableClassBuilder deepArrayProperty(String name, Function getter, BiConsumer setter); + + /** + * Declares a property with an explicit deep clone decision. + */ + CloneableClassBuilder property(String name, Function getter, BiConsumer setter, + CloningSpecification.DeepCloneDecision decision); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ConstraintWeightSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ConstraintWeightSpecification.java new file mode 100644 index 00000000000..b7c174a3fb8 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ConstraintWeightSpecification.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; + +/** + * Describes constraint weight overrides on a planning solution. + * + * @param getter reads the constraint weight overrides from the solution + * @param the solution type + */ +public record ConstraintWeightSpecification( + Function> getter) { +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/DeclarativeShadowBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/DeclarativeShadowBuilder.java new file mode 100644 index 00000000000..83382a4f62f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/DeclarativeShadowBuilder.java @@ -0,0 +1,19 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.function.Function; + +/** + * Builder for configuring a declarative shadow variable. + * + * @param the solution type + * @param the entity type + * @param the shadow variable value type + */ +public interface DeclarativeShadowBuilder { + + DeclarativeShadowBuilder supplier(Function supplier); + + DeclarativeShadowBuilder sources(String... sourcePaths); + + DeclarativeShadowBuilder alignmentKey(String key); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntityCollectionSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntityCollectionSpecification.java new file mode 100644 index 00000000000..7ed4366f664 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntityCollectionSpecification.java @@ -0,0 +1,37 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * Describes an entity collection property on a planning solution. + * + * @param name the property name + * @param getter reads the entity collection from the solution + * @param setter writes the entity collection to the solution (may be null) + * @param isSingular true if this is a singular {@code @PlanningEntityProperty} (not a collection) + * @param the solution type + */ +public record EntityCollectionSpecification( + String name, + Function> getter, + @Nullable BiConsumer setter, + boolean isSingular) { + + /** + * Constructor without setter (programmatic API, always treated as collection). + */ + public EntityCollectionSpecification(String name, Function> getter) { + this(name, getter, null, false); + } + + /** + * Constructor without setter (annotation path). + */ + public EntityCollectionSpecification(String name, Function> getter, boolean isSingular) { + this(name, getter, null, isSingular); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecification.java new file mode 100644 index 00000000000..bf31f779a8a --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecification.java @@ -0,0 +1,67 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.ToIntFunction; + +/** + * Describes a planning entity. + * + * @param entityClass the entity class + * @param planningIdGetter optional getter for the planning ID + * @param difficultyComparator optional comparator for entity difficulty sorting + * @param difficultyComparatorFactoryClass optional factory class for solution-aware entity difficulty sorting + * @param pinnedPredicate optional predicate to determine if an entity is pinned + * @param pinToIndexFunction optional function to determine the pin-to-index + * @param variables the genuine planning variables + * @param shadows the shadow variables + * @param entityScopedValueRanges value ranges scoped to this entity + * @param the solution type + */ +public record EntitySpecification( + Class entityClass, + Function planningIdGetter, + java.util.function.BiConsumer planningIdSetter, + Comparator difficultyComparator, + Class difficultyComparatorFactoryClass, + Predicate pinnedPredicate, + ToIntFunction pinToIndexFunction, + List> variables, + List> shadows, + List> entityScopedValueRanges) { + + /** + * Backward-compatible constructor without planningIdSetter and difficultyComparatorFactoryClass. + */ + public EntitySpecification( + Class entityClass, + Function planningIdGetter, + Comparator difficultyComparator, + Predicate pinnedPredicate, + ToIntFunction pinToIndexFunction, + List> variables, + List> shadows, + List> entityScopedValueRanges) { + this(entityClass, planningIdGetter, null, difficultyComparator, null, + pinnedPredicate, pinToIndexFunction, variables, shadows, entityScopedValueRanges); + } + + /** + * Backward-compatible constructor without planningIdSetter. + */ + public EntitySpecification( + Class entityClass, + Function planningIdGetter, + Comparator difficultyComparator, + Class difficultyComparatorFactoryClass, + Predicate pinnedPredicate, + ToIntFunction pinToIndexFunction, + List> variables, + List> shadows, + List> entityScopedValueRanges) { + this(entityClass, planningIdGetter, null, difficultyComparator, difficultyComparatorFactoryClass, + pinnedPredicate, pinToIndexFunction, variables, shadows, entityScopedValueRanges); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecificationBuilder.java new file mode 100644 index 00000000000..05e1647c90e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/EntitySpecificationBuilder.java @@ -0,0 +1,60 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Comparator; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ObjIntConsumer; +import java.util.function.Predicate; +import java.util.function.ToIntFunction; + +/** + * Fluent builder for an {@link EntitySpecification}. + * + * @param the solution type + * @param the entity type + */ +public interface EntitySpecificationBuilder { + + > EntitySpecificationBuilder planningId(Function getter); + + > EntitySpecificationBuilder planningId(Function getter, + BiConsumer setter); + + EntitySpecificationBuilder difficultyComparator(Comparator comparator); + + EntitySpecificationBuilder pinned(Predicate isPinned); + + EntitySpecificationBuilder pinToIndex(ToIntFunction pinToIndex); + + EntitySpecificationBuilder valueRange(String id, Function getter); + + EntitySpecificationBuilder variable(String name, Class valueType, + Consumer> config); + + EntitySpecificationBuilder listVariable(String name, Class elementType, + Consumer> config); + + EntitySpecificationBuilder inverseRelationShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config); + + EntitySpecificationBuilder indexShadow(String name, + ToIntFunction getter, ObjIntConsumer setter, Consumer config); + + EntitySpecificationBuilder previousElementShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config); + + EntitySpecificationBuilder nextElementShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config); + + EntitySpecificationBuilder declarativeShadow(String name, Class type, + Function getter, BiConsumer setter, + Consumer> config); + + EntitySpecificationBuilder cascadingUpdateShadow(String name, Class type, + Function getter, BiConsumer setter, + Consumer> config); + + EntitySpecificationBuilder shadowVariablesInconsistent(String name, + Function getter, BiConsumer setter); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/FactSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/FactSpecification.java new file mode 100644 index 00000000000..f481c35cf8e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/FactSpecification.java @@ -0,0 +1,39 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.lang.reflect.Type; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * Describes a problem fact or problem fact collection on a planning solution. + * + * @param name the property name + * @param getter reads the fact(s) from the solution + * @param setter writes the fact(s) to the solution (may be null for annotation path without cloning spec) + * @param isCollection true if this is a collection of facts + * @param genericType the generic return type of the member (e.g., {@code List}) + * @param the solution type + */ +public record FactSpecification( + String name, + Function getter, + @Nullable BiConsumer setter, + boolean isCollection, + Type genericType) { + + /** + * Constructor without setter or generic type (programmatic API). + */ + public FactSpecification(String name, Function getter, boolean isCollection) { + this(name, getter, null, isCollection, null); + } + + /** + * Constructor without setter (annotation path where setter is resolved later if needed). + */ + public FactSpecification(String name, Function getter, boolean isCollection, Type genericType) { + this(name, getter, null, isCollection, genericType); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ListVariableSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ListVariableSpecificationBuilder.java new file mode 100644 index 00000000000..779110deaca --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ListVariableSpecificationBuilder.java @@ -0,0 +1,24 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Fluent builder for a list {@link VariableSpecification}. + * + * @param the solution type + * @param the entity type + * @param the list element type + */ +public interface ListVariableSpecificationBuilder { + + ListVariableSpecificationBuilder accessors(Function> getter, BiConsumer> setter); + + ListVariableSpecificationBuilder valueRange(String... refs); + + ListVariableSpecificationBuilder allowsUnassignedValues(boolean allows); + + ListVariableSpecificationBuilder strengthComparator(Comparator comparator); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/PlanningSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/PlanningSpecification.java new file mode 100644 index 00000000000..7be098b2926 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/PlanningSpecification.java @@ -0,0 +1,44 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.List; + +import ai.timefold.solver.core.impl.domain.specification.DefaultSolutionSpecificationBuilder; + +/** + * The complete description of a planning problem's structure. + *

+ * This is an immutable value object produced by the builder API via {@link #of(Class)}. + * It holds lambdas and configuration that the solver uses to access the domain model + * without reflection. + * + * @param solutionClass the solution class + * @param score how to access the score + * @param facts problem fact properties + * @param entityCollections entity collection properties + * @param valueRanges value range providers + * @param entities entity specifications + * @param cloning how to clone the solution (null if not specified) + * @param constraintWeights constraint weight overrides (null if not specified) + * @param the solution type + */ +public record PlanningSpecification( + Class solutionClass, + ScoreSpecification score, + List> facts, + List> entityCollections, + List> valueRanges, + List> entities, + CloningSpecification cloning, + ConstraintWeightSpecification constraintWeights) { + + /** + * Entry point for building a {@link PlanningSpecification} programmatically. + * + * @param solutionClass the solution class + * @param the solution type + * @return a builder for the specification + */ + public static SolutionSpecificationBuilder of(Class solutionClass) { + return new DefaultSolutionSpecificationBuilder<>(solutionClass); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ScoreSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ScoreSpecification.java new file mode 100644 index 00000000000..24913f8ac07 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ScoreSpecification.java @@ -0,0 +1,28 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.function.BiConsumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.score.Score; + +/** + * Describes how the score is accessed on a planning solution. + * + * @param scoreType the score class + * @param getter reads the score from the solution + * @param setter writes the score to the solution + * @param bendableHardLevelsSize for bendable scores, the number of hard levels (-1 if not bendable) + * @param bendableSoftLevelsSize for bendable scores, the number of soft levels (-1 if not bendable) + * @param the solution type + */ +public record ScoreSpecification( + Class> scoreType, + Function getter, + BiConsumer setter, + int bendableHardLevelsSize, + int bendableSoftLevelsSize) { + + public ScoreSpecification(Class> scoreType, Function getter, BiConsumer setter) { + this(scoreType, getter, setter, -1, -1); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ShadowSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ShadowSpecification.java new file mode 100644 index 00000000000..b1f716b6704 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ShadowSpecification.java @@ -0,0 +1,107 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ObjIntConsumer; +import java.util.function.ToIntFunction; + +/** + * Describes a shadow variable on an entity. + * + * @param the solution type + */ +public sealed interface ShadowSpecification { + + String name(); + + Class type(); + + Function getter(); + + BiConsumer setter(); + + record InverseRelation(String name, Class type, + Function getter, BiConsumer setter, + String sourceVariableName) implements ShadowSpecification { + } + + record Index(String name, Class type, + ToIntFunction intGetter, ObjIntConsumer intSetter, + Function rawGetter, BiConsumer rawSetter, + String sourceVariableName) implements ShadowSpecification { + + /** + * Convenience constructor for programmatic API — wraps int-specific accessors. + */ + @SuppressWarnings("unchecked") + public Index(String name, Class type, + ToIntFunction intGetter, ObjIntConsumer intSetter, + String sourceVariableName) { + this(name, type, intGetter, intSetter, + (Function) entity -> ((ToIntFunction) intGetter).applyAsInt(entity), + (BiConsumer) (entity, value) -> ((ObjIntConsumer) intSetter) + .accept(entity, value != null ? (Integer) value : -1), + sourceVariableName); + } + + @Override + public Function getter() { + return rawGetter; + } + + @Override + public BiConsumer setter() { + return rawSetter; + } + } + + record PreviousElement(String name, Class type, + Function getter, BiConsumer setter, + String sourceVariableName) implements ShadowSpecification { + } + + record NextElement(String name, Class type, + Function getter, BiConsumer setter, + String sourceVariableName) implements ShadowSpecification { + } + + record Declarative(String name, Class type, + Function getter, BiConsumer setter, + Function supplier, List sourcePaths, + String alignmentKey, Method supplierMethod) implements ShadowSpecification { + + /** + * Constructor for programmatic API (no supplierMethod). + */ + public Declarative(String name, Class type, + Function getter, BiConsumer setter, + Function supplier, List sourcePaths, + String alignmentKey) { + this(name, type, getter, setter, supplier, sourcePaths, alignmentKey, null); + } + } + + record CascadingUpdate(String name, Class type, + Function getter, BiConsumer setter, + Consumer updateMethod, + List sourcePaths, + String targetMethodName) implements ShadowSpecification { + + /** + * Constructor for programmatic API (no targetMethodName). + */ + public CascadingUpdate(String name, Class type, + Function getter, BiConsumer setter, + Consumer updateMethod, + List sourcePaths) { + this(name, type, getter, setter, updateMethod, sourcePaths, null); + } + } + + record Inconsistent(String name, Class type, + Function getter, BiConsumer setter) implements ShadowSpecification { + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SolutionSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SolutionSpecificationBuilder.java new file mode 100644 index 00000000000..845f3777e39 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SolutionSpecificationBuilder.java @@ -0,0 +1,50 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Collection; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.score.Score; + +/** + * Fluent builder for a {@link PlanningSpecification}. + * + * @param the solution type + */ +public interface SolutionSpecificationBuilder { + + > SolutionSpecificationBuilder score( + Class scoreType, Function getter, BiConsumer setter); + + SolutionSpecificationBuilder problemFact(String name, Function getter); + + SolutionSpecificationBuilder problemFact(String name, Function getter, BiConsumer setter); + + SolutionSpecificationBuilder problemFacts(String name, Function> getter); + + SolutionSpecificationBuilder problemFacts(String name, Function> getter, + BiConsumer setter); + + SolutionSpecificationBuilder entityCollection(String name, Function> getter); + + SolutionSpecificationBuilder entityCollection(String name, Function> getter, + BiConsumer setter); + + SolutionSpecificationBuilder valueRange(String id, Function getter); + + SolutionSpecificationBuilder valueRange(Function getter); + + SolutionSpecificationBuilder constraintWeightOverrides(Function> getter); + + SolutionSpecificationBuilder entity(Class entityClass, + Consumer> config); + + SolutionSpecificationBuilder cloning(Consumer> config); + + SolutionSpecificationBuilder solutionCloner(SolutionCloner cloner); + + PlanningSpecification build(); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SourceRefBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SourceRefBuilder.java new file mode 100644 index 00000000000..90da9b7f04e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/SourceRefBuilder.java @@ -0,0 +1,9 @@ +package ai.timefold.solver.core.api.domain.specification; + +/** + * Builder for specifying the source variable of a shadow variable. + */ +public interface SourceRefBuilder { + + SourceRefBuilder sourceVariable(String variableName); +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ValueRangeSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ValueRangeSpecification.java new file mode 100644 index 00000000000..cbaca20bdd0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/ValueRangeSpecification.java @@ -0,0 +1,29 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.lang.reflect.Type; +import java.util.function.Function; + +/** + * Describes a value range provider on either the solution or an entity. + * + * @param id the value range id (null for anonymous/type-matched) + * @param getter reads the value range from the owner + * @param ownerClass the class that owns this value range (solution or entity class) + * @param isEntityScoped true if this value range is on an entity rather than the solution + * @param genericReturnType the generic return type of the value range provider (e.g., {@code List}) + * @param the solution type + */ +public record ValueRangeSpecification( + String id, + Function getter, + Class ownerClass, + boolean isEntityScoped, + Type genericReturnType) { + + /** + * Backwards-compatible constructor for the programmatic API. + */ + public ValueRangeSpecification(String id, Function getter, Class ownerClass, boolean isEntityScoped) { + this(id, getter, ownerClass, isEntityScoped, null); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecification.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecification.java new file mode 100644 index 00000000000..5389c5af775 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecification.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Describes a planning variable on an entity. + * + * @param name the variable name + * @param valueType the type of value this variable holds (or element type for list variables) + * @param getter reads the variable value from the entity + * @param setter writes the variable value to the entity + * @param isList true if this is a list variable + * @param allowsUnassigned true if the variable may remain unassigned + * @param valueRangeRefs references to value range provider IDs + * @param strengthComparator optional comparator for value strength sorting + * @param strengthComparatorFactoryClass optional factory class for solution-aware value strength sorting + * @param the solution type + */ +public record VariableSpecification( + String name, + Class valueType, + Function getter, + BiConsumer setter, + boolean isList, + boolean allowsUnassigned, + List valueRangeRefs, + Comparator strengthComparator, + Class strengthComparatorFactoryClass) { + + /** + * Backward-compatible constructor without comparatorFactoryClass. + */ + public VariableSpecification( + String name, + Class valueType, + Function getter, + BiConsumer setter, + boolean isList, + boolean allowsUnassigned, + List valueRangeRefs, + Comparator strengthComparator) { + this(name, valueType, getter, setter, isList, allowsUnassigned, valueRangeRefs, strengthComparator, null); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecificationBuilder.java new file mode 100644 index 00000000000..8469df87e08 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/api/domain/specification/VariableSpecificationBuilder.java @@ -0,0 +1,23 @@ +package ai.timefold.solver.core.api.domain.specification; + +import java.util.Comparator; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * Fluent builder for a basic (non-list) {@link VariableSpecification}. + * + * @param the solution type + * @param the entity type + * @param the variable value type + */ +public interface VariableSpecificationBuilder { + + VariableSpecificationBuilder accessors(Function getter, BiConsumer setter); + + VariableSpecificationBuilder valueRange(String... refs); + + VariableSpecificationBuilder allowsUnassigned(boolean allows); + + VariableSpecificationBuilder strengthComparator(Comparator comparator); +} diff --git a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java index b9fa907fdc9..43b25d7cada 100644 --- a/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java +++ b/core/src/main/java/ai/timefold/solver/core/config/solver/SolverConfig.java @@ -8,6 +8,7 @@ import java.io.InputStreamReader; import java.io.Reader; import java.io.UnsupportedEncodingException; +import java.lang.invoke.MethodHandles; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; @@ -26,7 +27,7 @@ import jakarta.xml.bind.annotation.XmlTransient; import jakarta.xml.bind.annotation.XmlType; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.api.solver.Solver; @@ -44,6 +45,7 @@ import ai.timefold.solver.core.config.solver.random.RandomType; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import ai.timefold.solver.core.impl.io.jaxb.SolverConfigIO; @@ -227,9 +229,13 @@ public final class SolverConfig extends AbstractConfig { @XmlElement(name = "entityClass") private List> entityClassList = null; @XmlTransient + private DomainAccessType domainAccessType = null; + @XmlTransient private Map gizmoMemberAccessorMap = null; @XmlTransient - private Map gizmoSolutionClonerMap = null; + private PlanningSpecification planningSpecification = null; + @XmlTransient + private MethodHandles.Lookup lookup = null; @XmlElement(name = "scoreDirectorFactory") private ScoreDirectorFactoryConfig scoreDirectorFactoryConfig = null; @@ -396,6 +402,14 @@ public void setEntityClassList(@Nullable List> entityClassList) { this.entityClassList = entityClassList; } + public @Nullable DomainAccessType getDomainAccessType() { + return domainAccessType; + } + + public void setDomainAccessType(@Nullable DomainAccessType domainAccessType) { + this.domainAccessType = domainAccessType; + } + public @Nullable Map<@NonNull String, @NonNull MemberAccessor> getGizmoMemberAccessorMap() { return gizmoMemberAccessorMap; } @@ -404,12 +418,20 @@ public void setGizmoMemberAccessorMap(@Nullable Map<@NonNull String, @NonNull Me this.gizmoMemberAccessorMap = gizmoMemberAccessorMap; } - public @Nullable Map<@NonNull String, @NonNull SolutionCloner> getGizmoSolutionClonerMap() { - return gizmoSolutionClonerMap; + public @Nullable PlanningSpecification getPlanningSpecification() { + return planningSpecification; + } + + public void setPlanningSpecification(@Nullable PlanningSpecification planningSpecification) { + this.planningSpecification = planningSpecification; + } + + public MethodHandles.Lookup getLookup() { + return lookup; } - public void setGizmoSolutionClonerMap(@Nullable Map<@NonNull String, @NonNull SolutionCloner> gizmoSolutionClonerMap) { - this.gizmoSolutionClonerMap = gizmoSolutionClonerMap; + public void setLookup(MethodHandles.Lookup lookup) { + this.lookup = lookup; } public @Nullable ScoreDirectorFactoryConfig getScoreDirectorFactoryConfig() { @@ -522,9 +544,13 @@ public void setMonitoringConfig(@Nullable MonitoringConfig monitoringConfig) { return this; } - public @NonNull SolverConfig - withGizmoSolutionClonerMap(@NonNull Map<@NonNull String, @NonNull SolutionCloner> solutionClonerMap) { - this.gizmoSolutionClonerMap = solutionClonerMap; + public @NonNull SolverConfig withPlanningSpecification(@NonNull PlanningSpecification planningSpecification) { + this.planningSpecification = planningSpecification; + return this; + } + + public @NonNull SolverConfig withLookup(MethodHandles.Lookup lookup) { + this.lookup = lookup; return this; } @@ -678,10 +704,13 @@ public void offerRandomSeedFromSubSingleIndex(long subSingleIndex) { solutionClass = ConfigUtils.inheritOverwritableProperty(solutionClass, inheritedConfig.getSolutionClass()); entityClassList = ConfigUtils.inheritMergeableListProperty(entityClassList, inheritedConfig.getEntityClassList()); + domainAccessType = ConfigUtils.inheritOverwritableProperty(domainAccessType, + inheritedConfig.getDomainAccessType()); gizmoMemberAccessorMap = ConfigUtils.inheritMergeableMapProperty( gizmoMemberAccessorMap, inheritedConfig.getGizmoMemberAccessorMap()); - gizmoSolutionClonerMap = ConfigUtils.inheritMergeableMapProperty( - gizmoSolutionClonerMap, inheritedConfig.getGizmoSolutionClonerMap()); + planningSpecification = ConfigUtils.inheritOverwritableProperty(planningSpecification, + inheritedConfig.getPlanningSpecification()); + lookup = ConfigUtils.inheritOverwritableProperty(lookup, inheritedConfig.getLookup()); scoreDirectorFactoryConfig = ConfigUtils.inheritConfig(scoreDirectorFactoryConfig, inheritedConfig.getScoreDirectorFactoryConfig()); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessor.java new file mode 100644 index 00000000000..7bd99b09cd8 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessor.java @@ -0,0 +1,93 @@ +package ai.timefold.solver.core.impl.domain.common.accessor; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * A {@link MemberAccessor} that delegates to user-provided lambda functions + * instead of using reflection. Used by the programmatic specification API. + *

+ * Annotations always return null since configuration comes from the specification, not annotations. + */ +public final class LambdaMemberAccessor extends AbstractMemberAccessor { + + private final String name; + private final Class declaringClass; + private final Class type; + private final Type genericType; + private final Function getter; + private final BiConsumer setter; + + @SuppressWarnings("unchecked") + public LambdaMemberAccessor(String name, Class declaringClass, Class type, Type genericType, + Function getter, BiConsumer setter) { + this.name = name; + this.declaringClass = declaringClass; + this.type = type; + this.genericType = genericType != null ? genericType : type; + this.getter = (Function) getter; + this.setter = (BiConsumer) setter; + } + + @Override + public Class getDeclaringClass() { + return declaringClass; + } + + @Override + public String getName() { + return name; + } + + @Override + public Class getType() { + return type; + } + + @Override + public Type getGenericType() { + return genericType; + } + + @Override + public Object executeGetter(Object bean) { + return getter.apply(bean); + } + + @Override + public boolean supportSetter() { + return setter != null; + } + + @Override + public void executeSetter(Object bean, Object value) { + if (setter == null) { + throw new UnsupportedOperationException( + "The lambda member accessor '%s' on %s does not support setting." + .formatted(name, declaringClass.getSimpleName())); + } + setter.accept(bean, value); + } + + @Override + public String getSpeedNote() { + return "lambda"; + } + + @Override + public T getAnnotation(Class annotationClass) { + return null; + } + + @Override + public T[] getDeclaredAnnotationsByType(Class annotationClass) { + return null; + } + + @Override + public String toString() { + return "lambda:" + declaringClass.getSimpleName() + "." + name; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java index 9b6d760cbb2..ba6b4acbe76 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java @@ -147,6 +147,77 @@ public EntityForEachFilter getEntityForEachFilter() { return entityForEachFilter; } + /** + * Initialize variable descriptor maps without annotation processing. + * Used by the programmatic specification API. + */ + public void initializeVariableMaps() { + declaredGenuineVariableDescriptorMap = new LinkedHashMap<>(); + declaredShadowVariableDescriptorMap = new LinkedHashMap<>(); + declaredCascadingUpdateShadowVariableDecriptorMap = new LinkedHashMap<>(); + declaredPinEntityFilterList = new ArrayList<>(2); + } + + /** + * Add a genuine variable descriptor directly. + * Used by the programmatic specification API. + */ + public void addGenuineVariableDescriptor(GenuineVariableDescriptor variableDescriptor) { + declaredGenuineVariableDescriptorMap.put(variableDescriptor.getVariableName(), variableDescriptor); + } + + /** + * Add a shadow variable descriptor directly. + * Used by the programmatic specification API. + */ + public void addShadowVariableDescriptor(ShadowVariableDescriptor variableDescriptor) { + addShadowVariableDescriptor(variableDescriptor, true); + } + + /** + * Add a shadow variable descriptor directly, optionally registering it as a primary cascading descriptor. + * When {@code registerInCascadingMap} is false, the descriptor is added to the shadow map only, + * not to the cascading update map. This is used for secondary fields that share the same targetMethodName. + */ + public void addShadowVariableDescriptor(ShadowVariableDescriptor variableDescriptor, + boolean registerInCascadingMap) { + declaredShadowVariableDescriptorMap.put(variableDescriptor.getVariableName(), variableDescriptor); + if (registerInCascadingMap + && variableDescriptor instanceof CascadingUpdateShadowVariableDescriptor cascading) { + declaredCascadingUpdateShadowVariableDecriptorMap.put(cascading.getVariableName(), cascading); + } + if (variableDescriptor instanceof ShadowVariablesInconsistentVariableDescriptor inconsistent) { + shadowVariablesInconsistentDescriptor = inconsistent; + } + } + + /** + * Add a pin filter from a predicate (equivalent to @PlanningPin). + * Used by the programmatic specification API. + */ + public void addPinnedPredicate(java.util.function.Predicate pinnedPredicate) { + if (declaredPinEntityFilterList == null) { + declaredPinEntityFilterList = new ArrayList<>(2); + } + declaredPinEntityFilterList.add((solution, entity) -> !pinnedPredicate.test(entity)); + } + + /** + * Set the descending difficulty sorter. + * Used by the programmatic specification API. + */ + public void setDescendingSorter(SelectionSorter sorter) { + this.descendingSorter = sorter; + } + + /** + * Set the pin-to-index reader. + * Used by the programmatic specification API. + */ + public void setPlanningPinToIndexReader(PlanningPinToIndexReader reader) { + this.effectivePlanningPinToIndexReader = reader; + } + // ************************************************************************ // Lifecycle methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/policy/DescriptorPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/policy/DescriptorPolicy.java index 57775043947..a91b2706e9b 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/policy/DescriptorPolicy.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/policy/DescriptorPolicy.java @@ -13,7 +13,6 @@ import java.util.Set; import ai.timefold.solver.core.api.domain.solution.PlanningScore; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; import ai.timefold.solver.core.api.score.BendableBigDecimalScore; import ai.timefold.solver.core.api.score.BendableScore; @@ -27,6 +26,7 @@ import ai.timefold.solver.core.api.score.SimpleScore; import ai.timefold.solver.core.config.solver.PreviewFeature; import ai.timefold.solver.core.impl.domain.common.DomainAccessType; +import ai.timefold.solver.core.impl.domain.common.accessor.LambdaMemberAccessor; import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorFactory; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; @@ -53,7 +53,6 @@ @NullMarked public class DescriptorPolicy { - private Map generatedSolutionClonerMap = new LinkedHashMap<>(); private final Map fromSolutionValueRangeProviderMap = new LinkedHashMap<>(); private final Set anonymousFromSolutionValueRangeProviderSet = new LinkedHashSet<>(); private final Map fromEntityValueRangeProviderMap = new LinkedHashMap<>(); @@ -90,12 +89,20 @@ public CompositeValueRangeDescriptor buildCompositeValueR public FromSolutionPropertyValueRangeDescriptor buildFromSolutionPropertyValueRangeDescriptor( GenuineVariableDescriptor variableDescriptor, MemberAccessor valueRangeProviderMemberAccessor) { + if (valueRangeProviderMemberAccessor instanceof LambdaMemberAccessor) { + return new FromSolutionPropertyValueRangeDescriptor<>(valueRangeDescriptorCount++, variableDescriptor, + valueRangeProviderMemberAccessor, true); + } return new FromSolutionPropertyValueRangeDescriptor<>(valueRangeDescriptorCount++, variableDescriptor, valueRangeProviderMemberAccessor); } public FromEntityPropertyValueRangeDescriptor buildFromEntityPropertyValueRangeDescriptor( GenuineVariableDescriptor variableDescriptor, MemberAccessor valueRangeProviderMemberAccessor) { + if (valueRangeProviderMemberAccessor instanceof LambdaMemberAccessor) { + return new FromEntityPropertyValueRangeDescriptor<>(valueRangeDescriptorCount++, variableDescriptor, + valueRangeProviderMemberAccessor, true); + } return new FromEntityPropertyValueRangeDescriptor<>(valueRangeDescriptorCount++, variableDescriptor, valueRangeProviderMemberAccessor); } @@ -190,6 +197,64 @@ The solutionClass (%s) has a @%s annotated member (%s) that returns a bendable s } } + /** + * Build a ScoreDescriptor from a MemberAccessor and score type, bypassing annotation reading. + * Used by the programmatic specification API. + */ + @SuppressWarnings("unchecked") + public > ScoreDescriptor buildScoreDescriptorFromType( + MemberAccessor scoreMemberAccessor, Class scoreType) { + return buildScoreDescriptorFromType(scoreMemberAccessor, scoreType, -1, -1); + } + + /** + * Build a ScoreDescriptor from a MemberAccessor and score type with optional bendable level sizes. + * Used by the programmatic specification API. + */ + @SuppressWarnings("unchecked") + public > ScoreDescriptor buildScoreDescriptorFromType( + MemberAccessor scoreMemberAccessor, Class scoreType, + int bendableHardLevelsSize, int bendableSoftLevelsSize) { + ScoreDefinition scoreDefinition; + if (IBendableScore.class.isAssignableFrom(scoreType)) { + if (bendableHardLevelsSize == -1 || bendableSoftLevelsSize == -1) { + throw new IllegalArgumentException( + "The bendable score type (%s) requires bendableHardLevelsSize and bendableSoftLevelsSize to be set." + .formatted(scoreType.getSimpleName())); + } + if (scoreType.equals(BendableScore.class)) { + scoreDefinition = (ScoreDefinition) new BendableScoreDefinition( + bendableHardLevelsSize, bendableSoftLevelsSize); + } else if (scoreType.equals(BendableBigDecimalScore.class)) { + scoreDefinition = (ScoreDefinition) new BendableBigDecimalScoreDefinition( + bendableHardLevelsSize, bendableSoftLevelsSize); + } else { + throw new IllegalArgumentException( + "The bendable score type (%s) is not a recognized Score implementation." + .formatted(scoreType.getSimpleName())); + } + } else { + if (scoreType.equals(SimpleScore.class)) { + scoreDefinition = (ScoreDefinition) new SimpleScoreDefinition(); + } else if (scoreType.equals(SimpleBigDecimalScore.class)) { + scoreDefinition = (ScoreDefinition) new SimpleBigDecimalScoreDefinition(); + } else if (scoreType.equals(HardSoftScore.class)) { + scoreDefinition = (ScoreDefinition) new HardSoftScoreDefinition(); + } else if (scoreType.equals(HardSoftBigDecimalScore.class)) { + scoreDefinition = (ScoreDefinition) new HardSoftBigDecimalScoreDefinition(); + } else if (scoreType.equals(HardMediumSoftScore.class)) { + scoreDefinition = (ScoreDefinition) new HardMediumSoftScoreDefinition(); + } else if (scoreType.equals(HardMediumSoftBigDecimalScore.class)) { + scoreDefinition = (ScoreDefinition) new HardMediumSoftBigDecimalScoreDefinition(); + } else { + throw new IllegalArgumentException( + "The score type (%s) is not a recognized Score implementation." + .formatted(scoreType.getSimpleName())); + } + } + return new ScoreDescriptor<>(scoreMemberAccessor, scoreDefinition); + } + public MemberAccessor buildScoreMemberAccessor(Member member) { return getMemberAccessorFactory().buildAndCacheMemberAccessor( member, @@ -207,6 +272,32 @@ public void addFromSolutionValueRangeProvider(MemberAccessor memberAccessor) { } } + /** + * Register a value range provider with an explicit ID, bypassing annotation reading. + * Used by the programmatic specification API. + */ + public void addFromSolutionValueRangeProvider(String id, MemberAccessor memberAccessor) { + if (id == null) { + anonymousFromSolutionValueRangeProviderSet.add(memberAccessor); + } else { + validateUniqueValueRangeProviderId(id, memberAccessor); + fromSolutionValueRangeProviderMap.put(id, memberAccessor); + } + } + + /** + * Register an entity-scoped value range provider with an explicit ID, bypassing annotation reading. + * Used by the programmatic specification API. + */ + public void addFromEntityValueRangeProvider(String id, MemberAccessor memberAccessor) { + if (id == null) { + anonymousFromEntityValueRangeProviderSet.add(memberAccessor); + } else { + validateUniqueValueRangeProviderId(id, memberAccessor); + fromEntityValueRangeProviderMap.put(id, memberAccessor); + } + } + public boolean isFromSolutionValueRangeProvider(MemberAccessor memberAccessor) { return fromSolutionValueRangeProviderMap.containsValue(memberAccessor) || anonymousFromSolutionValueRangeProviderSet.contains(memberAccessor); @@ -262,17 +353,6 @@ public void setEnabledPreviewFeatureSet(Set enabledPreviewFeatur this.enabledPreviewFeatureSet = enabledPreviewFeatureSet; } - /** - * @return never null - */ - public Map getGeneratedSolutionClonerMap() { - return generatedSolutionClonerMap; - } - - public void setGeneratedSolutionClonerMap(Map generatedSolutionClonerMap) { - this.generatedSolutionClonerMap = generatedSolutionClonerMap; - } - public MemberAccessorFactory getMemberAccessorFactory() { return memberAccessorFactory; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/OverridesBasedConstraintWeightSupplier.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/OverridesBasedConstraintWeightSupplier.java index f42907e6b06..f974a20a22d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/OverridesBasedConstraintWeightSupplier.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/OverridesBasedConstraintWeightSupplier.java @@ -43,6 +43,13 @@ public static > ConstraintWeightSupplier return new OverridesBasedConstraintWeightSupplier<>(solutionDescriptor, memberAccessor, overridesClass); } + @SuppressWarnings("unchecked") + public static > ConstraintWeightSupplier create( + SolutionDescriptor solutionDescriptor, MemberAccessor memberAccessor) { + return new OverridesBasedConstraintWeightSupplier<>(solutionDescriptor, memberAccessor, + (Class>) (Class) ConstraintWeightOverrides.class); + } + private final SolutionDescriptor solutionDescriptor; private final MemberAccessor overridesAccessor; private final Class> overridesClass; diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningFieldCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningFieldCloner.java deleted file mode 100644 index e3c84c09970..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/DeepCloningFieldCloner.java +++ /dev/null @@ -1,90 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.reflect.Field; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import ai.timefold.solver.core.impl.domain.common.accessor.FieldHandle; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -/** - * @implNote This class is thread-safe. - */ -final class DeepCloningFieldCloner { - - private final AtomicReference valueDeepCloneDecision = new AtomicReference<>(); - private final AtomicInteger fieldDeepCloneDecision = new AtomicInteger(-1); - private final FieldHandle fieldHandle; - - public DeepCloningFieldCloner(Field field) { - this.fieldHandle = FieldHandle.of(Objects.requireNonNull(field)); - } - - public FieldHandle getFieldHandles() { - return fieldHandle; - } - - /** - * - * @param solutionDescriptor never null - * @param original never null, source object - * @param clone never null, target object - * @return null if cloned, the original uncloned value otherwise - * @param - */ - public Object clone(SolutionDescriptor solutionDescriptor, C original, C clone) { - Object originalValue = FieldCloningUtils.getObjectFieldValue(original, fieldHandle); - if (deepClone(solutionDescriptor, original.getClass(), originalValue)) { // Defer filling in the field. - return originalValue; - } else { // Shallow copy. - FieldCloningUtils.setObjectFieldValue(clone, fieldHandle, originalValue); - return null; - } - } - - /** - * Obtaining the decision on whether or not to deep-clone is expensive. - * This method exists to cache those computations as much as possible, - * while maintaining thread-safety. - * - * @param solutionDescriptor never null - * @param fieldTypeClass never null - * @param originalValue never null - * @return true if the value needs to be deep-cloned - */ - private boolean deepClone(SolutionDescriptor solutionDescriptor, Class fieldTypeClass, Object originalValue) { - if (originalValue == null) { - return false; - } - /* - * This caching mechanism takes advantage of the fact that, for a particular field on a particular class, - * the types of values contained are unlikely to change and therefore it is safe to cache the calculation. - * In the unlikely event of a cache miss, we recompute. - */ - boolean isValueDeepCloned = valueDeepCloneDecision.updateAndGet(old -> { - Class originalClass = originalValue.getClass(); - if (old == null || old.clz != originalClass) { - return new Metadata(originalClass, DeepCloningUtils.isClassDeepCloned(solutionDescriptor, originalClass)); - } else { - return old; - } - }).decision; - if (isValueDeepCloned) { // The value has to be deep-cloned. Does not matter what the field says. - return true; - } - /* - * The decision to clone a field is constant once it has been made. - * The fieldTypeClass is guaranteed to not change for the particular field. - */ - if (fieldDeepCloneDecision.get() < 0) { - fieldDeepCloneDecision.set( - DeepCloningUtils.isFieldDeepCloned(solutionDescriptor, getFieldHandles().field(), fieldTypeClass) ? 1 : 0); - } - return fieldDeepCloneDecision.get() == 1; - } - - private record Metadata(Class clz, boolean decision) { - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionCloner.java deleted file mode 100644 index 3fce08744c6..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionCloner.java +++ /dev/null @@ -1,496 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.reflect.Array; -import java.lang.reflect.Modifier; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Deque; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; -import java.util.IdentityHashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.Map; -import java.util.Queue; -import java.util.Set; -import java.util.SortedMap; -import java.util.SortedSet; -import java.util.TreeMap; -import java.util.TreeSet; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; - -import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.util.ConcurrentMemoization; - -import org.jspecify.annotations.NonNull; - -/** - * This class is thread-safe; score directors from the same solution descriptor will share the same instance. - */ -public final class FieldAccessingSolutionCloner implements SolutionCloner { - - private static final int MINIMUM_EXPECTED_OBJECT_COUNT = 1_000; - - private final Map, ClassMetadata> classMetadataMemoization = new ConcurrentMemoization<>(); - private final SolutionDescriptor solutionDescriptor; - // Exists to avoid creating a new lambda instance on every call to map.computeIfAbsent. - private final Function, ClassMetadata> classMetadataConstructor; - // Updated at the end of cloning, with the bet that the next solution to clone will have a similar number of objects. - private final AtomicInteger expectedObjectCountRef = new AtomicInteger(MINIMUM_EXPECTED_OBJECT_COUNT); - - public FieldAccessingSolutionCloner(SolutionDescriptor solutionDescriptor) { - this.solutionDescriptor = solutionDescriptor; - this.classMetadataConstructor = clz -> new ClassMetadata(solutionDescriptor, clz); - } - - // ************************************************************************ - // Worker methods - // ************************************************************************ - - @Override - public @NonNull Solution_ cloneSolution(@NonNull Solution_ originalSolution) { - var expectedObjectCount = expectedObjectCountRef.get(); - var originalToCloneMap = new IdentityHashMap<>(expectedObjectCount); - var unprocessedQueue = new ArrayDeque(expectedObjectCount); - var cloneSolution = clone(originalSolution, originalToCloneMap, unprocessedQueue, - retrieveClassMetadata(originalSolution.getClass())); - while (!unprocessedQueue.isEmpty()) { - var unprocessed = unprocessedQueue.remove(); - var cloneValue = process(unprocessed, originalToCloneMap, unprocessedQueue); - FieldCloningUtils.setObjectFieldValue(unprocessed.bean, unprocessed.cloner.getFieldHandles(), cloneValue); - } - expectedObjectCountRef.updateAndGet(old -> decideNextExpectedObjectCount(old, originalToCloneMap.size())); - validateCloneSolution(originalSolution, cloneSolution); - return cloneSolution; - } - - private static int decideNextExpectedObjectCount(int currentExpectedObjectCount, int currentObjectCount) { - // For cases where solutions of vastly different sizes are cloned in a row, - // we want to make sure the memory requirements don't grow too large, or the capacity too small. - var halfTheDifference = (int) Math.round(Math.abs(currentObjectCount - currentExpectedObjectCount) / 2.0); - if (currentObjectCount > currentExpectedObjectCount) { - // Guard against integer overflow. - return Math.min(currentExpectedObjectCount + halfTheDifference, Integer.MAX_VALUE); - } else if (currentObjectCount < currentExpectedObjectCount) { - // Don't go exceedingly low; cloning so few objects is always fast, re-growing the map back would be. - return Math.max(currentExpectedObjectCount - halfTheDifference, MINIMUM_EXPECTED_OBJECT_COUNT); - } else { - return currentExpectedObjectCount; - } - } - - /** - * Used by GIZMO when it encounters an undeclared entity class, such as when an abstract planning entity is extended. - */ - @SuppressWarnings("unused") - public Object gizmoFallbackDeepClone(Object originalValue, Map originalToCloneMap) { - if (originalValue == null) { - return null; - } - var unprocessedQueue = new ArrayDeque(expectedObjectCountRef.get()); - var fieldType = originalValue.getClass(); - return clone(originalValue, originalToCloneMap, unprocessedQueue, fieldType); - } - - private Object clone(Object originalValue, Map originalToCloneMap, Queue unprocessedQueue, - Class fieldType) { - if (originalValue instanceof Collection collection) { - return cloneCollection(fieldType, collection, originalToCloneMap, unprocessedQueue); - } else if (originalValue instanceof Map map) { - return cloneMap(fieldType, map, originalToCloneMap, unprocessedQueue); - } - var originalClass = originalValue.getClass(); - if (originalClass.isArray()) { - return cloneArray(fieldType, originalValue, originalToCloneMap, unprocessedQueue); - } else { - return clone(originalValue, originalToCloneMap, unprocessedQueue, retrieveClassMetadata(originalClass)); - } - } - - private Object process(Unprocessed unprocessed, Map originalToCloneMap, - Queue unprocessedQueue) { - var originalValue = unprocessed.originalValue; - var field = unprocessed.cloner.getFieldHandles().field(); - var fieldType = field.getType(); - return clone(originalValue, originalToCloneMap, unprocessedQueue, fieldType); - } - - @SuppressWarnings("unchecked") - private C clone(C original, Map originalToCloneMap, Queue unprocessedQueue, - ClassMetadata declaringClassMetadata) { - if (original == null) { - return null; - } - var existingClone = (C) originalToCloneMap.get(original); - if (existingClone != null) { - return existingClone; - } - - var declaringClass = (Class) original.getClass(); - var clone = FieldAccessingSolutionCloner. constructClone(declaringClassMetadata); - originalToCloneMap.put(original, clone); - copyFields(declaringClass, original, clone, unprocessedQueue, declaringClassMetadata); - return clone; - } - - @SuppressWarnings("unchecked") - private static C constructClone(ClassMetadata classMetadata) { - var constructor = classMetadata.getConstructor(); - try { - return (C) constructor.invoke(); - } catch (Throwable e) { - throw new IllegalStateException( - "Can not create a new instance of class (%s) for a planning clone, using its no-arg constructor." - .formatted(classMetadata.declaringClass.getCanonicalName()), - e); - } - } - - private void copyFields(Class clazz, C original, C clone, Queue unprocessedQueue, - ClassMetadata declaringClassMetadata) { - for (var fieldCloner : declaringClassMetadata.getCopiedFieldArray()) { - fieldCloner.clone(original, clone); - } - for (var fieldCloner : declaringClassMetadata.getClonedFieldArray()) { - var unprocessedValue = fieldCloner.clone(solutionDescriptor, original, clone); - if (unprocessedValue != null) { - unprocessedQueue.add(new Unprocessed(clone, fieldCloner, unprocessedValue)); - } - } - var superclass = clazz.getSuperclass(); - if (superclass != null && superclass != Object.class) { - copyFields(superclass, original, clone, unprocessedQueue, retrieveClassMetadata(superclass)); - } - } - - private Object cloneArray(Class expectedType, Object originalArray, Map originalToCloneMap, - Queue unprocessedQueue) { - var arrayLength = Array.getLength(originalArray); - if (arrayLength == 0) { - return originalArray; // No need to clone an empty array. - } - var cloneArray = Array.newInstance(originalArray.getClass().getComponentType(), arrayLength); - if (!expectedType.isInstance(cloneArray)) { - throw new IllegalStateException(""" - The cloneArrayClass (%s) created for originalArrayClass (%s) is not assignable to the field's type (%s). - Maybe consider replacing the default %s.""" - .formatted(cloneArray.getClass(), originalArray.getClass(), expectedType, - SolutionCloner.class.getSimpleName())); - } - var reuseHelper = new ClassMetadataReuseHelper(this::retrieveClassMetadata); - for (var i = 0; i < arrayLength; i++) { - var cloneElement = cloneCollectionsElementIfNeeded(Array.get(originalArray, i), originalToCloneMap, - unprocessedQueue, reuseHelper); - Array.set(cloneArray, i, cloneElement); - } - return cloneArray; - } - - @SuppressWarnings({ "unchecked", "rawtypes" }) - private Collection cloneCollection(Class expectedType, Collection originalCollection, - Map originalToCloneMap, Queue unprocessedQueue) { - if (originalCollection instanceof EnumSet enumSet) { - return EnumSet.copyOf(enumSet); - } - var cloneCollection = constructCloneCollection(originalCollection); - if (!expectedType.isInstance(cloneCollection)) { - throw new IllegalStateException( - """ - The cloneCollectionClass (%s) created for originalCollectionClass (%s) is not assignable to the field's type (%s). - Maybe consider replacing the default %s.""" - .formatted(cloneCollection.getClass(), originalCollection.getClass(), expectedType, - SolutionCloner.class.getSimpleName())); - } - if (originalCollection.isEmpty()) { - return cloneCollection; // No need to clone any elements. - } - var reuseHelper = new ClassMetadataReuseHelper(this::retrieveClassMetadata); - for (var originalElement : originalCollection) { - var cloneElement = - cloneCollectionsElementIfNeeded(originalElement, originalToCloneMap, unprocessedQueue, reuseHelper); - cloneCollection.add(cloneElement); - } - return cloneCollection; - } - - public static Collection constructCloneCollection(Collection originalCollection) { - // TODO Don't hardcode all standard collections - if (originalCollection instanceof LinkedList) { - return new LinkedList<>(); - } - var size = originalCollection.size(); - if (originalCollection instanceof Set) { - if (originalCollection instanceof SortedSet set) { - var setComparator = set.comparator(); - return new TreeSet<>(setComparator); - } else if (!(originalCollection instanceof LinkedHashSet)) { - // Set is explicitly not ordered, so we can use a HashSet. - // Can be replaced by checking for SequencedSet, but that is Java 21+. - return HashSet.newHashSet(size); - } else { // Default to a LinkedHashSet to respect order. - return LinkedHashSet.newLinkedHashSet(size); - } - } else if (originalCollection instanceof Deque) { - return new ArrayDeque<>(size); - } - // Default collection - return new ArrayList<>(size); - } - - @SuppressWarnings({ "unchecked", "rawtypes" }) - private Map cloneMap(Class expectedType, Map originalMap, Map originalToCloneMap, - Queue unprocessedQueue) { - if (originalMap instanceof EnumMap enumMap) { - var cloneMap = new EnumMap(enumMap); - if (cloneMap.isEmpty()) { - return (Map) cloneMap; - } - var reuseHelper = new ClassMetadataReuseHelper(this::retrieveClassMetadata); - for (var originalEntry : enumMap.entrySet()) { - var originalValue = originalEntry.getValue(); - var cloneValue = cloneCollectionsElementIfNeeded(originalValue, originalToCloneMap, unprocessedQueue, - reuseHelper); - if (originalValue != cloneValue) { // Already exists in the map. - cloneMap.put(originalEntry.getKey(), cloneValue); - } - } - return cloneMap; - } - var cloneMap = constructCloneMap(originalMap); - if (!expectedType.isInstance(cloneMap)) { - throw new IllegalStateException(""" - The cloneMapClass (%s) created for originalMapClass (%s) is not assignable to the field's type (%s). - Maybe consider replacing the default %s.""" - .formatted(cloneMap.getClass(), originalMap.getClass(), expectedType, - SolutionCloner.class.getSimpleName())); - } - if (originalMap.isEmpty()) { - return cloneMap; // No need to clone any entries. - } - var keyReuseHelper = new ClassMetadataReuseHelper(this::retrieveClassMetadata); - var valueReuseHelper = new ClassMetadataReuseHelper(this::retrieveClassMetadata); - for (var originalEntry : originalMap.entrySet()) { - var cloneKey = cloneCollectionsElementIfNeeded(originalEntry.getKey(), originalToCloneMap, unprocessedQueue, - keyReuseHelper); - var cloneValue = cloneCollectionsElementIfNeeded(originalEntry.getValue(), originalToCloneMap, unprocessedQueue, - valueReuseHelper); - cloneMap.put(cloneKey, cloneValue); - } - return cloneMap; - } - - public static Map constructCloneMap(Map originalMap) { - // Normally, a Map will never be selected for cloning, but extending implementations might anyway. - if (originalMap instanceof SortedMap map) { - var setComparator = map.comparator(); - return new TreeMap<>(setComparator); - } - var originalMapSize = originalMap.size(); - if (!(originalMap instanceof LinkedHashMap)) { - // Map is explicitly not ordered, so we can use a HashMap. - // Can be replaced by checking for SequencedMap, but that is Java 21+. - return HashMap.newHashMap(originalMapSize); - } else { // Default to a LinkedHashMap to respect order. - - return LinkedHashMap.newLinkedHashMap(originalMapSize); - } - } - - private ClassMetadata retrieveClassMetadata(Class declaringClass) { - return classMetadataMemoization.computeIfAbsent(declaringClass, classMetadataConstructor); - } - - @SuppressWarnings("unchecked") - private C cloneCollectionsElementIfNeeded(C original, Map originalToCloneMap, - Queue unprocessedQueue, ClassMetadataReuseHelper classMetadataReuseHelper) { - if (original == null) { - return null; - } - /* - * Because an element which is itself a Collection or Map might hold an entity, - * we clone it too. - * The List in Map> needs to be cloned if the List is a shadow, - * despite that Long never needs to be cloned (because it's immutable). - */ - if (original instanceof Collection collection) { - return (C) cloneCollection(Collection.class, collection, originalToCloneMap, unprocessedQueue); - } else if (original instanceof Map map) { - return (C) cloneMap(Map.class, map, originalToCloneMap, unprocessedQueue); - } else if (original.getClass().isArray()) { - return (C) cloneArray(original.getClass(), original, originalToCloneMap, unprocessedQueue); - } - var classMetadata = classMetadataReuseHelper.getClassMetadata(original); - if (classMetadata.isDeepCloned) { - return clone(original, originalToCloneMap, unprocessedQueue, classMetadata); - } else { - return original; - } - } - - /** - * Fails fast if {@link DeepCloningUtils#isFieldAnEntityPropertyOnSolution} assumptions were wrong. - * - * @param originalSolution never null - * @param cloneSolution never null - */ - private void validateCloneSolution(Solution_ originalSolution, Solution_ cloneSolution) { - for (var memberAccessor : solutionDescriptor.getEntityMemberAccessorMap().values()) { - validateCloneProperty(originalSolution, cloneSolution, memberAccessor); - } - for (var memberAccessor : solutionDescriptor.getEntityCollectionMemberAccessorMap().values()) { - validateCloneProperty(originalSolution, cloneSolution, memberAccessor); - } - } - - private static void validateCloneProperty(Solution_ originalSolution, Solution_ cloneSolution, - MemberAccessor memberAccessor) { - var originalProperty = memberAccessor.executeGetter(originalSolution); - if (originalProperty != null) { - var cloneProperty = memberAccessor.executeGetter(cloneSolution); - if (originalProperty == cloneProperty) { - throw new IllegalStateException(""" - The solutionProperty (%s) was not cloned as expected. - The %s failed to recognize that property's field, probably because its field name is different.""" - .formatted(memberAccessor.getName(), FieldAccessingSolutionCloner.class.getSimpleName())); - } - } - } - - private static final class ClassMetadata { - - private final SolutionDescriptor solutionDescriptor; - private final Class declaringClass; - private final boolean isDeepCloned; - - /** - * Contains the MethodHandle for the no-arg constructor of the class. - * Lazily initialized to avoid unnecessary reflection overhead when the constructor is not called. - */ - private volatile MethodHandle constructor = null; - /** - * Contains one cloner for every field that needs to be shallow cloned (= copied). - * Lazy initialized; some types (such as String) will never get here. - */ - private volatile ShallowCloningFieldCloner[] copiedFieldArray; - /** - * Contains one cloner for every field that needs to be deep-cloned. - * Lazy initialized; some types (such as String) will never get here. - */ - private volatile DeepCloningFieldCloner[] clonedFieldArray; - - public ClassMetadata(SolutionDescriptor solutionDescriptor, Class declaringClass) { - this.solutionDescriptor = solutionDescriptor; - this.declaringClass = declaringClass; - this.isDeepCloned = DeepCloningUtils.isClassDeepCloned(solutionDescriptor, declaringClass); - } - - public MethodHandle getConstructor() { - if (constructor == null) { - synchronized (this) { - if (constructor == null) { // Double-checked locking - try { - var ctor = declaringClass.getDeclaredConstructor(); - ctor.setAccessible(true); - constructor = MethodHandles.lookup() - .unreflectConstructor(ctor); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "To create a planning clone, the class (%s) must have a no-arg constructor." - .formatted(declaringClass.getCanonicalName()), - e); - } - } - } - } - return constructor; - } - - public ShallowCloningFieldCloner[] getCopiedFieldArray() { - if (copiedFieldArray == null) { - synchronized (this) { - if (copiedFieldArray == null) { // Double-checked locking - copiedFieldArray = Arrays.stream(declaringClass.getDeclaredFields()) - .filter(f -> !Modifier.isStatic(f.getModifiers())) - .filter(field -> DeepCloningUtils.isImmutable(field.getType())) - .peek(f -> { - if (DeepCloningUtils.needsDeepClone(solutionDescriptor, f, declaringClass)) { - throw new IllegalStateException(""" - The field (%s) of class (%s) needs to be deep-cloned, - but its type (%s) is immutable and can not be deep-cloned. - Maybe remove the @%s annotation from the field? - Maybe do not reference planning entities inside Java records?""" - .formatted(f.getName(), declaringClass.getCanonicalName(), - f.getType().getCanonicalName(), - DeepPlanningClone.class.getSimpleName())); - } - }) - .map(ShallowCloningFieldCloner::of) - .toArray(ShallowCloningFieldCloner[]::new); - } - } - } - return copiedFieldArray; - } - - public DeepCloningFieldCloner[] getClonedFieldArray() { - if (clonedFieldArray == null) { - synchronized (this) { - if (clonedFieldArray == null) { // Double-checked locking - clonedFieldArray = Arrays.stream(declaringClass.getDeclaredFields()) - .filter(f -> !Modifier.isStatic(f.getModifiers())) - .filter(field -> !DeepCloningUtils.isImmutable(field.getType())) - .map(DeepCloningFieldCloner::new) - .toArray(DeepCloningFieldCloner[]::new); - } - } - } - return clonedFieldArray; - } - - } - - private record Unprocessed(Object bean, DeepCloningFieldCloner cloner, Object originalValue) { - } - - /** - * Helper class to reuse ClassMetadata for the same class. - * Use when cloning multiple objects of the same class, - * such as when cloning a collection of objects, - * where it's likely that they will be of the same class. - * This is useful for performance, - * as it avoids repeated calls to the function that retrieves {@link ClassMetadata}. - */ - private static final class ClassMetadataReuseHelper { - - private final Function, ClassMetadata> classMetadataFunction; - private Object previousClass; - private ClassMetadata previousClassMetadata; - - public ClassMetadataReuseHelper(Function, ClassMetadata> classMetadataFunction) { - this.classMetadataFunction = classMetadataFunction; - } - - public @NonNull ClassMetadata getClassMetadata(@NonNull Object object) { - var clazz = object.getClass(); - if (clazz != previousClass) { - // Class of the element has changed, so we need to retrieve the metadata again. - previousClass = clazz; - previousClassMetadata = classMetadataFunction.apply(clazz); - } - return previousClassMetadata; - } - - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldCloningUtils.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldCloningUtils.java deleted file mode 100644 index 34b6c9296a9..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldCloningUtils.java +++ /dev/null @@ -1,208 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.reflect.Field; - -import ai.timefold.solver.core.impl.domain.common.accessor.FieldHandle; - -final class FieldCloningUtils { - - static void copyBoolean(Field field, Object original, Object clone) { - boolean originalValue = getBooleanFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static boolean getBooleanFieldValue(Object bean, Field field) { - try { - return field.getBoolean(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, boolean value) { - try { - field.setBoolean(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyByte(Field field, Object original, Object clone) { - byte originalValue = getByteFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static byte getByteFieldValue(Object bean, Field field) { - try { - return field.getByte(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, byte value) { - try { - field.setByte(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyChar(Field field, Object original, Object clone) { - char originalValue = getCharFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static char getCharFieldValue(Object bean, Field field) { - try { - return field.getChar(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, char value) { - try { - field.setChar(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyShort(Field field, Object original, Object clone) { - short originalValue = getShortFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static short getShortFieldValue(Object bean, Field field) { - try { - return field.getShort(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, short value) { - try { - field.setShort(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyInt(Field field, Object original, Object clone) { - int originalValue = getIntFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static int getIntFieldValue(Object bean, Field field) { - try { - return field.getInt(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, int value) { - try { - field.setInt(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyLong(Field field, Object original, Object clone) { - long originalValue = getLongFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static long getLongFieldValue(Object bean, Field field) { - try { - return field.getLong(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, long value) { - try { - field.setLong(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyFloat(Field field, Object original, Object clone) { - float originalValue = getFloatFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static float getFloatFieldValue(Object bean, Field field) { - try { - return field.getFloat(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, float value) { - try { - field.setFloat(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyDouble(Field field, Object original, Object clone) { - double originalValue = getDoubleFieldValue(original, field); - setFieldValue(clone, field, originalValue); - } - - private static double getDoubleFieldValue(Object bean, Field field) { - try { - return field.getDouble(bean); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnRead(bean, field, e); - } - } - - private static void setFieldValue(Object bean, Field field, double value) { - try { - field.setDouble(bean, value); - } catch (IllegalAccessException e) { - throw FieldCloningUtils.createExceptionOnWrite(bean, field, value, e); - } - } - - static void copyObject(FieldHandle handles, Object original, Object clone) { - Object originalValue = FieldCloningUtils.getObjectFieldValue(original, handles); - FieldCloningUtils.setObjectFieldValue(clone, handles, originalValue); - } - - static Object getObjectFieldValue(Object bean, FieldHandle handle) { - return handle.get(bean); - } - - private static RuntimeException createExceptionOnRead(Object bean, Field field, Throwable rootCause) { - return new IllegalStateException("The class (" + bean.getClass() + ") has a field (" + field - + ") which cannot be read to create a planning clone.", rootCause); - } - - static void setObjectFieldValue(Object bean, FieldHandle handle, Object value) { - try { - handle.set(bean, value); - } catch (Throwable e) { - throw createExceptionOnWrite(bean, handle.field(), value, e); - } - } - - private static RuntimeException createExceptionOnWrite(Object bean, Field field, Object value, Throwable rootCause) { - return new IllegalStateException("The class (" + bean.getClass() + ") has a field (" + field - + ") which cannot be written with the value (" + value + ") to create a planning clone.", rootCause); - } - - private FieldCloningUtils() { - // No external instances. - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionCloner.java new file mode 100644 index 00000000000..6b2ef657afb --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionCloner.java @@ -0,0 +1,573 @@ +package ai.timefold.solver.core.impl.domain.solution.cloner; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.SortedMap; +import java.util.SortedSet; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone; +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.CloneableClassDescriptor; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.DeepCloneDecision; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.PropertyCopyDescriptor; + +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link SolutionCloner} that uses pre-built lambda accessors from a {@link CloningSpecification} + * to clone solutions without runtime reflection or {@code setAccessible}. + *

+ * Uses a queue-based algorithm to handle + * circular references and deferred deep-cloning. + * + * @param the solution type + */ +public final class LambdaBasedSolutionCloner implements SolutionCloner { + + private static final Logger LOGGER = LoggerFactory.getLogger(LambdaBasedSolutionCloner.class); + private static final int MINIMUM_EXPECTED_OBJECT_COUNT = 1_000; + + private final CloningSpecification cloningSpec; + private final Map, CloneableClassDescriptor> runtimeDescriptorCache = new ConcurrentHashMap<>(); + private final AtomicInteger expectedObjectCountRef = new AtomicInteger(MINIMUM_EXPECTED_OBJECT_COUNT); + + public LambdaBasedSolutionCloner(CloningSpecification cloningSpec) { + this.cloningSpec = cloningSpec; + } + + @Override + @SuppressWarnings("unchecked") + public @NonNull S cloneSolution(@NonNull S original) { + var expectedObjectCount = expectedObjectCountRef.get(); + var cloneMap = new IdentityHashMap(expectedObjectCount); + var queue = new ArrayDeque(expectedObjectCount); + + // 1. Clone solution via factory + var factory = cloningSpec.solutionFactory(); + S solutionClone; + List solutionProperties; + if (factory != null) { + solutionClone = factory.get(); + solutionProperties = cloningSpec.solutionProperties(); + } else { + // Interface or abstract solution class: use the runtime class to create the clone + // and build properties from the concrete class + solutionClone = createInstanceFromRuntimeClass(original); + solutionProperties = buildRuntimeProperties(original.getClass()); + } + cloneMap.put(original, solutionClone); + + // 2. Process all solution properties + for (var prop : solutionProperties) { + processCopy(original, solutionClone, prop, cloneMap, queue); + } + + // 3. Drain queue: process deferred work + drainQueue(queue, cloneMap); + + expectedObjectCountRef.updateAndGet(old -> decideNextExpectedObjectCount(old, cloneMap.size())); + return solutionClone; + } + + private void drainQueue(Queue queue, IdentityHashMap cloneMap) { + while (!queue.isEmpty()) { + var deferred = queue.poll(); + switch (deferred) { + case DeferredValueClone d -> { + var cloneValue = deepCloneValue(d.originalValue, cloneMap, queue); + d.setter.accept(d.bean, cloneValue); + } + case DeferredSingleProperty d -> + processCopy(d.original, d.clonedObject, d.prop, cloneMap, queue); + } + } + } + + private static int decideNextExpectedObjectCount(int currentExpected, int actual) { + var halfDiff = (int) Math.round(Math.abs(actual - currentExpected) / 2.0); + if (actual > currentExpected) { + return Math.min(currentExpected + halfDiff, Integer.MAX_VALUE); + } else if (actual < currentExpected) { + return Math.max(currentExpected - halfDiff, MINIMUM_EXPECTED_OBJECT_COUNT); + } + return currentExpected; + } + + private void processCopy(Object original, Object clone, PropertyCopyDescriptor prop, + IdentityHashMap cloneMap, Queue queue) { + // Clone-time validation (deferred from spec-build time) + if (prop.cloneTimeValidationMessage() != null) { + throw new IllegalStateException(prop.cloneTimeValidationMessage()); + } + var value = prop.getter().apply(original); + if (value == null) { + prop.setter().accept(clone, null); + return; + } + switch (prop.deepCloneDecision()) { + case SHALLOW -> prop.setter().accept(clone, value); + case RESOLVE_ENTITY_REFERENCE -> { + var resolved = cloneMap.get(value); + if (resolved != null) { + prop.setter().accept(clone, resolved); + } else if (isDeepCloneable(value.getClass())) { + // Entity/deep-clone type not yet cloned — defer + queue.add(new DeferredValueClone(clone, prop.setter(), value)); + } else { + // Not a cloneable type — shallow copy + prop.setter().accept(clone, value); + } + } + case SHALLOW_OR_DEEP_BY_RUNTIME_TYPE -> { + // Check the VALUE's actual class at runtime (not the field's declared type). + // This handles subclasses annotated with @DeepPlanningClone. + if (isDeepCloneable(value.getClass())) { + var resolved = cloneMap.get(value); + if (resolved != null) { + prop.setter().accept(clone, resolved); + } else { + queue.add(new DeferredValueClone(clone, prop.setter(), value)); + } + } else { + prop.setter().accept(clone, value); + } + } + case ALWAYS_DEEP -> queue.add(new DeferredValueClone(clone, prop.setter(), value)); + case DEEP_COLLECTION -> { + var clonedCollection = cloneCollection(value, cloneMap, queue); + prop.setter().accept(clone, clonedCollection); + } + case DEEP_MAP -> { + var clonedMap = cloneMap(value, cloneMap, queue); + prop.setter().accept(clone, clonedMap); + } + case DEEP_ARRAY -> { + var clonedArray = cloneArray(value, cloneMap, queue); + prop.setter().accept(clone, clonedArray); + } + } + } + + @SuppressWarnings("unchecked") + private Object deepCloneValue(Object originalValue, IdentityHashMap cloneMap, + Queue queue) { + if (originalValue == null) { + return null; + } + // Already cloned? + var existing = cloneMap.get(originalValue); + if (existing != null) { + return existing; + } + // Collection/Map/Array + if (originalValue instanceof Collection) { + return cloneCollection(originalValue, cloneMap, queue); + } else if (originalValue instanceof Map) { + return cloneMap(originalValue, cloneMap, queue); + } else if (originalValue.getClass().isArray()) { + return cloneArray(originalValue, cloneMap, queue); + } + // Object: look up descriptor + return deepCloneObject(originalValue, cloneMap, queue); + } + + private Object deepCloneObject(Object original, IdentityHashMap cloneMap, + Queue queue) { + var existing = cloneMap.get(original); + if (existing != null) { + return existing; + } + var descriptor = findDescriptor(original.getClass()); + if (descriptor == null) { + // Unknown type — return as-is (e.g., a problem fact not annotated with @DeepPlanningClone) + return original; + } + var factory = descriptor.factory(); + var clone = factory != null ? factory.get() : createInstanceFromRuntimeClass(original); + cloneMap.put(original, clone); + + for (var prop : descriptor.properties()) { + processCopy(original, clone, prop, cloneMap, queue); + } + return clone; + } + + private CloneableClassDescriptor findDescriptor(Class clazz) { + var descriptor = cloningSpec.cloneableClasses().get(clazz); + if (descriptor != null) { + return descriptor; + } + // Check runtime cache (for dynamically built descriptors) + descriptor = runtimeDescriptorCache.get(clazz); + if (descriptor != null) { + return descriptor; + } + // Walk superclass chain for entity subclasses + var current = clazz.getSuperclass(); + while (current != null && current != Object.class) { + descriptor = cloningSpec.cloneableClasses().get(current); + if (descriptor != null) { + LOGGER.debug("Found cloneable class descriptor for {} via superclass {}.", + clazz.getSimpleName(), current.getSimpleName()); + return descriptor; + } + current = current.getSuperclass(); + } + // Check implemented interfaces (for interface-based entity declarations) + for (var iface : clazz.getInterfaces()) { + descriptor = cloningSpec.cloneableClasses().get(iface); + if (descriptor != null) { + // Interface found — build a runtime descriptor for the concrete class + // since the interface descriptor has no fields + descriptor = buildRuntimeDescriptor(clazz); + runtimeDescriptorCache.put(clazz, descriptor); + return descriptor; + } + } + return null; + } + + private boolean isDeepCloneable(Class clazz) { + if (cloningSpec.entityClasses().contains(clazz) || cloningSpec.deepCloneClasses().contains(clazz)) { + return true; + } + // Check @DeepPlanningClone annotation directly (for runtime subclass types not discovered at build time) + if (clazz.isAnnotationPresent(DeepPlanningClone.class)) { + return true; + } + // Check superclass chain + var current = clazz.getSuperclass(); + while (current != null && current != Object.class) { + if (cloningSpec.entityClasses().contains(current) || cloningSpec.deepCloneClasses().contains(current)) { + return true; + } + current = current.getSuperclass(); + } + // Check implemented interfaces + for (var iface : clazz.getInterfaces()) { + if (cloningSpec.entityClasses().contains(iface) || cloningSpec.deepCloneClasses().contains(iface)) { + return true; + } + } + return false; + } + + @SuppressWarnings("unchecked") + private static T createInstanceFromRuntimeClass(T original) { + try { + var ctor = original.getClass().getDeclaredConstructor(); + ctor.setAccessible(true); + return (T) ctor.newInstance(); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create a planning clone of %s. The class must have a no-arg constructor." + .formatted(original.getClass().getCanonicalName()), + e); + } + } + + /** + * Builds a {@link CloneableClassDescriptor} for a concrete runtime class not known at spec-build time. + * This handles interface/abstract entity classes whose concrete implementations are discovered at clone time. + */ + private CloneableClassDescriptor buildRuntimeDescriptor(Class clazz) { + var properties = buildRuntimeProperties(clazz); + return new CloneableClassDescriptor(clazz, + () -> { + try { + var ctor = clazz.getDeclaredConstructor(); + ctor.setAccessible(true); + return ctor.newInstance(); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create instance of %s.".formatted(clazz.getCanonicalName()), e); + } + }, + properties); + } + + /** + * Builds property copy descriptors for a class using reflection. + * Used at runtime for classes not known at spec-build time (interface implementations). + */ + private List buildRuntimeProperties(Class clazz) { + var properties = new ArrayList(); + for (var current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + for (var field : current.getDeclaredFields()) { + var modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers)) { + continue; + } + field.setAccessible(true); + Function getter = bean -> { + try { + return field.get(bean); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + }; + BiConsumer setter; + if (Modifier.isFinal(modifiers)) { + setter = (bean, value) -> { + throw new IllegalStateException( + "Cannot set final field %s on %s.".formatted(field.getName(), clazz.getSimpleName())); + }; + } else { + setter = (bean, value) -> { + try { + field.set(bean, value); + } catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + }; + } + var decision = classifyFieldAtRuntime(field); + properties.add(new PropertyCopyDescriptor(field.getName(), getter, setter, decision, null)); + } + } + return List.copyOf(properties); + } + + /** + * Classifies a field for deep clone decision at runtime. + */ + private DeepCloneDecision classifyFieldAtRuntime(Field field) { + var fieldType = field.getType(); + if (DeepCloningUtils.isImmutable(fieldType)) { + return DeepCloneDecision.SHALLOW; + } + if (field.isAnnotationPresent(DeepPlanningClone.class)) { + if (Collection.class.isAssignableFrom(fieldType)) { + return DeepCloneDecision.DEEP_COLLECTION; + } else if (Map.class.isAssignableFrom(fieldType)) { + return DeepCloneDecision.DEEP_MAP; + } else if (fieldType.isArray()) { + return DeepCloneDecision.DEEP_ARRAY; + } + return DeepCloneDecision.ALWAYS_DEEP; + } + if (isDeepCloneable(fieldType)) { + return DeepCloneDecision.RESOLVE_ENTITY_REFERENCE; + } + if (Collection.class.isAssignableFrom(fieldType) || Map.class.isAssignableFrom(fieldType)) { + // Conservatively deep-clone collections/maps — they might contain entities + return Collection.class.isAssignableFrom(fieldType) + ? DeepCloneDecision.DEEP_COLLECTION + : DeepCloneDecision.DEEP_MAP; + } + if (fieldType.isArray() && !fieldType.getComponentType().isPrimitive()) { + return DeepCloneDecision.DEEP_ARRAY; + } + return DeepCloneDecision.SHALLOW; + } + + // ************************************************************************ + // Collection/Map/Array cloning + // ************************************************************************ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private Object cloneCollection(Object originalValue, IdentityHashMap cloneMap, + Queue queue) { + var originalCollection = (Collection) originalValue; + if (originalCollection instanceof EnumSet enumSet) { + return EnumSet.copyOf(enumSet); + } + var cloneCollection = constructCloneCollection(originalCollection); + if (originalCollection.isEmpty()) { + return cloneCollection; + } + for (var element : originalCollection) { + cloneCollection.add(resolveElement(element, cloneMap, queue)); + } + return cloneCollection; + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private Object cloneMap(Object originalValue, IdentityHashMap cloneMap, + Queue queue) { + var originalMap = (Map) originalValue; + if (originalMap instanceof EnumMap enumMap) { + var cloneEnumMap = new EnumMap(enumMap); + if (!cloneEnumMap.isEmpty()) { + for (var entry : ((Map) cloneEnumMap).entrySet()) { + entry.setValue(resolveElement(entry.getValue(), cloneMap, queue)); + } + } + return cloneEnumMap; + } + var cloneMapInstance = constructCloneMap(originalMap); + if (originalMap.isEmpty()) { + return cloneMapInstance; + } + for (var entry : originalMap.entrySet()) { + cloneMapInstance.put( + resolveElement(entry.getKey(), cloneMap, queue), + resolveElement(entry.getValue(), cloneMap, queue)); + } + return cloneMapInstance; + } + + private Object cloneArray(Object originalArray, IdentityHashMap cloneMap, + Queue queue) { + var length = Array.getLength(originalArray); + if (length == 0) { + return originalArray; + } + var cloneArray = Array.newInstance(originalArray.getClass().getComponentType(), length); + for (var i = 0; i < length; i++) { + Array.set(cloneArray, i, resolveElement(Array.get(originalArray, i), cloneMap, queue)); + } + return cloneArray; + } + + /** + * Resolves an element within a collection/map/array. + *

+ * For deep-cloneable entities, creates the clone eagerly (for the collection reference) + * but defers property processing to the queue (to avoid unbounded recursion). + */ + @SuppressWarnings("unchecked") + private Object resolveElement(Object element, IdentityHashMap cloneMap, + Queue queue) { + if (element == null) { + return null; + } + // Nested collection/map/array + if (element instanceof Collection) { + return cloneCollection(element, cloneMap, queue); + } else if (element instanceof Map) { + return cloneMap(element, cloneMap, queue); + } else if (element.getClass().isArray()) { + return cloneArray(element, cloneMap, queue); + } + // Already cloned? + var existing = cloneMap.get(element); + if (existing != null) { + return existing; + } + // Deep-cloneable? Create clone eagerly but defer property processing + if (isDeepCloneable(element.getClass())) { + return registerAndDeferProperties(element, cloneMap, queue); + } + return element; + } + + /** + * Creates a clone of an object and registers it in the cloneMap. + * Shallow fields are copied immediately (so comparators in TreeSets work), + * but deep-clone fields are deferred to the queue to avoid unbounded recursion. + */ + private Object registerAndDeferProperties(Object original, IdentityHashMap cloneMap, + Queue queue) { + var descriptor = findDescriptor(original.getClass()); + if (descriptor == null) { + return original; + } + var factory = descriptor.factory(); + var clone = factory != null ? factory.get() : createInstanceFromRuntimeClass(original); + cloneMap.put(original, clone); + for (var prop : descriptor.properties()) { + if (prop.deepCloneDecision() == CloningSpecification.DeepCloneDecision.SHALLOW) { + // Copy shallow fields immediately (needed for TreeSet comparators, etc.) + var value = prop.getter().apply(original); + prop.setter().accept(clone, value); + } else { + // Defer deep-clone properties to the queue + queue.add(new DeferredSingleProperty(original, clone, prop)); + } + } + return clone; + } + + // ************************************************************************ + // Deferred work types + // ************************************************************************ + + private sealed interface Deferred permits DeferredValueClone, DeferredSingleProperty { + } + + /** + * Deep-clone a value and set it on a bean. + */ + private record DeferredValueClone( + Object bean, + BiConsumer setter, + Object originalValue) implements Deferred { + } + + /** + * Process a single property copy from an original object to its clone. + * Used when entity cloning is deferred from within collection resolution + * (shallow fields are already copied; only deep-clone fields are deferred). + */ + private record DeferredSingleProperty( + Object original, + Object clonedObject, + PropertyCopyDescriptor prop) implements Deferred { + } + + // ************************************************************************ + // Collection/Map construction helpers + // ************************************************************************ + + @SuppressWarnings("unchecked") + static Collection constructCloneCollection(Collection originalCollection) { + if (originalCollection instanceof LinkedList) { + return new LinkedList<>(); + } + var size = originalCollection.size(); + if (originalCollection instanceof Set) { + if (originalCollection instanceof SortedSet set) { + var setComparator = set.comparator(); + return new TreeSet<>(setComparator); + } else if (!(originalCollection instanceof LinkedHashSet)) { + return HashSet.newHashSet(size); + } else { + return LinkedHashSet.newLinkedHashSet(size); + } + } else if (originalCollection instanceof Deque) { + return new ArrayDeque<>(size); + } + return new ArrayList<>(size); + } + + @SuppressWarnings("unchecked") + static Map constructCloneMap(Map originalMap) { + if (originalMap instanceof SortedMap map) { + var setComparator = map.comparator(); + return new TreeMap<>(setComparator); + } + var originalMapSize = originalMap.size(); + if (!(originalMap instanceof LinkedHashMap)) { + return HashMap.newHashMap(originalMapSize); + } else { + return LinkedHashMap.newLinkedHashMap(originalMapSize); + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningFieldCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningFieldCloner.java deleted file mode 100644 index 055509d4312..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningFieldCloner.java +++ /dev/null @@ -1,34 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.reflect.Field; - -sealed interface ShallowCloningFieldCloner - permits ShallowCloningPrimitiveFieldCloner, ShallowCloningReferenceFieldCloner { - - static ShallowCloningFieldCloner of(Field field) { - Class fieldType = field.getType(); - if (fieldType == boolean.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyBoolean); - } else if (fieldType == byte.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyByte); - } else if (fieldType == char.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyChar); - } else if (fieldType == short.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyShort); - } else if (fieldType == int.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyInt); - } else if (fieldType == long.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyLong); - } else if (fieldType == float.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyFloat); - } else if (fieldType == double.class) { - return new ShallowCloningPrimitiveFieldCloner(field, FieldCloningUtils::copyDouble); - } else { - return new ShallowCloningReferenceFieldCloner(field, FieldCloningUtils::copyObject); - } - - } - - void clone(C original, C clone); - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningPrimitiveFieldCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningPrimitiveFieldCloner.java deleted file mode 100644 index 25d887ce64d..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningPrimitiveFieldCloner.java +++ /dev/null @@ -1,28 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.reflect.Field; -import java.util.Objects; - -import ai.timefold.solver.core.api.function.TriConsumer; - -/** - * Copies values of primitive fields from one object to another. - * Unlike {@link ShallowCloningReferenceFieldCloner}, this does not use method handles, - * as we prefer to avoid boxing and unboxing of primitive types. - */ -final class ShallowCloningPrimitiveFieldCloner implements ShallowCloningFieldCloner { - - private final Field field; - private final TriConsumer copyOperation; - - ShallowCloningPrimitiveFieldCloner(Field field, TriConsumer copyOperation) { - field.setAccessible(true); - this.field = Objects.requireNonNull(field); - this.copyOperation = Objects.requireNonNull(copyOperation); - } - - public void clone(C original, C clone) { - copyOperation.accept(field, original, clone); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningReferenceFieldCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningReferenceFieldCloner.java deleted file mode 100644 index ee486a7c218..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/ShallowCloningReferenceFieldCloner.java +++ /dev/null @@ -1,28 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import java.lang.reflect.Field; -import java.util.Objects; - -import ai.timefold.solver.core.api.function.TriConsumer; -import ai.timefold.solver.core.impl.domain.common.accessor.FieldHandle; - -/** - * Copies the reference value from a field of one object to another. - * Unlike {@link ShallowCloningPrimitiveFieldCloner}, - * this uses method handles for improved performance of field access. - */ -final class ShallowCloningReferenceFieldCloner implements ShallowCloningFieldCloner { - - private final FieldHandle fieldHandle; - private final TriConsumer copyOperation; - - ShallowCloningReferenceFieldCloner(Field field, TriConsumer copyOperation) { - this.fieldHandle = FieldHandle.of(field); - this.copyOperation = Objects.requireNonNull(copyOperation); - } - - public void clone(C original, C clone) { - copyOperation.accept(fieldHandle, original, clone); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtils.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtils.java deleted file mode 100644 index c6bf0a2db50..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtils.java +++ /dev/null @@ -1,99 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import java.lang.reflect.Field; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import ai.timefold.solver.core.impl.domain.solution.cloner.DeepCloningUtils; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -public final class GizmoCloningUtils { - public static Set> getDeepClonedClasses(SolutionDescriptor solutionDescriptor, - Collection> entitySubclasses) { - Set> deepClonedClassSet = new HashSet<>(); - Set> classesToProcess = new LinkedHashSet<>(solutionDescriptor.getEntityClassSet()); - classesToProcess.add(solutionDescriptor.getSolutionClass()); - classesToProcess.addAll(entitySubclasses); - - // deepClonedClassSet contains all processed classes so far, - // so we can use it to determine if we need to add a class - // to the classesToProcess set. - // - // This is important, since SolverConfig does not contain - // info on @DeepPlanningCloned classes, so we need to discover - // them from the domain classes, possibly recursively - // (when a @DeepPlanningCloned class reference another @DeepPlanningCloned - // that is not referenced by any entity). - while (!classesToProcess.isEmpty()) { - var clazz = classesToProcess.iterator().next(); - classesToProcess.remove(clazz); - deepClonedClassSet.add(clazz); - for (Field field : getAllFields(clazz)) { - var deepClonedTypeArguments = getDeepClonedTypeArguments(solutionDescriptor, field.getGenericType()); - for (var deepClonedTypeArgument : deepClonedTypeArguments) { - if (!deepClonedClassSet.contains(deepClonedTypeArgument)) { - classesToProcess.add(deepClonedTypeArgument); - deepClonedClassSet.add(deepClonedTypeArgument); - } - } - - // Ignore Collections and Maps, as there is collection/map/clonable logic to clone them. - if (DeepCloningUtils.isFieldDeepCloned(solutionDescriptor, field, clazz) - && !Collection.class.isAssignableFrom(field.getType()) - && !Map.class.isAssignableFrom(field.getType()) - && !deepClonedClassSet.contains(field.getType())) { - classesToProcess.add(field.getType()); - deepClonedClassSet.add(field.getType()); - } - } - } - return deepClonedClassSet; - } - - /** - * @return never null - */ - private static Set> getDeepClonedTypeArguments(SolutionDescriptor solutionDescriptor, Type genericType) { - // Check the generic type arguments of the field. - // It is possible for fields and methods, but not instances. - if (!(genericType instanceof ParameterizedType)) { - return Collections.emptySet(); - } - - Set> deepClonedTypeArguments = new HashSet<>(); - ParameterizedType parameterizedType = (ParameterizedType) genericType; - for (Type actualTypeArgument : parameterizedType.getActualTypeArguments()) { - if (actualTypeArgument instanceof Class class1 - && DeepCloningUtils.isClassDeepCloned(solutionDescriptor, class1)) { - deepClonedTypeArguments.add(class1); - } - deepClonedTypeArguments.addAll(getDeepClonedTypeArguments(solutionDescriptor, actualTypeArgument)); - } - return deepClonedTypeArguments; - } - - private static List getAllFields(Class baseClass) { - Class clazz = baseClass; - Stream memberStream = Stream.empty(); - while (clazz != null) { - Stream fieldStream = Stream.of(clazz.getDeclaredFields()); - memberStream = Stream.concat(memberStream, fieldStream); - clazz = clazz.getSuperclass(); - } - return memberStream.collect(Collectors.toList()); - } - - private GizmoCloningUtils() { - // No external instances. - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionCloner.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionCloner.java deleted file mode 100644 index 6a115ecf775..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionCloner.java +++ /dev/null @@ -1,8 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -public interface GizmoSolutionCloner extends SolutionCloner { - void setSolutionDescriptor(SolutionDescriptor descriptor); -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerClassOutput.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerClassOutput.java deleted file mode 100644 index 4d8c04380c8..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerClassOutput.java +++ /dev/null @@ -1,32 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Map; - -import io.quarkus.gizmo2.ClassOutput; - -record GizmoSolutionClonerClassOutput(Map classNameToBytecode) implements ClassOutput { - private static final String CLASS_FILE_SUFFIX = ".class"; - - @Override - public void write(String classFileLocation, byte[] byteCode) { - var className = - classFileLocation.substring(0, classFileLocation.length() - CLASS_FILE_SUFFIX.length()).replace('/', '.'); - classNameToBytecode.put(className, byteCode); - if (GizmoSolutionClonerImplementor.DEBUG) { - Path debugRoot = Paths.get("target/timefold-solver-generated-classes"); - Path rest = Paths.get(classFileLocation); - Path destination = debugRoot.resolve(rest); - - try { - Files.createDirectories(destination.getParent()); - Files.write(destination, byteCode); - } catch (IOException e) { - throw new IllegalStateException("Fail to write debug class file (%s).".formatted(destination), e); - } - } - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerFactory.java deleted file mode 100644 index ec6a1ca8718..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerFactory.java +++ /dev/null @@ -1,31 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoClassLoader; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -public final class GizmoSolutionClonerFactory { - /** - * Returns the generated class name for a given solutionDescriptor. - * (Here as accessing any method of GizmoMemberAccessorImplementor - * will try to load Gizmo code) - * - * @param solutionDescriptor The solutionDescriptor to get the generated class name for - * @return The generated class name for solutionDescriptor - */ - public static String getGeneratedClassName(SolutionDescriptor solutionDescriptor) { - return solutionDescriptor.getSolutionClass().getName() + "$Timefold$SolutionCloner"; - } - - public static SolutionCloner build(SolutionDescriptor solutionDescriptor, GizmoClassLoader gizmoClassLoader) { - return new GizmoSolutionClonerImplementor().createClonerFor(solutionDescriptor, - gizmoClassLoader); - } - - // ************************************************************************ - // Private constructor - // ************************************************************************ - - private GizmoSolutionClonerFactory() { - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java deleted file mode 100644 index e01c81142fb..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerImplementor.java +++ /dev/null @@ -1,1110 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import java.lang.constant.ClassDesc; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Modifier; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.lang.reflect.WildcardType; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.IdentityHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.SortedSet; -import java.util.TreeSet; -import java.util.function.BiConsumer; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoClassLoader; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberDescriptor; -import ai.timefold.solver.core.impl.domain.solution.cloner.DeepCloningUtils; -import ai.timefold.solver.core.impl.domain.solution.cloner.FieldAccessingSolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -import io.quarkus.gizmo2.ClassOutput; -import io.quarkus.gizmo2.Const; -import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.LocalVar; -import io.quarkus.gizmo2.StaticFieldVar; -import io.quarkus.gizmo2.Var; -import io.quarkus.gizmo2.creator.BlockCreator; -import io.quarkus.gizmo2.creator.ClassCreator; -import io.quarkus.gizmo2.desc.ClassMethodDesc; -import io.quarkus.gizmo2.desc.ConstructorDesc; -import io.quarkus.gizmo2.desc.MethodDesc; - -public class GizmoSolutionClonerImplementor { - private static final String FALLBACK_CLONER = "fallbackCloner"; - public static final boolean DEBUG = false; - - /** - * Return a comparator that sorts classes into instanceof check order. - * In particular, if x is a subclass of y, then x will appear earlier - * than y in the list. - * - * @param deepClonedClassSet The set of classes to generate a comparator for - * @return A comparator that sorts classes from deepClonedClassSet such that - * x < y if x is assignable from y. - */ - public static Comparator> getInstanceOfComparator(Set> deepClonedClassSet) { - var classToSubclassLevel = new HashMap, Integer>(); - deepClonedClassSet - .forEach(clazz -> { - if (deepClonedClassSet.stream() - .allMatch( - otherClazz -> clazz.isAssignableFrom(otherClazz) || !otherClazz.isAssignableFrom(clazz))) { - classToSubclassLevel.put(clazz, 0); - } - }); - var isChanged = true; - while (isChanged) { - // Need to iterate over all classes - // since maxSubclassLevel can change - // (for instance, Tiger extends Cat (1) implements Animal (0)) - isChanged = false; - for (Class clazz : deepClonedClassSet) { - isChanged |= classToSubclassLevel.keySet().stream() - .filter(otherClazz -> otherClazz != clazz && otherClazz.isAssignableFrom(clazz)) - .map(classToSubclassLevel::get) - .max(Integer::compare) - .map(subclassLevel -> { - var oldVal = (int) classToSubclassLevel.getOrDefault(clazz, -1); - var newVal = subclassLevel + 1; - if (newVal > oldVal) { - classToSubclassLevel.put(clazz, newVal); - return true; - } - return false; - }).orElse(false); - } - } - - return Comparator., Integer> comparing(classToSubclassLevel::get) - .thenComparing(Class::getName).reversed(); - } - - protected ClonerDescriptor withFallbackClonerField(ClonerDescriptor clonerDescriptor) { - return clonerDescriptor.withFallbackClonerField(clonerDescriptor.classCreator.staticField(FALLBACK_CLONER, field -> { - field.private_(); - field.setType(FieldAccessingSolutionCloner.class); - })); - } - - /** - * Generates the constructor and implementations of SolutionCloner methods for the given SolutionDescriptor using the given - * ClassCreator - */ - public static void defineClonerFor(ClassCreator classCreator, - SolutionDescriptor solutionDescriptor, - Set> solutionClassSet, - Map, GizmoSolutionOrEntityDescriptor> memoizedSolutionOrEntityDescriptorMap, - Set> deepClonedClassSet) { - defineClonerFor(GizmoSolutionClonerImplementor::new, classCreator, solutionDescriptor, solutionClassSet, - memoizedSolutionOrEntityDescriptorMap, deepClonedClassSet); - } - - public static boolean isCloneableClass(Class clazz) { - return !clazz.isInterface() && !Modifier.isAbstract(clazz.getModifiers()); - } - - /** - * Generates the constructor and implementations of SolutionCloner - * methods for the given SolutionDescriptor using the given ClassCreator - */ - public static void defineClonerFor(Supplier implementorSupplier, - ClassCreator classCreator, - SolutionDescriptor solutionDescriptor, - Set> solutionClassSet, - Map, GizmoSolutionOrEntityDescriptor> memoizedSolutionOrEntityDescriptorMap, - Set> deepClonedClassSet) { - var implementor = implementorSupplier.get(); - // Classes that are not instances of any other class in the collection - // have a subclass level of 0. - // Other classes subclass level is the maximum of the subclass level - // of the classes it is a subclass of + 1 - var deepCloneClassesThatAreNotSolutionSet = - deepClonedClassSet.stream() - .filter(clazz -> !solutionClassSet.contains(clazz) && !clazz.isArray()) - .filter(GizmoSolutionClonerImplementor::isCloneableClass) - .collect(Collectors.toSet()); - - var instanceOfComparator = getInstanceOfComparator(deepClonedClassSet); - var deepCloneClassesThatAreNotSolutionSortedSet = new TreeSet<>(instanceOfComparator); - deepCloneClassesThatAreNotSolutionSortedSet.addAll(deepCloneClassesThatAreNotSolutionSet); - - var clonerDescriptor = new ClonerDescriptor(solutionDescriptor, memoizedSolutionOrEntityDescriptorMap, - deepCloneClassesThatAreNotSolutionSortedSet, - classCreator, null); - - classCreator.defaultConstructor(); - - clonerDescriptor = implementor.withFallbackClonerField(clonerDescriptor); - implementor.createSetSolutionDescriptor(clonerDescriptor); - - createCloneSolutionRun(clonerDescriptor, solutionClassSet, instanceOfComparator); - createCloneSolution(clonerDescriptor); - - for (var deepClonedClass : deepCloneClassesThatAreNotSolutionSortedSet) { - implementor.createDeepCloneHelperMethod(clonerDescriptor, deepClonedClass); - } - - var abstractDeepCloneClassSet = - deepClonedClassSet.stream() - .filter(clazz -> !solutionClassSet.contains(clazz) && !clazz.isArray()) - .filter(Predicate.not(GizmoSolutionClonerImplementor::isCloneableClass)) - .collect(Collectors.toSet()); - - for (var abstractDeepClonedClass : abstractDeepCloneClassSet) { - implementor.createAbstractDeepCloneHelperMethod(clonerDescriptor, abstractDeepClonedClass); - } - } - - public static ClassOutput createClassOutputWithDebuggingCapability(Map classBytecodeHolder) { - return new GizmoSolutionClonerClassOutput(classBytecodeHolder); - } - - static SolutionCloner createClonerFor(SolutionDescriptor solutionDescriptor, - GizmoClassLoader gizmoClassLoader) { - var implementor = new GizmoSolutionClonerImplementor(); - var className = GizmoSolutionClonerFactory.getGeneratedClassName(solutionDescriptor); - if (gizmoClassLoader.hasBytecodeFor(className)) { - return implementor.createInstance(className, gizmoClassLoader, solutionDescriptor); - } - var classBytecodeHolder = new HashMap(); - - var gizmo = Gizmo.create(createClassOutputWithDebuggingCapability(classBytecodeHolder)); - gizmo.class_(className, classCreator -> { - classCreator.implements_(GizmoSolutionCloner.class); - classCreator.extends_(Object.class); - classCreator.final_(); - - var deepClonedClassSet = GizmoCloningUtils.getDeepClonedClasses(solutionDescriptor, Collections.emptyList()); - - defineClonerFor(() -> implementor, classCreator, solutionDescriptor, - Collections.singleton(solutionDescriptor.getSolutionClass()), - new HashMap<>(), deepClonedClassSet); - }); - - for (var bytecodeEntry : classBytecodeHolder.entrySet()) { - gizmoClassLoader.storeBytecode(bytecodeEntry.getKey(), bytecodeEntry.getValue()); - } - - return implementor.createInstance(className, gizmoClassLoader, solutionDescriptor); - } - - private SolutionCloner createInstance(String className, ClassLoader gizmoClassLoader, - SolutionDescriptor solutionDescriptor) { - try { - @SuppressWarnings("unchecked") - var outClass = - (Class>) gizmoClassLoader.loadClass(className); - var out = outClass.getConstructor().newInstance(); - out.setSolutionDescriptor(solutionDescriptor); - return out; - } catch (InvocationTargetException | InstantiationException | IllegalAccessException | ClassNotFoundException - | NoSuchMethodException e) { - throw new IllegalStateException(e); - } - } - - protected void createSetSolutionDescriptor(ClonerDescriptor clonerDescriptor) { - clonerDescriptor.classCreator.method("setSolutionDescriptor", methodCreator -> { - methodCreator.public_(); - methodCreator.returning(void.class); - var solutionDescriptor = methodCreator.parameter("solutionDescriptor", SolutionDescriptor.class); - methodCreator.body(blockCreator -> { - blockCreator.set(clonerDescriptor.fallbackClonerField, - blockCreator.new_(FieldAccessingSolutionCloner.class, solutionDescriptor)); - blockCreator.return_(); - }); - }); - } - - private static void createCloneSolution(ClonerDescriptor clonerDescriptor) { - var solutionClass = clonerDescriptor.solutionDescriptor.getSolutionClass(); - clonerDescriptor.classCreator.method("cloneSolution", methodCreator -> { - methodCreator.returning(Object.class); - var original = methodCreator.parameter("original", Object.class); - methodCreator.body(blockCreator -> { - var clone = blockCreator.invokeStatic( - ClassMethodDesc.of( - ClassDesc.of( - GizmoSolutionClonerFactory.getGeneratedClassName(clonerDescriptor.solutionDescriptor)), - "cloneSolutionRun", solutionClass, solutionClass, Map.class), - original, - blockCreator.new_(IdentityHashMap.class)); - blockCreator.return_(clone); - }); - }); - } - - private static void createCloneSolutionRun(ClonerDescriptor clonerDescriptor, - Set> solutionClassSet, Comparator> instanceOfComparator) { - var solutionClass = clonerDescriptor.solutionDescriptor.getSolutionClass(); - - clonerDescriptor.classCreator.staticMethod("cloneSolutionRun", methodCreator -> { - methodCreator.public_(); - methodCreator.returning(solutionClass); - var thisObj = methodCreator.parameter("original", solutionClass); - var createdCloneMap = methodCreator.parameter("cloneMap", Map.class); - methodCreator.body(blockCreator -> { - blockCreator.ifNull(thisObj, BlockCreator::returnNull); - var maybeClone = blockCreator.localVar("existingClone", solutionClass, - blockCreator.withMap(createdCloneMap).get(thisObj)); - blockCreator.ifNotNull(maybeClone, hasCloneBranch -> hasCloneBranch.return_(maybeClone)); - - var sortedSolutionClassList = new ArrayList<>(solutionClassSet); - sortedSolutionClassList.sort(instanceOfComparator); - - var thisObjClass = - blockCreator.localVar("clonedObjectClass", - blockCreator.withObject(thisObj).getClass_()); - for (Class solutionSubclass : sortedSolutionClassList) { - var solutionSubclassConst = Const.of(solutionSubclass); - var isSubclass = blockCreator.exprEquals(solutionSubclassConst, thisObjClass); - blockCreator.if_(isSubclass, isExactMatchBranch -> { - // Note: it appears Gizmo2 does not have a way to cast expressions, so we need to - // use an ifInstanceOf to get a casted version - // uncheckedCast does NOT do a checkcast, and will fail class verification - isExactMatchBranch.ifInstanceOf(thisObj, solutionSubclass, - (isExactMatchWithCastBranch, castedSolution) -> { - var solutionSubclassDescriptor = - clonerDescriptor.memoizedSolutionOrEntityDescriptorMap.computeIfAbsent( - solutionSubclass, - key -> new GizmoSolutionOrEntityDescriptor( - clonerDescriptor.solutionDescriptor, - solutionSubclass)); - - var clone = isExactMatchWithCastBranch.localVar("newClone", solutionSubclass, - Const.ofNull(solutionSubclass)); - isExactMatchWithCastBranch.set(clone, - isExactMatchWithCastBranch.new_(ConstructorDesc.of(solutionSubclass))); - isExactMatchWithCastBranch.withMap(createdCloneMap).put(castedSolution, clone); - - cloneShallowlyClonedFieldsOfObject(solutionSubclassDescriptor, clonerDescriptor, - new ClonerMethodDescriptor( - solutionSubclassDescriptor, - isExactMatchWithCastBranch, createdCloneMap, - true, - isExactMatchWithCastBranch.localVar("cloneQueue", - isExactMatchWithCastBranch - .new_(ConstructorDesc.of(ArrayDeque.class)))), - castedSolution, clone); - cloneDeepClonedFieldsOfSolution(clonerDescriptor, solutionSubclassDescriptor, - isExactMatchWithCastBranch, - castedSolution, - createdCloneMap, clone); - - isExactMatchWithCastBranch.return_(clone); - }); - }); - } - var errorBuilder = blockCreator.localVar("errorMessageBuilder", - blockCreator.new_(ConstructorDesc.of(StringBuilder.class, String.class), - Const.of("Failed to create clone: encountered ("))); - - var errorTemplate = - """ - which is not a known subclass of the solution class (%s). - The known subclasses are: %s. - Maybe use DomainAccessType.REFLECTION? - """.formatted( - clonerDescriptor.solutionDescriptor.getSolutionClass(), - solutionClassSet.stream().map(Class::getName).collect(Collectors.joining(", ", "[", "]"))); - var APPEND = - MethodDesc.of(StringBuilder.class, "append", StringBuilder.class, Object.class); - - blockCreator.invokeVirtual(APPEND, errorBuilder, thisObjClass); - blockCreator.invokeVirtual(APPEND, errorBuilder, Const.of(") " + errorTemplate)); - var errorMsg = blockCreator - .invokeVirtual(MethodDesc.of(Object.class, "toString", String.class), errorBuilder); - var error = blockCreator - .new_(ConstructorDesc.of(IllegalArgumentException.class, String.class), errorMsg); - blockCreator.throw_(error); - }); - }); - } - - private static void cloneDeepClonedFieldsOfSolution(ClonerDescriptor clonerDescriptor, - GizmoSolutionOrEntityDescriptor solutionSubclassDescriptor, - BlockCreator isSubclassBranch, Var thisObj, Var createdCloneMap, Var clone) { - for (Field deeplyClonedField : solutionSubclassDescriptor.getDeepClonedFields()) { - var gizmoMemberDescriptor = - solutionSubclassDescriptor.getMemberDescriptorForField(deeplyClonedField); - - var fieldValue = isSubclassBranch.localVar(deeplyClonedField.getName() + "$Value", - gizmoMemberDescriptor.readMemberValue(isSubclassBranch, thisObj)); - var cloneValue = isSubclassBranch.localVar(deeplyClonedField.getName() + "$Clone", deeplyClonedField.getType(), - Const.ofNull(deeplyClonedField.getType())); - writeDeepCloneInstructions(clonerDescriptor, - new ClonerMethodDescriptor(solutionSubclassDescriptor, isSubclassBranch, createdCloneMap, - true, - isSubclassBranch.localVar(deeplyClonedField.getName() + "$Queue", - isSubclassBranch.new_(ArrayDeque.class))), - deeplyClonedField, - gizmoMemberDescriptor, fieldValue, cloneValue); - - if (!gizmoMemberDescriptor.writeMemberValue(isSubclassBranch, clone, cloneValue)) { - throw new IllegalStateException("The member (%s) of class (%s) does not have a setter.".formatted( - gizmoMemberDescriptor.getName(), gizmoMemberDescriptor.getDeclaringClassName())); - } - } - } - - private static void cloneShallowlyClonedFieldsOfObject(GizmoSolutionOrEntityDescriptor solutionSubclassDescriptor, - ClonerDescriptor clonerDescriptor, ClonerMethodDescriptor solutionSubclassDescriptor1, Var thisObj, - Var clone) { - for (GizmoMemberDescriptor shallowlyClonedField : solutionSubclassDescriptor - .getShallowClonedMemberDescriptors()) { - writeShallowCloneInstructions(clonerDescriptor, solutionSubclassDescriptor1, - shallowlyClonedField, thisObj, clone); - } - } - - /** - * Writes the following code: - * - *

-     * // If getter a field
-     * clone.member = original.member
-     * // If getter a method (i.e. Quarkus)
-     * clone.setMember(original.getMember());
-     * 
- */ - private static void writeShallowCloneInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - GizmoMemberDescriptor shallowlyClonedField, - Var thisObj, Var clone) { - try { - var isArray = shallowlyClonedField.getTypeName().endsWith("[]"); - Class type = null; - if (shallowlyClonedField.getType() instanceof Class) { - type = (Class) shallowlyClonedField.getType(); - } - - List> entitySubclasses = Collections.emptyList(); - if (type == null && !isArray) { - type = Class.forName(shallowlyClonedField.getTypeName().replace('/', '.'), false, - Thread.currentThread().getContextClassLoader()); - } - - if (type != null && !isArray) { - entitySubclasses = - clonerDescriptor.deepClonedClassesSortedSet.stream().filter(type::isAssignableFrom).toList(); - } - - var fieldValue = clonerMethodDescriptor.blockCreator.localVar(shallowlyClonedField.getName() + "$Value", - shallowlyClonedField.readMemberValue(clonerMethodDescriptor.blockCreator, thisObj)); - if (!entitySubclasses.isEmpty()) { - var cloneResultHolder = clonerMethodDescriptor.blockCreator - .localVar(shallowlyClonedField.getName() + "$Clone", type, Const.ofNull(type)); - writeDeepCloneEntityOrFactInstructions(clonerDescriptor, - clonerMethodDescriptor, - type, - fieldValue, cloneResultHolder, - UnhandledCloneType.SHALLOW); - fieldValue = cloneResultHolder; - } - if (!shallowlyClonedField.writeMemberValue(clonerMethodDescriptor.blockCreator, clone, fieldValue)) { - throw new IllegalStateException("Field (%s) of class (%s) does not have a setter.".formatted( - shallowlyClonedField.getName(), shallowlyClonedField.getDeclaringClassName())); - } - } catch (ClassNotFoundException e) { - throw new IllegalStateException("Error creating Gizmo Solution Cloner", e); - } - } - - /** - * @see #writeDeepCloneInstructions(ClonerDescriptor, ClonerMethodDescriptor, Class, Type, Var, - * Var) - */ - private static void writeDeepCloneInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Field deeplyClonedField, - GizmoMemberDescriptor gizmoMemberDescriptor, Var toClone, Var cloneResultHolder) { - BlockCreator blockCreator = clonerMethodDescriptor.blockCreator; - - blockCreator.ifNull(toClone, - isNullBranch -> isNullBranch.set(cloneResultHolder, Const.ofNull(cloneResultHolder.type()))); - - blockCreator.ifNotNull(toClone, isNotNullBranch -> { - var deeplyClonedFieldClass = deeplyClonedField.getType(); - var type = gizmoMemberDescriptor.getType(); - if (clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor().getSolutionClass() - .isAssignableFrom(deeplyClonedFieldClass)) { - writeDeepCloneSolutionInstructions(clonerMethodDescriptor.withBlockCreator(isNotNullBranch), toClone, - cloneResultHolder); - } else if (Collection.class.isAssignableFrom(deeplyClonedFieldClass)) { - writeDeepCloneCollectionInstructions(clonerDescriptor, - clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, type, - toClone, cloneResultHolder); - } else if (Map.class.isAssignableFrom(deeplyClonedFieldClass)) { - writeDeepCloneMapInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, type, - toClone, cloneResultHolder); - } else if (deeplyClonedFieldClass.isArray()) { - writeDeepCloneArrayInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, toClone, cloneResultHolder); - } else { - var unknownClassCloneType = - (DeepCloningUtils.isFieldDeepCloned(clonerMethodDescriptor.entityDescriptor.solutionDescriptor, - deeplyClonedField, deeplyClonedField.getDeclaringClass())) - ? UnhandledCloneType.DEEP - : UnhandledCloneType.SHALLOW; - writeDeepCloneEntityOrFactInstructions(clonerDescriptor, - clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, - toClone, cloneResultHolder, unknownClassCloneType); - } - }); - } - - /** - * Writes the following code: - * - *
-     * // For a Collection
-     * Collection original = field;
-     * Collection clone = new ActualCollectionType();
-     * Iterator iterator = original.iterator();
-     * while (iterator.hasNext()) {
-     *     Object nextClone = (result from recursion on iterator.next());
-     *     clone.add(nextClone);
-     * }
-     *
-     * // For a Map
-     * Map original = field;
-     * Map clone = new ActualMapType();
-     * Iterator iterator = original.entrySet().iterator();
-     * while (iterator.hasNext()) {
-     *      Entry next = iterator.next();
-     *      nextClone = (result from recursion on next.getValue());
-     *      clone.put(next.getKey(), nextClone);
-     * }
-     *
-     * // For an array
-     * Object[] original = field;
-     * Object[] clone = new Object[original.length];
-     *
-     * for (int i = 0; i < original.length; i++) {
-     *     clone[i] = (result from recursion on original[i]);
-     * }
-     *
-     * // For an entity
-     * if (original instanceof SubclassOfEntity1) {
-     *     SubclassOfEntity1 original = field;
-     *     SubclassOfEntity1 clone = new SubclassOfEntity1();
-     *
-     *     // shallowly clone fields using writeShallowCloneInstructions()
-     *     // for any deeply cloned field, do recursion on it
-     * } else if (original instanceof SubclassOfEntity2) {
-     *     // ...
-     * }
-     * 
- */ - private static void writeDeepCloneInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, - Var cloneResultHolder) { - BlockCreator blockCreator = clonerMethodDescriptor.blockCreator; - - blockCreator.ifNull(toClone, - ifNullBranch -> ifNullBranch.set(cloneResultHolder, Const.ofNull(cloneResultHolder.type()))); - - blockCreator.ifNotNull(toClone, isNotNullBranch -> { - if (clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor().getSolutionClass() - .isAssignableFrom(deeplyClonedFieldClass)) { - writeDeepCloneSolutionInstructions(clonerMethodDescriptor, toClone, cloneResultHolder); - } else if (Collection.class.isAssignableFrom(deeplyClonedFieldClass)) { - // Clone collection - writeDeepCloneCollectionInstructions(clonerDescriptor, - clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, type, - toClone, cloneResultHolder); - } else if (Map.class.isAssignableFrom(deeplyClonedFieldClass)) { - // Clone map - writeDeepCloneMapInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, type, - toClone, cloneResultHolder); - } else if (deeplyClonedFieldClass.isArray()) { - // Clone array - writeDeepCloneArrayInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, - toClone, cloneResultHolder); - } else { - // Clone entity - UnhandledCloneType unknownClassCloneType = - (DeepCloningUtils.isClassDeepCloned(clonerMethodDescriptor.entityDescriptor.solutionDescriptor, - deeplyClonedFieldClass)) - ? UnhandledCloneType.DEEP - : UnhandledCloneType.SHALLOW; - writeDeepCloneEntityOrFactInstructions(clonerDescriptor, - clonerMethodDescriptor.withBlockCreator(isNotNullBranch), - deeplyClonedFieldClass, - toClone, cloneResultHolder, unknownClassCloneType); - } - }); - } - - private static void writeDeepCloneSolutionInstructions( - ClonerMethodDescriptor clonerMethodDescriptor, Var toClone, Var cloneResultHolder) { - clonerMethodDescriptor.blockCreator.ifNull(toClone, - isNullBranch -> isNullBranch.set(cloneResultHolder, Const.ofNull(cloneResultHolder.type()))); - clonerMethodDescriptor.blockCreator.ifNotNull(toClone, isNotNullBranch -> { - var clone = isNotNullBranch.invokeStatic( - ClassMethodDesc.of( - ClassDesc.of(GizmoSolutionClonerFactory - .getGeneratedClassName(clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor())), - "cloneSolutionRun", - clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor().getSolutionClass(), - clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor().getSolutionClass(), Map.class), - toClone, - clonerMethodDescriptor.createdCloneMap); - isNotNullBranch.set(cloneResultHolder, clone); - }); - } - - /** - * Writes the following code: - * - *
-     * // For a Collection
-     * Collection clone = new ActualCollectionType();
-     * Iterator iterator = toClone.iterator();
-     * while (iterator.hasNext()) {
-     *     Object toCloneElement = iterator.next();
-     *     Object nextClone = (result from recursion on toCloneElement);
-     *     clone.add(nextClone);
-     * }
-     * cloneResultHolder = clone;
-     * 
- **/ - private static void writeDeepCloneCollectionInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, - Var cloneResultHolder) { - var blockCreator = clonerMethodDescriptor.blockCreator; - - var constructedCollection = blockCreator.localVar(toClone.name() + "$ConstructedCollection", blockCreator.invokeStatic( - MethodDesc.of(FieldAccessingSolutionCloner.class, "constructCloneCollection", Collection.class, - Collection.class), - toClone)); - checkCastAndAssign(blockCreator, deeplyClonedFieldClass, cloneResultHolder, constructedCollection); - - var iterator = - blockCreator.localVar(toClone.name() + "$Iterator", blockCreator.withCollection(toClone).iterator()); - blockCreator.while_(condition -> condition.yield(condition.withIterator(iterator).hasNext()), whileLoopBlock -> { - Class elementClass; - java.lang.reflect.Type elementClassType; - if (type instanceof ParameterizedType parameterizedType) { - // Assume Collection follow Collection convention of first type argument = element class - elementClassType = parameterizedType.getActualTypeArguments()[0]; - if (elementClassType instanceof Class class1) { - elementClass = class1; - } else if (elementClassType instanceof ParameterizedType parameterizedElementClassType) { - elementClass = (Class) parameterizedElementClassType.getRawType(); - } else if (elementClassType instanceof WildcardType wildcardType) { - elementClass = (Class) wildcardType.getUpperBounds()[0]; - } else { - throw new IllegalStateException("Unhandled type (%s).".formatted(elementClassType)); - } - } else { - throw new IllegalStateException("Cannot infer element type for Collection type (%s).".formatted(type)); - } - - // Odd case of member get and set being on different classes; will work as we only - // use get on the original and set on the clone. - var next = whileLoopBlock.localVar(toClone.name() + "$Item", whileLoopBlock.withIterator(iterator).next()); - var clonedElement = - whileLoopBlock.localVar(cloneResultHolder.name() + "$Item", elementClass, Const.ofNull(elementClass)); - writeDeepCloneInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(whileLoopBlock), - elementClass, elementClassType, next, clonedElement); - whileLoopBlock.withCollection(cloneResultHolder).add(clonedElement); - }); - } - - /** - * Writes the following code: - * - *
-     * if (constructedCollection instanceof deeplyClonedFieldClass temp) {
-     *     cloneResultHolder = temp;
-     * } else {
-     *     throw new IllegalStateException("...");
-     * }
-     * 
- */ - private static void checkCastAndAssign(BlockCreator blockCreator, Class deeplyClonedFieldClass, Var cloneResultHolder, - Var constructedCollection) { - blockCreator.ifInstanceOfElse(constructedCollection, deeplyClonedFieldClass, (isInstanceCreator, casted) -> { - isInstanceCreator.set(cloneResultHolder, casted); - }, isNotInstanceCreator -> { - try { - var baseMessage = isNotInstanceCreator.localVar("message", - Const.of("Constructed type (%s) is not assignable to field type (%s).")); - // Apparently we need to get the method from String.class so it sees the String[] as varargs? - var formattedMessage = isNotInstanceCreator.invokeVirtual( - MethodDesc.of(String.class.getMethod("formatted", Object[].class)), - baseMessage, - isNotInstanceCreator.newArray(String.class, - isNotInstanceCreator - .withClass(isNotInstanceCreator.withObject(constructedCollection).getClass_()) - .getName(), - Const.of(deeplyClonedFieldClass.getName()))); - isNotInstanceCreator.throw_(isNotInstanceCreator.new_(IllegalStateException.class, formattedMessage)); - } catch (NoSuchMethodException e) { - throw new RuntimeException(e); - } - }); - } - - /** - * Writes the following code: - * - *
-     * // For a Map
-     * Map clone = new ActualMapType();
-     * Iterator iterator = toClone.entrySet().iterator();
-     * while (iterator.hasNext()) {
-     *      Entry next = iterator.next();
-     *      Object toCloneValue = next.getValue();
-     *      nextClone = (result from recursion on toCloneValue);
-     *      clone.put(next.getKey(), nextClone);
-     * }
-     * cloneResultHolder = clone;
-     * 
- **/ - private static void writeDeepCloneMapInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, java.lang.reflect.Type type, Var toClone, - Var cloneResultHolder) { - var blockCreator = clonerMethodDescriptor.blockCreator; - - var constructedMap = blockCreator.localVar(toClone.name() + "$ConstructedMap", - blockCreator.invokeStatic( - MethodDesc.of(FieldAccessingSolutionCloner.class, "constructCloneMap", Map.class, Map.class), - toClone)); - checkCastAndAssign(blockCreator, deeplyClonedFieldClass, cloneResultHolder, constructedMap); - - var entrySet = blockCreator.withMap(toClone).entrySet(); - var iterator = blockCreator.localVar(toClone.name() + "$EntrySet$Iterator", - blockCreator.withCollection(entrySet).iterator()); - - blockCreator.while_(condition -> condition.yield(condition.withIterator(iterator).hasNext()), whileLoopBlock -> { - Class keyClass; - Class elementClass; - java.lang.reflect.Type keyType; - java.lang.reflect.Type elementClassType; - if (type instanceof ParameterizedType parameterizedType) { - // Assume Map follow Map convention of second type argument = value class - keyType = parameterizedType.getActualTypeArguments()[0]; - elementClassType = parameterizedType.getActualTypeArguments()[1]; - if (elementClassType instanceof Class class1) { - elementClass = class1; - } else if (elementClassType instanceof ParameterizedType parameterizedElementClassType) { - elementClass = (Class) parameterizedElementClassType.getRawType(); - } else { - throw new IllegalStateException("Unhandled type (%s).".formatted(elementClassType)); - } - - if (keyType instanceof Class class1) { - keyClass = class1; - } else if (keyType instanceof ParameterizedType parameterizedElementClassType) { - keyClass = (Class) parameterizedElementClassType.getRawType(); - } else { - throw new IllegalStateException("Unhandled type (%s).".formatted(keyType)); - } - } else { - throw new IllegalStateException("Cannot infer element type for Map type (%s).".formatted(type)); - } - - var entitySubclasses = clonerDescriptor.deepClonedClassesSortedSet.stream() - .filter(keyClass::isAssignableFrom).toList(); - var entry = whileLoopBlock.localVar(toClone.name() + "$Entry", whileLoopBlock.withIterator(iterator).next()); - var toCloneValue = whileLoopBlock.localVar(toClone.name() + "$Value", - whileLoopBlock.invokeInterface(MethodDesc.of(Map.Entry.class, "getValue", Object.class), entry)); - var clonedElement = - whileLoopBlock.localVar(cloneResultHolder.name() + "$Element", elementClass, Const.ofNull(elementClass)); - writeDeepCloneInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(whileLoopBlock), - elementClass, elementClassType, toCloneValue, clonedElement); - - var key = whileLoopBlock.localVar(toClone.name() + "$Key", - whileLoopBlock.invokeInterface(MethodDesc.of(Map.Entry.class, "getKey", Object.class), entry)); - if (!entitySubclasses.isEmpty()) { - var keyCloneResultHolder = - whileLoopBlock.localVar(cloneResultHolder.name() + "$Key", keyClass, Const.ofNull(keyClass)); - writeDeepCloneEntityOrFactInstructions(clonerDescriptor, - clonerMethodDescriptor.withBlockCreator(whileLoopBlock), - keyClass, - key, keyCloneResultHolder, UnhandledCloneType.DEEP); - whileLoopBlock.withMap(cloneResultHolder).put(keyCloneResultHolder, clonedElement); - } else { - whileLoopBlock.withMap(cloneResultHolder).put(key, clonedElement); - } - }); - } - - /** - * Writes the following code: - * - *
-     * // For an array
-     * Object[] clone = new Object[toClone.length];
-     *
-     * for (int i = 0; i < original.length; i++) {
-     *     clone[i] = (result from recursion on toClone[i]);
-     * }
-     * cloneResultHolder = clone;
-     * 
- **/ - private static void writeDeepCloneArrayInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, Var toClone, Var cloneResultHolder) { - var blockCreator = clonerMethodDescriptor.blockCreator; - - // Clone array - var arrayComponent = deeplyClonedFieldClass.getComponentType(); - var arrayLength = toClone.length(); - blockCreator.set(cloneResultHolder, blockCreator.newEmptyArray(arrayComponent, arrayLength)); - - var iterations = blockCreator.localVar("i", Const.of(0)); - blockCreator.while_(condition -> condition.yield(condition.lt(iterations, arrayLength)), - whileLoopBlock -> { - var toCloneElement = whileLoopBlock.localVar(toClone.name() + "$Element", toClone.elem(iterations)); - var clonedElement = whileLoopBlock.localVar(cloneResultHolder.name() + "$Element", arrayComponent, - Const.ofNull(arrayComponent)); - - writeDeepCloneInstructions(clonerDescriptor, clonerMethodDescriptor.withBlockCreator(whileLoopBlock), - arrayComponent, - arrayComponent, toCloneElement, clonedElement); - whileLoopBlock.set(cloneResultHolder.elem(iterations), clonedElement); - whileLoopBlock.inc(iterations); - }); - } - - /** - * Writes the following code: - * - *
-     * // For an entity
-     * if (toClone instanceof SubclassOfEntity1) {
-     *     SubclassOfEntity1 clone = new SubclassOfEntity1();
-     *
-     *     // shallowly clone fields using writeShallowCloneInstructions()
-     *     // for any deeply cloned field, do recursion on it
-     *     cloneResultHolder = clone;
-     * } else if (toClone instanceof SubclassOfEntity2) {
-     *     // ...
-     * }
-     * // ...
-     * else if (toClone instanceof SubclassOfEntityN) {
-     *     // ...
-     * } else {
-     *     // shallow or deep clone based on whether deep cloning is forced
-     * }
-     * 
- **/ - private static void writeDeepCloneEntityOrFactInstructions(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class deeplyClonedFieldClass, - Var toClone, - Var cloneResultHolder, - UnhandledCloneType unhandledCloneType) { - var deepClonedSubclasses = clonerDescriptor.deepClonedClassesSortedSet.stream() - .filter(deeplyClonedFieldClass::isAssignableFrom) - .filter(type -> DeepCloningUtils.isClassDeepCloned( - clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor(), - type)) - .toList(); - var currentBranch = clonerMethodDescriptor.blockCreator; - var isHandled = currentBranch.localVar(cloneResultHolder.name() + "$IsHandled", Const.of(false)); - // If the field holds an instance of one of the field's declared type's subtypes, clone the subtype instead. - for (Class deepClonedSubclass : deepClonedSubclasses) { - currentBranch.ifNot(isHandled, notHandledBranch -> notHandledBranch.ifInstanceOf(toClone, deepClonedSubclass, - (isInstanceBranch, castedToClone) -> { - var cloneObj = isInstanceBranch.invokeStatic( - ClassMethodDesc.of( - ClassDesc.of(GizmoSolutionClonerFactory - .getGeneratedClassName( - clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor())), - getEntityHelperMethodName(deepClonedSubclass), deepClonedSubclass, deepClonedSubclass, - Map.class, - boolean.class, ArrayDeque.class), - castedToClone, clonerMethodDescriptor.createdCloneMap, - Const.of(clonerMethodDescriptor.isBottom), - clonerMethodDescriptor.cloneQueue); - isInstanceBranch.set(cloneResultHolder, cloneObj); - isInstanceBranch.set(isHandled, Const.of(true)); - })); - } - currentBranch.ifNot(isHandled, notHandledBranch -> { - // We are certain that the instance is of the same type as the declared field type. - // (Or is an undeclared subclass of the planning entity) - switch (unhandledCloneType) { - case SHALLOW -> notHandledBranch.set(cloneResultHolder, toClone); - case DEEP -> { - var cloneObj = notHandledBranch.invokeStatic( - ClassMethodDesc.of( - ClassDesc.of(GizmoSolutionClonerFactory - .getGeneratedClassName( - clonerMethodDescriptor.entityDescriptor.getSolutionDescriptor())), - getEntityHelperMethodName(deeplyClonedFieldClass), deeplyClonedFieldClass, - deeplyClonedFieldClass, Map.class, boolean.class, ArrayDeque.class), - toClone, clonerMethodDescriptor.createdCloneMap, Const.of(clonerMethodDescriptor.isBottom), - clonerMethodDescriptor.cloneQueue); - notHandledBranch.set(cloneResultHolder, cloneObj); - } - } - }); - } - - protected static String getEntityHelperMethodName(Class entityClass) { - return "$clone" + entityClass.getName().replace('.', '_'); - } - - /** - * Writes the following code: - *

- * In Quarkus: (nothing) - *

- * Outside Quarkus: - * - *

-     * if (toClone.getClass() != entityClass) {
-     *     Cloner.fallbackCloner.gizmoFallbackDeepClone(toClone, cloneMap);
-     * } else {
-     *     // code knownClassHandler produces
-     * }
-     * 
- */ - protected void handleUnknownClass(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class entityClass, - Var toClone, - Consumer knownClassHandler) { - var actualClass = clonerMethodDescriptor.blockCreator.withObject(toClone).getClass_(); - var isClassReferenceNotEqual = clonerMethodDescriptor.blockCreator.localVar("isUnknownClass", - clonerMethodDescriptor.blockCreator.ne(actualClass, Const.of(entityClass))); - - clonerMethodDescriptor.blockCreator.if_(isClassReferenceNotEqual, currentBranch -> { - var fallbackCloner = clonerDescriptor.fallbackClonerField; - var cloneObj = - currentBranch.invokeVirtual(MethodDesc.of(FieldAccessingSolutionCloner.class, - "gizmoFallbackDeepClone", Object.class, Object.class, Map.class), - fallbackCloner, toClone, clonerMethodDescriptor.createdCloneMap); - currentBranch.return_(cloneObj); - }); - - knownClassHandler.accept(clonerMethodDescriptor.blockCreator); - } - - /** - * Writes the following method: - * - *
-     * public static Entity $cloneEntity(Entity entity, Map cloneMap, boolean isBottom, ArrayDeque cloneQueue) {
-     *     var existingClonedEntity = (Entity) cloneMap.get(entity);
-     *     if (existingClonedEntity != null) {
-     *         return existingClonedEntity;
-     *     }
-     *     final var clonedEntity = new Entity();
-     *     clonedEntity.shallowField1 = entity.shallowField1;
-     *     // ...
-     *     clonedEntity.shallowFieldN = entity.shallowFieldN;
-     *     cloneQueue.push(() -> clonedEntity.deepClonedField1 = ...);
-     *     // ...
-     *     cloneQueue.push(() -> clonedEntity.deepClonedFieldN = ...);
-     *     if (isBottom) {
-     *         while (!cloneQueue.isEmpty()) {
-     *             cloneQueue.pop().run();
-     *         }
-     *     }
-     * }
-     * 
- * - * The cloneQueue is to prevent stack overflow on models where many entities can be reached from a single entity. - **/ - private void createDeepCloneHelperMethod(ClonerDescriptor clonerDescriptor, - Class entityClass) { - clonerDescriptor.classCreator.staticMethod(getEntityHelperMethodName(entityClass), methodCreator -> { - var toClone = methodCreator.parameter("toClone", entityClass); - var cloneMap = methodCreator.parameter("cloneMap", Map.class); - var isBottom = methodCreator.parameter("isBottom", boolean.class); - var cloneQueue = methodCreator.parameter("cloneQueue", ArrayDeque.class); - - methodCreator.returning(entityClass); - methodCreator.public_(); - methodCreator.body(blockCreator -> { - var entityDescriptor = - clonerDescriptor.memoizedSolutionOrEntityDescriptorMap.computeIfAbsent(entityClass, - key -> new GizmoSolutionOrEntityDescriptor(clonerDescriptor.solutionDescriptor, entityClass)); - var maybeClone = blockCreator.localVar("existingClone", blockCreator.withMap(cloneMap).get(toClone)); - blockCreator.ifNotNull(maybeClone, hasCloneBranch -> hasCloneBranch.return_(maybeClone)); - - handleUnknownClass(clonerDescriptor, - new ClonerMethodDescriptor(entityDescriptor, blockCreator, cloneMap, false, cloneQueue), - entityClass, - toClone, - newNoCloneBranch -> { - LocalVar cloneObj = - newNoCloneBranch.localVar("clonedObject", entityClass, Const.ofNull(entityClass)); - newNoCloneBranch.set(cloneObj, newNoCloneBranch.new_(entityClass)); - newNoCloneBranch.withMap(cloneMap).put(toClone, cloneObj); - - // When deep cloning fields, they cannot be the first entity in the stack, since - // the current entity is below them in the stack. - var clonerMethodDescriptor = - new ClonerMethodDescriptor(entityDescriptor, newNoCloneBranch, cloneMap, false, cloneQueue); - cloneShallowlyClonedFieldsOfObject(entityDescriptor, clonerDescriptor, clonerMethodDescriptor, - toClone, - cloneObj); - - for (Field deeplyClonedField : entityDescriptor.getDeepClonedFields()) { - addDeepCloneFieldInitializerToQueue(clonerDescriptor, clonerMethodDescriptor, deeplyClonedField, - toClone, - cloneObj); - } - - // To prevent stack overflow, only the bottom/first encountered entity can - // create new deep cloned objects. Deep-cloned fields add their initializers - // (which potentially create a new deep cloned object) to the queue, and we - // iterate through the queue until it is empty, at which point this object - // is fully initialized. - newNoCloneBranch.if_(isBottom, bottomObjectBranch -> bottomObjectBranch.while_( - condition -> condition.yield(condition.logicalNot(condition.invokeVirtual( - MethodDesc.of(ArrayDeque.class, "isEmpty", boolean.class), cloneQueue))), - queueNotEmptyBlock -> { - var next = queueNotEmptyBlock - .invokeVirtual(MethodDesc.of(ArrayDeque.class, "pop", Object.class), - cloneQueue); - queueNotEmptyBlock.invokeInterface( - MethodDesc.of(BiConsumer.class, "accept", void.class, Object.class, - Object.class), - next, cloneMap, - cloneQueue); - })); - newNoCloneBranch.return_(cloneObj); - }); - }); - }); - } - - private static void addDeepCloneFieldInitializerToQueue(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Field deeplyClonedField, Var toClone, Var cloneObj) { - var entityDescriptor = clonerMethodDescriptor.entityDescriptor; - var blockCreator = clonerMethodDescriptor.blockCreator; - var cloneQueue = clonerMethodDescriptor.cloneQueue; - - var gizmoMemberDescriptor = - entityDescriptor.getMemberDescriptorForField(deeplyClonedField); - - // Initialize the field inside a Runnable (BiConsumer here since you - // cannot share ResultHandles across different BytecodeCreators). - var consumer = blockCreator.newAnonymousClass(BiConsumer.class, - consumerClassCreator -> consumerClassCreator.method("accept", consumerMethodCreator -> { - consumerMethodCreator.returning(void.class); - var innerCloneMapObject = consumerMethodCreator.parameter("cloneMapObject", Object.class); - var innerCloneQueueObject = consumerMethodCreator.parameter("cloneQueueObject", Object.class); - consumerMethodCreator.body(consumerBlockCreator -> { - var innerCloneMap = - consumerBlockCreator.localVar("cloneMap", innerCloneMapObject); - var innerCloneQueue = consumerBlockCreator.localVar("cloneQueue", innerCloneQueueObject); - var subfieldValue = consumerBlockCreator.localVar("toClone", - gizmoMemberDescriptor.readMemberValue(consumerBlockCreator, - consumerClassCreator.capture(toClone))); - var cloneValue = consumerBlockCreator.localVar("clonedValue", deeplyClonedField.getType(), - Const.ofNull(deeplyClonedField.getType())); - writeDeepCloneInstructions(clonerDescriptor, clonerMethodDescriptor - .withBlockCreator(consumerBlockCreator) - .withCreatedCloneMap(innerCloneMap) - .withCloneQueue(innerCloneQueue), - deeplyClonedField, gizmoMemberDescriptor, subfieldValue, - cloneValue); - - if (!gizmoMemberDescriptor.writeMemberValue(consumerBlockCreator, - consumerClassCreator.capture(cloneObj), - cloneValue)) { - throw new IllegalStateException("The member (%s) of class (%s) does not have a setter.".formatted( - gizmoMemberDescriptor.getName(), gizmoMemberDescriptor.getDeclaringClassName())); - } - consumerBlockCreator.return_(); - }); - })); - - // Add the initializer to the queue - blockCreator.invokeVirtual( - MethodDesc.of(ArrayDeque.class, "push", void.class, Object.class), - cloneQueue, consumer); - } - - protected void createAbstractDeepCloneHelperMethod(ClonerDescriptor clonerDescriptor, - Class entityClass) { - clonerDescriptor.classCreator.staticMethod(getEntityHelperMethodName(entityClass), methodCreator -> { - var toClone = methodCreator.parameter("toClone", entityClass); - var cloneMap = methodCreator.parameter("cloneMap", Map.class); - var ignoredIsBottom = methodCreator.parameter("isBottom", boolean.class); - var ignoredQueue = methodCreator.parameter("cloneQueue", ArrayDeque.class); - - methodCreator.public_(); - methodCreator.returning(entityClass); - methodCreator.body(blockCreator -> { - var maybeClone = blockCreator.localVar("existingClone", blockCreator.withMap(cloneMap).get(toClone)); - blockCreator.ifNotNull(maybeClone, hasCloneBranch -> hasCloneBranch.return_(maybeClone)); - - var fallbackCloner = clonerDescriptor.fallbackClonerField; - var cloneObj = - blockCreator.invokeVirtual(MethodDesc.of(FieldAccessingSolutionCloner.class, - "gizmoFallbackDeepClone", Object.class, Object.class, Map.class), - fallbackCloner, toClone, cloneMap); - blockCreator.return_(cloneObj); - }); - }); - } - - private enum UnhandledCloneType { - SHALLOW, - DEEP - } - - protected record ClonerDescriptor(SolutionDescriptor solutionDescriptor, - Map, GizmoSolutionOrEntityDescriptor> memoizedSolutionOrEntityDescriptorMap, - SortedSet> deepClonedClassesSortedSet, - ClassCreator classCreator, StaticFieldVar fallbackClonerField) { - public ClonerDescriptor withFallbackClonerField(StaticFieldVar fallbackClonerField) { - return new ClonerDescriptor(solutionDescriptor, - memoizedSolutionOrEntityDescriptorMap, deepClonedClassesSortedSet, - classCreator, fallbackClonerField); - } - } - - protected record ClonerMethodDescriptor(GizmoSolutionOrEntityDescriptor entityDescriptor, - BlockCreator blockCreator, - Var createdCloneMap, - boolean isBottom, - Var cloneQueue) { - public ClonerMethodDescriptor withBlockCreator(BlockCreator blockCreator) { - return new ClonerMethodDescriptor(entityDescriptor, blockCreator, createdCloneMap, isBottom, cloneQueue); - } - - public ClonerMethodDescriptor withCreatedCloneMap(Var createdCloneMap) { - return new ClonerMethodDescriptor(entityDescriptor, blockCreator, createdCloneMap, isBottom, cloneQueue); - } - - public ClonerMethodDescriptor withCloneQueue(Var cloneQueue) { - return new ClonerMethodDescriptor(entityDescriptor, blockCreator, createdCloneMap, isBottom, cloneQueue); - } - } -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionOrEntityDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionOrEntityDescriptor.java deleted file mode 100644 index ea40176dffa..00000000000 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionOrEntityDescriptor.java +++ /dev/null @@ -1,73 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberDescriptor; -import ai.timefold.solver.core.impl.domain.solution.cloner.DeepCloningUtils; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -public class GizmoSolutionOrEntityDescriptor { - SolutionDescriptor solutionDescriptor; - Map solutionFieldToMemberDescriptorMap; - Set deepClonedFields; - Set shallowlyClonedFields; - - public GizmoSolutionOrEntityDescriptor(SolutionDescriptor solutionDescriptor, Class entityOrSolutionClass) { - this(solutionDescriptor, entityOrSolutionClass, - getFieldsToSolutionFieldToMemberDescriptorMap(entityOrSolutionClass, new HashMap<>())); - } - - public GizmoSolutionOrEntityDescriptor(SolutionDescriptor solutionDescriptor, Class entityOrSolutionClass, - Map solutionFieldToMemberDescriptorMap) { - this.solutionDescriptor = solutionDescriptor; - this.solutionFieldToMemberDescriptorMap = solutionFieldToMemberDescriptorMap; - deepClonedFields = new HashSet<>(); - shallowlyClonedFields = new HashSet<>(); - - for (Field field : solutionFieldToMemberDescriptorMap.keySet()) { - if (DeepCloningUtils.isDeepCloned(solutionDescriptor, field, entityOrSolutionClass, field.getType())) { - deepClonedFields.add(field); - } else { - shallowlyClonedFields.add(field); - } - } - } - - private static Map getFieldsToSolutionFieldToMemberDescriptorMap(Class clazz, - Map solutionFieldToMemberDescriptorMap) { - for (Field field : clazz.getDeclaredFields()) { - if (!Modifier.isStatic(field.getModifiers())) { - solutionFieldToMemberDescriptorMap.put(field, new GizmoMemberDescriptor(field)); - } - } - if (clazz.getSuperclass() != null) { - getFieldsToSolutionFieldToMemberDescriptorMap(clazz.getSuperclass(), solutionFieldToMemberDescriptorMap); - } - return solutionFieldToMemberDescriptorMap; - } - - public SolutionDescriptor getSolutionDescriptor() { - return solutionDescriptor; - } - - public Set getShallowClonedMemberDescriptors() { - return solutionFieldToMemberDescriptorMap.keySet().stream() - .filter(field -> shallowlyClonedFields.contains(field)) - .map(solutionFieldToMemberDescriptorMap::get).collect(Collectors.toSet()); - } - - public Set getDeepClonedFields() { - return deepClonedFields; - } - - public GizmoMemberDescriptor getMemberDescriptorForField(Field field) { - return solutionFieldToMemberDescriptorMap.get(field); - } - -} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java index daef562296c..1efafc5c23d 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java @@ -2,7 +2,6 @@ import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_GETTER_METHOD; import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_READ_METHOD; -import static ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor.extractInheritedClasses; import static java.util.stream.Stream.concat; import java.lang.annotation.Annotation; @@ -59,9 +58,7 @@ import ai.timefold.solver.core.impl.domain.score.descriptor.ScoreDescriptor; import ai.timefold.solver.core.impl.domain.solution.ConstraintWeightSupplier; import ai.timefold.solver.core.impl.domain.solution.OverridesBasedConstraintWeightSupplier; -import ai.timefold.solver.core.impl.domain.solution.cloner.FieldAccessingSolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionClonerFactory; +import ai.timefold.solver.core.impl.domain.specification.AnnotationSpecificationFactory; import ai.timefold.solver.core.impl.domain.variable.declarative.DeclarativeShadowVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; @@ -108,50 +105,21 @@ public static SolutionDescriptor buildSolutionDescriptor( public static SolutionDescriptor buildSolutionDescriptor( Set enabledPreviewFeaturesSet, Class solutionClass, List> entityClassList) { - return buildSolutionDescriptor(enabledPreviewFeaturesSet, DomainAccessType.FORCE_REFLECTION, solutionClass, null, null, + return buildSolutionDescriptor(enabledPreviewFeaturesSet, DomainAccessType.FORCE_REFLECTION, solutionClass, null, entityClassList); } public static SolutionDescriptor buildSolutionDescriptor( Set enabledPreviewFeatureSet, DomainAccessType domainAccessType, Class solutionClass, - Map memberAccessorMap, Map solutionClonerMap, + Map memberAccessorMap, List> entityClassList) { assertMutable(solutionClass, "solutionClass"); assertSingleInheritance(solutionClass); assertValidAnnotatedMembers(solutionClass); - solutionClonerMap = Objects.requireNonNullElse(solutionClonerMap, Collections.emptyMap()); - var solutionDescriptor = new SolutionDescriptor<>(solutionClass, memberAccessorMap); - var descriptorPolicy = new DescriptorPolicy(); - if (enabledPreviewFeatureSet != null) { - descriptorPolicy.setEnabledPreviewFeatureSet(enabledPreviewFeatureSet); - } - descriptorPolicy.setDomainAccessType(domainAccessType); - descriptorPolicy.setGeneratedSolutionClonerMap(solutionClonerMap); - descriptorPolicy.setMemberAccessorFactory(solutionDescriptor.getMemberAccessorFactory()); - - solutionDescriptor.processUnannotatedFieldsAndMethods(descriptorPolicy); - solutionDescriptor.processAnnotations(descriptorPolicy); - // Before iterating over the entity classes, we need to read the inheritance chain, - // add all parent and child classes, and sort them. - var updatedEntityClassList = new ArrayList<>(entityClassList); - for (var entityClass : entityClassList) { - var inheritedEntityClasses = extractInheritedClasses(entityClass); - var filteredInheritedEntityClasses = inheritedEntityClasses.stream() - .filter(c -> !updatedEntityClassList.contains(c)) - .toList(); - updatedEntityClassList.addAll(filteredInheritedEntityClasses); - } - for (var entityClass : sortEntityClassList(updatedEntityClassList)) { - var entityDescriptor = descriptorPolicy.buildEntityDescriptor(solutionDescriptor, entityClass); - entityDescriptor.processAnnotations(descriptorPolicy); - } - solutionDescriptor.afterAnnotationsProcessed(descriptorPolicy); - if (solutionDescriptor.constraintWeightSupplier != null) { - // The scoreDescriptor is definitely initialized at this point. - solutionDescriptor.constraintWeightSupplier.initialize(solutionDescriptor, - descriptorPolicy.getMemberAccessorFactory(), descriptorPolicy.getDomainAccessType()); - } - return solutionDescriptor; + var annotationSpec = AnnotationSpecificationFactory.fromAnnotations( + solutionClass, entityClassList, domainAccessType, memberAccessorMap); + return SpecificationCompiler.compile(annotationSpec, enabledPreviewFeatureSet, + domainAccessType, memberAccessorMap, true); } public static void assertMutable(Class clz, String classType) { @@ -170,7 +138,7 @@ public static void assertMutable(Class clz, String classType) { * If a class declares any annotated member, it must be annotated as a solution, * even if a supertype already has the annotation. */ - private static void assertValidAnnotatedMembers(Class clazz) { + public static void assertValidAnnotatedMembers(Class clazz) { // We first check the entity class if (clazz.getAnnotation(PlanningSolution.class) == null && hasAnyAnnotatedMembers(clazz)) { var annotatedMembers = extractAnnotatedMembers(clazz).stream() @@ -196,7 +164,7 @@ Maybe remove the annotated members (%s).""".formatted(otherClazz.getName(), othe } } - private static void assertSingleInheritance(Class solutionClass) { + public static void assertSingleInheritance(Class solutionClass) { var inheritedClassList = ConfigUtils.getAllAnnotatedLineageClasses(solutionClass.getSuperclass(), PlanningSolution.class); if (inheritedClassList.size() > 1) { @@ -271,7 +239,7 @@ private static boolean hasAnyAnnotatedMembers(Class solutionClass) { // Constructors and simple getters/setters // ************************************************************************ - private SolutionDescriptor(Class solutionClass, Map memberAccessorMap) { + SolutionDescriptor(Class solutionClass, Map memberAccessorMap) { this.solutionClass = solutionClass; if (solutionClass.getPackage() == null) { LOGGER.warn("The solutionClass ({}) should be in a proper java package.", solutionClass); @@ -279,6 +247,40 @@ private SolutionDescriptor(Class solutionClass, Map scoreDescriptor) { + this.scoreDescriptor = scoreDescriptor; + } + + void setSolutionCloner(SolutionCloner solutionCloner) { + this.solutionCloner = solutionCloner; + } + + void setDomainAccessType(DomainAccessType domainAccessType) { + this.domainAccessType = domainAccessType; + } + + void setLookUpStrategyResolver(LookupStrategyResolver lookUpStrategyResolver) { + this.lookUpStrategyResolver = lookUpStrategyResolver; + } + + void setProblemFactOrEntityClassSet(SequencedSet> problemFactOrEntityClassSet) { + this.problemFactOrEntityClassSet = problemFactOrEntityClassSet; + } + + void setListVariableDescriptorList(List> listVariableDescriptorList) { + this.listVariableDescriptorList = listVariableDescriptorList; + } + + void setConstraintWeightSupplier(ConstraintWeightSupplier constraintWeightSupplier) { + this.constraintWeightSupplier = constraintWeightSupplier; + } + + ConcurrentMap, MemberAccessor> getPlanningIdMemberAccessorMap() { + return planningIdMemberAccessorMap; + } + public void addEntityDescriptor(EntityDescriptor entityDescriptor) { var entityClass = entityDescriptor.getEntityClass(); for (var otherEntityClass : entityDescriptorMap.keySet()) { @@ -525,35 +527,7 @@ The solutionClass (%s) has a @%s annotated member (%s) that is duplicated by a @ : "Maybe 2 mutually exclusive annotations are configured.")); } - private void afterAnnotationsProcessed(DescriptorPolicy descriptorPolicy) { - for (var entityDescriptor : entityDescriptorMap.values()) { - entityDescriptor.linkEntityDescriptors(descriptorPolicy); - } - for (var entityDescriptor : entityDescriptorMap.values()) { - entityDescriptor.linkVariableDescriptors(descriptorPolicy); - } - determineGlobalShadowOrder(); - problemFactOrEntityClassSet = collectEntityAndProblemFactClasses(); - listVariableDescriptorList = findListVariableDescriptors(); - validateListVariableDescriptors(); - - // And finally log the successful completion of processing. - if (LOGGER.isTraceEnabled()) { - LOGGER.trace(" Model annotations parsed for solution {}:", solutionClass.getSimpleName()); - for (var entry : entityDescriptorMap.entrySet()) { - var entityDescriptor = entry.getValue(); - LOGGER.trace(" Entity {}:", entityDescriptor.getEntityClass().getSimpleName()); - for (var variableDescriptor : entityDescriptor.getDeclaredVariableDescriptors()) { - LOGGER.trace(" {} variable {} ({})", - variableDescriptor instanceof GenuineVariableDescriptor ? "Genuine" : "Shadow", - variableDescriptor.getVariableName(), variableDescriptor.getMemberAccessorSpeedNote()); - } - } - } - initSolutionCloner(descriptorPolicy); - } - - private void determineGlobalShadowOrder() { + void determineGlobalShadowOrder() { // Topological sorting with Kahn's algorithm var pairList = new ArrayList, Integer>>(); var shadowToPairMap = @@ -640,23 +614,6 @@ private List> findListVariableDescriptors() { .toList(); } - private void initSolutionCloner(DescriptorPolicy descriptorPolicy) { - solutionCloner = solutionCloner == null - ? descriptorPolicy.getGeneratedSolutionClonerMap().get(GizmoSolutionClonerFactory.getGeneratedClassName(this)) - : solutionCloner; - - if (solutionCloner instanceof GizmoSolutionCloner gizmoSolutionCloner) { - gizmoSolutionCloner.setSolutionDescriptor(this); - } - if (solutionCloner == null) { - solutionCloner = switch (descriptorPolicy.getDomainAccessType()) { - case FORCE_GIZMO -> GizmoSolutionClonerFactory.build(this, memberAccessorFactory.getGizmoClassLoader()); - // AUTO means we are probably in plain Java, so we need to use reflection so we can clone final fields - case AUTO, FORCE_REFLECTION -> new FieldAccessingSolutionCloner<>(this); - }; - } - } - public Class getSolutionClass() { return solutionClass; } diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SpecificationCompiler.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SpecificationCompiler.java new file mode 100644 index 00000000000..fb3892b557c --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SpecificationCompiler.java @@ -0,0 +1,720 @@ +package ai.timefold.solver.core.impl.domain.solution.descriptor; + +import static ai.timefold.solver.core.config.heuristic.selector.common.decorator.SelectionSorterOrder.DESCENDING; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.SequencedSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import ai.timefold.solver.core.api.domain.common.ComparatorFactory; +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification; +import ai.timefold.solver.core.api.domain.specification.EntitySpecification; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.domain.specification.ShadowSpecification; +import ai.timefold.solver.core.api.domain.specification.ValueRangeSpecification; +import ai.timefold.solver.core.api.domain.specification.VariableSpecification; +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.config.solver.PreviewFeature; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; +import ai.timefold.solver.core.impl.domain.common.LookupStrategyResolver; +import ai.timefold.solver.core.impl.domain.common.accessor.LambdaMemberAccessor; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; +import ai.timefold.solver.core.impl.domain.policy.DescriptorPolicy; +import ai.timefold.solver.core.impl.domain.solution.OverridesBasedConstraintWeightSupplier; +import ai.timefold.solver.core.impl.domain.solution.cloner.LambdaBasedSolutionCloner; +import ai.timefold.solver.core.impl.domain.variable.IndexShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.cascade.CascadingUpdateShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.declarative.DeclarativeShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.declarative.ShadowVariablesInconsistentVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.ShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.inverserelation.InverseRelationShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.NextElementShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.nextprev.PreviousElementShadowVariableDescriptor; +import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.ComparatorFactorySelectionSorter; +import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.ComparatorSelectionSorter; + +/** + * Converts a {@link PlanningSpecification} into a fully initialized {@link SolutionDescriptor} + * without calling {@code processAnnotations()}. + *

+ * This class is placed in the {@code SolutionDescriptor}'s package to access package-private members. + */ +public final class SpecificationCompiler { + + private SpecificationCompiler() { + } + + /** + * Compile a specification from the programmatic API (no annotations). + */ + @SuppressWarnings("unchecked") + public static SolutionDescriptor compile( + PlanningSpecification spec, + Set enabledPreviewFeatureSet) { + return compile(spec, enabledPreviewFeatureSet, DomainAccessType.FORCE_REFLECTION, + Collections.emptyMap(), false); + } + + /** + * Compile a specification, optionally delegating entity-level processing to annotations. + * + * @param annotationBasedEntities if true, entity descriptors will use {@code processAnnotations()} + * instead of building from specification data. Used when the specification was built from annotations. + */ + @SuppressWarnings("unchecked") + public static SolutionDescriptor compile( + PlanningSpecification spec, + Set enabledPreviewFeatureSet, + DomainAccessType domainAccessType, + Map memberAccessorMap, + boolean annotationBasedEntities) { + + memberAccessorMap = memberAccessorMap != null ? memberAccessorMap : Collections.emptyMap(); + + // 1. Create SolutionDescriptor shell + var solutionDescriptor = new SolutionDescriptor<>(spec.solutionClass(), memberAccessorMap); + + // 2. Create and configure DescriptorPolicy + var descriptorPolicy = new DescriptorPolicy(); + if (enabledPreviewFeatureSet != null) { + descriptorPolicy.setEnabledPreviewFeatureSet(enabledPreviewFeatureSet); + } + descriptorPolicy.setDomainAccessType(domainAccessType); + descriptorPolicy.setMemberAccessorFactory(solutionDescriptor.getMemberAccessorFactory()); + solutionDescriptor.setDomainAccessType(domainAccessType); + solutionDescriptor.setLookUpStrategyResolver(new LookupStrategyResolver(descriptorPolicy)); + + // 3. Build score descriptor (with bendable score support) + var scoreSpec = spec.score(); + var scoreMemberAccessor = new LambdaMemberAccessor( + "score", spec.solutionClass(), + (Class) scoreSpec.scoreType(), scoreSpec.scoreType(), + scoreSpec.getter(), scoreSpec.setter()); + var scoreDescriptor = descriptorPolicy.buildScoreDescriptorFromType( + scoreMemberAccessor, (Class) scoreSpec.scoreType(), + scoreSpec.bendableHardLevelsSize(), scoreSpec.bendableSoftLevelsSize()); + solutionDescriptor.setScoreDescriptor(scoreDescriptor); + + // 4. Register problem fact accessors + for (var factSpec : spec.facts()) { + Type factGenericType = factSpec.genericType(); + Class factReturnType; + if (factSpec.isCollection()) { + if (factGenericType instanceof ParameterizedType pt) { + factReturnType = (Class) pt.getRawType(); + } else { + factReturnType = Collection.class; + if (factGenericType == null) { + factGenericType = new SyntheticParameterizedType(Collection.class, + new Type[] { Object.class }); + } + } + } else { + factReturnType = factGenericType instanceof Class cls ? cls : Object.class; + } + var factAccessor = new LambdaMemberAccessor( + factSpec.name(), spec.solutionClass(), + factReturnType, + factGenericType, + factSpec.getter(), null); + if (factSpec.isCollection()) { + solutionDescriptor.getProblemFactCollectionMemberAccessorMap() + .put(factSpec.name(), factAccessor); + } else { + solutionDescriptor.getProblemFactMemberAccessorMap() + .put(factSpec.name(), factAccessor); + } + } + + // 5. Register entity collection/member accessors + for (var ecSpec : spec.entityCollections()) { + if (ecSpec.isSingular()) { + // Singular @PlanningEntityProperty goes into entityMemberAccessorMap + // The spec getter wraps the entity in List.of(), so unwrap it for the raw accessor + var collectionGetter = ecSpec.getter(); + java.util.function.Function rawGetter = solution -> { + var collection = collectionGetter.apply((Solution_) solution); + return (collection != null && !collection.isEmpty()) ? collection.iterator().next() : null; + }; + var ecAccessor = new LambdaMemberAccessor( + ecSpec.name(), spec.solutionClass(), + Object.class, null, + rawGetter, null); + solutionDescriptor.getEntityMemberAccessorMap() + .put(ecSpec.name(), ecAccessor); + } else { + var ecAccessor = new LambdaMemberAccessor( + ecSpec.name(), spec.solutionClass(), + Collection.class, null, + ecSpec.getter(), null); + solutionDescriptor.getEntityCollectionMemberAccessorMap() + .put(ecSpec.name(), ecAccessor); + } + } + + // 6. Register constraint weight overrides + if (spec.constraintWeights() != null) { + var cwAccessor = new LambdaMemberAccessor( + "constraintWeightOverrides", spec.solutionClass(), + ConstraintWeightOverrides.class, null, + spec.constraintWeights().getter(), null); + solutionDescriptor.setConstraintWeightSupplier( + OverridesBasedConstraintWeightSupplier.create(solutionDescriptor, cwAccessor)); + } + + // 7. Register value range providers in the policy (solution-level only for annotation path) + for (var vrSpec : spec.valueRanges()) { + if (!vrSpec.isEntityScoped()) { + var vrAccessor = createValueRangeAccessor(vrSpec, spec.solutionClass()); + descriptorPolicy.addFromSolutionValueRangeProvider(vrSpec.id(), vrAccessor); + } + } + + if (annotationBasedEntities) { + // For annotation-based entities, delegate to EntityDescriptor.processAnnotations() + compileWithAnnotationBasedEntities(spec, solutionDescriptor, descriptorPolicy); + } else { + // For programmatic entities, build from specification data + compileWithSpecificationEntities(spec, solutionDescriptor, descriptorPolicy); + } + + // Build cloner + buildAndSetCloner(spec, solutionDescriptor); + + return solutionDescriptor; + } + + /** + * Annotation-based entity compilation: entities process their own annotations. + * Solution-level concerns come from the specification. + */ + private static void compileWithAnnotationBasedEntities( + PlanningSpecification spec, + SolutionDescriptor solutionDescriptor, + DescriptorPolicy descriptorPolicy) { + + // Build entity class list from the spec + var entityClassList = new ArrayList>(); + for (var entitySpec : spec.entities()) { + entityClassList.add(entitySpec.entityClass()); + } + + // Let each entity descriptor process its own annotations + for (var entityClass : entityClassList) { + var entityDescriptor = descriptorPolicy.buildEntityDescriptor(solutionDescriptor, entityClass); + entityDescriptor.processAnnotations(descriptorPolicy); + } + + // Link descriptors (same as afterAnnotationsProcessed in SolutionDescriptor) + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + entityDescriptor.linkEntityDescriptors(descriptorPolicy); + } + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + entityDescriptor.linkVariableDescriptors(descriptorPolicy); + } + + solutionDescriptor.determineGlobalShadowOrder(); + + var problemFactOrEntityClassSet = collectProblemFactOrEntityClasses(solutionDescriptor); + solutionDescriptor.setProblemFactOrEntityClassSet(problemFactOrEntityClassSet); + + var listVarDescriptors = findListVariableDescriptors(solutionDescriptor); + solutionDescriptor.setListVariableDescriptorList(listVarDescriptors); + if (listVarDescriptors.size() > 1) { + throw new UnsupportedOperationException( + "Defining multiple list variables (%s) across the model is currently not supported." + .formatted(listVarDescriptors)); + } + + // Initialize constraint weight supplier if needed + if (solutionDescriptor.getConstraintWeightSupplier() != null) { + solutionDescriptor.getConstraintWeightSupplier().initialize(solutionDescriptor, + descriptorPolicy.getMemberAccessorFactory(), descriptorPolicy.getDomainAccessType()); + } + } + + /** + * Programmatic entity compilation: entities built from specification data. + */ + private static void compileWithSpecificationEntities( + PlanningSpecification spec, + SolutionDescriptor solutionDescriptor, + DescriptorPolicy descriptorPolicy) { + + // Register entity-scoped value ranges + for (var vrSpec : spec.valueRanges()) { + if (vrSpec.isEntityScoped()) { + var vrAccessor = createValueRangeAccessor(vrSpec, vrSpec.ownerClass()); + descriptorPolicy.addFromEntityValueRangeProvider(vrSpec.id(), vrAccessor); + } + } + + // Create entity descriptors + for (var entitySpec : spec.entities()) { + int variableOrdinal = 0; + var entityDescriptor = descriptorPolicy.buildEntityDescriptor( + solutionDescriptor, entitySpec.entityClass()); + entityDescriptor.initializeVariableMaps(); + + // Register entity-scoped value range providers + for (var vrSpec : entitySpec.entityScopedValueRanges()) { + var vrAccessor = createValueRangeAccessor(vrSpec, entitySpec.entityClass()); + descriptorPolicy.addFromEntityValueRangeProvider(vrSpec.id(), vrAccessor); + } + + // Create genuine variable descriptors + for (var varSpec : entitySpec.variables()) { + variableOrdinal = createGenuineVariable( + entityDescriptor, descriptorPolicy, varSpec, variableOrdinal); + } + + // Create shadow variable descriptors + var cascadingGroupMap = new java.util.HashMap>(); + for (var shadowSpec : entitySpec.shadows()) { + variableOrdinal = createShadowVariable( + entityDescriptor, descriptorPolicy, shadowSpec, variableOrdinal, cascadingGroupMap); + } + + // Set up pinning + if (entitySpec.pinnedPredicate() != null) { + var predicate = (java.util.function.Predicate) entitySpec.pinnedPredicate(); + entityDescriptor.addPinnedPredicate(predicate); + } + + // Set up pin-to-index + if (entitySpec.pinToIndexFunction() != null) { + var pinToIndex = (java.util.function.ToIntFunction) entitySpec.pinToIndexFunction(); + entityDescriptor.setPlanningPinToIndexReader(pinToIndex::applyAsInt); + } + + // Set up difficulty sorting + if (entitySpec.difficultyComparator() != null) { + @SuppressWarnings({ "unchecked", "rawtypes" }) + var sorter = new ComparatorSelectionSorter( + (java.util.Comparator) entitySpec.difficultyComparator(), DESCENDING); + entityDescriptor.setDescendingSorter(sorter); + } else if (entitySpec.difficultyComparatorFactoryClass() != null) { + @SuppressWarnings({ "unchecked", "rawtypes" }) + var factory = (ComparatorFactory) ConfigUtils.newInstance( + () -> entitySpec.entityClass().toString(), "comparatorFactoryClass", + (Class) entitySpec.difficultyComparatorFactoryClass()); + entityDescriptor.setDescendingSorter( + new ComparatorFactorySelectionSorter<>(factory, DESCENDING)); + } + + // Register planning ID accessor + if (entitySpec.planningIdGetter() != null) { + var idAccessor = new LambdaMemberAccessor( + "planningId", entitySpec.entityClass(), + Comparable.class, null, + entitySpec.planningIdGetter(), null); + solutionDescriptor.getPlanningIdMemberAccessorMap() + .put(entitySpec.entityClass(), idAccessor); + } + } + + // Link entity descriptors (resolves inheritance) + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + entityDescriptor.linkEntityDescriptors(descriptorPolicy); + } + + // Process value range refs for each genuine variable + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + for (var variableDescriptor : entityDescriptor.getDeclaredGenuineVariableDescriptors()) { + var entitySpec = findEntitySpec(spec, entityDescriptor.getEntityClass()); + if (entitySpec != null) { + var varSpec = findVariableSpec(entitySpec, variableDescriptor.getVariableName()); + if (varSpec != null) { + // Always call processValueRangeRefsFromSpecification — when refs are empty, + // it falls through to anonymous matching by type. + variableDescriptor.processValueRangeRefsFromSpecification( + descriptorPolicy, varSpec.valueRangeRefs().toArray(new String[0])); + } + } + } + } + + // Link shadow variable sources + linkShadowVariables(solutionDescriptor, spec); + + // For declarative shadows, call linkVariableDescriptors to resolve source paths + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + for (var shadowDescriptor : entityDescriptor.getDeclaredShadowVariableDescriptors()) { + if (shadowDescriptor instanceof DeclarativeShadowVariableDescriptor) { + shadowDescriptor.linkVariableDescriptors(descriptorPolicy); + } else if (shadowDescriptor instanceof CascadingUpdateShadowVariableDescriptor cascading) { + cascading.completeTargetLinking(); + } + } + } + + solutionDescriptor.determineGlobalShadowOrder(); + + var problemFactOrEntityClassSet = collectProblemFactOrEntityClasses(solutionDescriptor); + solutionDescriptor.setProblemFactOrEntityClassSet(problemFactOrEntityClassSet); + + var listVarDescriptors = findListVariableDescriptors(solutionDescriptor); + solutionDescriptor.setListVariableDescriptorList(listVarDescriptors); + if (listVarDescriptors.size() > 1) { + throw new UnsupportedOperationException( + "Defining multiple list variables (%s) across the model is currently not supported." + .formatted(listVarDescriptors)); + } + } + + private static SequencedSet> collectProblemFactOrEntityClasses( + SolutionDescriptor solutionDescriptor) { + var entityClassStream = solutionDescriptor.getEntityDescriptors().stream() + .map(EntityDescriptor::getEntityClass); + var factClassStream = solutionDescriptor.getProblemFactMemberAccessorMap().values().stream() + .map(MemberAccessor::getType); + var factCollectionClassStream = solutionDescriptor.getProblemFactCollectionMemberAccessorMap().values().stream() + .map(accessor -> { + var genericType = accessor.getGenericType(); + if (genericType instanceof ParameterizedType paramType) { + var typeArgs = paramType.getActualTypeArguments(); + if (typeArgs.length > 0 && typeArgs[0] instanceof Class elementType) { + return elementType; + } + } + return (Class) Object.class; + }); + + var constraintWeightClassStream = solutionDescriptor.getConstraintWeightSupplier() != null + ? Stream.of(solutionDescriptor.getConstraintWeightSupplier().getProblemFactClass()) + : Stream.> empty(); + + return Stream.of(entityClassStream, factClassStream, factCollectionClassStream, constraintWeightClassStream) + .flatMap(s -> s) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static List> findListVariableDescriptors( + SolutionDescriptor solutionDescriptor) { + var listVarDescriptors = new ArrayList>(); + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + for (var varDesc : entityDescriptor.getGenuineVariableDescriptorList()) { + if (varDesc instanceof ListVariableDescriptor listVarDesc) { + listVarDescriptors.add(listVarDesc); + } + } + } + return listVarDescriptors; + } + + @SuppressWarnings("unchecked") + private static void buildAndSetCloner( + PlanningSpecification spec, + SolutionDescriptor solutionDescriptor) { + if (spec.cloning() != null && spec.cloning().customCloner() != null) { + solutionDescriptor.setSolutionCloner(spec.cloning().customCloner()); + } else if (spec.cloning() != null) { + // solutionFactory may be null for interface/abstract solution classes; + // LambdaBasedSolutionCloner handles this by falling back to runtime class instantiation. + solutionDescriptor.setSolutionCloner(new LambdaBasedSolutionCloner<>(spec.cloning())); + } else { + // No cloning configuration — build a minimal one from entity classes. + // LambdaBasedSolutionCloner will use runtime reflection for all field access. + Set> entityClasses = spec.entities().stream() + .map(e -> (Class) e.entityClass()) + .collect(Collectors.toSet()); + var minimalCloning = new CloningSpecification( + null, List.of(), Map.of(), entityClasses, Set.of(), null); + solutionDescriptor.setSolutionCloner(new LambdaBasedSolutionCloner<>(minimalCloning)); + } + } + + private static MemberAccessor createValueRangeAccessor( + ValueRangeSpecification vrSpec, Class ownerClass) { + Type genericType = vrSpec.genericReturnType() != null + ? vrSpec.genericReturnType() + : new SyntheticParameterizedType(Collection.class, new Type[] { Object.class }); + Class returnType; + if (genericType instanceof ParameterizedType pt) { + returnType = (Class) pt.getRawType(); + } else if (genericType instanceof Class cls) { + returnType = cls; + } else { + returnType = Collection.class; + } + return new LambdaMemberAccessor( + vrSpec.id() != null ? vrSpec.id() : "anonymousValueRange", + ownerClass, + returnType, + genericType, + vrSpec.getter(), null); + } + + @SuppressWarnings("unchecked") + private static int createGenuineVariable( + EntityDescriptor entityDescriptor, + DescriptorPolicy descriptorPolicy, + VariableSpecification varSpec, + int ordinal) { + Type genericType; + Class accessorType; + if (varSpec.isList()) { + accessorType = List.class; + genericType = new SyntheticParameterizedType(List.class, new Type[] { varSpec.valueType() }); + } else { + accessorType = varSpec.valueType(); + genericType = varSpec.valueType(); + } + + var accessor = new LambdaMemberAccessor( + varSpec.name(), entityDescriptor.getEntityClass(), + accessorType, genericType, + varSpec.getter(), varSpec.setter()); + + GenuineVariableDescriptor variableDescriptor; + if (varSpec.isList()) { + var listDesc = new ListVariableDescriptor(ordinal, entityDescriptor, accessor); + listDesc.setAllowsUnassignedValues(varSpec.allowsUnassigned()); + variableDescriptor = listDesc; + } else { + var basicDesc = new BasicVariableDescriptor(ordinal, entityDescriptor, accessor); + basicDesc.setAllowsUnassigned(varSpec.allowsUnassigned()); + variableDescriptor = basicDesc; + } + if (varSpec.strengthComparator() != null && varSpec.strengthComparatorFactoryClass() != null) { + throw new IllegalStateException( + "The entityClass (%s) property (%s) cannot have a comparatorClass (%s) and a comparatorFactoryClass (%s) at the same time." + .formatted(entityDescriptor.getEntityClass(), varSpec.name(), + varSpec.strengthComparator().getClass().getName(), + varSpec.strengthComparatorFactoryClass().getName())); + } + if (varSpec.strengthComparator() != null) { + variableDescriptor.setStrengthSorting(varSpec.strengthComparator()); + } else if (varSpec.strengthComparatorFactoryClass() != null) { + variableDescriptor.setStrengthSortingFromFactory(varSpec.strengthComparatorFactoryClass()); + } + entityDescriptor.addGenuineVariableDescriptor(variableDescriptor); + return ordinal + 1; + } + + @SuppressWarnings("unchecked") + private static int createShadowVariable( + EntityDescriptor entityDescriptor, + DescriptorPolicy descriptorPolicy, + ShadowSpecification shadowSpec, + int ordinal, + Map> cascadingGroupMap) { + + var accessor = new LambdaMemberAccessor( + shadowSpec.name(), entityDescriptor.getEntityClass(), + shadowSpec.type(), shadowSpec.type(), + shadowSpec.getter(), shadowSpec.setter()); + + ShadowVariableDescriptor shadowDescriptor; + + switch (shadowSpec) { + case ShadowSpecification.InverseRelation inv -> { + shadowDescriptor = new InverseRelationShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + } + case ShadowSpecification.Index idx -> { + shadowDescriptor = new IndexShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + } + case ShadowSpecification.PreviousElement prev -> { + shadowDescriptor = new PreviousElementShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + } + case ShadowSpecification.NextElement next -> { + shadowDescriptor = new NextElementShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + } + case ShadowSpecification.Declarative decl -> { + var declDesc = new DeclarativeShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + MemberAccessor calculatorAccessor; + if (decl.supplierMethod() != null) { + // Annotation-based path: use the actual method to support both 0-param and 1-param suppliers + calculatorAccessor = descriptorPolicy.getMemberAccessorFactory().buildAndCacheMemberAccessor( + decl.supplierMethod(), + ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_READ_METHOD_WITH_OPTIONAL_PARAMETER, + null, descriptorPolicy.getDomainAccessType()); + } else { + // Programmatic path: use the lambda directly + calculatorAccessor = new LambdaMemberAccessor( + shadowSpec.name() + "Calculator", entityDescriptor.getEntityClass(), + shadowSpec.type(), shadowSpec.type(), + decl.supplier(), null); + } + declDesc.setSpecificationData( + calculatorAccessor, + decl.sourcePaths().toArray(new String[0]), + decl.alignmentKey()); + shadowDescriptor = declDesc; + } + case ShadowSpecification.CascadingUpdate casc -> { + // Check if a CascadingUpdate descriptor with the same targetMethodName already exists + if (casc.targetMethodName() != null + && cascadingGroupMap != null + && cascadingGroupMap.containsKey(casc.targetMethodName())) { + var existingCascading = cascadingGroupMap.get(casc.targetMethodName()); + existingCascading.addTargetVariable(entityDescriptor, accessor); + // Register in shadow map only (not cascading map) — secondary target + var cascDesc = new CascadingUpdateShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + entityDescriptor.addShadowVariableDescriptor(cascDesc, false); + return ordinal + 1; + } + var cascDesc = new CascadingUpdateShadowVariableDescriptor<>(ordinal, entityDescriptor, accessor); + if (casc.updateMethod() != null) { + var updateAccessor = new LambdaMemberAccessor( + shadowSpec.name() + "Update", entityDescriptor.getEntityClass(), + void.class, void.class, + entity -> { + ((java.util.function.Consumer) casc.updateMethod()).accept(entity); + return null; + }, null); + cascDesc.setTargetMethod(updateAccessor); + } + if (casc.targetMethodName() != null && cascadingGroupMap != null) { + cascadingGroupMap.put(casc.targetMethodName(), cascDesc); + } + shadowDescriptor = cascDesc; + } + case ShadowSpecification.Inconsistent inc -> { + shadowDescriptor = new ShadowVariablesInconsistentVariableDescriptor<>(ordinal, entityDescriptor, accessor); + } + } + + entityDescriptor.addShadowVariableDescriptor(shadowDescriptor); + return ordinal + 1; + } + + private static void linkShadowVariables( + SolutionDescriptor solutionDescriptor, + PlanningSpecification spec) { + + for (var entitySpec : spec.entities()) { + var entityDescriptor = solutionDescriptor.findEntityDescriptor(entitySpec.entityClass()); + if (entityDescriptor == null) { + continue; + } + + for (var shadowSpec : entitySpec.shadows()) { + var shadowDescriptor = entityDescriptor.getShadowVariableDescriptor(shadowSpec.name()); + if (shadowDescriptor == null) { + continue; + } + + switch (shadowSpec) { + case ShadowSpecification.InverseRelation inv -> { + var sourceVar = findVariableDescriptorByName( + solutionDescriptor, inv.sourceVariableName()); + if (sourceVar != null) { + ((InverseRelationShadowVariableDescriptor) shadowDescriptor) + .linkSourceVariable(sourceVar); + } + } + case ShadowSpecification.Index idx -> { + var sourceVar = findListVariableDescriptorByName( + solutionDescriptor, idx.sourceVariableName()); + if (sourceVar != null) { + ((IndexShadowVariableDescriptor) shadowDescriptor) + .linkSourceVariable(sourceVar); + } + } + case ShadowSpecification.PreviousElement prev -> { + var sourceVar = findListVariableDescriptorByName( + solutionDescriptor, prev.sourceVariableName()); + if (sourceVar != null) { + ((PreviousElementShadowVariableDescriptor) shadowDescriptor) + .linkSourceVariable(sourceVar); + } + } + case ShadowSpecification.NextElement next -> { + var sourceVar = findListVariableDescriptorByName( + solutionDescriptor, next.sourceVariableName()); + if (sourceVar != null) { + ((NextElementShadowVariableDescriptor) shadowDescriptor) + .linkSourceVariable(sourceVar); + } + } + case ShadowSpecification.Declarative decl -> { + // Declarative shadows are linked via linkVariableDescriptors() later + } + case ShadowSpecification.CascadingUpdate casc -> { + // Cascading updates are linked via completeTargetLinking() later + } + case ShadowSpecification.Inconsistent inc -> { + // No linking needed + } + } + } + } + } + + private static VariableDescriptor findVariableDescriptorByName( + SolutionDescriptor solutionDescriptor, String variableName) { + for (var entityDescriptor : solutionDescriptor.getEntityDescriptors()) { + var varDesc = entityDescriptor.getVariableDescriptor(variableName); + if (varDesc != null) { + return varDesc; + } + } + return null; + } + + private static ListVariableDescriptor findListVariableDescriptorByName( + SolutionDescriptor solutionDescriptor, String variableName) { + var varDesc = findVariableDescriptorByName(solutionDescriptor, variableName); + if (varDesc instanceof ListVariableDescriptor listVarDesc) { + return listVarDesc; + } + return null; + } + + private static EntitySpecification findEntitySpec( + PlanningSpecification spec, Class entityClass) { + for (var entitySpec : spec.entities()) { + if (entitySpec.entityClass().equals(entityClass)) { + return entitySpec; + } + } + return null; + } + + private static VariableSpecification findVariableSpec( + EntitySpecification entitySpec, String variableName) { + for (var varSpec : entitySpec.variables()) { + if (varSpec.name().equals(variableName)) { + return varSpec; + } + } + return null; + } + + /** + * A synthetic ParameterizedType used for list variables (e.g., List<ElementType>). + */ + record SyntheticParameterizedType(Class rawType, Type[] typeArgs) implements ParameterizedType { + @Override + public Type[] getActualTypeArguments() { + return typeArgs; + } + + @Override + public Type getRawType() { + return rawType; + } + + @Override + public Type getOwnerType() { + return null; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactory.java new file mode 100644 index 00000000000..8d69e07756e --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactory.java @@ -0,0 +1,2153 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_GETTER_METHOD; +import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_GETTER_METHOD_WITH_SETTER; +import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_READ_METHOD; +import static ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType.FIELD_OR_READ_METHOD_WITH_OPTIONAL_PARAMETER; +import static ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor.extractInheritedClasses; +import static ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptorValidator.assertNotMixedInheritance; +import static ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptorValidator.assertSingleInheritance; +import static ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptorValidator.assertValidPlanningVariables; + +import java.lang.annotation.Annotation; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.function.ToIntFunction; + +import ai.timefold.solver.core.api.domain.common.ComparatorFactory; +import ai.timefold.solver.core.api.domain.common.PlanningId; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.entity.PlanningPin; +import ai.timefold.solver.core.api.domain.entity.PlanningPinToIndex; +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningEntityProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.ProblemFactProperty; +import ai.timefold.solver.core.api.domain.solution.cloner.DeepPlanningClone; +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.CloneableClassDescriptor; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.DeepCloneDecision; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.PropertyCopyDescriptor; +import ai.timefold.solver.core.api.domain.specification.ConstraintWeightSpecification; +import ai.timefold.solver.core.api.domain.specification.EntityCollectionSpecification; +import ai.timefold.solver.core.api.domain.specification.EntitySpecification; +import ai.timefold.solver.core.api.domain.specification.FactSpecification; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.domain.specification.ScoreSpecification; +import ai.timefold.solver.core.api.domain.specification.ShadowSpecification; +import ai.timefold.solver.core.api.domain.specification.ValueRangeSpecification; +import ai.timefold.solver.core.api.domain.specification.VariableSpecification; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.domain.variable.CascadingUpdateShadowVariable; +import ai.timefold.solver.core.api.domain.variable.IndexShadowVariable; +import ai.timefold.solver.core.api.domain.variable.InverseRelationShadowVariable; +import ai.timefold.solver.core.api.domain.variable.NextElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningListVariable; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; +import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.api.domain.variable.ShadowVariablesInconsistent; +import ai.timefold.solver.core.api.score.Score; +import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; +import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorFactory; +import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType; +import ai.timefold.solver.core.impl.domain.solution.cloner.DeepCloningUtils; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; + +/** + * Scans annotated domain classes and produces a {@link PlanningSpecification}. + * This unifies the annotation path through the same intermediate layer as the programmatic API. + */ +public final class AnnotationSpecificationFactory { + + @SuppressWarnings("unchecked") + private static final Class[] VARIABLE_ANNOTATION_CLASSES = + new Class[] { PlanningVariable.class, PlanningListVariable.class, InverseRelationShadowVariable.class, + IndexShadowVariable.class, PreviousElementShadowVariable.class, NextElementShadowVariable.class, + ShadowVariable.class, CascadingUpdateShadowVariable.class, ShadowVariablesInconsistent.class }; + + private static final MethodHandles.Lookup FRAMEWORK_LOOKUP = MethodHandles.lookup(); + + private AnnotationSpecificationFactory() { + } + + // ************************************************************************ + // LambdaMetafactory helpers + // ************************************************************************ + + /** + * Creates a direct lambda getter from a Method using LambdaMetafactory. + * The MethodHandle is consumed during lambda class generation, not stored. + */ + @SuppressWarnings("unchecked") + static Function createGetter(MethodHandles.Lookup lookup, Method method) throws Throwable { + var handle = lookup.unreflect(method); + var callSite = LambdaMetafactory.metafactory( + lookup, "apply", + MethodType.methodType(Function.class), + MethodType.methodType(Object.class, Object.class), + handle, + MethodType.methodType(method.getReturnType(), method.getDeclaringClass())); + return (Function) callSite.getTarget().invokeExact(); + } + + /** + * Creates a direct lambda setter from a Method using LambdaMetafactory. + */ + @SuppressWarnings("unchecked") + static BiConsumer createSetter(MethodHandles.Lookup lookup, Method method) throws Throwable { + var handle = lookup.unreflect(method); + // The instantiated type must use boxed wrapper types for primitives because + // LambdaMetafactory validates that each instantiated parameter is a subtype of Object + // (the SAM parameter type), and primitives are not subtypes of Object. + var paramType = method.getParameterTypes()[0]; + var instantiatedParamType = paramType.isPrimitive() + ? MethodType.methodType(paramType).wrap().returnType() + : paramType; + var callSite = LambdaMetafactory.metafactory( + lookup, "accept", + MethodType.methodType(BiConsumer.class), + MethodType.methodType(void.class, Object.class, Object.class), + handle, + MethodType.methodType(void.class, method.getDeclaringClass(), instantiatedParamType)); + return (BiConsumer) callSite.getTarget().invokeExact(); + } + + /** + * Creates a direct lambda getter from a Field using MethodHandle. + * Cannot use LambdaMetafactory here because field MethodHandles (getField/putField) + * are not supported by LambdaMetafactory — only method invocations are. + * The MethodHandle is converted to a generic form so the JIT can still optimize it. + */ + static Function createFieldGetter(MethodHandles.Lookup lookup, Field field) throws Throwable { + var handle = lookup.unreflectGetter(field) + .asType(MethodType.methodType(Object.class, Object.class)); + return bean -> { + try { + return handle.invokeExact(bean); + } catch (Throwable e) { + throw new IllegalStateException("Failed to read field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + } + + /** + * Creates a direct lambda setter from a Field using MethodHandle. + * Cannot use LambdaMetafactory here because field MethodHandles (getField/putField) + * are not supported by LambdaMetafactory — only method invocations are. + */ + static BiConsumer createFieldSetter(MethodHandles.Lookup lookup, Field field) throws Throwable { + var handle = lookup.unreflectSetter(field) + .asType(MethodType.methodType(void.class, Object.class, Object.class)); + return (bean, value) -> { + try { + handle.invokeExact(bean, value); + } catch (Throwable e) { + throw new IllegalStateException("Failed to write field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + } + + /** + * Attempts to create a fast LambdaMetafactory-based getter for a member. + * Returns null if the optimization is not possible (e.g., due to access restrictions). + */ + private static Function tryCreateFastGetter(MethodHandles.Lookup lookup, Member member) { + try { + if (member instanceof Method method) { + method.setAccessible(true); + return createGetter(lookup, method); + } else if (member instanceof Field field) { + var getterMethod = ReflectionHelper.getGetterMethod(field.getDeclaringClass(), field.getName()); + if (getterMethod != null) { + getterMethod.setAccessible(true); + return createGetter(lookup, getterMethod); + } + field.setAccessible(true); + return createFieldGetter(lookup, field); + } + } catch (Throwable e) { + // Fall back to accessor-based wrapping + // Fall back silently + } + return null; + } + + /** + * Attempts to create a fast LambdaMetafactory-based setter for a member. + * Returns null if the optimization is not possible. + */ + private static BiConsumer tryCreateFastSetter(MethodHandles.Lookup lookup, Member member, + Class propertyType, String propertyName) { + try { + if (member instanceof Method method) { + var setterMethod = ReflectionHelper.getDeclaredSetterMethod( + method.getDeclaringClass(), propertyType, propertyName); + if (setterMethod != null) { + setterMethod.setAccessible(true); + return createSetter(lookup, setterMethod); + } + } else if (member instanceof Field field) { + var setterMethod = ReflectionHelper.getDeclaredSetterMethod( + field.getDeclaringClass(), propertyType, propertyName); + if (setterMethod != null) { + setterMethod.setAccessible(true); + return createSetter(lookup, setterMethod); + } + field.setAccessible(true); + return createFieldSetter(lookup, field); + } + } catch (Throwable e) { + // Fall back to accessor-based wrapping + } + return null; + } + + // ************************************************************************ + // Existing annotation path (with LambdaMetafactory optimization) + // ************************************************************************ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PlanningSpecification fromAnnotations( + Class solutionClass, + List> entityClassList, + DomainAccessType domainAccessType, + Map gizmoMemberAccessorMap) { + + gizmoMemberAccessorMap = gizmoMemberAccessorMap != null ? gizmoMemberAccessorMap : Collections.emptyMap(); + // LambdaMetafactory fast path only works when the framework lookup can see application classes. + // In Quarkus with FORCE_REFLECTION, the framework classloader can't see app classes at invocation time. + var useFastPath = domainAccessType != DomainAccessType.FORCE_REFLECTION; + + var memberAccessorFactory = new MemberAccessorFactory(gizmoMemberAccessorMap); + + ScoreSpecification scoreSpec = null; + var facts = new ArrayList>(); + var entityCollections = new ArrayList>(); + var valueRanges = new ArrayList>(); + ConstraintWeightSpecification constraintWeightsSpec = null; + CloningSpecification cloningSpec = null; + + // Process @PlanningSolution annotation for custom cloner + var solutionAnnotation = extractPlanningSolutionAnnotation(solutionClass); + var solutionClonerClass = solutionAnnotation.solutionCloner(); + if (solutionClonerClass != PlanningSolution.NullSolutionCloner.class) { + var customCloner = (SolutionCloner) ConfigUtils.newInstance( + () -> solutionClass.toString(), "solutionClonerClass", solutionClonerClass); + cloningSpec = new CloningSpecification<>(null, null, null, null, null, customCloner); + } + + // Detect ConstraintWeightOverrides field (unannotated) - per-class iteration matching old behavior + for (var lineageClass : ConfigUtils.getAllParents(solutionClass)) { + var constraintWeightFieldList = new ArrayList(); + for (var member : ConfigUtils.getDeclaredMembers(lineageClass)) { + if (member instanceof Field field + && ConstraintWeightOverrides.class.isAssignableFrom(field.getType())) { + constraintWeightFieldList.add(field); + } + } + switch (constraintWeightFieldList.size()) { + case 0 -> { + // Do nothing. + } + case 1 -> { + if (constraintWeightsSpec != null) { + // The bottom-most class wins, they are parsed first due to ConfigUtil.getAllParents(). + throw new IllegalStateException( + "The solutionClass (%s) has a field of type (%s) which was already found on its parent class." + .formatted(lineageClass, ConstraintWeightOverrides.class)); + } + var cwField = constraintWeightFieldList.getFirst(); + var accessor = buildAccessor(memberAccessorFactory, cwField, + FIELD_OR_GETTER_METHOD_WITH_SETTER, null, domainAccessType); + var cwGetter = fastOrSlowGetter(cwField, accessor, useFastPath); + constraintWeightsSpec = new ConstraintWeightSpecification<>( + solution -> (ConstraintWeightOverrides) cwGetter.apply(solution)); + } + default -> + throw new IllegalStateException("The solutionClass (%s) has more than one field (%s) of type %s." + .formatted(solutionClass, constraintWeightFieldList, ConstraintWeightOverrides.class)); + } + } + + // Scan annotated members on the solution class + var lineageClassList = ConfigUtils.getAllAnnotatedLineageClasses(solutionClass, PlanningSolution.class); + if (lineageClassList.isEmpty() && solutionClass.getSuperclass() != null + && solutionClass.getSuperclass().isAnnotationPresent(PlanningSolution.class)) { + lineageClassList = ConfigUtils.getAllAnnotatedLineageClasses( + solutionClass.getSuperclass(), PlanningSolution.class); + } + // Track seen member names for duplicate field+getter detection + var seenFactNames = new HashMap>(); + var seenEntityNames = new HashMap>(); + // Track member accessors for duplicate error messages + var seenFactAccessors = new HashMap(); + var seenEntityAccessors = new HashMap(); + MemberAccessor firstScoreAccessor = null; + + var potentiallyOverwritingMethodList = new ArrayList(); + for (var lineageClass : lineageClassList) { + var memberList = ConfigUtils.getDeclaredMembers(lineageClass); + for (var member : memberList) { + if (member instanceof Method method + && potentiallyOverwritingMethodList.stream().anyMatch( + m -> member.getName().equals(m.getName()) + && ReflectionHelper.isMethodOverwritten(method, m.getDeclaringClass()))) { + continue; + } + // @ValueRangeProvider on solution + if (((AnnotatedElement) member).isAnnotationPresent(ValueRangeProvider.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, ValueRangeProvider.class, domainAccessType); + var vrAnnotation = accessor.getAnnotation(ValueRangeProvider.class); + String id = vrAnnotation.id(); + if (id != null && id.isEmpty()) { + id = null; + } + valueRanges.add(new ValueRangeSpecification<>(id, + wrapGetter(accessor, member, useFastPath), solutionClass, false, accessor.getGenericType())); + } + // Fact/Entity/Score annotations + var annotationClass = ConfigUtils.extractAnnotationClass(member, + ProblemFactProperty.class, ProblemFactCollectionProperty.class, + PlanningEntityProperty.class, PlanningEntityCollectionProperty.class, PlanningScore.class); + if (annotationClass != null) { + if (annotationClass.equals(ProblemFactProperty.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, annotationClass, domainAccessType); + assertNoFieldAndGetterDuplicationOrConflict(solutionClass, accessor, annotationClass, + seenFactNames, seenFactAccessors, seenEntityNames, seenEntityAccessors); + seenFactNames.put(accessor.getName(), annotationClass); + seenFactAccessors.put(accessor.getName(), accessor); + // Validate entity-as-fact + var problemFactType = accessor.getType(); + if (problemFactType.isAnnotationPresent(PlanningEntity.class)) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s-annotated member (%s) that returns a @%s. + Maybe use @%s instead?""".formatted(solutionClass, annotationClass.getSimpleName(), + accessor.getName(), PlanningEntity.class.getSimpleName(), + PlanningEntityProperty.class.getSimpleName())); + } + facts.add(new FactSpecification<>(accessor.getName(), wrapGetter(accessor, member, useFastPath), + wrapSetter(accessor, member, useFastPath), false, accessor.getGenericType())); + } else if (annotationClass.equals(ProblemFactCollectionProperty.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, annotationClass, domainAccessType); + assertNoFieldAndGetterDuplicationOrConflict(solutionClass, accessor, annotationClass, + seenFactNames, seenFactAccessors, seenEntityNames, seenEntityAccessors); + seenFactNames.put(accessor.getName(), annotationClass); + seenFactAccessors.put(accessor.getName(), accessor); + // Validate collection type + var type = accessor.getType(); + if (!(Collection.class.isAssignableFrom(type) || type.isArray())) { + throw new IllegalStateException( + "The solutionClass (%s) has a @%s-annotated member (%s) that does not return a %s or an array." + .formatted(solutionClass, + ProblemFactCollectionProperty.class.getSimpleName(), + member, Collection.class.getSimpleName())); + } + // Validate entity-as-fact for collection + Class problemFactType; + if (type.isArray()) { + problemFactType = type.getComponentType(); + } else { + problemFactType = ConfigUtils.extractGenericTypeParameterOrFail( + PlanningSolution.class.getSimpleName(), + accessor.getDeclaringClass(), type, accessor.getGenericType(), + annotationClass, accessor.getName()); + } + if (problemFactType.isAnnotationPresent(PlanningEntity.class)) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s-annotated member (%s) that returns a @%s. + Maybe use @%s instead?""".formatted(solutionClass, annotationClass.getSimpleName(), + accessor.getName(), PlanningEntity.class.getSimpleName(), + PlanningEntityCollectionProperty.class.getSimpleName())); + } + facts.add(new FactSpecification<>(accessor.getName(), wrapGetter(accessor, member, useFastPath), + wrapSetter(accessor, member, useFastPath), true, accessor.getGenericType())); + } else if (annotationClass.equals(PlanningEntityProperty.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_GETTER_METHOD, annotationClass, domainAccessType); + assertNoFieldAndGetterDuplicationOrConflict(solutionClass, accessor, annotationClass, + seenFactNames, seenFactAccessors, seenEntityNames, seenEntityAccessors); + seenEntityNames.put(accessor.getName(), annotationClass); + seenEntityAccessors.put(accessor.getName(), accessor); + var entityGetter = fastOrSlowGetter(member, accessor, useFastPath); + var entitySetter = fastOrSlowSetter(member, accessor, useFastPath); + entityCollections.add(new EntityCollectionSpecification<>( + accessor.getName(), + solution -> { + var entity = entityGetter.apply(solution); + return entity != null ? List.of(entity) : Collections.emptyList(); + }, + (BiConsumer) (solution, value) -> { + // Singular entity: unwrap from the collection + if (value instanceof Collection coll) { + entitySetter.accept(solution, coll.isEmpty() ? null : coll.iterator().next()); + } else { + entitySetter.accept(solution, value); + } + }, + true)); + } else if (annotationClass.equals(PlanningEntityCollectionProperty.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_GETTER_METHOD, annotationClass, domainAccessType); + assertNoFieldAndGetterDuplicationOrConflict(solutionClass, accessor, annotationClass, + seenFactNames, seenFactAccessors, seenEntityNames, seenEntityAccessors); + seenEntityNames.put(accessor.getName(), annotationClass); + seenEntityAccessors.put(accessor.getName(), accessor); + // Validate collection type + var type = accessor.getType(); + if (!(Collection.class.isAssignableFrom(type) || type.isArray())) { + throw new IllegalStateException( + "The solutionClass (%s) has a @%s annotated member (%s) that does not return a %s or an array." + .formatted(solutionClass, + PlanningEntityCollectionProperty.class.getSimpleName(), + member, Collection.class.getSimpleName())); + } + entityCollections.add(new EntityCollectionSpecification<>( + accessor.getName(), wrapCollectionGetter(accessor, member, useFastPath), + wrapSetter(accessor, member, useFastPath), false)); + } else if (annotationClass.equals(PlanningScore.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_GETTER_METHOD_WITH_SETTER, PlanningScore.class, domainAccessType); + if (scoreSpec == null) { + // Bottom class wins. Bottom classes are parsed first due to ConfigUtil.getAllAnnotatedLineageClasses(). + firstScoreAccessor = accessor; + var scoreAnnotation = accessor.getAnnotation(PlanningScore.class); + int bendableHard = scoreAnnotation != null + ? scoreAnnotation.bendableHardLevelsSize() + : -1; + int bendableSoft = scoreAnnotation != null + ? scoreAnnotation.bendableSoftLevelsSize() + : -1; + scoreSpec = new ScoreSpecification<>( + (Class>) accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + bendableHard, bendableSoft); + } else { + // Duplicate score detection + if (!firstScoreAccessor.getName().equals(accessor.getName()) + || !firstScoreAccessor.equals(accessor) + || !firstScoreAccessor.getClass().equals(accessor.getClass())) { + throw new IllegalStateException( + "The solutionClass (" + solutionClass + + ") has a @" + PlanningScore.class.getSimpleName() + + " annotated member (" + accessor + + ") that is duplicated by another member (" + firstScoreAccessor + + ").\n" + + "Maybe the annotation is defined on both the field and its getter."); + } + } + } + } + } + potentiallyOverwritingMethodList.ensureCapacity( + potentiallyOverwritingMethodList.size() + memberList.size()); + memberList.stream().filter(Method.class::isInstance) + .forEach(m -> potentiallyOverwritingMethodList.add((Method) m)); + } + + // Validate at least one entity collection + if (entityCollections.isEmpty()) { + throw new IllegalStateException( + "The solutionClass (%s) must have at least 1 member with a %s annotation or a %s annotation." + .formatted(solutionClass, + PlanningEntityCollectionProperty.class.getSimpleName(), + PlanningEntityProperty.class.getSimpleName())); + } + + // Validate @PlanningScore exists + if (scoreSpec == null) { + throw new IllegalStateException(""" + The solutionClass (%s) must have 1 member with a @%s annotation. + Maybe add a getScore() method with a @%s annotation.""".formatted(solutionClass, + PlanningScore.class.getSimpleName(), PlanningScore.class.getSimpleName())); + } + + // Build entity specifications + var sortedEntityClassList = buildSortedEntityClassList(entityClassList); + + var entities = new ArrayList>(); + for (var entityClass : sortedEntityClassList) { + validateEntityInheritance(entityClass); + entities.add(buildEntitySpec(entityClass, solutionClass, memberAccessorFactory, domainAccessType, useFastPath)); + } + + // Build complete cloning spec (unless custom cloner is set) + if (cloningSpec == null) { + cloningSpec = buildCloningSpec(solutionClass, sortedEntityClassList, useFastPath, null); + } + + return new PlanningSpecification<>( + solutionClass, scoreSpec, + List.copyOf(facts), List.copyOf(entityCollections), + List.copyOf(valueRanges), List.copyOf(entities), + cloningSpec, constraintWeightsSpec); + } + + // ************************************************************************ + // Lookup-based annotation path + // ************************************************************************ + + /** + * Scans annotated domain classes and produces a {@link PlanningSpecification} + * using the provided {@link MethodHandles.Lookup} for member access. + *

+ * This allows package-private classes and methods to be used without {@code setAccessible()}. + * All getters and setters are generated via {@link LambdaMetafactory} for optimal performance. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public static PlanningSpecification fromAnnotations( + Class solutionClass, + List> entityClassList, + MethodHandles.Lookup lookup) { + + ScoreSpecification scoreSpec = null; + var facts = new ArrayList>(); + var entityCollections = new ArrayList>(); + var valueRanges = new ArrayList>(); + ConstraintWeightSpecification constraintWeightsSpec = null; + CloningSpecification cloningSpec = null; + + // Process @PlanningSolution annotation for custom cloner + var solutionAnnotation = extractPlanningSolutionAnnotation(solutionClass); + var solutionClonerClass = solutionAnnotation.solutionCloner(); + if (solutionClonerClass != PlanningSolution.NullSolutionCloner.class) { + var customCloner = (SolutionCloner) ConfigUtils.newInstance( + () -> solutionClass.toString(), "solutionClonerClass", solutionClonerClass); + cloningSpec = new CloningSpecification<>(null, null, null, null, null, customCloner); + } + + // Detect ConstraintWeightOverrides field (unannotated) + for (var lineageClass : ConfigUtils.getAllParents(solutionClass)) { + var constraintWeightFieldList = new ArrayList(); + for (var member : ConfigUtils.getDeclaredMembers(lineageClass)) { + if (member instanceof Field field + && ConstraintWeightOverrides.class.isAssignableFrom(field.getType())) { + constraintWeightFieldList.add(field); + } + } + switch (constraintWeightFieldList.size()) { + case 0 -> { + // Do nothing. + } + case 1 -> { + if (constraintWeightsSpec != null) { + throw new IllegalStateException( + "The solutionClass (%s) has a field of type (%s) which was already found on its parent class." + .formatted(lineageClass, ConstraintWeightOverrides.class)); + } + var cwField = constraintWeightFieldList.getFirst(); + var cwGetter = createGetterForMember(lookup, cwField); + constraintWeightsSpec = new ConstraintWeightSpecification<>( + solution -> (ConstraintWeightOverrides) cwGetter.apply(solution)); + } + default -> + throw new IllegalStateException("The solutionClass (%s) has more than one field (%s) of type %s." + .formatted(solutionClass, constraintWeightFieldList, ConstraintWeightOverrides.class)); + } + } + + // Scan annotated members on the solution class + var lineageClassList = ConfigUtils.getAllAnnotatedLineageClasses(solutionClass, PlanningSolution.class); + if (lineageClassList.isEmpty() && solutionClass.getSuperclass() != null + && solutionClass.getSuperclass().isAnnotationPresent(PlanningSolution.class)) { + lineageClassList = ConfigUtils.getAllAnnotatedLineageClasses( + solutionClass.getSuperclass(), PlanningSolution.class); + } + var seenFactNames = new HashMap(); + var seenEntityNames = new HashMap(); + String firstScoreMemberName = null; + + var potentiallyOverwritingMethodList = new ArrayList(); + for (var lineageClass : lineageClassList) { + var memberList = ConfigUtils.getDeclaredMembers(lineageClass); + for (var member : memberList) { + if (member instanceof Method method + && potentiallyOverwritingMethodList.stream().anyMatch( + m -> member.getName().equals(m.getName()) + && ReflectionHelper.isMethodOverwritten(method, m.getDeclaringClass()))) { + continue; + } + var propertyName = getPropertyName(member); + var propertyType = getPropertyType(member); + var genericPropertyType = getGenericPropertyType(member); + + // @ValueRangeProvider on solution + if (((AnnotatedElement) member).isAnnotationPresent(ValueRangeProvider.class)) { + var vrAnnotation = ((AnnotatedElement) member).getAnnotation(ValueRangeProvider.class); + String id = vrAnnotation.id(); + if (id != null && id.isEmpty()) { + id = null; + } + valueRanges.add(new ValueRangeSpecification<>(id, + wrapGetterForLookup(lookup, member), solutionClass, false, genericPropertyType)); + } + // Fact/Entity/Score annotations + var annotationClass = ConfigUtils.extractAnnotationClass(member, + ProblemFactProperty.class, ProblemFactCollectionProperty.class, + PlanningEntityProperty.class, PlanningEntityCollectionProperty.class, PlanningScore.class); + if (annotationClass != null) { + if (annotationClass.equals(ProblemFactProperty.class)) { + assertNoLookupDuplication(solutionClass, propertyName, annotationClass.getSimpleName(), + seenFactNames, seenEntityNames); + seenFactNames.put(propertyName, annotationClass.getSimpleName()); + if (propertyType.isAnnotationPresent(PlanningEntity.class)) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s-annotated member (%s) that returns a @%s. + Maybe use @%s instead?""".formatted(solutionClass, annotationClass.getSimpleName(), + propertyName, PlanningEntity.class.getSimpleName(), + PlanningEntityProperty.class.getSimpleName())); + } + facts.add(new FactSpecification<>(propertyName, wrapGetterForLookup(lookup, member), + wrapSetterForLookup(lookup, member, propertyType, propertyName), false, + genericPropertyType)); + } else if (annotationClass.equals(ProblemFactCollectionProperty.class)) { + assertNoLookupDuplication(solutionClass, propertyName, annotationClass.getSimpleName(), + seenFactNames, seenEntityNames); + seenFactNames.put(propertyName, annotationClass.getSimpleName()); + if (!(Collection.class.isAssignableFrom(propertyType) || propertyType.isArray())) { + throw new IllegalStateException( + "The solutionClass (%s) has a @%s-annotated member (%s) that does not return a %s or an array." + .formatted(solutionClass, + ProblemFactCollectionProperty.class.getSimpleName(), + member, Collection.class.getSimpleName())); + } + Class problemFactType; + if (propertyType.isArray()) { + problemFactType = propertyType.getComponentType(); + } else { + problemFactType = ConfigUtils.extractGenericTypeParameterOrFail( + PlanningSolution.class.getSimpleName(), + member.getDeclaringClass(), propertyType, genericPropertyType, + annotationClass, propertyName); + } + if (problemFactType.isAnnotationPresent(PlanningEntity.class)) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s-annotated member (%s) that returns a @%s. + Maybe use @%s instead?""".formatted(solutionClass, annotationClass.getSimpleName(), + propertyName, PlanningEntity.class.getSimpleName(), + PlanningEntityCollectionProperty.class.getSimpleName())); + } + facts.add(new FactSpecification<>(propertyName, wrapGetterForLookup(lookup, member), + wrapSetterForLookup(lookup, member, propertyType, propertyName), true, + genericPropertyType)); + } else if (annotationClass.equals(PlanningEntityProperty.class)) { + assertNoLookupDuplication(solutionClass, propertyName, annotationClass.getSimpleName(), + seenFactNames, seenEntityNames); + seenEntityNames.put(propertyName, annotationClass.getSimpleName()); + var entityGetter = createGetterForMember(lookup, member); + var entitySetter = createSetterForMember(lookup, member, propertyType, propertyName); + entityCollections.add(new EntityCollectionSpecification<>( + propertyName, + solution -> { + var entity = entityGetter.apply(solution); + return entity != null ? List.of(entity) : Collections.emptyList(); + }, + (BiConsumer) (solution, value) -> { + if (value instanceof Collection coll) { + entitySetter.accept(solution, coll.isEmpty() ? null : coll.iterator().next()); + } else { + entitySetter.accept(solution, value); + } + }, + true)); + } else if (annotationClass.equals(PlanningEntityCollectionProperty.class)) { + assertNoLookupDuplication(solutionClass, propertyName, annotationClass.getSimpleName(), + seenFactNames, seenEntityNames); + seenEntityNames.put(propertyName, annotationClass.getSimpleName()); + if (!(Collection.class.isAssignableFrom(propertyType) || propertyType.isArray())) { + throw new IllegalStateException( + "The solutionClass (%s) has a @%s annotated member (%s) that does not return a %s or an array." + .formatted(solutionClass, + PlanningEntityCollectionProperty.class.getSimpleName(), + member, Collection.class.getSimpleName())); + } + entityCollections.add(new EntityCollectionSpecification<>( + propertyName, + (Function>) (Function) createGetterForMember(lookup, member), + wrapSetterForLookup(lookup, member, propertyType, propertyName), + false)); + } else if (annotationClass.equals(PlanningScore.class)) { + if (scoreSpec == null) { + firstScoreMemberName = propertyName; + var scoreAnnotation = ((AnnotatedElement) member).getAnnotation(PlanningScore.class); + int bendableHard = scoreAnnotation != null + ? scoreAnnotation.bendableHardLevelsSize() + : -1; + int bendableSoft = scoreAnnotation != null + ? scoreAnnotation.bendableSoftLevelsSize() + : -1; + scoreSpec = new ScoreSpecification<>( + (Class>) propertyType, + wrapGetterForLookup(lookup, member), + wrapSetterForLookup(lookup, member, propertyType, propertyName), + bendableHard, bendableSoft); + } else { + if (!firstScoreMemberName.equals(propertyName)) { + throw new IllegalStateException( + "The solutionClass (" + solutionClass + + ") has a @" + PlanningScore.class.getSimpleName() + + " annotated member (" + propertyName + + ") that is duplicated by another member (" + firstScoreMemberName + + ").\n" + + "Maybe the annotation is defined on both the field and its getter."); + } + } + } + } + } + potentiallyOverwritingMethodList.ensureCapacity( + potentiallyOverwritingMethodList.size() + memberList.size()); + memberList.stream().filter(Method.class::isInstance) + .forEach(m -> potentiallyOverwritingMethodList.add((Method) m)); + } + + if (entityCollections.isEmpty()) { + throw new IllegalStateException( + "The solutionClass (%s) must have at least 1 member with a %s annotation or a %s annotation." + .formatted(solutionClass, + PlanningEntityCollectionProperty.class.getSimpleName(), + PlanningEntityProperty.class.getSimpleName())); + } + + if (scoreSpec == null) { + throw new IllegalStateException(""" + The solutionClass (%s) must have 1 member with a @%s annotation. + Maybe add a getScore() method with a @%s annotation.""".formatted(solutionClass, + PlanningScore.class.getSimpleName(), PlanningScore.class.getSimpleName())); + } + + var sortedEntityClassList = buildSortedEntityClassList(entityClassList); + + var entities = new ArrayList>(); + for (var entityClass : sortedEntityClassList) { + validateEntityInheritance(entityClass); + entities.add(buildEntitySpecForLookup(entityClass, solutionClass, lookup)); + } + + // Build complete cloning spec (unless custom cloner is set) + if (cloningSpec == null) { + cloningSpec = buildCloningSpec(solutionClass, sortedEntityClassList, true, lookup); + } + + return new PlanningSpecification<>( + solutionClass, scoreSpec, + List.copyOf(facts), List.copyOf(entityCollections), + List.copyOf(valueRanges), List.copyOf(entities), + cloningSpec, constraintWeightsSpec); + } + + // ************************************************************************ + // Cloning specification builder + // ************************************************************************ + + /** + * Builds a complete {@link CloningSpecification} by scanning all fields on the solution class, + * entity classes, and transitively-discovered {@code @DeepPlanningClone} types. + *

+ * For each field, a getter/setter lambda pair is created (via {@link LambdaMetafactory} when possible) + * and a {@link DeepCloneDecision} is pre-classified so no runtime type inspection is needed during cloning. + * + * @param solutionClass the solution class + * @param entityClassList all entity classes (including inherited) + * @param useFastPath true to use LambdaMetafactory, false for reflection (Quarkus FORCE_REFLECTION) + * @param userLookup optional user-provided Lookup for package-private access (null for framework Lookup) + */ + @SuppressWarnings("unchecked") + private static CloningSpecification buildCloningSpec( + Class solutionClass, + List> entityClassList, + boolean useFastPath, + MethodHandles.Lookup userLookup) { + + var entityClasses = new LinkedHashSet<>(entityClassList); + var deepCloneClasses = new LinkedHashSet>(); + var descriptorNeededClasses = new LinkedHashSet>(); + var cloneableClasses = new LinkedHashMap, CloneableClassDescriptor>(); + + // The solution class itself must be deep-cloneable so that entity→solution backlinking + // references are resolved to the cloned solution via cloneMap. + deepCloneClasses.add(solutionClass); + + // Discover @DeepPlanningClone types transitively from solution and entity fields + discoverDeepCloneTypes(solutionClass, entityClasses, deepCloneClasses, descriptorNeededClasses); + for (var entityClass : entityClassList) { + discoverDeepCloneTypes(entityClass, entityClasses, deepCloneClasses, descriptorNeededClasses); + } + // Transitively discover from deep-clone types themselves + var toScan = new ArrayList<>(deepCloneClasses); + toScan.addAll(descriptorNeededClasses); + while (!toScan.isEmpty()) { + var scanning = new ArrayList<>(toScan); + toScan.clear(); + for (var dcClass : scanning) { + var sizeBefore = deepCloneClasses.size() + descriptorNeededClasses.size(); + discoverDeepCloneTypes(dcClass, entityClasses, deepCloneClasses, descriptorNeededClasses); + if (deepCloneClasses.size() + descriptorNeededClasses.size() > sizeBefore) { + // New types discovered — add them to the scan list + for (var dc : deepCloneClasses) { + if (!scanning.contains(dc) && !cloneableClasses.containsKey(dc)) { + toScan.add(dc); + } + } + for (var dc : descriptorNeededClasses) { + if (!scanning.contains(dc) && !cloneableClasses.containsKey(dc)) { + toScan.add(dc); + } + } + } + } + } + + // Build solution factory + Supplier solutionFactory = createFactory(solutionClass, useFastPath, userLookup); + + // Build solution properties + var solutionProperties = buildPropertiesForClass(solutionClass, entityClasses, deepCloneClasses, + useFastPath, userLookup); + + // Build entity descriptors + for (var entityClass : entityClassList) { + if (!cloneableClasses.containsKey(entityClass)) { + // For interfaces/abstract classes, factory will be null — LambdaBasedSolutionCloner + // will fall back to runtime class instantiation. + Supplier factory = isConcrete(entityClass) + ? (Supplier) (Supplier) createFactory(entityClass, useFastPath, userLookup) + : null; + var properties = buildPropertiesForClass(entityClass, entityClasses, deepCloneClasses, + useFastPath, userLookup); + cloneableClasses.put(entityClass, new CloneableClassDescriptor(entityClass, factory, properties)); + } + } + + // Build deep-clone fact descriptors (for inherently deep-cloneable types) + for (var dcClass : deepCloneClasses) { + if (!cloneableClasses.containsKey(dcClass) && isConcrete(dcClass)) { + Supplier factory = (Supplier) (Supplier) createFactory(dcClass, useFastPath, userLookup); + var properties = buildPropertiesForClass(dcClass, entityClasses, deepCloneClasses, + useFastPath, userLookup); + cloneableClasses.put(dcClass, new CloneableClassDescriptor(dcClass, factory, properties)); + } + } + + // Build descriptors for types that need cloning support (field/getter @DeepPlanningClone) + // but aren't inherently deep-cloneable (not in deepCloneClasses) + for (var dnClass : descriptorNeededClasses) { + if (!cloneableClasses.containsKey(dnClass) && isConcrete(dnClass)) { + Supplier factory = (Supplier) (Supplier) createFactory(dnClass, useFastPath, userLookup); + var properties = buildPropertiesForClass(dnClass, entityClasses, deepCloneClasses, + useFastPath, userLookup); + cloneableClasses.put(dnClass, new CloneableClassDescriptor(dnClass, factory, properties)); + } + } + + return new CloningSpecification<>( + solutionFactory, + List.copyOf(solutionProperties), + Map.copyOf(cloneableClasses), + Set.copyOf(entityClasses), + Set.copyOf(deepCloneClasses), + null); + } + + /** + * Discovers {@code @DeepPlanningClone}-annotated types reachable from fields of the given class. + * + * @param clazz the class to scan + * @param entityClasses known entity classes + * @param deepCloneClasses types that are inherently deep-cloneable (type itself has @DeepPlanningClone) + * @param descriptorNeededClasses types that need a cloning descriptor but aren't inherently deep-cloneable + * (e.g. because a field/getter pointing to them has @DeepPlanningClone) + */ + private static void discoverDeepCloneTypes(Class clazz, Set> entityClasses, + Set> deepCloneClasses, Set> descriptorNeededClasses) { + for (var current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + for (var field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + var fieldType = field.getType(); + if (DeepCloningUtils.isImmutable(fieldType)) { + continue; + } + // Check if the field type itself is @DeepPlanningClone → inherently deep-cloneable + if (fieldType.isAnnotationPresent(DeepPlanningClone.class) && !entityClasses.contains(fieldType)) { + deepCloneClasses.add(fieldType); + } + // Check if @DeepPlanningClone is on the field or getter → type needs a descriptor + // but is NOT inherently deep-cloneable (only specific fields force deep-cloning) + boolean deepCloneOnFieldOrGetter = field.isAnnotationPresent(DeepPlanningClone.class); + if (!deepCloneOnFieldOrGetter) { + var getter = ReflectionHelper.getGetterMethod(clazz, field.getName()); + if (getter != null && getter.isAnnotationPresent(DeepPlanningClone.class)) { + deepCloneOnFieldOrGetter = true; + } + } + if (deepCloneOnFieldOrGetter) { + if (!Collection.class.isAssignableFrom(fieldType) && !Map.class.isAssignableFrom(fieldType) + && !fieldType.isArray() && !entityClasses.contains(fieldType) + && !deepCloneClasses.contains(fieldType)) { + descriptorNeededClasses.add(fieldType); + } + } + // Check generic type arguments for entity/deep-clone references + checkGenericTypeForDeepClone(field.getGenericType(), entityClasses, deepCloneClasses); + } + } + } + + private static void checkGenericTypeForDeepClone(Type genericType, Set> entityClasses, + Set> deepCloneClasses) { + if (genericType instanceof ParameterizedType paramType) { + for (var typeArg : paramType.getActualTypeArguments()) { + if (typeArg instanceof Class argClass) { + if (argClass.isAnnotationPresent(DeepPlanningClone.class) && !entityClasses.contains(argClass)) { + deepCloneClasses.add(argClass); + } + } + checkGenericTypeForDeepClone(typeArg, entityClasses, deepCloneClasses); + } + } + } + + private record FieldClassification(DeepCloneDecision decision, String validationMessage) { + FieldClassification(DeepCloneDecision decision) { + this(decision, null); + } + } + + /** + * Builds {@link PropertyCopyDescriptor}s for all declared fields on the given class hierarchy. + */ + private static List buildPropertiesForClass( + Class clazz, Set> entityClasses, Set> deepCloneClasses, + boolean useFastPath, MethodHandles.Lookup userLookup) { + var properties = new ArrayList(); + for (var current = clazz; current != null && current != Object.class; current = current.getSuperclass()) { + for (var field : current.getDeclaredFields()) { + var modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers)) { + continue; + } + var accessors = createCloningAccessors(field, Modifier.isFinal(modifiers), useFastPath, userLookup); + var getter = accessors.getter(); + var setter = accessors.setter(); + var classification = classifyField(field, clazz, entityClasses, deepCloneClasses); + properties.add(new PropertyCopyDescriptor(field.getName(), getter, setter, + classification.decision(), classification.validationMessage())); + } + } + return List.copyOf(properties); + } + + /** + * Classifies a field's deep clone decision at spec-build time. + * Replicates the logic from {@link DeepCloningUtils#needsDeepClone} but pre-computes the decision. + */ + private static FieldClassification classifyField(Field field, Class owningClass, + Set> entityClasses, Set> deepCloneClasses) { + var fieldType = field.getType(); + + // Immutable types → SHALLOW + if (DeepCloningUtils.isImmutable(fieldType)) { + return new FieldClassification(DeepCloneDecision.SHALLOW); + } + + // @DeepPlanningClone on field or getter → force deep clone + boolean fieldAnnotated = field.isAnnotationPresent(DeepPlanningClone.class); + Method getterMethod = ReflectionHelper.getGetterMethod(owningClass, field.getName()); + if (!fieldAnnotated && getterMethod != null && getterMethod.isAnnotationPresent(DeepPlanningClone.class)) { + fieldAnnotated = true; + } + + // Deferred validation: @PlanningVariable + @DeepPlanningClone on non-deep-cloned type + // We store the message and throw at clone time. + String validationMessage = null; + if (fieldAnnotated && isFieldAPlanningBasicVariable(field, owningClass, getterMethod) + && !isDeepCloneableType(fieldType, entityClasses, deepCloneClasses)) { + validationMessage = """ + The field (%s) of class (%s) is configured to be deep-cloned, \ + but its type (%s) is not deep-cloned. \ + Maybe remove the @%s annotation from the field? \ + Maybe annotate the type (%s) with @%s?""" + .formatted(field.getName(), owningClass.getCanonicalName(), + fieldType.getCanonicalName(), + DeepPlanningClone.class.getSimpleName(), + fieldType.getCanonicalName(), + DeepPlanningClone.class.getSimpleName()); + } + + if (fieldAnnotated) { + DeepCloneDecision decision; + if (Collection.class.isAssignableFrom(fieldType)) { + decision = DeepCloneDecision.DEEP_COLLECTION; + } else if (Map.class.isAssignableFrom(fieldType)) { + decision = DeepCloneDecision.DEEP_MAP; + } else if (fieldType.isArray()) { + decision = DeepCloneDecision.DEEP_ARRAY; + } else { + decision = DeepCloneDecision.ALWAYS_DEEP; + } + return new FieldClassification(decision, validationMessage); + } + + // @PlanningListVariable → DEEP_COLLECTION (list variable contents must be deep-cloned) + if (isFieldAPlanningListVariable(field, owningClass, getterMethod)) { + return new FieldClassification(DeepCloneDecision.DEEP_COLLECTION); + } + + // @PlanningEntityCollectionProperty on field or getter → deep clone the container + if (hasAnnotationOnFieldOrGetter(field, getterMethod, PlanningEntityCollectionProperty.class)) { + if (fieldType.isArray()) { + return new FieldClassification(DeepCloneDecision.DEEP_ARRAY); + } + return new FieldClassification(DeepCloneDecision.DEEP_COLLECTION); + } + + // @PlanningEntityProperty on field or getter → RESOLVE_ENTITY_REFERENCE + if (hasAnnotationOnFieldOrGetter(field, getterMethod, PlanningEntityProperty.class)) { + return new FieldClassification(DeepCloneDecision.RESOLVE_ENTITY_REFERENCE); + } + + // Field type is entity or deep-cloneable → RESOLVE_ENTITY_REFERENCE + if (isDeepCloneableType(fieldType, entityClasses, deepCloneClasses)) { + return new FieldClassification(DeepCloneDecision.RESOLVE_ENTITY_REFERENCE); + } + + // Collection/Map/Array with entity or deep-clone type args + if (Collection.class.isAssignableFrom(fieldType)) { + if (hasDeepCloneTypeArg(field.getGenericType(), entityClasses, deepCloneClasses)) { + return new FieldClassification(DeepCloneDecision.DEEP_COLLECTION); + } + return new FieldClassification(DeepCloneDecision.SHALLOW); + } else if (Map.class.isAssignableFrom(fieldType)) { + if (hasDeepCloneTypeArg(field.getGenericType(), entityClasses, deepCloneClasses)) { + return new FieldClassification(DeepCloneDecision.DEEP_MAP); + } + return new FieldClassification(DeepCloneDecision.SHALLOW); + } else if (fieldType.isArray()) { + var componentType = fieldType.getComponentType(); + if (isDeepCloneableType(componentType, entityClasses, deepCloneClasses)) { + return new FieldClassification(DeepCloneDecision.DEEP_ARRAY); + } + return new FieldClassification(DeepCloneDecision.SHALLOW); + } + + // Non-immutable, non-container type: check runtime value type at clone time + // (handles subclass types annotated with @DeepPlanningClone) + return new FieldClassification(DeepCloneDecision.SHALLOW_OR_DEEP_BY_RUNTIME_TYPE); + } + + private static boolean hasAnnotationOnFieldOrGetter(Field field, Method getterMethod, + Class annotationClass) { + if (field.isAnnotationPresent(annotationClass)) { + return true; + } + return getterMethod != null && getterMethod.isAnnotationPresent(annotationClass); + } + + private static boolean isDeepCloneableType(Class type, Set> entityClasses, + Set> deepCloneClasses) { + return entityClasses.contains(type) || deepCloneClasses.contains(type) + || type.isAnnotationPresent(DeepPlanningClone.class); + } + + private static boolean isFieldAPlanningListVariable(Field field, Class owningClass, Method getterMethod) { + if (field.isAnnotationPresent(PlanningListVariable.class)) { + return true; + } + return getterMethod != null && getterMethod.isAnnotationPresent(PlanningListVariable.class); + } + + private static boolean isFieldAPlanningBasicVariable(Field field, Class owningClass, Method getterMethod) { + if (field.isAnnotationPresent(PlanningVariable.class)) { + return true; + } + return getterMethod != null && getterMethod.isAnnotationPresent(PlanningVariable.class); + } + + private static boolean hasDeepCloneTypeArg(Type genericType, Set> entityClasses, + Set> deepCloneClasses) { + if (genericType instanceof ParameterizedType paramType) { + for (var typeArg : paramType.getActualTypeArguments()) { + if (typeArg instanceof Class argClass) { + if (entityClasses.contains(argClass) || deepCloneClasses.contains(argClass) + || argClass.isAnnotationPresent(DeepPlanningClone.class)) { + return true; + } + } + if (hasDeepCloneTypeArg(typeArg, entityClasses, deepCloneClasses)) { + return true; + } + } + } + return false; + } + + private record CloningAccessors(Function getter, BiConsumer setter) { + } + + /** + * Creates getter and setter lambdas for a field for use in cloning. + *

+ * Strategy (in order of preference): + *

    + *
  1. Getter + setter methods via {@link LambdaMetafactory} — JIT-inlineable lambdas, + * best performance. Used when both getter and setter methods exist. We require BOTH because + * wrapping getters (e.g., returning {@code Collections.unmodifiableList()}) virtually always + * have no corresponding setter; requiring both ensures the getter returns the raw field value.
  2. + *
  3. Direct field access via MethodHandle — fallback when getter/setter methods don't exist. + * Not JIT-inlineable but still avoids {@code setAccessible}. Zero reflection.
  4. + *
  5. Final fields via {@code field.setAccessible(true)} — narrow exception; the JVM does not + * allow setting final fields via MethodHandles or VarHandle.
  6. + *
  7. Reflection — Quarkus {@code FORCE_REFLECTION} only (build-time {@code setAccessible}).
  8. + *
+ */ + private static CloningAccessors createCloningAccessors(Field field, boolean isFinal, + boolean useFastPath, MethodHandles.Lookup userLookup) { + if (isFinal) { + // Final fields cannot be set via MethodHandles or VarHandle. + // Use reflection as a narrow exception — the JVM still allows Field.set() after setAccessible(true). + // For the getter, we still use the fast path if available (reading final fields works fine). + var getter = createCloningGetter(field, useFastPath, userLookup); + field.setAccessible(true); + BiConsumer setter = (bean, value) -> { + try { + field.set(bean, value); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Failed to write final field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + return new CloningAccessors(getter, setter); + } + if (useFastPath) { + try { + var lookup = getLookupForField(field, userLookup); + // Try getter + setter methods via LambdaMetafactory (JIT-inlineable). + // We require BOTH to exist — wrapping getters (e.g., Collections.unmodifiableList()) + // have no corresponding setter, so requiring both ensures the getter is safe. + var getterMethod = ReflectionHelper.getGetterMethod(field.getDeclaringClass(), field.getName()); + if (getterMethod == null) { + getterMethod = findDeclaredGetterMethod(field.getDeclaringClass(), field.getName()); + } + var setterMethod = ReflectionHelper.getDeclaredSetterMethod( + field.getDeclaringClass(), field.getType(), field.getName()); + if (getterMethod != null && setterMethod != null) { + return new CloningAccessors( + createGetter(lookup, getterMethod), + createSetter(lookup, setterMethod)); + } + // Fallback: direct field access via MethodHandle (not JIT-inlineable, but no setAccessible) + return new CloningAccessors( + createFieldGetter(lookup, field), + createFieldSetter(lookup, field)); + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot access field (%s) on class (%s) for planning cloning. To use private fields, either:\n" + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()) + + " 1. Make the field package-private or public, or\n" + + " 2. Provide a MethodHandles.Lookup via SolverConfig.withLookup(MethodHandles.lookup()), or\n" + + " 3. Use the programmatic PlanningSpecification API.", + e); + } catch (Throwable e) { + throw new IllegalStateException( + "Failed to create accessors for field (%s) on class (%s)." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), + e); + } + } + // Reflection fallback (Quarkus FORCE_REFLECTION only) + field.setAccessible(true); + Function getter = bean -> { + try { + return field.get(bean); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Failed to read field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + BiConsumer setter = (bean, value) -> { + try { + field.set(bean, value); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Failed to write field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + return new CloningAccessors(getter, setter); + } + + /** + * Creates just a getter for a field — used for final fields where the setter is handled separately. + */ + private static Function createCloningGetter(Field field, + boolean useFastPath, MethodHandles.Lookup userLookup) { + if (useFastPath) { + try { + var lookup = getLookupForField(field, userLookup); + return createFieldGetter(lookup, field); + } catch (Throwable e) { + // Fall through to reflection + } + } + field.setAccessible(true); + return bean -> { + try { + return field.get(bean); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Failed to read field '%s' on %s." + .formatted(field.getName(), field.getDeclaringClass().getSimpleName()), e); + } + }; + } + + /** + * Gets the appropriate Lookup for a field. + * Uses {@code privateLookupIn} to teleport into the target class's module, + * which allows accessing private fields without {@code setAccessible}. + * For the unnamed module (most user code), all packages are implicitly open so this always works. + * For named modules, this works if the user's module {@code opens} the package. + */ + private static MethodHandles.Lookup getLookupForField(Field field, MethodHandles.Lookup userLookup) throws Throwable { + var baseLookup = userLookup != null ? userLookup : FRAMEWORK_LOOKUP; + return MethodHandles.privateLookupIn(field.getDeclaringClass(), baseLookup); + } + + private static boolean isConcrete(Class clazz) { + return !clazz.isInterface() && !java.lang.reflect.Modifier.isAbstract(clazz.getModifiers()); + } + + /** + * Creates a no-arg constructor factory for a class. + * Returns {@code null} if the class has no no-arg constructor (clone-time error will be raised if needed). + */ + @SuppressWarnings("unchecked") + private static Supplier createFactory(Class clazz, boolean useFastPath, + MethodHandles.Lookup userLookup) { + if (useFastPath) { + try { + var lookup = userLookup != null + ? MethodHandles.privateLookupIn(clazz, userLookup) + : FRAMEWORK_LOOKUP; + var ctorHandle = lookup.findConstructor(clazz, MethodType.methodType(void.class)); + var callSite = LambdaMetafactory.metafactory( + lookup, "get", + MethodType.methodType(Supplier.class), + MethodType.methodType(Object.class), + ctorHandle, + MethodType.methodType(clazz)); + return (Supplier) callSite.getTarget().invokeExact(); + } catch (Throwable e) { + // Fall through to reflection + } + } + // Reflection fallback + try { + var ctor = clazz.getDeclaredConstructor(); + ctor.setAccessible(true); + return () -> { + try { + return ctor.newInstance(); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create instance of %s.".formatted(clazz.getSimpleName()), e); + } + }; + } catch (NoSuchMethodException e) { + // No no-arg constructor — return null; error will be raised at clone time if this class is actually cloned + return null; + } + } + + // ************************************************************************ + // Entity spec builders + // ************************************************************************ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static EntitySpecification buildEntitySpec( + Class entityClass, + Class solutionClass, + MemberAccessorFactory memberAccessorFactory, + DomainAccessType domainAccessType, + boolean useFastPath) { + + // Check mutability before processing members (records/enums can't be entities) + SolutionDescriptor.assertMutable(entityClass, "entityClass"); + + var entityAnnotation = findEntityAnnotation(entityClass); + java.util.Comparator difficultyComparator = extractDifficultyComparator(entityClass, entityAnnotation); + Class difficultyComparatorFactoryClass = extractDifficultyComparatorFactoryClass(entityAnnotation); + + var variables = new ArrayList>(); + var shadows = new ArrayList>(); + var entityScopedValueRanges = new ArrayList>(); + Function planningIdGetter = null; + Predicate pinnedPredicate = null; + ToIntFunction pinToIndexFunction = null; + + for (var member : ConfigUtils.getDeclaredMembers(entityClass)) { + // @ValueRangeProvider on entity + if (((AnnotatedElement) member).isAnnotationPresent(ValueRangeProvider.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD_WITH_OPTIONAL_PARAMETER, ValueRangeProvider.class, domainAccessType); + var vrAnnotation = accessor.getAnnotation(ValueRangeProvider.class); + String id = vrAnnotation.id(); + if (id != null && id.isEmpty()) { + id = null; + } + entityScopedValueRanges.add(new ValueRangeSpecification<>(id, + wrapGetter(accessor, member, useFastPath), entityClass, true, accessor.getGenericType())); + } + + // Planning variables + var varAnnotationClass = ConfigUtils.extractAnnotationClass(member, VARIABLE_ANNOTATION_CLASSES); + if (varAnnotationClass != null) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_GETTER_METHOD_WITH_SETTER, varAnnotationClass, domainAccessType); + processVariable(entityClass, solutionClass, accessor, member, varAnnotationClass, variables, shadows, + useFastPath); + } + + // @PlanningPin + if (((AnnotatedElement) member).isAnnotationPresent(PlanningPin.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, PlanningPin.class, domainAccessType); + var pinGetter = fastOrSlowGetter(member, accessor, useFastPath); + pinnedPredicate = (Predicate) entity -> Boolean.TRUE.equals(pinGetter.apply(entity)); + } + + // @PlanningPinToIndex + if (((AnnotatedElement) member).isAnnotationPresent(PlanningPinToIndex.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, PlanningPinToIndex.class, domainAccessType); + var pinIndexGetter = fastOrSlowGetter(member, accessor, useFastPath); + pinToIndexFunction = (ToIntFunction) entity -> (int) pinIndexGetter.apply(entity); + } + + // @PlanningId + if (((AnnotatedElement) member).isAnnotationPresent(PlanningId.class)) { + var accessor = buildAccessor(memberAccessorFactory, member, + FIELD_OR_READ_METHOD, PlanningId.class, domainAccessType); + planningIdGetter = wrapGetter(accessor, member, useFastPath); + } + } + + return new EntitySpecification<>( + entityClass, planningIdGetter, difficultyComparator, difficultyComparatorFactoryClass, + pinnedPredicate, pinToIndexFunction, + List.copyOf(variables), List.copyOf(shadows), + List.copyOf(entityScopedValueRanges)); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static EntitySpecification buildEntitySpecForLookup( + Class entityClass, + Class solutionClass, + MethodHandles.Lookup lookup) { + + SolutionDescriptor.assertMutable(entityClass, "entityClass"); + + var entityAnnotation = findEntityAnnotation(entityClass); + java.util.Comparator difficultyComparator = extractDifficultyComparator(entityClass, entityAnnotation); + Class difficultyComparatorFactoryClass = extractDifficultyComparatorFactoryClass(entityAnnotation); + + var variables = new ArrayList>(); + var shadows = new ArrayList>(); + var entityScopedValueRanges = new ArrayList>(); + Function planningIdGetter = null; + Predicate pinnedPredicate = null; + ToIntFunction pinToIndexFunction = null; + + for (var member : ConfigUtils.getDeclaredMembers(entityClass)) { + var propertyName = getPropertyName(member); + var propertyType = getPropertyType(member); + + // @ValueRangeProvider on entity + if (((AnnotatedElement) member).isAnnotationPresent(ValueRangeProvider.class)) { + var vrAnnotation = ((AnnotatedElement) member).getAnnotation(ValueRangeProvider.class); + String id = vrAnnotation.id(); + if (id != null && id.isEmpty()) { + id = null; + } + entityScopedValueRanges.add(new ValueRangeSpecification<>(id, + wrapGetterForLookup(lookup, member), entityClass, true, getGenericPropertyType(member))); + } + + // Planning variables + var varAnnotationClass = ConfigUtils.extractAnnotationClass(member, VARIABLE_ANNOTATION_CLASSES); + if (varAnnotationClass != null) { + processVariableForLookup(entityClass, solutionClass, member, lookup, varAnnotationClass, variables, shadows); + } + + // @PlanningPin + if (((AnnotatedElement) member).isAnnotationPresent(PlanningPin.class)) { + var pinGetter = createGetterForMember(lookup, member); + pinnedPredicate = (Predicate) entity -> Boolean.TRUE.equals(pinGetter.apply(entity)); + } + + // @PlanningPinToIndex + if (((AnnotatedElement) member).isAnnotationPresent(PlanningPinToIndex.class)) { + var pinIndexGetter = createGetterForMember(lookup, member); + pinToIndexFunction = (ToIntFunction) entity -> (int) pinIndexGetter.apply(entity); + } + + // @PlanningId + if (((AnnotatedElement) member).isAnnotationPresent(PlanningId.class)) { + planningIdGetter = wrapGetterForLookup(lookup, member); + } + } + + return new EntitySpecification<>( + entityClass, planningIdGetter, difficultyComparator, difficultyComparatorFactoryClass, + pinnedPredicate, pinToIndexFunction, + List.copyOf(variables), List.copyOf(shadows), + List.copyOf(entityScopedValueRanges)); + } + + // ************************************************************************ + // Variable processing + // ************************************************************************ + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static void processVariable( + Class entityClass, + Class solutionClass, + MemberAccessor accessor, + Member member, + Class annotationClass, + List> variables, + List> shadows, + boolean useFastPath) { + + var name = accessor.getName(); + + if (annotationClass.equals(PlanningVariable.class)) { + var annotation = accessor.getAnnotation(PlanningVariable.class); + var valueRangeRefs = annotation.valueRangeProviderRefs(); + var strengthComparator = extractStrengthComparator(annotation.comparatorClass(), + PlanningVariable.NullComparator.class); + var comparatorFactoryClass = extractComparatorFactoryClass(annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + assertNoMixedComparators(entityClass, name, annotation.comparatorClass(), + PlanningVariable.NullComparator.class, annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + variables.add(new VariableSpecification<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + false, annotation.allowsUnassigned(), + valueRangeRefs.length > 0 ? List.of(valueRangeRefs) : List.of(), + strengthComparator, comparatorFactoryClass)); + } else if (annotationClass.equals(PlanningListVariable.class)) { + var annotation = accessor.getAnnotation(PlanningListVariable.class); + var valueRangeRefs = annotation.valueRangeProviderRefs(); + var elementType = ConfigUtils.extractGenericTypeParameterOrFail( + "entityClass", entityClass, accessor.getType(), accessor.getGenericType(), + PlanningListVariable.class, name); + var strengthComparator = extractStrengthComparator(annotation.comparatorClass(), + PlanningVariable.NullComparator.class); + var comparatorFactoryClass = extractComparatorFactoryClass(annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + assertNoMixedComparators(entityClass, name, annotation.comparatorClass(), + PlanningVariable.NullComparator.class, annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + variables.add(new VariableSpecification<>( + name, elementType, + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + true, annotation.allowsUnassignedValues(), + valueRangeRefs.length > 0 ? List.of(valueRangeRefs) : List.of(), + strengthComparator, comparatorFactoryClass)); + } else if (annotationClass.equals(InverseRelationShadowVariable.class)) { + var annotation = accessor.getAnnotation(InverseRelationShadowVariable.class); + shadows.add(new ShadowSpecification.InverseRelation<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + annotation.sourceVariableName())); + } else if (annotationClass.equals(IndexShadowVariable.class)) { + var annotation = accessor.getAnnotation(IndexShadowVariable.class); + var indexGetter = fastOrSlowGetter(member, accessor, useFastPath); + var indexSetter = fastOrSlowSetter(member, accessor, useFastPath); + shadows.add(new ShadowSpecification.Index<>( + name, accessor.getType(), + (ToIntFunction) entity -> (int) indexGetter.apply(entity), + (java.util.function.ObjIntConsumer) (entity, value) -> indexSetter.accept(entity, value), + indexGetter, (BiConsumer) indexSetter::accept, + annotation.sourceVariableName())); + } else if (annotationClass.equals(PreviousElementShadowVariable.class)) { + var annotation = accessor.getAnnotation(PreviousElementShadowVariable.class); + shadows.add(new ShadowSpecification.PreviousElement<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + annotation.sourceVariableName())); + } else if (annotationClass.equals(NextElementShadowVariable.class)) { + var annotation = accessor.getAnnotation(NextElementShadowVariable.class); + shadows.add(new ShadowSpecification.NextElement<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + annotation.sourceVariableName())); + } else if (annotationClass.equals(ShadowVariable.class)) { + var annotation = accessor.getAnnotation(ShadowVariable.class); + var supplierName = annotation.supplierName(); + // Find the supplier method on the entity class (0 or 1 param) + var supplierMethod = ReflectionHelper.getDeclaredMethod(entityClass, supplierName); + if (supplierMethod == null) { + supplierMethod = ReflectionHelper.getDeclaredMethod(entityClass, supplierName, solutionClass); + } + if (supplierMethod == null) { + throw new IllegalArgumentException( + "@%s (%s) defines a supplierName (%s) that does not exist inside its declaring class (%s)." + .formatted(ShadowVariable.class.getSimpleName(), name, supplierName, + entityClass.getCanonicalName())); + } + var sourcesAnnotation = supplierMethod.getAnnotation( + ai.timefold.solver.core.api.domain.variable.ShadowSources.class); + if (sourcesAnnotation == null) { + throw new IllegalArgumentException( + "Method \"%s\" referenced from @%s member %s is not annotated with @%s." + .formatted(supplierName, ShadowVariable.class.getSimpleName(), name, + ai.timefold.solver.core.api.domain.variable.ShadowSources.class + .getSimpleName())); + } + var sourcePaths = List.of(sourcesAnnotation.value()); + var alignmentKey = (sourcesAnnotation.alignmentKey() != null + && !sourcesAnnotation.alignmentKey().isEmpty()) + ? sourcesAnnotation.alignmentKey() + : null; + // For 0-param suppliers, create a fast getter; for 1-param, store the method for the compiler + Function supplierGetter = null; + if (supplierMethod.getParameterCount() == 0) { + if (useFastPath) { + supplierMethod.setAccessible(true); + supplierGetter = tryCreateFastGetter(FRAMEWORK_LOOKUP, supplierMethod); + } + if (supplierGetter == null) { + var m = supplierMethod; + m.setAccessible(true); + supplierGetter = entity -> { + try { + return m.invoke(entity); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to invoke supplier method '%s' on entity." + .formatted(m.getName()), + e); + } + }; + } + } + shadows.add(new ShadowSpecification.Declarative<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + supplierGetter, sourcePaths, alignmentKey, supplierMethod)); + } else if (annotationClass.equals(CascadingUpdateShadowVariable.class)) { + var annotation = accessor.getAnnotation(CascadingUpdateShadowVariable.class); + var targetMethodName = annotation.targetMethodName(); + // Find the target method on the entity class + var targetMethodList = ConfigUtils.getDeclaredMembers(entityClass).stream() + .filter(m -> m.getName().equals(targetMethodName) + && m instanceof Method method + && method.getParameterCount() == 0) + .toList(); + java.util.function.Consumer updateMethod = null; + if (!targetMethodList.isEmpty()) { + var targetMember = (Method) targetMethodList.getFirst(); + targetMember.setAccessible(true); + var tm = targetMember; + updateMethod = entity -> { + try { + tm.invoke(entity); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to invoke target method '%s' on entity." + .formatted(tm.getName()), + e); + } + }; + } + shadows.add(new ShadowSpecification.CascadingUpdate<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath), + updateMethod, List.of(), targetMethodName)); + } else if (annotationClass.equals(ShadowVariablesInconsistent.class)) { + shadows.add(new ShadowSpecification.Inconsistent<>( + name, accessor.getType(), + wrapGetter(accessor, member, useFastPath), wrapSetter(accessor, member, useFastPath))); + } + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static void processVariableForLookup( + Class entityClass, + Class solutionClass, + Member member, + MethodHandles.Lookup lookup, + Class annotationClass, + List> variables, + List> shadows) { + + var name = getPropertyName(member); + var propertyType = getPropertyType(member); + var genericPropertyType = getGenericPropertyType(member); + + if (annotationClass.equals(PlanningVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(PlanningVariable.class); + var valueRangeRefs = annotation.valueRangeProviderRefs(); + var strengthComparator = extractStrengthComparator(annotation.comparatorClass(), + PlanningVariable.NullComparator.class); + var comparatorFactoryClass = extractComparatorFactoryClass(annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + assertNoMixedComparators(entityClass, name, annotation.comparatorClass(), + PlanningVariable.NullComparator.class, annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + variables.add(new VariableSpecification<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + false, annotation.allowsUnassigned(), + valueRangeRefs.length > 0 ? List.of(valueRangeRefs) : List.of(), + strengthComparator, comparatorFactoryClass)); + } else if (annotationClass.equals(PlanningListVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(PlanningListVariable.class); + var valueRangeRefs = annotation.valueRangeProviderRefs(); + var elementType = ConfigUtils.extractGenericTypeParameterOrFail( + "entityClass", entityClass, propertyType, genericPropertyType, + PlanningListVariable.class, name); + var strengthComparator = extractStrengthComparator(annotation.comparatorClass(), + PlanningVariable.NullComparator.class); + var comparatorFactoryClass = extractComparatorFactoryClass(annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + assertNoMixedComparators(entityClass, name, annotation.comparatorClass(), + PlanningVariable.NullComparator.class, annotation.comparatorFactoryClass(), + PlanningVariable.NullComparatorFactory.class); + variables.add(new VariableSpecification<>( + name, elementType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + true, annotation.allowsUnassignedValues(), + valueRangeRefs.length > 0 ? List.of(valueRangeRefs) : List.of(), + strengthComparator, comparatorFactoryClass)); + } else if (annotationClass.equals(InverseRelationShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(InverseRelationShadowVariable.class); + shadows.add(new ShadowSpecification.InverseRelation<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + annotation.sourceVariableName())); + } else if (annotationClass.equals(IndexShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(IndexShadowVariable.class); + var indexGetter = createGetterForMember(lookup, member); + var indexSetter = createSetterForMember(lookup, member, propertyType, name); + shadows.add(new ShadowSpecification.Index<>( + name, propertyType, + (ToIntFunction) entity -> (int) indexGetter.apply(entity), + (java.util.function.ObjIntConsumer) (entity, value) -> indexSetter.accept(entity, value), + indexGetter, (BiConsumer) indexSetter::accept, + annotation.sourceVariableName())); + } else if (annotationClass.equals(PreviousElementShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(PreviousElementShadowVariable.class); + shadows.add(new ShadowSpecification.PreviousElement<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + annotation.sourceVariableName())); + } else if (annotationClass.equals(NextElementShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(NextElementShadowVariable.class); + shadows.add(new ShadowSpecification.NextElement<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + annotation.sourceVariableName())); + } else if (annotationClass.equals(ShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(ShadowVariable.class); + var supplierName = annotation.supplierName(); + var supplierMethod = ReflectionHelper.getDeclaredMethod(entityClass, supplierName); + if (supplierMethod == null) { + supplierMethod = ReflectionHelper.getDeclaredMethod(entityClass, supplierName, solutionClass); + } + if (supplierMethod == null) { + throw new IllegalArgumentException( + "@%s (%s) defines a supplierName (%s) that does not exist inside its declaring class (%s)." + .formatted(ShadowVariable.class.getSimpleName(), name, supplierName, + entityClass.getCanonicalName())); + } + var sourcesAnnotation = supplierMethod.getAnnotation( + ai.timefold.solver.core.api.domain.variable.ShadowSources.class); + if (sourcesAnnotation == null) { + throw new IllegalArgumentException( + "Method \"%s\" referenced from @%s member %s is not annotated with @%s." + .formatted(supplierName, ShadowVariable.class.getSimpleName(), name, + ai.timefold.solver.core.api.domain.variable.ShadowSources.class + .getSimpleName())); + } + var sourcePaths = List.of(sourcesAnnotation.value()); + var alignmentKey = (sourcesAnnotation.alignmentKey() != null + && !sourcesAnnotation.alignmentKey().isEmpty()) + ? sourcesAnnotation.alignmentKey() + : null; + Function supplierGetter = null; + if (supplierMethod.getParameterCount() == 0) { + supplierGetter = createGetterForMember(lookup, supplierMethod); + } + shadows.add(new ShadowSpecification.Declarative<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + supplierGetter, sourcePaths, alignmentKey, supplierMethod)); + } else if (annotationClass.equals(CascadingUpdateShadowVariable.class)) { + var annotation = ((AnnotatedElement) member).getAnnotation(CascadingUpdateShadowVariable.class); + var targetMethodName = annotation.targetMethodName(); + var targetMethodList = ConfigUtils.getDeclaredMembers(entityClass).stream() + .filter(m -> m.getName().equals(targetMethodName) + && m instanceof Method method + && method.getParameterCount() == 0) + .toList(); + java.util.function.Consumer updateMethod = null; + if (!targetMethodList.isEmpty()) { + var targetMember = (Method) targetMethodList.getFirst(); + try { + var handle = lookup.unreflect(targetMember); + updateMethod = entity -> { + try { + handle.invoke(entity); + } catch (Throwable e) { + throw new IllegalStateException( + "Failed to invoke target method '%s' on entity." + .formatted(targetMember.getName()), + e); + } + }; + } catch (IllegalAccessException e) { + targetMember.setAccessible(true); + var tm = targetMember; + updateMethod = entity -> { + try { + tm.invoke(entity); + } catch (Exception ex) { + throw new IllegalStateException( + "Failed to invoke target method '%s' on entity." + .formatted(tm.getName()), + ex); + } + }; + } + } + shadows.add(new ShadowSpecification.CascadingUpdate<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name), + updateMethod, List.of(), targetMethodName)); + } else if (annotationClass.equals(ShadowVariablesInconsistent.class)) { + shadows.add(new ShadowSpecification.Inconsistent<>( + name, propertyType, + wrapGetterForLookup(lookup, member), wrapSetterForLookup(lookup, member, propertyType, name))); + } + } + + // ************************************************************************ + // Shared helpers + // ************************************************************************ + + private static void validateEntityInheritance(Class entityClass) { + var inheritedEntityClasses = extractInheritedClasses(entityClass); + assertNotMixedInheritance(entityClass, inheritedEntityClasses); + assertSingleInheritance(entityClass, inheritedEntityClasses); + assertValidPlanningVariables(entityClass); + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private static void assertNoMixedComparators( + Class entityClass, String propertyName, + Class comparatorClass, Class comparatorNullSentinel, + Class comparatorFactoryClass, Class factoryNullSentinel) { + boolean hasComparator = comparatorClass != null && !comparatorNullSentinel.isAssignableFrom(comparatorClass); + boolean hasFactory = comparatorFactoryClass != null && !factoryNullSentinel.isAssignableFrom(comparatorFactoryClass); + if (hasComparator && hasFactory) { + throw new IllegalStateException( + "The entityClass (%s) property (%s) cannot have a comparatorClass (%s) and a comparatorFactoryClass (%s) at the same time." + .formatted(entityClass, propertyName, comparatorClass.getName(), + comparatorFactoryClass.getName())); + } + } + + private static java.util.Comparator extractStrengthComparator( + Class comparatorClass, + Class nullSentinelClass) { + if (comparatorClass == null || nullSentinelClass.isAssignableFrom(comparatorClass)) { + return null; + } + return ConfigUtils.newInstance(() -> "variable", "comparatorClass", comparatorClass); + } + + @SuppressWarnings("rawtypes") + private static Class extractComparatorFactoryClass( + Class comparatorFactoryClass, + Class nullSentinelClass) { + if (comparatorFactoryClass == null || nullSentinelClass.isAssignableFrom(comparatorFactoryClass)) { + return null; + } + return comparatorFactoryClass; + } + + private static PlanningSolution extractPlanningSolutionAnnotation(Class solutionClass) { + var annotation = solutionClass.getAnnotation(PlanningSolution.class); + if (annotation != null) { + return annotation; + } + var superclass = solutionClass.getSuperclass(); + if (superclass != null) { + var parentAnnotation = superclass.getAnnotation(PlanningSolution.class); + if (parentAnnotation != null) { + return parentAnnotation; + } + } + throw new IllegalStateException( + "The solutionClass (%s) does not have a @%s annotation." + .formatted(solutionClass.getCanonicalName(), PlanningSolution.class.getSimpleName())); + } + + private static PlanningEntity findEntityAnnotation(Class entityClass) { + var entityAnnotation = entityClass.getAnnotation(PlanningEntity.class); + if (entityAnnotation == null) { + for (var ic : extractInheritedClasses(entityClass)) { + entityAnnotation = ic.getAnnotation(PlanningEntity.class); + if (entityAnnotation != null) { + break; + } + } + } + return entityAnnotation; + } + + private static java.util.Comparator extractDifficultyComparator( + Class entityClass, PlanningEntity entityAnnotation) { + if (entityAnnotation != null) { + var comparatorClass = entityAnnotation.comparatorClass(); + if (comparatorClass != null + && !PlanningEntity.NullComparator.class.isAssignableFrom(comparatorClass)) { + return ConfigUtils.newInstance( + () -> entityClass.toString(), "comparatorClass", comparatorClass); + } + } + return null; + } + + @SuppressWarnings("rawtypes") + private static Class extractDifficultyComparatorFactoryClass(PlanningEntity entityAnnotation) { + if (entityAnnotation != null) { + var factoryClass = entityAnnotation.comparatorFactoryClass(); + if (factoryClass != null + && !PlanningEntity.NullComparatorFactory.class.isAssignableFrom(factoryClass)) { + return factoryClass; + } + } + return null; + } + + private static List> buildSortedEntityClassList(List> entityClassList) { + var updatedEntityClassList = new ArrayList<>(entityClassList); + for (var entityClass : entityClassList) { + for (var inherited : extractInheritedClasses(entityClass)) { + if (!updatedEntityClassList.contains(inherited)) { + updatedEntityClassList.add(inherited); + } + } + } + return sortEntityClassList(updatedEntityClassList); + } + + private static List> sortEntityClassList(List> entityClassList) { + var sortedEntityClassList = new ArrayList>(entityClassList.size()); + for (var entityClass : entityClassList) { + var added = false; + for (var i = 0; i < sortedEntityClassList.size(); i++) { + if (entityClass.isAssignableFrom(sortedEntityClassList.get(i))) { + sortedEntityClassList.add(i, entityClass); + added = true; + break; + } + } + if (!added) { + sortedEntityClassList.add(entityClass); + } + } + return sortedEntityClassList; + } + + // ************************************************************************ + // Member metadata helpers + // ************************************************************************ + + private static String getPropertyName(Member member) { + if (member instanceof Field field) { + return field.getName(); + } else if (member instanceof Method) { + return ReflectionHelper.getGetterPropertyName(member); + } + return member.getName(); + } + + private static Class getPropertyType(Member member) { + if (member instanceof Field field) { + return field.getType(); + } else if (member instanceof Method method) { + return method.getReturnType(); + } + throw new IllegalStateException("Unsupported member type: " + member.getClass()); + } + + private static Type getGenericPropertyType(Member member) { + if (member instanceof Field field) { + return field.getGenericType(); + } else if (member instanceof Method method) { + return method.getGenericReturnType(); + } + throw new IllegalStateException("Unsupported member type: " + member.getClass()); + } + + // ************************************************************************ + // Getter/setter wrapper methods (existing accessor path with fast optimization) + // ************************************************************************ + + @SuppressWarnings("unchecked") + private static Function wrapGetter(MemberAccessor accessor, Member member, boolean useFastPath) { + if (useFastPath) { + var fast = tryCreateFastGetter(FRAMEWORK_LOOKUP, member); + if (fast != null) { + return (Function) (Function) fast; + } + } + return (Function) (Function) accessor::executeGetter; + } + + @SuppressWarnings("unchecked") + private static BiConsumer wrapSetter(MemberAccessor accessor, Member member, boolean useFastPath) { + if (useFastPath) { + var fast = tryCreateFastSetter(FRAMEWORK_LOOKUP, member, accessor.getType(), accessor.getName()); + if (fast != null) { + return (BiConsumer) (BiConsumer) fast; + } + } + return (BiConsumer) (BiConsumer) accessor::executeSetter; + } + + @SuppressWarnings("unchecked") + private static Function> wrapCollectionGetter(MemberAccessor accessor, Member member, + boolean useFastPath) { + if (useFastPath) { + var fast = tryCreateFastGetter(FRAMEWORK_LOOKUP, member); + if (fast != null) { + return (Function>) (Function) fast; + } + } + return (Function>) (Function) accessor::executeGetter; + } + + private static Function fastOrSlowGetter(Member member, MemberAccessor accessor, + boolean useFastPath) { + if (useFastPath) { + var fast = tryCreateFastGetter(FRAMEWORK_LOOKUP, member); + if (fast != null) { + return fast; + } + } + return accessor::executeGetter; + } + + private static BiConsumer fastOrSlowSetter(Member member, MemberAccessor accessor, + boolean useFastPath) { + if (useFastPath) { + var fast = tryCreateFastSetter(FRAMEWORK_LOOKUP, member, accessor.getType(), accessor.getName()); + if (fast != null) { + return fast; + } + } + return accessor::executeSetter; + } + + // ************************************************************************ + // Getter/setter wrapper methods (Lookup path — always uses LambdaMetafactory) + // ************************************************************************ + + /** + * Creates a getter for a member using the user's Lookup. + * Throws a clear error if access fails. + */ + static Function createGetterForMember(MethodHandles.Lookup lookup, Member member) { + try { + if (member instanceof Method method) { + return createGetter(lookup, method); + } else if (member instanceof Field field) { + var getterMethod = findDeclaredGetterMethod(field.getDeclaringClass(), field.getName()); + if (getterMethod != null) { + return createGetter(lookup, getterMethod); + } + // Direct field access — need private access to the declaring class + var privateLookup = MethodHandles.privateLookupIn(field.getDeclaringClass(), lookup); + return createFieldGetter(privateLookup, field); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot access member (%s) on class (%s). To use package-private or protected members, either:\n" + .formatted(member.getName(), member.getDeclaringClass().getSimpleName()) + + " 1. Make the member public, or\n" + + " 2. Provide a MethodHandles.Lookup via SolverConfig.withLookup(MethodHandles.lookup()), or\n" + + " 3. Use the programmatic PlanningSpecification API.", + e); + } catch (Throwable e) { + throw new IllegalStateException( + "Failed to create getter for member (%s) on class (%s)." + .formatted(member.getName(), member.getDeclaringClass().getSimpleName()), + e); + } + throw new IllegalStateException("Unsupported member type: " + member.getClass()); + } + + /** + * Creates a setter for a member using the user's Lookup. + */ + static BiConsumer createSetterForMember(MethodHandles.Lookup lookup, Member member, + Class propertyType, String propertyName) { + try { + Class declaringClass; + if (member instanceof Method method) { + declaringClass = method.getDeclaringClass(); + } else if (member instanceof Field field) { + declaringClass = field.getDeclaringClass(); + } else { + throw new IllegalStateException("Unsupported member type: " + member.getClass()); + } + var setterMethod = ReflectionHelper.getDeclaredSetterMethod(declaringClass, propertyType, propertyName); + if (setterMethod != null) { + return createSetter(lookup, setterMethod); + } + if (member instanceof Field field) { + var privateLookup = MethodHandles.privateLookupIn(field.getDeclaringClass(), lookup); + return createFieldSetter(privateLookup, field); + } + } catch (IllegalAccessException e) { + throw new IllegalStateException( + "Cannot access setter for member (%s) on class (%s). To use package-private or protected members, either:\n" + .formatted(member.getName(), member.getDeclaringClass().getSimpleName()) + + " 1. Make the member public, or\n" + + " 2. Provide a MethodHandles.Lookup via SolverConfig.withLookup(MethodHandles.lookup()), or\n" + + " 3. Use the programmatic PlanningSpecification API.", + e); + } catch (Throwable e) { + throw new IllegalStateException( + "Failed to create setter for member (%s) on class (%s)." + .formatted(member.getName(), member.getDeclaringClass().getSimpleName()), + e); + } + throw new IllegalStateException( + "No setter found for member (%s) on class (%s)." + .formatted(propertyName, member.getDeclaringClass().getSimpleName())); + } + + /** + * Finds a declared getter method (including non-public) for a field name. + * Walks up the class hierarchy. + */ + private static Method findDeclaredGetterMethod(Class clazz, String fieldName) { + var capitalizedName = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + for (var prefix : new String[] { "get", "is" }) { + try { + return clazz.getDeclaredMethod(prefix + capitalizedName); + } catch (NoSuchMethodException e) { + // try next + } + } + if (clazz.getSuperclass() != null) { + return findDeclaredGetterMethod(clazz.getSuperclass(), fieldName); + } + return null; + } + + @SuppressWarnings("unchecked") + private static Function wrapGetterForLookup(MethodHandles.Lookup lookup, Member member) { + return (Function) (Function) createGetterForMember(lookup, member); + } + + @SuppressWarnings("unchecked") + private static BiConsumer wrapSetterForLookup(MethodHandles.Lookup lookup, Member member, + Class propertyType, String propertyName) { + return (BiConsumer) (BiConsumer) createSetterForMember(lookup, member, propertyType, propertyName); + } + + // ************************************************************************ + // Validation helpers + // ************************************************************************ + + private static void assertNoFieldAndGetterDuplicationOrConflict( + Class solutionClass, MemberAccessor memberAccessor, Class annotationClass, + Map> seenFactNames, Map seenFactAccessors, + Map> seenEntityNames, Map seenEntityAccessors) { + var memberName = memberAccessor.getName(); + MemberAccessor duplicate = null; + Class otherAnnotationClass = null; + if (seenFactNames.containsKey(memberName)) { + duplicate = seenFactAccessors.get(memberName); + otherAnnotationClass = seenFactNames.get(memberName); + } else if (seenEntityNames.containsKey(memberName)) { + duplicate = seenEntityAccessors.get(memberName); + otherAnnotationClass = seenEntityNames.get(memberName); + } + if (duplicate != null) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s annotated member (%s) that is duplicated by a @%s annotated member (%s). + %s""".formatted(solutionClass, annotationClass.getSimpleName(), memberAccessor, + otherAnnotationClass.getSimpleName(), duplicate, + annotationClass.equals(otherAnnotationClass) + ? "Maybe the annotation is defined on both the field and its getter." + : "Maybe 2 mutually exclusive annotations are configured.")); + } + } + + private static void assertNoLookupDuplication( + Class solutionClass, String propertyName, String annotationName, + Map seenFactNames, Map seenEntityNames) { + String otherAnnotation = seenFactNames.get(propertyName); + if (otherAnnotation == null) { + otherAnnotation = seenEntityNames.get(propertyName); + } + if (otherAnnotation != null) { + throw new IllegalStateException(""" + The solutionClass (%s) has a @%s annotated member (%s) that is duplicated by a @%s annotated member. + %s""".formatted(solutionClass, annotationName, propertyName, + otherAnnotation, + annotationName.equals(otherAnnotation) + ? "Maybe the annotation is defined on both the field and its getter." + : "Maybe 2 mutually exclusive annotations are configured.")); + } + } + + private static MemberAccessor buildAccessor(MemberAccessorFactory factory, Member member, + MemberAccessorType memberAccessorType, Class annotationClass, + DomainAccessType domainAccessType) { + return factory.buildAndCacheMemberAccessor(member, memberAccessorType, annotationClass, domainAccessType); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultCloningSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultCloningSpecificationBuilder.java new file mode 100644 index 00000000000..cddc25f475f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultCloningSpecificationBuilder.java @@ -0,0 +1,141 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +import ai.timefold.solver.core.api.domain.specification.CloningSpecification; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.CloneableClassDescriptor; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.DeepCloneDecision; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification.PropertyCopyDescriptor; +import ai.timefold.solver.core.api.domain.specification.CloningSpecificationBuilder; + +final class DefaultCloningSpecificationBuilder implements CloningSpecificationBuilder { + + private Supplier solutionFactory; + private final List solutionProperties = new ArrayList<>(); + private final Map, CloneableClassDescriptor> cloneableClasses = new LinkedHashMap<>(); + private final Set> entityClasses = new LinkedHashSet<>(); + private final Set> deepCloneClasses = new LinkedHashSet<>(); + + @Override + public CloningSpecificationBuilder solutionFactory(Supplier factory) { + this.solutionFactory = factory; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public CloningSpecificationBuilder solutionProperty(String name, Function getter, + BiConsumer setter) { + return solutionProperty(name, getter, setter, DeepCloneDecision.SHALLOW); + } + + @Override + @SuppressWarnings("unchecked") + public CloningSpecificationBuilder solutionProperty(String name, Function getter, + BiConsumer setter, DeepCloneDecision decision) { + solutionProperties.add(new PropertyCopyDescriptor( + name, + (Function) (Function) getter, + (BiConsumer) (BiConsumer) setter, + decision)); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public CloningSpecificationBuilder entityClass(Class entityClass, Supplier factory, + Consumer> config) { + entityClasses.add(entityClass); + var builder = new DefaultCloneableClassBuilder(); + config.accept(builder); + cloneableClasses.put(entityClass, new CloneableClassDescriptor( + entityClass, (Supplier) (Supplier) factory, + List.copyOf(builder.properties))); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public CloningSpecificationBuilder deepCloneFact(Class factClass, Supplier factory, + Consumer> config) { + deepCloneClasses.add(factClass); + var builder = new DefaultCloneableClassBuilder(); + config.accept(builder); + cloneableClasses.put(factClass, new CloneableClassDescriptor( + factClass, (Supplier) (Supplier) factory, + List.copyOf(builder.properties))); + return this; + } + + CloningSpecification build() { + return new CloningSpecification<>( + solutionFactory, + List.copyOf(solutionProperties), + Map.copyOf(cloneableClasses), + Set.copyOf(entityClasses), + Set.copyOf(deepCloneClasses), + null); + } + + private static final class DefaultCloneableClassBuilder implements CloneableClassBuilder { + + private final List properties = new ArrayList<>(); + + @Override + public CloneableClassBuilder shallowProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.SHALLOW); + } + + @Override + public CloneableClassBuilder entityRefProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.RESOLVE_ENTITY_REFERENCE); + } + + @Override + public CloneableClassBuilder deepProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.ALWAYS_DEEP); + } + + @Override + public CloneableClassBuilder deepCollectionProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.DEEP_COLLECTION); + } + + @Override + public CloneableClassBuilder deepMapProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.DEEP_MAP); + } + + @Override + public CloneableClassBuilder deepArrayProperty(String name, Function getter, + BiConsumer setter) { + return property(name, getter, setter, DeepCloneDecision.DEEP_ARRAY); + } + + @Override + @SuppressWarnings("unchecked") + public CloneableClassBuilder property(String name, Function getter, + BiConsumer setter, DeepCloneDecision decision) { + properties.add(new PropertyCopyDescriptor( + name, + (Function) (Function) getter, + (BiConsumer) (BiConsumer) setter, + decision)); + return this; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultEntitySpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultEntitySpecificationBuilder.java new file mode 100644 index 00000000000..6d7ab6e6da0 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultEntitySpecificationBuilder.java @@ -0,0 +1,247 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ObjIntConsumer; +import java.util.function.Predicate; +import java.util.function.ToIntFunction; + +import ai.timefold.solver.core.api.domain.specification.CascadingUpdateShadowBuilder; +import ai.timefold.solver.core.api.domain.specification.DeclarativeShadowBuilder; +import ai.timefold.solver.core.api.domain.specification.EntitySpecification; +import ai.timefold.solver.core.api.domain.specification.EntitySpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.ListVariableSpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.ShadowSpecification; +import ai.timefold.solver.core.api.domain.specification.SourceRefBuilder; +import ai.timefold.solver.core.api.domain.specification.ValueRangeSpecification; +import ai.timefold.solver.core.api.domain.specification.VariableSpecification; +import ai.timefold.solver.core.api.domain.specification.VariableSpecificationBuilder; + +final class DefaultEntitySpecificationBuilder implements EntitySpecificationBuilder { + + private final Class entityClass; + private Function planningIdGetter; + private BiConsumer planningIdSetter; + private Comparator difficultyComparator; + private Predicate pinnedPredicate; + private ToIntFunction pinToIndexFunction; + private final List> variables = new ArrayList<>(); + private final List> shadows = new ArrayList<>(); + private final List> entityScopedValueRanges = new ArrayList<>(); + + DefaultEntitySpecificationBuilder(Class entityClass) { + this.entityClass = entityClass; + } + + @Override + public > EntitySpecificationBuilder planningId(Function getter) { + this.planningIdGetter = getter; + return this; + } + + @Override + @SuppressWarnings("unchecked") + public > EntitySpecificationBuilder planningId(Function getter, + BiConsumer setter) { + this.planningIdGetter = getter; + this.planningIdSetter = (BiConsumer) (BiConsumer) setter; + return this; + } + + @Override + public EntitySpecificationBuilder difficultyComparator(Comparator comparator) { + this.difficultyComparator = comparator; + return this; + } + + @Override + public EntitySpecificationBuilder pinned(Predicate isPinned) { + this.pinnedPredicate = isPinned; + return this; + } + + @Override + public EntitySpecificationBuilder pinToIndex(ToIntFunction pinToIndex) { + this.pinToIndexFunction = pinToIndex; + return this; + } + + @Override + public EntitySpecificationBuilder valueRange(String id, Function getter) { + entityScopedValueRanges.add(new ValueRangeSpecification<>(id, getter, entityClass, true)); + return this; + } + + @Override + public EntitySpecificationBuilder variable(String name, Class valueType, + Consumer> config) { + var builder = new DefaultVariableSpecificationBuilder(name, valueType, false); + config.accept(builder); + variables.add(builder.build()); + return this; + } + + @Override + public EntitySpecificationBuilder listVariable(String name, Class elementType, + Consumer> config) { + var builder = new DefaultListVariableSpecificationBuilder(name, elementType); + config.accept(builder); + variables.add(builder.build()); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder inverseRelationShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config) { + var ref = new DefaultSourceRefBuilder(); + config.accept(ref); + shadows.add(new ShadowSpecification.InverseRelation<>(name, type, + (Function) getter, + (BiConsumer) (BiConsumer) setter, + ref.getSourceVariableName())); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder indexShadow(String name, + ToIntFunction getter, ObjIntConsumer setter, Consumer config) { + var ref = new DefaultSourceRefBuilder(); + config.accept(ref); + shadows.add(new ShadowSpecification.Index<>(name, Integer.class, + (ToIntFunction) getter, + (ObjIntConsumer) setter, + ref.getSourceVariableName())); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder previousElementShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config) { + var ref = new DefaultSourceRefBuilder(); + config.accept(ref); + shadows.add(new ShadowSpecification.PreviousElement<>(name, type, + (Function) getter, + (BiConsumer) (BiConsumer) setter, + ref.getSourceVariableName())); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder nextElementShadow(String name, Class type, + Function getter, BiConsumer setter, Consumer config) { + var ref = new DefaultSourceRefBuilder(); + config.accept(ref); + shadows.add(new ShadowSpecification.NextElement<>(name, type, + (Function) getter, + (BiConsumer) (BiConsumer) setter, + ref.getSourceVariableName())); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder declarativeShadow(String name, Class type, + Function getter, BiConsumer setter, + Consumer> config) { + var builder = new DefaultDeclarativeShadowBuilder(); + config.accept(builder); + shadows.add(new ShadowSpecification.Declarative<>(name, type, + (Function) getter, + (BiConsumer) (BiConsumer) setter, + builder.supplier, + builder.sourcePaths != null ? List.copyOf(builder.sourcePaths) : List.of(), + builder.alignmentKey)); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder cascadingUpdateShadow(String name, Class type, + Function getter, BiConsumer setter, + Consumer> config) { + var builder = new DefaultCascadingUpdateShadowBuilder(); + config.accept(builder); + shadows.add(new ShadowSpecification.CascadingUpdate<>(name, type, + (Function) getter, + (BiConsumer) (BiConsumer) setter, + builder.updateMethod, + builder.sourcePaths != null ? List.copyOf(builder.sourcePaths) : List.of())); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public EntitySpecificationBuilder shadowVariablesInconsistent(String name, + Function getter, BiConsumer setter) { + shadows.add(new ShadowSpecification.Inconsistent<>(name, Boolean.class, + (Function) getter, + (BiConsumer) (BiConsumer) setter)); + return this; + } + + EntitySpecification build() { + return new EntitySpecification<>( + entityClass, + planningIdGetter, + planningIdSetter, + difficultyComparator, + null, + pinnedPredicate, + pinToIndexFunction, + List.copyOf(variables), + List.copyOf(shadows), + List.copyOf(entityScopedValueRanges)); + } + + private static final class DefaultDeclarativeShadowBuilder + implements DeclarativeShadowBuilder { + Function supplier; + List sourcePaths; + String alignmentKey; + + @Override + public DeclarativeShadowBuilder supplier(Function supplier) { + this.supplier = supplier; + return this; + } + + @Override + public DeclarativeShadowBuilder sources(String... sourcePaths) { + this.sourcePaths = List.of(sourcePaths); + return this; + } + + @Override + public DeclarativeShadowBuilder alignmentKey(String key) { + this.alignmentKey = key; + return this; + } + } + + private static final class DefaultCascadingUpdateShadowBuilder + implements CascadingUpdateShadowBuilder { + Consumer updateMethod; + List sourcePaths; + + @Override + @SuppressWarnings("unchecked") + public CascadingUpdateShadowBuilder updateMethod(Consumer updater) { + this.updateMethod = updater; + return this; + } + + @Override + public CascadingUpdateShadowBuilder sources(String... sourcePaths) { + this.sourcePaths = List.of(sourcePaths); + return this; + } + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultListVariableSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultListVariableSpecificationBuilder.java new file mode 100644 index 00000000000..9e3ab85d5bc --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultListVariableSpecificationBuilder.java @@ -0,0 +1,61 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.specification.ListVariableSpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.VariableSpecification; + +final class DefaultListVariableSpecificationBuilder implements ListVariableSpecificationBuilder { + + private final String name; + private final Class elementType; + private Function getter; + private BiConsumer setter; + private List valueRangeRefs = List.of(); + private boolean allowsUnassignedValues; + private Comparator strengthComparator; + + DefaultListVariableSpecificationBuilder(String name, Class elementType) { + this.name = name; + this.elementType = elementType; + } + + @Override + @SuppressWarnings("unchecked") + public ListVariableSpecificationBuilder accessors(Function> getter, + BiConsumer> setter) { + this.getter = getter; + this.setter = (BiConsumer) (BiConsumer) setter; + return this; + } + + @Override + public ListVariableSpecificationBuilder valueRange(String... refs) { + this.valueRangeRefs = List.of(refs); + return this; + } + + @Override + public ListVariableSpecificationBuilder allowsUnassignedValues(boolean allows) { + this.allowsUnassignedValues = allows; + return this; + } + + @Override + public ListVariableSpecificationBuilder strengthComparator(Comparator comparator) { + this.strengthComparator = comparator; + return this; + } + + VariableSpecification build() { + if (getter == null || setter == null) { + throw new IllegalStateException( + "List variable '%s' requires accessors. Call accessors() before building.".formatted(name)); + } + return new VariableSpecification<>(name, elementType, getter, setter, + true, allowsUnassignedValues, valueRangeRefs, strengthComparator); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSolutionSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSolutionSpecificationBuilder.java new file mode 100644 index 00000000000..4fea02e6a4f --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSolutionSpecificationBuilder.java @@ -0,0 +1,152 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides; +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.domain.specification.CloningSpecification; +import ai.timefold.solver.core.api.domain.specification.CloningSpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.ConstraintWeightSpecification; +import ai.timefold.solver.core.api.domain.specification.EntityCollectionSpecification; +import ai.timefold.solver.core.api.domain.specification.EntitySpecification; +import ai.timefold.solver.core.api.domain.specification.EntitySpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.FactSpecification; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.domain.specification.ScoreSpecification; +import ai.timefold.solver.core.api.domain.specification.SolutionSpecificationBuilder; +import ai.timefold.solver.core.api.domain.specification.ValueRangeSpecification; +import ai.timefold.solver.core.api.score.Score; + +public final class DefaultSolutionSpecificationBuilder implements SolutionSpecificationBuilder { + + private final Class solutionClass; + private ScoreSpecification score; + private final List> facts = new ArrayList<>(); + private final List> entityCollections = new ArrayList<>(); + private final List> valueRanges = new ArrayList<>(); + private final List> entities = new ArrayList<>(); + private CloningSpecification cloning; + private ConstraintWeightSpecification constraintWeights; + + public DefaultSolutionSpecificationBuilder(Class solutionClass) { + this.solutionClass = solutionClass; + } + + @Override + @SuppressWarnings("unchecked") + public > SolutionSpecificationBuilder score( + Class scoreType, Function getter, BiConsumer setter) { + this.score = new ScoreSpecification<>(scoreType, + (Function) getter, + (BiConsumer) (BiConsumer) setter); + return this; + } + + @Override + public SolutionSpecificationBuilder problemFact(String name, Function getter) { + facts.add(new FactSpecification<>(name, getter, false)); + return this; + } + + @Override + public SolutionSpecificationBuilder problemFact(String name, Function getter, BiConsumer setter) { + facts.add(new FactSpecification<>(name, getter, setter, false, null)); + return this; + } + + @Override + public SolutionSpecificationBuilder problemFacts(String name, Function> getter) { + facts.add(new FactSpecification<>(name, getter, true)); + return this; + } + + @Override + @SuppressWarnings("unchecked") + public SolutionSpecificationBuilder problemFacts(String name, Function> getter, + BiConsumer setter) { + facts.add(new FactSpecification<>(name, getter, setter, true, null)); + return this; + } + + @Override + public SolutionSpecificationBuilder entityCollection(String name, Function> getter) { + entityCollections.add(new EntityCollectionSpecification<>(name, getter)); + return this; + } + + @Override + public SolutionSpecificationBuilder entityCollection(String name, Function> getter, + BiConsumer setter) { + entityCollections.add(new EntityCollectionSpecification<>(name, getter, setter, false)); + return this; + } + + @Override + public SolutionSpecificationBuilder valueRange(String id, Function getter) { + valueRanges.add(new ValueRangeSpecification<>(id, getter, solutionClass, false)); + return this; + } + + @Override + public SolutionSpecificationBuilder valueRange(Function getter) { + valueRanges.add(new ValueRangeSpecification<>(null, getter, solutionClass, false)); + return this; + } + + @Override + public SolutionSpecificationBuilder constraintWeightOverrides(Function> getter) { + this.constraintWeights = new ConstraintWeightSpecification<>(getter); + return this; + } + + @Override + public SolutionSpecificationBuilder entity(Class entityClass, + Consumer> config) { + var builder = new DefaultEntitySpecificationBuilder(entityClass); + config.accept(builder); + entities.add(builder.build()); + return this; + } + + @Override + public SolutionSpecificationBuilder cloning(Consumer> config) { + var builder = new DefaultCloningSpecificationBuilder(); + config.accept(builder); + this.cloning = builder.build(); + return this; + } + + @Override + public SolutionSpecificationBuilder solutionCloner(SolutionCloner cloner) { + this.cloning = new CloningSpecification<>(null, null, null, null, null, cloner); + return this; + } + + @Override + public PlanningSpecification build() { + if (score == null) { + throw new IllegalStateException("Score specification is required. Call score() before build()."); + } + if (entities.isEmpty()) { + throw new IllegalStateException("At least one entity is required. Call entity() before build()."); + } + if (entityCollections.isEmpty()) { + throw new IllegalStateException( + "At least one entity collection is required. Call entityCollection() before build()."); + } + return new PlanningSpecification<>( + solutionClass, + score, + List.copyOf(facts), + List.copyOf(entityCollections), + List.copyOf(valueRanges), + List.copyOf(entities), + cloning, + constraintWeights); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSourceRefBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSourceRefBuilder.java new file mode 100644 index 00000000000..836841701c5 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultSourceRefBuilder.java @@ -0,0 +1,21 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import ai.timefold.solver.core.api.domain.specification.SourceRefBuilder; + +final class DefaultSourceRefBuilder implements SourceRefBuilder { + + private String sourceVariableName; + + @Override + public SourceRefBuilder sourceVariable(String variableName) { + this.sourceVariableName = variableName; + return this; + } + + String getSourceVariableName() { + if (sourceVariableName == null) { + throw new IllegalStateException("Source variable name is required. Call sourceVariable() before building."); + } + return sourceVariableName; + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultVariableSpecificationBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultVariableSpecificationBuilder.java new file mode 100644 index 00000000000..6629dd30df4 --- /dev/null +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/specification/DefaultVariableSpecificationBuilder.java @@ -0,0 +1,62 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import java.util.Comparator; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import ai.timefold.solver.core.api.domain.specification.VariableSpecification; +import ai.timefold.solver.core.api.domain.specification.VariableSpecificationBuilder; + +final class DefaultVariableSpecificationBuilder implements VariableSpecificationBuilder { + + private final String name; + private final Class valueType; + private final boolean isList; + private Function getter; + private BiConsumer setter; + private List valueRangeRefs = List.of(); + private boolean allowsUnassigned; + private Comparator strengthComparator; + + DefaultVariableSpecificationBuilder(String name, Class valueType, boolean isList) { + this.name = name; + this.valueType = valueType; + this.isList = isList; + } + + @Override + @SuppressWarnings("unchecked") + public VariableSpecificationBuilder accessors(Function getter, BiConsumer setter) { + this.getter = getter; + this.setter = (BiConsumer) (BiConsumer) setter; + return this; + } + + @Override + public VariableSpecificationBuilder valueRange(String... refs) { + this.valueRangeRefs = List.of(refs); + return this; + } + + @Override + public VariableSpecificationBuilder allowsUnassigned(boolean allows) { + this.allowsUnassigned = allows; + return this; + } + + @Override + public VariableSpecificationBuilder strengthComparator(Comparator comparator) { + this.strengthComparator = comparator; + return this; + } + + VariableSpecification build() { + if (getter == null || setter == null) { + throw new IllegalStateException( + "Variable '%s' requires accessors. Call accessors() before building.".formatted(name)); + } + return new VariableSpecification<>(name, valueType, getter, setter, + isList, allowsUnassigned, valueRangeRefs, strengthComparator); + } +} diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java index 5b84d5c7fae..b02a7d9386c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/AbstractFromPropertyValueRangeDescriptor.java @@ -47,6 +47,20 @@ protected AbstractFromPropertyValueRangeDescriptor(int ordinalId, GenuineVariabl processValueRangeProviderAnnotation(valueRangeProviderAnnotation); } + /** + * Constructor that bypasses annotation checking. + * Used by the programmatic specification API where configuration comes from + * the specification rather than annotations. + */ + protected AbstractFromPropertyValueRangeDescriptor(int ordinalId, GenuineVariableDescriptor variableDescriptor, + MemberAccessor memberAccessor, boolean skipAnnotationCheck) { + super(ordinalId, variableDescriptor); + this.memberAccessor = memberAccessor; + var type = memberAccessor.getType(); + collectionWrapping = Collection.class.isAssignableFrom(type); + arrayWrapping = type.isArray(); + } + private void processValueRangeProviderAnnotation(ValueRangeProvider valueRangeProviderAnnotation) { EntityDescriptor entityDescriptor = variableDescriptor.getEntityDescriptor(); Class type = memberAccessor.getType(); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromEntityPropertyValueRangeDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromEntityPropertyValueRangeDescriptor.java index c56a9dec279..1c730d28512 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromEntityPropertyValueRangeDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromEntityPropertyValueRangeDescriptor.java @@ -23,6 +23,15 @@ public FromEntityPropertyValueRangeDescriptor(int ordinal, GenuineVariableDescri super(ordinal, variableDescriptor, memberAccessor); } + /** + * Constructor that bypasses annotation checking. + * Used by the programmatic specification API. + */ + public FromEntityPropertyValueRangeDescriptor(int ordinal, GenuineVariableDescriptor variableDescriptor, + MemberAccessor memberAccessor, boolean skipAnnotationCheck) { + super(ordinal, variableDescriptor, memberAccessor, skipAnnotationCheck); + } + @Override public ValueRange extractAllValues(Solution_ solution) { var entityList = variableDescriptor.getEntityDescriptor().extractEntities(solution); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromSolutionPropertyValueRangeDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromSolutionPropertyValueRangeDescriptor.java index 8c1c7f95f79..2f16deffd96 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromSolutionPropertyValueRangeDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/valuerange/descriptor/FromSolutionPropertyValueRangeDescriptor.java @@ -19,6 +19,15 @@ public FromSolutionPropertyValueRangeDescriptor(int ordinal, GenuineVariableDesc super(ordinal, variableDescriptor, memberAccessor); } + /** + * Constructor that bypasses annotation checking. + * Used by the programmatic specification API. + */ + public FromSolutionPropertyValueRangeDescriptor(int ordinal, GenuineVariableDescriptor variableDescriptor, + MemberAccessor memberAccessor, boolean skipAnnotationCheck) { + super(ordinal, variableDescriptor, memberAccessor, skipAnnotationCheck); + } + @Override public ValueRange extractAllValues(Solution_ solution) { return readValueRangeForSolution(solution); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/IndexShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/IndexShadowVariableDescriptor.java index 6ebf24f7cd7..b4f33af639c 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/IndexShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/IndexShadowVariableDescriptor.java @@ -84,6 +84,15 @@ the entityClass (%s) has a planning variable with sourceVariableName (%s).""" sourceVariableDescriptor.registerSinkVariableDescriptor(this); } + /** + * Link the source variable directly without reading annotations. + * Used by the programmatic specification API. + */ + public void linkSourceVariable(ListVariableDescriptor sourceVariable) { + this.sourceVariableDescriptor = sourceVariable; + sourceVariable.registerSinkVariableDescriptor(this); + } + @Override public List> getSourceVariableDescriptorList() { return Collections.singletonList(sourceVariableDescriptor); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java index 67863570351..92203de2d66 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/cascade/CascadingUpdateShadowVariableDescriptor.java @@ -42,6 +42,28 @@ public void addTargetVariable(EntityDescriptor entityDescriptor, shadowVariableTargetList.add(new ShadowVariableTarget<>(entityDescriptor, variableMemberAccessor)); } + /** + * Set the target method accessor and target variable descriptors directly. + * Used by the programmatic specification API. + */ + public void setTargetMethod(MemberAccessor targetMethod) { + this.targetMethod = targetMethod; + } + + /** + * Complete linking by populating target variable descriptors and setting first target. + * Used by the programmatic specification API after all shadow descriptors are registered. + */ + public void completeTargetLinking() { + for (var shadowVariableTarget : shadowVariableTargetList) { + targetVariableDescriptorList.add(shadowVariableTarget.entityDescriptor() + .getShadowVariableDescriptor(shadowVariableTarget.variableMemberAccessor().getName())); + } + if (!targetVariableDescriptorList.isEmpty()) { + firstTargetVariableDescriptor = targetVariableDescriptorList.getFirst(); + } + } + public String getTargetMethodName() { var variableMemberAccessor = shadowVariableTargetList.getFirst().variableMemberAccessor(); return Arrays diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java index fca3814d874..e1306ae4c59 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/declarative/DeclarativeShadowVariableDescriptor.java @@ -36,6 +36,17 @@ public DeclarativeShadowVariableDescriptor(int ordinal, super(ordinal, entityDescriptor, variableMemberAccessor); } + /** + * Set the calculator, source paths, and alignment key directly without reading annotations. + * Used by the programmatic specification API. + * The sources array will be resolved during linkVariableDescriptors. + */ + public void setSpecificationData(MemberAccessor calculator, String[] sourcePaths, @Nullable String alignmentKey) { + this.calculator = calculator; + this.sourcePaths = sourcePaths; + this.alignmentKey = alignmentKey; + } + @Override public void processAnnotations(DescriptorPolicy descriptorPolicy) { var annotation = variableMemberAccessor.getAnnotation(ShadowVariable.class); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/BasicVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/BasicVariableDescriptor.java index 352823e3789..b191620ea4a 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/BasicVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/BasicVariableDescriptor.java @@ -26,6 +26,14 @@ public boolean allowsUnassigned() { return allowsUnassigned; } + /** + * Set allowsUnassigned without reading annotations. + * Used by the programmatic specification API. + */ + public void setAllowsUnassigned(boolean allowsUnassigned) { + this.allowsUnassigned = allowsUnassigned; + } + // ************************************************************************ // Lifecycle methods // ************************************************************************ diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/GenuineVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/GenuineVariableDescriptor.java index 670c8ab1f31..96d51808a15 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/GenuineVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/GenuineVariableDescriptor.java @@ -42,6 +42,22 @@ protected GenuineVariableDescriptor(int ordinal, EntityDescriptor ent super(ordinal, entityDescriptor, variableMemberAccessor); } + /** + * Set the value range descriptor directly, bypassing annotation-based resolution. + * Used by the programmatic specification API. + */ + public void setValueRangeDescriptor(AbstractValueRangeDescriptor valueRangeDescriptor) { + this.valueRangeDescriptor = valueRangeDescriptor; + } + + /** + * Process value range refs from specification, delegating to the existing ref resolution logic. + * Used by the programmatic specification API. + */ + public void processValueRangeRefsFromSpecification(DescriptorPolicy descriptorPolicy, String[] valueRangeProviderRefs) { + processValueRangeRefs(descriptorPolicy, valueRangeProviderRefs); + } + // ************************************************************************ // Lifecycle methods // ************************************************************************ @@ -183,6 +199,30 @@ protected void processSorting(String comparatorPropertyName, Class comparator) { + ascendingSorter = new ComparatorSelectionSorter<>((Comparator) comparator, + SelectionSorterOrder.ASCENDING); + descendingSorter = new ComparatorSelectionSorter<>((Comparator) comparator, + SelectionSorterOrder.DESCENDING); + } + + /** + * Set the strength sorting from a comparator factory class (solution-aware comparators). + * Used by the programmatic specification API. + */ + @SuppressWarnings({ "unchecked", "rawtypes" }) + public void setStrengthSortingFromFactory(Class comparatorFactoryClass) { + ComparatorFactory comparatorFactory = (ComparatorFactory) ConfigUtils + .newInstance(this::toString, "comparatorFactoryClass", (Class) comparatorFactoryClass); + ascendingSorter = new ComparatorFactorySelectionSorter<>(comparatorFactory, SelectionSorterOrder.ASCENDING); + descendingSorter = new ComparatorFactorySelectionSorter<>(comparatorFactory, SelectionSorterOrder.DESCENDING); + } + @Override public void linkVariableDescriptors(DescriptorPolicy descriptorPolicy) { // Overriding this method so that subclasses can override it too and call super. diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java index a0e00884ea5..469921868e9 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/descriptor/ListVariableDescriptor.java @@ -56,6 +56,14 @@ public boolean allowsUnassignedValues() { return allowsUnassignedValues; } + /** + * Set allowsUnassignedValues without reading annotations. + * Used by the programmatic specification API. + */ + public void setAllowsUnassignedValues(boolean allowsUnassignedValues) { + this.allowsUnassignedValues = allowsUnassignedValues; + } + @Override protected void processPropertyAnnotations(DescriptorPolicy descriptorPolicy) { PlanningListVariable planningVariableAnnotation = variableMemberAccessor.getAnnotation(PlanningListVariable.class); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java index 08d2f83e28a..119700b9e40 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/inverserelation/InverseRelationShadowVariableDescriptor.java @@ -115,6 +115,17 @@ The entityClass (%s) has an @%s-annotated property (%s) \ sourceVariableDescriptor.registerSinkVariableDescriptor(this); } + /** + * Link the source variable directly without reading annotations. + * Used by the programmatic specification API. + */ + public void linkSourceVariable(VariableDescriptor sourceVariable) { + this.sourceVariableDescriptor = sourceVariable; + Class variablePropertyType = getVariablePropertyType(); + this.singleton = !Collection.class.isAssignableFrom(variablePropertyType); + sourceVariable.registerSinkVariableDescriptor(this); + } + @Override public List> getSourceVariableDescriptorList() { return Collections.singletonList(sourceVariableDescriptor); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java index 77f8b766b3e..0185f6d94d7 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/domain/variable/nextprev/AbstractNextPrevElementShadowVariableDescriptor.java @@ -84,6 +84,15 @@ which is not the type of elements (%s) of the source list variable (%s).""" sourceVariableDescriptor.registerSinkVariableDescriptor(this); } + /** + * Link the source variable directly without reading annotations. + * Used by the programmatic specification API. + */ + public void linkSourceVariable(ListVariableDescriptor sourceVariable) { + this.sourceVariableDescriptor = sourceVariable; + sourceVariable.registerSinkVariableDescriptor(this); + } + @Override public List> getSourceVariableDescriptorList() { return Collections.singletonList(sourceVariableDescriptor); diff --git a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java index 0a568175e4e..8dccf60e3ed 100644 --- a/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java +++ b/core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolverFactory.java @@ -9,6 +9,7 @@ import java.util.OptionalInt; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; import ai.timefold.solver.core.api.score.Score; import ai.timefold.solver.core.api.solver.Solver; import ai.timefold.solver.core.api.solver.SolverConfigOverride; @@ -29,6 +30,8 @@ import ai.timefold.solver.core.impl.domain.common.DomainAccessType; import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SpecificationCompiler; +import ai.timefold.solver.core.impl.domain.specification.AnnotationSpecificationFactory; import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy; import ai.timefold.solver.core.impl.phase.Phase; import ai.timefold.solver.core.impl.phase.PhaseFactory; @@ -70,7 +73,10 @@ public final class DefaultSolverFactory implements SolverFactory buildTermination(BasicPlumbingTermination buildSolutionDescriptor() { + var spec = solverConfig.getPlanningSpecification(); + if (spec != null) { + return SpecificationCompiler.compile( + (PlanningSpecification) spec, + solverConfig.getEnablePreviewFeatureSet()); + } if (solverConfig.getSolutionClass() == null) { throw new IllegalArgumentException( "The solver configuration must have a solutionClass (%s). If you're using the Quarkus extension or Spring Boot starter, it should have been filled in already." @@ -196,12 +209,26 @@ private SolutionDescriptor buildSolutionDescriptor() { "The solver configuration must have at least 1 entityClass (%s). If you're using the Quarkus extension or Spring Boot starter, it should have been filled in already." .formatted(solverConfig.getEntityClassList())); } - return SolutionDescriptor.buildSolutionDescriptor(solverConfig.getEnablePreviewFeatureSet(), + var solutionClass = (Class) solverConfig.getSolutionClass(); + SolutionDescriptor.assertMutable(solutionClass, "solutionClass"); + SolutionDescriptor.assertSingleInheritance(solutionClass); + SolutionDescriptor.assertValidAnnotatedMembers(solutionClass); + if (solverConfig.getLookup() != null) { + var lookupSpec = AnnotationSpecificationFactory.fromAnnotations( + solutionClass, solverConfig.getEntityClassList(), solverConfig.getLookup()); + return SpecificationCompiler.compile(lookupSpec, + solverConfig.getEnablePreviewFeatureSet()); + } + var annotationSpec = AnnotationSpecificationFactory.fromAnnotations( + solutionClass, + solverConfig.getEntityClassList(), + domainAccessType, + solverConfig.getGizmoMemberAccessorMap()); + return SpecificationCompiler.compile(annotationSpec, + solverConfig.getEnablePreviewFeatureSet(), domainAccessType, - (Class) solverConfig.getSolutionClass(), solverConfig.getGizmoMemberAccessorMap(), - solverConfig.getGizmoSolutionClonerMap(), - solverConfig.getEntityClassList()); + false); } private > ScoreDirectorFactory buildScoreDirectorFactory() { diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index 10ad6fb09ef..c2da9964503 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -7,6 +7,7 @@ exports ai.timefold.solver.core.api.domain.solution; exports ai.timefold.solver.core.api.domain.solution.cloner; exports ai.timefold.solver.core.api.domain.valuerange; + exports ai.timefold.solver.core.api.domain.specification; exports ai.timefold.solver.core.api.domain.variable; exports ai.timefold.solver.core.api.function; exports ai.timefold.solver.core.api.score; @@ -93,8 +94,6 @@ exports ai.timefold.solver.core.impl.constructionheuristic.scope to ai.timefold.solver.jackson, ai.timefold.solver.benchmark, ai.timefold.solver.enterprise.core; - exports ai.timefold.solver.core.impl.domain.solution.cloner.gizmo - to ai.timefold.solver.quarkus.deployment; exports ai.timefold.solver.core.impl.domain.common.accessor to ai.timefold.solver.quarkus.deployment, ai.timefold.solver.quarkus; exports ai.timefold.solver.core.impl.domain.common diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessorTest.java new file mode 100644 index 00000000000..33d050458a9 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/common/accessor/LambdaMemberAccessorTest.java @@ -0,0 +1,82 @@ +package ai.timefold.solver.core.impl.domain.common.accessor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class LambdaMemberAccessorTest { + + @Test + void getterAndSetter() { + var accessor = new LambdaMemberAccessor( + "value", TestBean.class, String.class, String.class, + (TestBean b) -> b.value, (TestBean b, Object v) -> b.value = (String) v); + + var bean = new TestBean("hello"); + assertThat(accessor.executeGetter(bean)).isEqualTo("hello"); + + accessor.executeSetter(bean, "world"); + assertThat(bean.value).isEqualTo("world"); + } + + @Test + void metadata() { + var accessor = new LambdaMemberAccessor( + "myField", TestBean.class, String.class, String.class, + (TestBean b) -> b.value, null); + + assertThat(accessor.getName()).isEqualTo("myField"); + assertThat(accessor.getDeclaringClass()).isEqualTo(TestBean.class); + assertThat(accessor.getType()).isEqualTo(String.class); + assertThat(accessor.getGenericType()).isEqualTo(String.class); + assertThat(accessor.getSpeedNote()).isEqualTo("lambda"); + } + + @Test + void annotationsReturnNull() { + var accessor = new LambdaMemberAccessor( + "value", TestBean.class, String.class, String.class, + (TestBean b) -> b.value, null); + + assertThat(accessor.getAnnotation(Override.class)).isNull(); + assertThat(accessor.getDeclaredAnnotationsByType(Override.class)).isNull(); + } + + @Test + void noSetterThrows() { + var accessor = new LambdaMemberAccessor( + "value", TestBean.class, String.class, String.class, + (TestBean b) -> b.value, null); + + assertThat(accessor.supportSetter()).isFalse(); + assertThatThrownBy(() -> accessor.executeSetter(new TestBean("x"), "y")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void genericTypeFallsBackToType() { + var accessor = new LambdaMemberAccessor( + "value", TestBean.class, String.class, null, + (TestBean b) -> b.value, null); + + assertThat(accessor.getGenericType()).isEqualTo(String.class); + } + + @Test + void toStringFormat() { + var accessor = new LambdaMemberAccessor( + "value", TestBean.class, String.class, String.class, + (TestBean b) -> b.value, null); + + assertThat(accessor.toString()).isEqualTo("lambda:TestBean.value"); + } + + private static class TestBean { + String value; + + TestBean(String value) { + this.value = value; + } + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionClonerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionClonerTest.java deleted file mode 100644 index f64fa79775b..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/FieldAccessingSolutionClonerTest.java +++ /dev/null @@ -1,13 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner; - -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -class FieldAccessingSolutionClonerTest extends AbstractSolutionClonerTest { - - @Override - protected SolutionCloner createSolutionCloner( - SolutionDescriptor solutionDescriptor) { - return new FieldAccessingSolutionCloner<>(solutionDescriptor); - } -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionClonerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionClonerTest.java new file mode 100644 index 00000000000..0095a28032e --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/LambdaBasedSolutionClonerTest.java @@ -0,0 +1,33 @@ +package ai.timefold.solver.core.impl.domain.solution.cloner; + +import static org.assertj.core.api.Assertions.assertThat; + +import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +/** + * Tests the {@link LambdaBasedSolutionCloner} both via the annotation path + * (where it should now be the default) and via programmatic specification. + *

+ * Inherits all tests from {@link AbstractSolutionClonerTest} using the lambda-based cloner + * retrieved from the SolutionDescriptor (annotation path). + */ +class LambdaBasedSolutionClonerTest extends AbstractSolutionClonerTest { + + @Override + protected SolutionCloner createSolutionCloner( + SolutionDescriptor solutionDescriptor) { + // The annotation path should now produce a LambdaBasedSolutionCloner + return solutionDescriptor.getSolutionCloner(); + } + + @Test + void annotationPathUsesLambdaBasedCloner() { + var solutionDescriptor = TestdataSolution.buildSolutionDescriptor(); + var cloner = solutionDescriptor.getSolutionCloner(); + assertThat(cloner).isInstanceOf(LambdaBasedSolutionCloner.class); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtilsTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtilsTest.java deleted file mode 100644 index a16b6b3f57e..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoCloningUtilsTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.List; - -import ai.timefold.solver.core.testdomain.clone.deepcloning.AnnotatedTestdataVariousTypes; -import ai.timefold.solver.core.testdomain.clone.deepcloning.ExtraDeepClonedObject; -import ai.timefold.solver.core.testdomain.clone.deepcloning.TestdataDeepCloningEntity; -import ai.timefold.solver.core.testdomain.clone.deepcloning.TestdataVariousTypes; -import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningEntity; -import ai.timefold.solver.core.testdomain.clone.deepcloning.field.TestdataFieldAnnotatedDeepCloningSolution; - -import org.junit.jupiter.api.Test; - -class GizmoCloningUtilsTest { - - @Test - void getDeepClonedClasses() { - assertThat(GizmoCloningUtils.getDeepClonedClasses( - TestdataFieldAnnotatedDeepCloningSolution.buildSolutionDescriptor(), - List.of(TestdataDeepCloningEntity.class))) - .containsExactlyInAnyOrder( - TestdataDeepCloningEntity.class, - ExtraDeepClonedObject.class, - TestdataFieldAnnotatedDeepCloningEntity.class, - AnnotatedTestdataVariousTypes.class, - TestdataFieldAnnotatedDeepCloningSolution.class, - TestdataVariousTypes.class); - } -} \ No newline at end of file diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerTest.java deleted file mode 100644 index 4ed5a838af8..00000000000 --- a/core/src/test/java/ai/timefold/solver/core/impl/domain/solution/cloner/gizmo/GizmoSolutionClonerTest.java +++ /dev/null @@ -1,210 +0,0 @@ -package ai.timefold.solver.core.impl.domain.solution.cloner.gizmo; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - -import java.lang.reflect.Field; -import java.lang.reflect.Modifier; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.stream.Stream; - -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; -import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.AccessorInfo; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoClassLoader; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberDescriptor; -import ai.timefold.solver.core.impl.domain.solution.cloner.AbstractSolutionClonerTest; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.testdomain.TestdataValue; -import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.childnot.TestdataOnlyBaseAnnotatedChildEntity; -import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.childnot.TestdataOnlyBaseAnnotatedExtendedSolution; -import ai.timefold.solver.core.testdomain.inheritance.solution.baseannotated.childnot.TestdataOnlyBaseAnnotatedSolution; - -import org.junit.jupiter.api.Test; - -import io.quarkus.gizmo2.Gizmo; -import io.quarkus.gizmo2.desc.FieldDesc; -import io.quarkus.gizmo2.desc.MethodDesc; - -class GizmoSolutionClonerTest extends AbstractSolutionClonerTest { - - @Test - void debuggingDisabled() { - assertThat(GizmoSolutionClonerImplementor.DEBUG) - .as("Gizmo debugging is enabled. Please disable before merging changes.") - .isFalse(); - } - - @Override - protected SolutionCloner createSolutionCloner(SolutionDescriptor solutionDescriptor) { - var className = GizmoSolutionClonerFactory.getGeneratedClassName(solutionDescriptor); - var classBytecodeHolder = new HashMap(); - var classOutput = - GizmoSolutionClonerImplementor.createClassOutputWithDebuggingCapability(classBytecodeHolder); - - if (GizmoSolutionClonerImplementor.DEBUG) { - System.setProperty("gizmo.debug", "true"); - } - var gizmo = Gizmo.create(classOutput); - - gizmo.class_(className, classCreator -> { - classCreator.implements_(GizmoSolutionCloner.class); - classCreator.extends_(Object.class); - classCreator.final_(); - - var memoizedSolutionOrEntityDescriptorMap = new HashMap, GizmoSolutionOrEntityDescriptor>(); - - var deepClonedClassSet = GizmoCloningUtils.getDeepClonedClasses(solutionDescriptor, Collections.emptyList()); - Stream.concat(Stream.of(solutionDescriptor.getSolutionClass()), - Stream.concat(solutionDescriptor.getEntityClassSet().stream(), - deepClonedClassSet.stream())) - .forEach(clazz -> { - memoizedSolutionOrEntityDescriptorMap.put(clazz, - generateGizmoSolutionOrEntityDescriptor(solutionDescriptor, clazz)); - }); - - GizmoSolutionClonerImplementor.defineClonerFor(classCreator, solutionDescriptor, - Collections.singleton(solutionDescriptor.getSolutionClass()), - memoizedSolutionOrEntityDescriptorMap, deepClonedClassSet); - }); - - var gizmoClassLoader = new GizmoClassLoader(classBytecodeHolder); - - try { - @SuppressWarnings("unchecked") - var solutionCloner = - (GizmoSolutionCloner) gizmoClassLoader.loadClass(className).getConstructor().newInstance(); - solutionCloner.setSolutionDescriptor(solutionDescriptor); - return solutionCloner; - } catch (Exception e) { - throw new IllegalStateException("Failed creating generated Gizmo Class (" + className + ").", e); - } - } - - // HACK: use public getters/setters of fields so test domain can remain private - // TODO: should this be another DomainAccessType? DomainAccessType.GIZMO_RELAXED_ACCESS? - private GizmoSolutionOrEntityDescriptor generateGizmoSolutionOrEntityDescriptor(SolutionDescriptor solutionDescriptor, - Class entityClass) { - var solutionFieldToMemberDescriptor = new HashMap(); - var currentClass = entityClass; - - while (currentClass != null) { - for (var field : currentClass.getDeclaredFields()) { - if (!Modifier.isStatic(field.getModifiers())) { - GizmoMemberDescriptor member; - var declaringClass = field.getDeclaringClass(); - var memberDescriptor = FieldDesc.of(field); - var name = field.getName(); - - if (Modifier.isPublic(field.getModifiers())) { - member = new GizmoMemberDescriptor(name, memberDescriptor, declaringClass, - AccessorInfo.withReturnValueAndNoArguments()); - } else { - var getter = ReflectionHelper.getGetterMethod(currentClass, field.getName()); - var setter = ReflectionHelper.getSetterMethod(currentClass, field.getName()); - if (getter != null && setter != null) { - var getterDescriptor = MethodDesc.of(field.getDeclaringClass(), - getter.getName(), - field.getType()); - var setterDescriptor = MethodDesc.of(field.getDeclaringClass(), - setter.getName(), - setter.getReturnType(), - field.getType()); - member = new GizmoMemberDescriptor(name, getterDescriptor, memberDescriptor, null, declaringClass, - setterDescriptor); - } else { - throw new IllegalStateException(""" - Failed to generate GizmoMemberDescriptor for (%s#%s): - Field is not public and does not have both a getter and a setter. - """.formatted(declaringClass.getName(), name)); - } - } - solutionFieldToMemberDescriptor.put(field, member); - } - } - currentClass = currentClass.getSuperclass(); - } - return new GizmoSolutionOrEntityDescriptor(solutionDescriptor, entityClass, solutionFieldToMemberDescriptor); - } - - private interface Animal { - } - - private interface Robot { - } - - private interface Zebra extends Animal { - } - - private interface RobotZebra extends Zebra, Robot { - } - - // This test verifies the instanceof comparator works correctly - @Test - void instanceOfComparatorTest() { - var classSet = new HashSet<>(Arrays.asList( - Animal.class, - Robot.class, - Zebra.class, - RobotZebra.class)); - - var comparator = GizmoSolutionClonerImplementor.getInstanceOfComparator(classSet); - - // assert that the comparator works on equality - assertThat(comparator.compare(Animal.class, Animal.class)).isZero(); - assertThat(comparator.compare(Robot.class, Robot.class)).isZero(); - assertThat(comparator.compare(Zebra.class, Zebra.class)).isZero(); - assertThat(comparator.compare(RobotZebra.class, RobotZebra.class)).isZero(); - - // Zebra < Animal and Robot - // Since Animal and Robot are base classes (i.e. not subclasses of anything in the set) - // and Zebra is a subclass of Animal - assertThat(comparator.compare(Zebra.class, Animal.class)).isLessThan(0); - assertThat(comparator.compare(Zebra.class, Robot.class)).isLessThan(0); - assertThat(comparator.compare(Animal.class, Zebra.class)).isGreaterThan(0); - assertThat(comparator.compare(Robot.class, Zebra.class)).isGreaterThan(0); - - // RobotZebra < Animal and Robot and Zebra - assertThat(comparator.compare(RobotZebra.class, Animal.class)).isLessThan(0); - assertThat(comparator.compare(RobotZebra.class, Robot.class)).isLessThan(0); - assertThat(comparator.compare(RobotZebra.class, Zebra.class)).isLessThan(0); - assertThat(comparator.compare(Animal.class, RobotZebra.class)).isGreaterThan(0); - assertThat(comparator.compare(Robot.class, RobotZebra.class)).isGreaterThan(0); - assertThat(comparator.compare(Zebra.class, RobotZebra.class)).isGreaterThan(0); - } - - // This test verifies a proper error message is thrown if an extended solution is passed. - @Override - @Test - protected void cloneExtendedSolution() { - var solutionDescriptor = TestdataOnlyBaseAnnotatedExtendedSolution.buildBaseSolutionDescriptor(); - var cloner = createSolutionCloner(solutionDescriptor); - - var val1 = new TestdataValue("1"); - var val2 = new TestdataValue("2"); - var val3 = new TestdataValue("3"); - var a = new TestdataOnlyBaseAnnotatedChildEntity("a", val1, null); - var b = new TestdataOnlyBaseAnnotatedChildEntity("b", val1, "extraObjectOnEntity"); - var c = new TestdataOnlyBaseAnnotatedChildEntity("c", val3); - var d = new TestdataOnlyBaseAnnotatedChildEntity("d", val3, c); - c.setExtraObject(d); - - var original = new TestdataOnlyBaseAnnotatedExtendedSolution("solution", "extraObjectOnSolution"); - var valueList = Arrays.asList(val1, val2, val3); - original.setValueList(valueList); - var originalEntityList = Arrays.asList(a, b, c, d); - original.setEntityList(originalEntityList); - - assertThatCode(() -> cloner.cloneSolution(original)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContainingAll( - "Failed to create clone: encountered (%s)".formatted(original.getClass()), - "which is not a known subclass of the solution class (%s)." - .formatted(TestdataOnlyBaseAnnotatedSolution.class), - "The known subclasses are: [%s]".formatted(TestdataOnlyBaseAnnotatedSolution.class.getName()), - "Maybe use DomainAccessType.REFLECTION?"); - } -} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactoryTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactoryTest.java new file mode 100644 index 00000000000..7d11ec1e931 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/AnnotationSpecificationFactoryTest.java @@ -0,0 +1,122 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SpecificationCompiler; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class AnnotationSpecificationFactoryTest { + + @Test + void basicSpecFromAnnotations() { + var spec = AnnotationSpecificationFactory.fromAnnotations( + TestdataSolution.class, + List.of(TestdataEntity.class), + DomainAccessType.FORCE_REFLECTION, + null); + + assertThat(spec.solutionClass()).isEqualTo(TestdataSolution.class); + assertThat(spec.score()).isNotNull(); + assertThat(spec.score().scoreType()).isEqualTo(SimpleScore.class); + assertThat(spec.entities()).hasSize(1); + assertThat(spec.entities().getFirst().entityClass()).isEqualTo(TestdataEntity.class); + assertThat(spec.facts()).isNotEmpty(); + assertThat(spec.entityCollections()).isNotEmpty(); + } + + @Test + void specProducesFunctionalSolutionDescriptor() { + var spec = AnnotationSpecificationFactory.fromAnnotations( + TestdataSolution.class, + List.of(TestdataEntity.class), + DomainAccessType.FORCE_REFLECTION, + null); + + var descriptor = SpecificationCompiler.compile(spec, null, + DomainAccessType.FORCE_REFLECTION, null, true); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getSolutionClass()).isEqualTo(TestdataSolution.class); + assertThat(descriptor.getEntityDescriptors()).hasSize(1); + assertThat(descriptor.getEntityDescriptors().iterator().next().getEntityClass()) + .isEqualTo(TestdataEntity.class); + assertThat(descriptor.getScoreDefinition()).isNotNull(); + } + + @Test + void roundTripProducesSameResult() { + // Build via the old path + var oldDescriptor = SolutionDescriptor.buildSolutionDescriptor( + TestdataSolution.class, TestdataEntity.class); + + // The old path now goes through the new pipeline too + // Verify the descriptor is fully functional + assertThat(oldDescriptor).isNotNull(); + assertThat(oldDescriptor.getSolutionClass()).isEqualTo(TestdataSolution.class); + assertThat(oldDescriptor.getEntityDescriptors()).hasSize(1); + + var entityDescriptor = oldDescriptor.getEntityDescriptors().iterator().next(); + assertThat(entityDescriptor.getEntityClass()).isEqualTo(TestdataEntity.class); + assertThat(entityDescriptor.getDeclaredGenuineVariableDescriptors()).hasSize(1); + + var variableDescriptor = entityDescriptor.getDeclaredGenuineVariableDescriptors().iterator().next(); + assertThat(variableDescriptor.getVariableName()).isEqualTo("value"); + } + + @Test + void solutionClonerWorks() { + var descriptor = SolutionDescriptor.buildSolutionDescriptor( + TestdataSolution.class, TestdataEntity.class); + + var solution = TestdataSolution.generateSolution(); + var cloned = descriptor.getSolutionCloner().cloneSolution(solution); + + assertThat(cloned).isNotSameAs(solution); + assertThat(cloned.getEntityList()).hasSize(solution.getEntityList().size()); + } + + @Test + void scoreAccessorWorks() { + var descriptor = SolutionDescriptor.buildSolutionDescriptor( + TestdataSolution.class, TestdataEntity.class); + var solution = TestdataSolution.generateSolution(); + solution.setScore(SimpleScore.of(42)); + + var score = descriptor.getScoreDescriptor().getScoreDefinition(); + assertThat(score).isNotNull(); + } + + @Test + void entityCollectionsWork() { + var descriptor = SolutionDescriptor.buildSolutionDescriptor( + TestdataSolution.class, TestdataEntity.class); + var solution = TestdataSolution.generateSolution(3, 5); + + var entities = descriptor.getEntityCollectionMemberAccessorMap(); + assertThat(entities).isNotEmpty(); + + var entityAccessor = entities.values().iterator().next(); + var entityCollection = entityAccessor.executeGetter(solution); + assertThat(entityCollection).isNotNull(); + } + + @Test + void valueRangesWork() { + var descriptor = SolutionDescriptor.buildSolutionDescriptor( + TestdataSolution.class, TestdataEntity.class); + + var entityDescriptor = descriptor.getEntityDescriptors().iterator().next(); + var variableDescriptor = entityDescriptor.getDeclaredGenuineVariableDescriptors().iterator().next(); + + // Value range descriptor should be set + assertThat(variableDescriptor.getValueRangeDescriptor()).isNotNull(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/LookupAnnotationSpecificationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/LookupAnnotationSpecificationTest.java new file mode 100644 index 00000000000..7b48b7f53e5 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/LookupAnnotationSpecificationTest.java @@ -0,0 +1,137 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.invoke.MethodHandles; +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SpecificationCompiler; +import ai.timefold.solver.core.impl.domain.specification.testdata.LookupTestHelper; +import ai.timefold.solver.core.testdomain.TestdataEntity; +import ai.timefold.solver.core.testdomain.TestdataSolution; + +import org.junit.jupiter.api.Test; + +class LookupAnnotationSpecificationTest { + + @Test + void packagePrivateClassesWithLookup() { + var lookup = LookupTestHelper.lookup(); + var solutionClass = LookupTestHelper.solutionClass(); + var entityClassList = LookupTestHelper.entityClassList(); + + @SuppressWarnings("unchecked") + var spec = AnnotationSpecificationFactory.fromAnnotations( + (Class) solutionClass, entityClassList, lookup); + + assertThat(spec.solutionClass()).isEqualTo(solutionClass); + assertThat(spec.score()).isNotNull(); + assertThat(spec.score().scoreType()).isEqualTo(SimpleScore.class); + assertThat(spec.entities()).hasSize(1); + assertThat(spec.entities().getFirst().entityClass()).isEqualTo(entityClassList.getFirst()); + assertThat(spec.facts()).isNotEmpty(); + assertThat(spec.entityCollections()).isNotEmpty(); + } + + @Test + void packagePrivateSpecCompilesToValidDescriptor() { + var lookup = LookupTestHelper.lookup(); + var solutionClass = LookupTestHelper.solutionClass(); + var entityClassList = LookupTestHelper.entityClassList(); + + @SuppressWarnings("unchecked") + var spec = AnnotationSpecificationFactory.fromAnnotations( + (Class) solutionClass, entityClassList, lookup); + + var descriptor = SpecificationCompiler.compile(spec, null); + + assertThat(descriptor).isNotNull(); + assertThat(descriptor.getSolutionClass()).isEqualTo(solutionClass); + assertThat(descriptor.getEntityDescriptors()).hasSize(1); + } + + @Test + void publicClassesWithLookupProduceValidSpec() { + // Using the framework's own lookup — public classes should work fine + var lookup = MethodHandles.lookup(); + + var spec = AnnotationSpecificationFactory.fromAnnotations( + TestdataSolution.class, List.of(TestdataEntity.class), lookup); + + assertThat(spec.solutionClass()).isEqualTo(TestdataSolution.class); + assertThat(spec.score()).isNotNull(); + assertThat(spec.score().scoreType()).isEqualTo(SimpleScore.class); + assertThat(spec.entities()).hasSize(1); + assertThat(spec.entities().getFirst().entityClass()).isEqualTo(TestdataEntity.class); + } + + @Test + void lookupSpecGettersAndSettersWork() { + var lookup = LookupTestHelper.lookup(); + var solutionClass = LookupTestHelper.solutionClass(); + var entityClassList = LookupTestHelper.entityClassList(); + + @SuppressWarnings("unchecked") + var spec = AnnotationSpecificationFactory.fromAnnotations( + (Class) solutionClass, entityClassList, lookup); + + // Test that score getter/setter lambdas work + var solution = LookupTestHelper.createUninitializedSolution(); + var scoreSpec = spec.score(); + var scoreSetter = scoreSpec.setter(); + var scoreGetter = scoreSpec.getter(); + + scoreSetter.accept(solution, SimpleScore.of(42)); + var score = scoreGetter.apply(solution); + assertThat(score).isEqualTo(SimpleScore.of(42)); + } + + @Test + void lookupSpecEntityCollectionGetterWorks() { + var lookup = LookupTestHelper.lookup(); + var solutionClass = LookupTestHelper.solutionClass(); + var entityClassList = LookupTestHelper.entityClassList(); + + @SuppressWarnings("unchecked") + var spec = AnnotationSpecificationFactory.fromAnnotations( + (Class) solutionClass, entityClassList, lookup); + + var solution = LookupTestHelper.createUninitializedSolution(); + var entityCollectionSpec = spec.entityCollections().getFirst(); + var entities = entityCollectionSpec.getter().apply(solution); + assertThat(entities).hasSize(2); + } + + @Test + void lookupSpecVariableGetterAndSetterWork() { + var lookup = LookupTestHelper.lookup(); + var solutionClass = LookupTestHelper.solutionClass(); + var entityClassList = LookupTestHelper.entityClassList(); + + @SuppressWarnings("unchecked") + var spec = AnnotationSpecificationFactory.fromAnnotations( + (Class) solutionClass, entityClassList, lookup); + + var entitySpec = spec.entities().getFirst(); + assertThat(entitySpec.variables()).hasSize(1); + var variableSpec = entitySpec.variables().getFirst(); + + // Variable getter/setter should work on an entity instance + var solution = LookupTestHelper.createUninitializedSolution(); + var entityCollectionSpec = spec.entityCollections().getFirst(); + var entities = entityCollectionSpec.getter().apply(solution); + var entity = entities.iterator().next(); + + // Initially null (uninitialized) + @SuppressWarnings("unchecked") + var varGetter = (java.util.function.Function) variableSpec.getter(); + var value = varGetter.apply(entity); + assertThat(value).isNull(); + + // Get a value from the value range + var factSpec = spec.facts().getFirst(); + var facts = factSpec.getter().apply(solution); + assertThat(facts).isInstanceOf(java.util.Collection.class); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/PlanningSpecificationBuilderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/PlanningSpecificationBuilderTest.java new file mode 100644 index 00000000000..fa0c17d1ff0 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/PlanningSpecificationBuilderTest.java @@ -0,0 +1,237 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.score.SimpleScore; + +import org.junit.jupiter.api.Test; + +class PlanningSpecificationBuilderTest { + + // Simple domain classes (no annotations needed) + static class MySolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class MyEntity { + String id; + MyValue value; + + MyEntity() { + } + + MyEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + MyValue getValue() { + return value; + } + + void setValue(MyValue value) { + this.value = value; + } + } + + static class MyValue { + String code; + + MyValue() { + } + + MyValue(String code) { + this.code = code; + } + } + + @Test + void minimalSpecification() { + var spec = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .problemFacts("values", MySolution::getValues) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .planningId(MyEntity::getId) + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))) + .build(); + + assertThat(spec.solutionClass()).isEqualTo(MySolution.class); + assertThat(spec.score()).isNotNull(); + assertThat(spec.score().scoreType()).isEqualTo(SimpleScore.class); + assertThat(spec.facts()).hasSize(1); + assertThat(spec.facts().getFirst().name()).isEqualTo("values"); + assertThat(spec.facts().getFirst().isCollection()).isTrue(); + assertThat(spec.entityCollections()).hasSize(1); + assertThat(spec.entityCollections().getFirst().name()).isEqualTo("entities"); + assertThat(spec.valueRanges()).hasSize(1); + assertThat(spec.valueRanges().getFirst().id()).isEqualTo("valueRange"); + assertThat(spec.entities()).hasSize(1); + + var entitySpec = spec.entities().getFirst(); + assertThat(entitySpec.entityClass()).isEqualTo(MyEntity.class); + assertThat(entitySpec.planningIdGetter()).isNotNull(); + assertThat(entitySpec.variables()).hasSize(1); + + var varSpec = entitySpec.variables().getFirst(); + assertThat(varSpec.name()).isEqualTo("value"); + assertThat(varSpec.valueType()).isEqualTo(MyValue.class); + assertThat(varSpec.isList()).isFalse(); + assertThat(varSpec.allowsUnassigned()).isFalse(); + assertThat(varSpec.valueRangeRefs()).containsExactly("valueRange"); + } + + @Test + void lambdasWork() { + var spec = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))) + .build(); + + // Verify the score getter/setter lambdas work + var solution = new MySolution(); + solution.setScore(SimpleScore.of(42)); + assertThat(spec.score().getter().apply(solution)).isEqualTo(SimpleScore.of(42)); + + // Verify entity collection getter + var entity = new MyEntity("e1"); + solution.getEntities().add(entity); + assertThat(spec.entityCollections().getFirst().getter().apply(solution)).hasSize(1); + + // Verify variable getter/setter + var value = new MyValue("v1"); + var varSpec = spec.entities().getFirst().variables().getFirst(); + @SuppressWarnings("unchecked") + var setter = (java.util.function.BiConsumer) (java.util.function.BiConsumer) varSpec.setter(); + setter.accept(entity, value); + assertThat(entity.getValue()).isSameAs(value); + + @SuppressWarnings("unchecked") + var getter = (java.util.function.Function) (java.util.function.Function) varSpec.getter(); + assertThat(getter.apply(entity)).isSameAs(value); + } + + @Test + void problemFact_notCollection() { + var spec = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .problemFact("singleFact", s -> "constant") + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))) + .build(); + + assertThat(spec.facts()).hasSize(1); + assertThat(spec.facts().getFirst().name()).isEqualTo("singleFact"); + assertThat(spec.facts().getFirst().isCollection()).isFalse(); + } + + @Test + void allowsUnassigned() { + var spec = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange") + .allowsUnassigned(true))) + .build(); + + assertThat(spec.entities().getFirst().variables().getFirst().allowsUnassigned()).isTrue(); + } + + @Test + void buildFailsWithoutScore() { + var builder = PlanningSpecification.of(MySolution.class) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void buildFailsWithoutEntities() { + var builder = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void buildFailsWithoutEntityCollections() { + var builder = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void specificationIsImmutable() { + var spec = PlanningSpecification.of(MySolution.class) + .score(SimpleScore.class, MySolution::getScore, MySolution::setScore) + .entityCollection("entities", MySolution::getEntities) + .valueRange("valueRange", MySolution::getValues) + .entity(MyEntity.class, entity -> entity + .variable("value", MyValue.class, var -> var + .accessors(MyEntity::getValue, MyEntity::setValue) + .valueRange("valueRange"))) + .build(); + + // Records are immutable by default — just verify we can access all fields + assertThat(spec.cloning()).isNull(); + assertThat(spec.constraintWeights()).isNull(); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/ProgrammaticSpecificationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/ProgrammaticSpecificationTest.java new file mode 100644 index 00000000000..629e39905b1 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/ProgrammaticSpecificationTest.java @@ -0,0 +1,1093 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SpecificationCompiler; +import ai.timefold.solver.core.impl.domain.variable.IndexShadowVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.descriptor.BasicVariableDescriptor; +import ai.timefold.solver.core.impl.domain.variable.inverserelation.InverseRelationShadowVariableDescriptor; + +import org.junit.jupiter.api.Test; + +/** + * Tests for various programmatic {@link PlanningSpecification} configurations + * beyond the basic single-entity/single-variable case. + */ +class ProgrammaticSpecificationTest { + + // ── Basic variable with allowsUnassigned ──────────────────────── + + static class NullableSolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class NullableEntity { + String id; + NullableValue value; // nullable + + NullableEntity() { + } + + NullableEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + NullableValue getValue() { + return value; + } + + void setValue(NullableValue value) { + this.value = value; + } + } + + static class NullableValue { + String code; + + NullableValue() { + } + + NullableValue(String code) { + this.code = code; + } + } + + @Test + void allowsUnassigned_compilesAndSolves() { + var spec = PlanningSpecification.of(NullableSolution.class) + .score(SimpleScore.class, NullableSolution::getScore, NullableSolution::setScore) + .problemFacts("values", NullableSolution::getValues) + .entityCollection("entities", NullableSolution::getEntities) + .valueRange("vr", NullableSolution::getValues) + .entity(NullableEntity.class, e -> e + .planningId(NullableEntity::getId) + .variable("value", NullableValue.class, v -> v + .accessors(NullableEntity::getValue, NullableEntity::setValue) + .valueRange("vr") + .allowsUnassigned(true))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(NullableEntity.class); + var vd = (BasicVariableDescriptor) ed.getGenuineVariableDescriptor("value"); + assertThat(vd.allowsUnassigned()).isTrue(); + + // Solve with more entities than values — some must remain null + var solverConfig = solverConfig(spec, NullableScoreCalculator.class); + var solver = SolverFactory. create(solverConfig).buildSolver(); + + var problem = new NullableSolution(); + problem.getValues().add(new NullableValue("v1")); + for (int i = 0; i < 5; i++) { + problem.getEntities().add(new NullableEntity("e" + i)); + } + var solution = solver.solve(problem); + assertThat(solution.getScore()).isNotNull(); + } + + public static class NullableScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(NullableSolution solution) { + int penalty = 0; + for (var entity : solution.getEntities()) { + if (entity.getValue() == null) { + penalty--; + } + } + return SimpleScore.of(penalty); + } + } + + // ── Pinning ───────────────────────────────────────────────────── + + static class PinningSolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class PinEntity { + String id; + boolean pinned; + String value; + + PinEntity() { + } + + PinEntity(String id, boolean pinned) { + this.id = id; + this.pinned = pinned; + } + + PinEntity(String id, boolean pinned, String value) { + this.id = id; + this.pinned = pinned; + this.value = value; + } + + String getId() { + return id; + } + + boolean isPinned() { + return pinned; + } + + String getValue() { + return value; + } + + void setValue(String value) { + this.value = value; + } + } + + @Test + void pinnedEntity_isNotChanged() { + var spec = PlanningSpecification.of(PinningSolution.class) + .score(SimpleScore.class, PinningSolution::getScore, PinningSolution::setScore) + .problemFacts("values", PinningSolution::getValues) + .entityCollection("entities", PinningSolution::getEntities) + .valueRange("vr", PinningSolution::getValues) + .entity(PinEntity.class, e -> e + .planningId(PinEntity::getId) + .pinned(PinEntity::isPinned) + .variable("value", String.class, v -> v + .accessors(PinEntity::getValue, PinEntity::setValue) + .valueRange("vr"))) + .build(); + + // Verify pinning is registered + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(PinEntity.class); + assertThat(ed.supportsPinning()).isTrue(); + + // Solve: pinned entity should keep its original value + var solverConfig = solverConfig(spec, PinScoreCalculator.class); + var solver = SolverFactory. create(solverConfig).buildSolver(); + + var problem = new PinningSolution(); + problem.getValues().addAll(List.of("A", "B", "C")); + problem.getEntities().add(new PinEntity("pinned1", true, "A")); // pinned with value "A" + problem.getEntities().add(new PinEntity("free1", false)); + problem.getEntities().add(new PinEntity("free2", false)); + + var solution = solver.solve(problem); + // Pinned entity must still have its original value + var pinnedEntity = solution.getEntities().stream() + .filter(e -> "pinned1".equals(e.getId())).findFirst().orElseThrow(); + assertThat(pinnedEntity.getValue()).isEqualTo("A"); + } + + public static class PinScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(PinningSolution solution) { + return SimpleScore.of(0); + } + } + + // ── Entity difficulty comparator ──────────────────────────────── + + static class DifficultySolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class DifficultyEntity { + String id; + int difficulty; + Integer value; + + DifficultyEntity() { + } + + DifficultyEntity(String id, int difficulty) { + this.id = id; + this.difficulty = difficulty; + } + + String getId() { + return id; + } + + int getDifficulty() { + return difficulty; + } + + Integer getValue() { + return value; + } + + void setValue(Integer value) { + this.value = value; + } + } + + @Test + void entityDifficultyComparator_isRegistered() { + var spec = PlanningSpecification.of(DifficultySolution.class) + .score(SimpleScore.class, DifficultySolution::getScore, DifficultySolution::setScore) + .problemFacts("values", DifficultySolution::getValues) + .entityCollection("entities", DifficultySolution::getEntities) + .valueRange("vr", DifficultySolution::getValues) + .entity(DifficultyEntity.class, e -> e + .planningId(DifficultyEntity::getId) + .difficultyComparator(Comparator.comparingInt(DifficultyEntity::getDifficulty)) + .variable("value", Integer.class, v -> v + .accessors(DifficultyEntity::getValue, DifficultyEntity::setValue) + .valueRange("vr"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(DifficultyEntity.class); + // Difficulty comparator produces a descending sorter + assertThat(ed.getDescendingSorter()).isNotNull(); + } + + // ── Variable strength comparator ──────────────────────────────── + + @Test + void variableStrengthComparator_isRegistered() { + var spec = PlanningSpecification.of(DifficultySolution.class) + .score(SimpleScore.class, DifficultySolution::getScore, DifficultySolution::setScore) + .problemFacts("values", DifficultySolution::getValues) + .entityCollection("entities", DifficultySolution::getEntities) + .valueRange("vr", DifficultySolution::getValues) + .entity(DifficultyEntity.class, e -> e + .planningId(DifficultyEntity::getId) + .variable("value", Integer.class, v -> v + .accessors(DifficultyEntity::getValue, DifficultyEntity::setValue) + .valueRange("vr") + .strengthComparator(Comparator.naturalOrder()))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(DifficultyEntity.class); + var vd = ed.getGenuineVariableDescriptor("value"); + assertThat(vd.getValueRangeDescriptor()).isNotNull(); + // Strength comparator produces ascending/descending sorters on the variable + assertThat(vd.getAscendingSorter()).isNotNull(); + assertThat(vd.getDescendingSorter()).isNotNull(); + } + + // ── Cloning with factories ────────────────────────────────────── + + static class CloneSolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + CloneSolution() { + } + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + + void setEntities(List entities) { + this.entities = entities; + } + } + + static class CloneEntity { + String id; + String value; + + CloneEntity() { + } + + CloneEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + String getValue() { + return value; + } + + void setValue(String value) { + this.value = value; + } + } + + @Test + void cloningWithFactories_compilesAndClones() { + var spec = PlanningSpecification.of(CloneSolution.class) + .score(SimpleScore.class, CloneSolution::getScore, CloneSolution::setScore) + .problemFacts("values", CloneSolution::getValues) + .entityCollection("entities", CloneSolution::getEntities) + .valueRange("vr", CloneSolution::getValues) + .entity(CloneEntity.class, e -> e + .planningId(CloneEntity::getId) + .variable("value", String.class, v -> v + .accessors(CloneEntity::getValue, CloneEntity::setValue) + .valueRange("vr"))) + .cloning(c -> c + .solutionFactory(CloneSolution::new) + .solutionProperty("score", CloneSolution::getScore, CloneSolution::setScore) + .solutionProperty("values", CloneSolution::getValues, (s, v) -> { + }) + .solutionProperty("entities", + (CloneSolution s) -> s.getEntities(), + (CloneSolution s, Object v) -> s.setEntities((List) v), + ai.timefold.solver.core.api.domain.specification.CloningSpecification.DeepCloneDecision.DEEP_COLLECTION) + .entityClass(CloneEntity.class, CloneEntity::new, e -> e + .shallowProperty("id", CloneEntity::getId, (entity, v) -> { + }) + .shallowProperty("value", CloneEntity::getValue, CloneEntity::setValue))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var cloner = sd.getSolutionCloner(); + + var original = new CloneSolution(); + original.getValues().addAll(List.of("A", "B")); + original.getEntities().add(new CloneEntity("e1")); + original.getEntities().add(new CloneEntity("e2")); + original.getEntities().get(0).setValue("A"); + original.setScore(SimpleScore.of(10)); + + var clone = cloner.cloneSolution(original); + assertThat(clone).isNotSameAs(original); + assertThat(clone.getEntities()).hasSize(2); + assertThat(clone.getEntities().get(0)).isNotSameAs(original.getEntities().get(0)); + assertThat(clone.getEntities().get(0).getValue()).isEqualTo("A"); + assertThat(clone.getScore()).isEqualTo(SimpleScore.of(10)); + } + + // ── Custom SolutionCloner ─────────────────────────────────────── + + @Test + void customSolutionCloner_isUsed() { + var spec = PlanningSpecification.of(CloneSolution.class) + .score(SimpleScore.class, CloneSolution::getScore, CloneSolution::setScore) + .problemFacts("values", CloneSolution::getValues) + .entityCollection("entities", CloneSolution::getEntities) + .valueRange("vr", CloneSolution::getValues) + .entity(CloneEntity.class, e -> e + .planningId(CloneEntity::getId) + .variable("value", String.class, v -> v + .accessors(CloneEntity::getValue, CloneEntity::setValue) + .valueRange("vr"))) + .solutionCloner(original -> { + var clone = new CloneSolution(); + clone.setScore(original.getScore()); + clone.getValues().addAll(original.getValues()); + for (var entity : original.getEntities()) { + var clonedEntity = new CloneEntity(entity.getId()); + clonedEntity.setValue(entity.getValue()); + clone.getEntities().add(clonedEntity); + } + return clone; + }) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var cloner = sd.getSolutionCloner(); + + var original = new CloneSolution(); + original.getValues().addAll(List.of("X", "Y")); + original.getEntities().add(new CloneEntity("e1")); + original.setScore(SimpleScore.of(-5)); + + var clone = cloner.cloneSolution(original); + assertThat(clone).isNotSameAs(original); + assertThat(clone.getScore()).isEqualTo(SimpleScore.of(-5)); + assertThat(clone.getValues()).containsExactly("X", "Y"); + assertThat(clone.getEntities()).hasSize(1); + } + + // ── List variable with inverse relation + index shadows ───────── + + static class RoutingSolution { + SimpleScore score; + List visits = new ArrayList<>(); + List vehicles = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getVisits() { + return visits; + } + + List getVehicles() { + return vehicles; + } + } + + static class Vehicle { + String id; + List route = new ArrayList<>(); + + Vehicle() { + } + + Vehicle(String id) { + this.id = id; + } + + String getId() { + return id; + } + + List getRoute() { + return route; + } + + void setRoute(List route) { + this.route = route; + } + } + + static class Visit { + String id; + Vehicle vehicle; // inverse relation shadow + int index = -1; // index shadow + + Visit() { + } + + Visit(String id) { + this.id = id; + } + + String getId() { + return id; + } + + Vehicle getVehicle() { + return vehicle; + } + + void setVehicle(Vehicle vehicle) { + this.vehicle = vehicle; + } + + int getIndex() { + return index; + } + + void setIndex(int index) { + this.index = index; + } + } + + @Test + void listVariable_withInverseAndIndex_compiles() { + var spec = PlanningSpecification.of(RoutingSolution.class) + .score(SimpleScore.class, RoutingSolution::getScore, RoutingSolution::setScore) + .problemFacts("visits", RoutingSolution::getVisits) + .entityCollection("vehicles", RoutingSolution::getVehicles) + .valueRange("visitRange", RoutingSolution::getVisits) + .entity(Vehicle.class, e -> e + .planningId(Vehicle::getId) + .listVariable("route", Visit.class, lv -> lv + .accessors(Vehicle::getRoute, Vehicle::setRoute) + .valueRange("visitRange"))) + .entity(Visit.class, e -> e + .planningId(Visit::getId) + .inverseRelationShadow("vehicle", Vehicle.class, + Visit::getVehicle, Visit::setVehicle, + src -> src.sourceVariable("route")) + .indexShadow("index", + Visit::getIndex, Visit::setIndex, + src -> src.sourceVariable("route"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + + // Vehicle entity has a list variable + var vehicleEd = sd.findEntityDescriptorOrFail(Vehicle.class); + assertThat(vehicleEd.hasAnyListVariables()).isTrue(); + var listVd = vehicleEd.getListVariableDescriptor(); + assertThat(listVd).isNotNull(); + assertThat(listVd.getVariableName()).isEqualTo("route"); + + // Visit entity has shadow variables + var visitEd = sd.findEntityDescriptorOrFail(Visit.class); + assertThat(visitEd.getShadowVariableDescriptors()).hasSize(2); + assertThat(visitEd.getShadowVariableDescriptor("vehicle")) + .isInstanceOf(InverseRelationShadowVariableDescriptor.class); + assertThat(visitEd.getShadowVariableDescriptor("index")) + .isInstanceOf(IndexShadowVariableDescriptor.class); + } + + @Test + void listVariable_compilesAndSolves() { + var spec = PlanningSpecification.of(RoutingSolution.class) + .score(SimpleScore.class, RoutingSolution::getScore, RoutingSolution::setScore) + .problemFacts("visits", RoutingSolution::getVisits) + .entityCollection("vehicles", RoutingSolution::getVehicles) + .valueRange("visitRange", RoutingSolution::getVisits) + .entity(Vehicle.class, e -> e + .planningId(Vehicle::getId) + .listVariable("route", Visit.class, lv -> lv + .accessors(Vehicle::getRoute, Vehicle::setRoute) + .valueRange("visitRange"))) + .entity(Visit.class, e -> e + .planningId(Visit::getId) + .inverseRelationShadow("vehicle", Vehicle.class, + Visit::getVehicle, Visit::setVehicle, + src -> src.sourceVariable("route")) + .indexShadow("index", + Visit::getIndex, Visit::setIndex, + src -> src.sourceVariable("route"))) + .build(); + + var solverConfig = chOnlySolverConfig(spec, RoutingScoreCalculator.class); + var solver = SolverFactory. create(solverConfig).buildSolver(); + + var problem = new RoutingSolution(); + problem.getVehicles().add(new Vehicle("truck1")); + problem.getVehicles().add(new Vehicle("truck2")); + for (int i = 0; i < 6; i++) { + problem.getVisits().add(new Visit("visit" + i)); + } + + var solution = solver.solve(problem); + assertThat(solution.getScore()).isNotNull(); + + // All visits should be assigned to some vehicle + int totalAssigned = solution.getVehicles().stream() + .mapToInt(v -> v.getRoute().size()).sum(); + assertThat(totalAssigned).isEqualTo(6); + + // Inverse relation shadows should be populated + for (var vehicle : solution.getVehicles()) { + for (var visit : vehicle.getRoute()) { + assertThat(visit.getVehicle()).isSameAs(vehicle); + } + } + + // Index shadows should be populated + for (var vehicle : solution.getVehicles()) { + for (int i = 0; i < vehicle.getRoute().size(); i++) { + assertThat(vehicle.getRoute().get(i).getIndex()).isEqualTo(i); + } + } + } + + public static class RoutingScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(RoutingSolution solution) { + return SimpleScore.of(0); + } + } + + // ── Multiple entities with different variable types ────────────── + + static class MultiEntitySolution { + SimpleScore score; + List colors = new ArrayList<>(); + List numbers = new ArrayList<>(); + List colorEntities = new ArrayList<>(); + List numberEntities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getColors() { + return colors; + } + + List getNumbers() { + return numbers; + } + + List getColorEntities() { + return colorEntities; + } + + List getNumberEntities() { + return numberEntities; + } + } + + static class ColorEntity { + String id; + String color; + + ColorEntity() { + } + + ColorEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + String getColor() { + return color; + } + + void setColor(String color) { + this.color = color; + } + } + + static class NumberEntity { + String id; + Integer number; + + NumberEntity() { + } + + NumberEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + Integer getNumber() { + return number; + } + + void setNumber(Integer number) { + this.number = number; + } + } + + @Test + void multipleEntityTypes_compileAndSolve() { + var spec = PlanningSpecification.of(MultiEntitySolution.class) + .score(SimpleScore.class, MultiEntitySolution::getScore, MultiEntitySolution::setScore) + .problemFacts("colors", MultiEntitySolution::getColors) + .problemFacts("numbers", MultiEntitySolution::getNumbers) + .entityCollection("colorEntities", MultiEntitySolution::getColorEntities) + .entityCollection("numberEntities", MultiEntitySolution::getNumberEntities) + .valueRange("colorRange", MultiEntitySolution::getColors) + .valueRange("numberRange", MultiEntitySolution::getNumbers) + .entity(ColorEntity.class, e -> e + .planningId(ColorEntity::getId) + .variable("color", String.class, v -> v + .accessors(ColorEntity::getColor, ColorEntity::setColor) + .valueRange("colorRange"))) + .entity(NumberEntity.class, e -> e + .planningId(NumberEntity::getId) + .variable("number", Integer.class, v -> v + .accessors(NumberEntity::getNumber, NumberEntity::setNumber) + .valueRange("numberRange"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + assertThat(sd.getEntityDescriptors()).hasSize(2); + + var colorEd = sd.findEntityDescriptorOrFail(ColorEntity.class); + assertThat(colorEd.getGenuineVariableDescriptor("color")).isNotNull(); + assertThat(colorEd.getGenuineVariableDescriptor("color").getValueRangeDescriptor()).isNotNull(); + + var numberEd = sd.findEntityDescriptorOrFail(NumberEntity.class); + assertThat(numberEd.getGenuineVariableDescriptor("number")).isNotNull(); + assertThat(numberEd.getGenuineVariableDescriptor("number").getValueRangeDescriptor()).isNotNull(); + + // Verify fact/entity collection accessors + assertThat(sd.getProblemFactCollectionMemberAccessorMap()).containsKey("colors"); + assertThat(sd.getProblemFactCollectionMemberAccessorMap()).containsKey("numbers"); + assertThat(sd.getEntityCollectionMemberAccessorMap()).containsKey("colorEntities"); + assertThat(sd.getEntityCollectionMemberAccessorMap()).containsKey("numberEntities"); + } + + public static class MultiEntityScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(MultiEntitySolution solution) { + return SimpleScore.of(0); + } + } + + // ── Singular problem fact (not collection) ────────────────────── + + static class SingularFactSolution { + SimpleScore score; + String config = "default"; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + String getConfig() { + return config; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class SingularFactEntity { + String id; + String value; + + SingularFactEntity() { + } + + SingularFactEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + String getValue() { + return value; + } + + void setValue(String value) { + this.value = value; + } + } + + @Test + void singularProblemFact_isRegistered() { + var spec = PlanningSpecification.of(SingularFactSolution.class) + .score(SimpleScore.class, SingularFactSolution::getScore, SingularFactSolution::setScore) + .problemFact("config", SingularFactSolution::getConfig) + .problemFacts("values", SingularFactSolution::getValues) + .entityCollection("entities", SingularFactSolution::getEntities) + .valueRange("vr", SingularFactSolution::getValues) + .entity(SingularFactEntity.class, e -> e + .planningId(SingularFactEntity::getId) + .variable("value", String.class, v -> v + .accessors(SingularFactEntity::getValue, SingularFactEntity::setValue) + .valueRange("vr"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + // Singular fact goes to problemFactMemberAccessorMap + assertThat(sd.getProblemFactMemberAccessorMap()).containsKey("config"); + // Collection fact goes to problemFactCollectionMemberAccessorMap + assertThat(sd.getProblemFactCollectionMemberAccessorMap()).containsKey("values"); + + // Verify the singular accessor works + var configAccessor = sd.getProblemFactMemberAccessorMap().get("config"); + var problem = new SingularFactSolution(); + assertThat(configAccessor.executeGetter(problem)).isEqualTo("default"); + } + + // ── Entity-scoped value range ─────────────────────────────────── + + static class EntityRangeSolution { + SimpleScore score; + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getEntities() { + return entities; + } + } + + static class EntityRangeEntity { + String id; + List possibleValues = new ArrayList<>(); + Integer value; + + EntityRangeEntity() { + } + + EntityRangeEntity(String id, List possibleValues) { + this.id = id; + this.possibleValues = new ArrayList<>(possibleValues); + } + + String getId() { + return id; + } + + List getPossibleValues() { + return possibleValues; + } + + Integer getValue() { + return value; + } + + void setValue(Integer value) { + this.value = value; + } + } + + @Test + void entityScopedValueRange_compilesAndSolves() { + var spec = PlanningSpecification.of(EntityRangeSolution.class) + .score(SimpleScore.class, EntityRangeSolution::getScore, EntityRangeSolution::setScore) + .entityCollection("entities", EntityRangeSolution::getEntities) + .entity(EntityRangeEntity.class, e -> e + .planningId(EntityRangeEntity::getId) + .valueRange("entityVr", EntityRangeEntity::getPossibleValues) + .variable("value", Integer.class, v -> v + .accessors(EntityRangeEntity::getValue, EntityRangeEntity::setValue) + .valueRange("entityVr"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(EntityRangeEntity.class); + var vd = ed.getGenuineVariableDescriptor("value"); + assertThat(vd.getValueRangeDescriptor()).isNotNull(); + + // Solve: each entity has its own value range + var solverConfig = solverConfig(spec, EntityRangeScoreCalculator.class); + var solver = SolverFactory. create(solverConfig).buildSolver(); + + var problem = new EntityRangeSolution(); + problem.getEntities().add(new EntityRangeEntity("e1", List.of(10, 20))); + problem.getEntities().add(new EntityRangeEntity("e2", List.of(30, 40))); + + var solution = solver.solve(problem); + assertThat(solution.getScore()).isNotNull(); + for (var entity : solution.getEntities()) { + assertThat(entity.getValue()).isNotNull(); + assertThat(entity.getPossibleValues()).contains(entity.getValue()); + } + } + + public static class EntityRangeScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(EntityRangeSolution solution) { + return SimpleScore.of(0); + } + } + + // ── Multiple value ranges per variable ────────────────────────── + + static class MultiRangeSolution { + SimpleScore score; + List lowValues = new ArrayList<>(); + List highValues = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getLowValues() { + return lowValues; + } + + List getHighValues() { + return highValues; + } + + List getEntities() { + return entities; + } + } + + static class MultiRangeEntity { + String id; + Integer value; + + MultiRangeEntity() { + } + + MultiRangeEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + Integer getValue() { + return value; + } + + void setValue(Integer value) { + this.value = value; + } + } + + @Test + void multipleValueRanges_compilesAndSolves() { + var spec = PlanningSpecification.of(MultiRangeSolution.class) + .score(SimpleScore.class, MultiRangeSolution::getScore, MultiRangeSolution::setScore) + .problemFacts("lowValues", MultiRangeSolution::getLowValues) + .problemFacts("highValues", MultiRangeSolution::getHighValues) + .entityCollection("entities", MultiRangeSolution::getEntities) + .valueRange("low", MultiRangeSolution::getLowValues) + .valueRange("high", MultiRangeSolution::getHighValues) + .entity(MultiRangeEntity.class, e -> e + .planningId(MultiRangeEntity::getId) + .variable("value", Integer.class, v -> v + .accessors(MultiRangeEntity::getValue, MultiRangeEntity::setValue) + .valueRange("low", "high"))) + .build(); + + var sd = SpecificationCompiler.compile(spec, null); + var ed = sd.findEntityDescriptorOrFail(MultiRangeEntity.class); + var vd = ed.getGenuineVariableDescriptor("value"); + assertThat(vd.getValueRangeDescriptor()).isNotNull(); + + // Solve: variable draws from merged ranges [1,2] + [100,200] + var solverConfig = solverConfig(spec, MultiRangeScoreCalculator.class); + var solver = SolverFactory. create(solverConfig).buildSolver(); + + var problem = new MultiRangeSolution(); + problem.getLowValues().addAll(List.of(1, 2)); + problem.getHighValues().addAll(List.of(100, 200)); + for (int i = 0; i < 3; i++) { + problem.getEntities().add(new MultiRangeEntity("e" + i)); + } + + var solution = solver.solve(problem); + assertThat(solution.getScore()).isNotNull(); + for (var entity : solution.getEntities()) { + assertThat(entity.getValue()).isNotNull(); + assertThat(entity.getValue()).isIn(1, 2, 100, 200); + } + } + + public static class MultiRangeScoreCalculator implements EasyScoreCalculator { + @Override + public SimpleScore calculateScore(MultiRangeSolution solution) { + return SimpleScore.of(0); + } + } + + // ── Helpers ────────────────────────────────────────────────────── + + @SuppressWarnings("rawtypes") + private static SolverConfig solverConfig(PlanningSpecification spec, Class calc) { + return new SolverConfig() + .withPlanningSpecification(spec) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withEasyScoreCalculatorClass(calc)) + .withTerminationConfig(new TerminationConfig() + .withSecondsSpentLimit(10L)) + .withPhases( + new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig() + .withTerminationConfig(new TerminationConfig() + .withStepCountLimit(5))); + } + + @SuppressWarnings("rawtypes") + private static SolverConfig chOnlySolverConfig(PlanningSpecification spec, + Class calc) { + return new SolverConfig() + .withPlanningSpecification(spec) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withEasyScoreCalculatorClass(calc)) + .withTerminationConfig(new TerminationConfig() + .withSecondsSpentLimit(10L)) + .withPhases(new ConstructionHeuristicPhaseConfig()); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/SpecificationCompilerIntegrationTest.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/SpecificationCompilerIntegrationTest.java new file mode 100644 index 00000000000..213d964017b --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/SpecificationCompilerIntegrationTest.java @@ -0,0 +1,216 @@ +package ai.timefold.solver.core.impl.domain.specification; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig; +import ai.timefold.solver.core.config.localsearch.LocalSearchPhaseConfig; +import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; +import ai.timefold.solver.core.config.solver.SolverConfig; +import ai.timefold.solver.core.config.solver.termination.TerminationConfig; +import ai.timefold.solver.core.impl.domain.solution.descriptor.SpecificationCompiler; + +import org.junit.jupiter.api.Test; + +class SpecificationCompilerIntegrationTest { + + // ── POJO domain model (no annotations) ────────────────────────── + + static class NoAnnotationSolution { + SimpleScore score; + List values = new ArrayList<>(); + List entities = new ArrayList<>(); + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } + + List getValues() { + return values; + } + + List getEntities() { + return entities; + } + } + + static class NoAnnotationEntity { + String id; + NoAnnotationValue value; + + NoAnnotationEntity() { + } + + NoAnnotationEntity(String id) { + this.id = id; + } + + String getId() { + return id; + } + + NoAnnotationValue getValue() { + return value; + } + + void setValue(NoAnnotationValue value) { + this.value = value; + } + } + + static class NoAnnotationValue { + String code; + + NoAnnotationValue() { + } + + NoAnnotationValue(String code) { + this.code = code; + } + } + + // ── Helpers ────────────────────────────────────────────────────── + + private static PlanningSpecification buildSpecification() { + return PlanningSpecification.of(NoAnnotationSolution.class) + .score(SimpleScore.class, NoAnnotationSolution::getScore, NoAnnotationSolution::setScore) + .problemFacts("values", NoAnnotationSolution::getValues) + .entityCollection("entities", NoAnnotationSolution::getEntities) + .valueRange("valueRange", NoAnnotationSolution::getValues) + .entity(NoAnnotationEntity.class, entity -> entity + .planningId(NoAnnotationEntity::getId) + .variable("value", NoAnnotationValue.class, var -> var + .accessors(NoAnnotationEntity::getValue, NoAnnotationEntity::setValue) + .valueRange("valueRange"))) + .build(); + } + + private static NoAnnotationSolution generateProblem(int valueCount, int entityCount) { + var solution = new NoAnnotationSolution(); + for (int i = 0; i < valueCount; i++) { + solution.getValues().add(new NoAnnotationValue("v" + i)); + } + for (int i = 0; i < entityCount; i++) { + solution.getEntities().add(new NoAnnotationEntity("e" + i)); + } + return solution; + } + + // ── Tests ──────────────────────────────────────────────────────── + + @Test + void compileSolutionDescriptor() { + var spec = buildSpecification(); + var solutionDescriptor = SpecificationCompiler.compile(spec, null); + + assertThat(solutionDescriptor).isNotNull(); + assertThat(solutionDescriptor.getSolutionClass()).isEqualTo(NoAnnotationSolution.class); + assertThat(solutionDescriptor.getEntityDescriptors()).hasSize(1); + + var entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(NoAnnotationEntity.class); + assertThat(entityDescriptor.getGenuineVariableDescriptorList()).hasSize(1); + assertThat(entityDescriptor.getGenuineVariableDescriptor("value")).isNotNull(); + } + + @Test + void compileAndSolve() { + var spec = buildSpecification(); + + var solverConfig = new SolverConfig() + .withPlanningSpecification(spec) + .withScoreDirectorFactory(new ScoreDirectorFactoryConfig() + .withEasyScoreCalculatorClass(DummyNoAnnotationScoreCalculator.class)) + .withPhases( + new ConstructionHeuristicPhaseConfig(), + new LocalSearchPhaseConfig() + .withTerminationConfig(new TerminationConfig() + .withStepCountLimit(5))); + + SolverFactory solverFactory = SolverFactory.create(solverConfig); + var solver = solverFactory.buildSolver(); + + var problem = generateProblem(3, 5); + var solution = solver.solve(problem); + + assertThat(solution).isNotNull(); + assertThat(solution.getScore()).isNotNull(); + // All entities should be initialized (assigned a value) + for (var entity : solution.getEntities()) { + assertThat(entity.getValue()).isNotNull(); + } + } + + @Test + void solutionDescriptorScoreAccessor() { + var spec = buildSpecification(); + var solutionDescriptor = SpecificationCompiler.compile(spec, null); + + var solution = new NoAnnotationSolution(); + solution.setScore(SimpleScore.of(42)); + + var scoreDescriptor = solutionDescriptor.getScoreDescriptor(); + assertThat(scoreDescriptor).isNotNull(); + } + + @Test + void entityCollectionAccessor() { + var spec = buildSpecification(); + var solutionDescriptor = SpecificationCompiler.compile(spec, null); + + var problem = generateProblem(2, 3); + var entityCollections = solutionDescriptor.getEntityCollectionMemberAccessorMap(); + assertThat(entityCollections).containsKey("entities"); + + var accessor = entityCollections.get("entities"); + @SuppressWarnings("unchecked") + var entities = (List) accessor.executeGetter(problem); + assertThat(entities).hasSize(3); + } + + @Test + void problemFactCollectionAccessor() { + var spec = buildSpecification(); + var solutionDescriptor = SpecificationCompiler.compile(spec, null); + + var problem = generateProblem(4, 2); + var factCollections = solutionDescriptor.getProblemFactCollectionMemberAccessorMap(); + assertThat(factCollections).containsKey("values"); + + var accessor = factCollections.get("values"); + @SuppressWarnings("unchecked") + var values = (List) accessor.executeGetter(problem); + assertThat(values).hasSize(4); + } + + @Test + void planningIdIsRegistered() { + var spec = buildSpecification(); + var solutionDescriptor = SpecificationCompiler.compile(spec, null); + + // The entity descriptor should have a planning ID configured. + // Verify by checking the entity descriptor can look up by planning ID. + var entityDescriptor = solutionDescriptor.findEntityDescriptorOrFail(NoAnnotationEntity.class); + assertThat(entityDescriptor).isNotNull(); + // If planningId is registered, the solution descriptor should support lookups. + assertThat(solutionDescriptor.getPlanningIdAccessor(NoAnnotationEntity.class)).isNotNull(); + } + + // Dummy score calculator for the no-annotation domain + public static class DummyNoAnnotationScoreCalculator + implements ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator { + + @Override + public SimpleScore calculateScore(NoAnnotationSolution solution) { + return SimpleScore.of(0); + } + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/LookupTestHelper.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/LookupTestHelper.java new file mode 100644 index 00000000000..7776d78620a --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/LookupTestHelper.java @@ -0,0 +1,34 @@ +package ai.timefold.solver.core.impl.domain.specification.testdata; + +import java.lang.invoke.MethodHandles; +import java.util.List; + +/** + * Helper class in the same package as the package-private domain classes, + * providing the necessary Lookup and test data. + */ +public class LookupTestHelper { + + /** + * Returns a Lookup from this package that can access the package-private domain classes. + */ + public static MethodHandles.Lookup lookup() { + return MethodHandles.lookup(); + } + + public static Class solutionClass() { + return PackagePrivateSolution.class; + } + + public static List> entityClassList() { + return List.of(PackagePrivateEntity.class); + } + + public static Object createUninitializedSolution() { + var v1 = new PackagePrivateValue("v1"); + var v2 = new PackagePrivateValue("v2"); + var e1 = new PackagePrivateEntity("e1"); + var e2 = new PackagePrivateEntity("e2"); + return new PackagePrivateSolution(List.of(v1, v2), List.of(e1, e2)); + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateEntity.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateEntity.java new file mode 100644 index 00000000000..87e4399aba7 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateEntity.java @@ -0,0 +1,47 @@ +package ai.timefold.solver.core.impl.domain.specification.testdata; + +import ai.timefold.solver.core.api.domain.common.PlanningId; +import ai.timefold.solver.core.api.domain.entity.PlanningEntity; +import ai.timefold.solver.core.api.domain.variable.PlanningVariable; + +/** + * A planning entity with package-private visibility for testing Lookup-based access. + */ +@PlanningEntity +class PackagePrivateEntity { + + @PlanningId + private String id; + + @PlanningVariable(valueRangeProviderRefs = "valueRange") + private PackagePrivateValue value; + + PackagePrivateEntity() { + } + + PackagePrivateEntity(String id) { + this.id = id; + } + + PackagePrivateEntity(String id, PackagePrivateValue value) { + this.id = id; + this.value = value; + } + + String getId() { + return id; + } + + PackagePrivateValue getValue() { + return value; + } + + void setValue(PackagePrivateValue value) { + this.value = value; + } + + @Override + public String toString() { + return "PackagePrivateEntity(" + id + ")"; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateSolution.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateSolution.java new file mode 100644 index 00000000000..47096f5f141 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateSolution.java @@ -0,0 +1,59 @@ +package ai.timefold.solver.core.impl.domain.specification.testdata; + +import java.util.List; + +import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty; +import ai.timefold.solver.core.api.domain.solution.PlanningScore; +import ai.timefold.solver.core.api.domain.solution.PlanningSolution; +import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty; +import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider; +import ai.timefold.solver.core.api.score.SimpleScore; + +/** + * A planning solution with package-private visibility for testing Lookup-based access. + */ +@PlanningSolution +class PackagePrivateSolution { + + @ValueRangeProvider(id = "valueRange") + @ProblemFactCollectionProperty + private List values; + + @PlanningEntityCollectionProperty + private List entities; + + @PlanningScore + private SimpleScore score; + + PackagePrivateSolution() { + } + + PackagePrivateSolution(List values, List entities) { + this.values = values; + this.entities = entities; + } + + List getValues() { + return values; + } + + void setValues(List values) { + this.values = values; + } + + List getEntities() { + return entities; + } + + void setEntities(List entities) { + this.entities = entities; + } + + SimpleScore getScore() { + return score; + } + + void setScore(SimpleScore score) { + this.score = score; + } +} diff --git a/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateValue.java b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateValue.java new file mode 100644 index 00000000000..65533f42448 --- /dev/null +++ b/core/src/test/java/ai/timefold/solver/core/impl/domain/specification/testdata/PackagePrivateValue.java @@ -0,0 +1,25 @@ +package ai.timefold.solver.core.impl.domain.specification.testdata; + +/** + * A simple value class with package-private visibility for testing Lookup-based access. + */ +class PackagePrivateValue { + + private String code; + + PackagePrivateValue() { + } + + PackagePrivateValue(String code) { + this.code = code; + } + + String getCode() { + return code; + } + + @Override + public String toString() { + return "PackagePrivateValue(" + code + ")"; + } +} diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java index a0ea83bb7e1..f6a788707aa 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/DotNames.java @@ -83,6 +83,8 @@ public final class DotNames { static final DotName SOLVER_FACTORY = DotName.createSimple(SolverFactory.class.getName()); static final DotName SOLVER_MANAGER = DotName.createSimple(SolverManager.class.getName()); static final DotName CONSTRAINT_VERIFIER = DotName.createSimple(ConstraintVerifier.class.getName()); + static final DotName PLANNING_SPECIFICATION = DotName.createSimple( + "ai.timefold.solver.core.api.domain.specification.PlanningSpecification"); static final DotName[] PLANNING_ENTITY_FIELD_ANNOTATIONS = { PLANNING_PIN, diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java index 44ebbadf82e..86d105a34b6 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/GizmoMemberAccessorEntityEnhancer.java @@ -17,20 +17,16 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.Arrays; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.AccessorInfo; @@ -38,19 +34,11 @@ import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberAccessorImplementor; import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberDescriptor; import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.GizmoMemberInfo; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoCloningUtils; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionCloner; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionClonerFactory; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionClonerImplementor; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionOrEntityDescriptor; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.quarkus.gizmo.TimefoldGizmoBeanFactory; import org.jboss.jandex.AnnotationInstance; import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.DotName; import org.jboss.jandex.FieldInfo; -import org.jboss.jandex.IndexView; import org.jboss.jandex.MethodInfo; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -105,7 +93,7 @@ public String generateFieldAccessor(AnnotationInstance annotationInstance, Class var declaringClass = Class.forName(fieldInfo.declaringClass().name().toString(), false, Thread.currentThread().getContextClassLoader()); var fieldMember = declaringClass.getDeclaredField(fieldInfo.name()); - var member = createMemberDescriptorForField(fieldMember, transformers, false); + var member = createMemberDescriptorForField(fieldMember, transformers); var memberInfo = new GizmoMemberInfo(member, true, false, (Class) Class .forName(annotationInstance.name().toString(), false, Thread.currentThread().getContextClassLoader())); var generatedClassName = GizmoMemberAccessorFactory.getGeneratedClassName(fieldMember); @@ -244,93 +232,6 @@ private Optional addVirtualMethodGetter(ClassInfo classInfo, MethodI md.returnType(), md.parameterTypes())); } - public String generateSolutionCloner(SolutionDescriptor solutionDescriptor, ClassOutput classOutput, - IndexView indexView, BuildProducer transformers) { - String generatedClassName = GizmoSolutionClonerFactory.getGeneratedClassName(solutionDescriptor); - var gizmo = Gizmo.create(classOutput); - gizmo.class_(generatedClassName, classCreator -> { - classCreator.implements_(GizmoSolutionCloner.class); - classCreator.final_(); - - Set> solutionSubclassSet = - indexView.getAllKnownSubclasses(DotName.createSimple(solutionDescriptor.getSolutionClass().getName())) - .stream() - .map(classInfo -> { - try { - return Class.forName(classInfo.name().toString(), false, - Thread.currentThread().getContextClassLoader()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Unable to find class (%s), which is a known subclass of the solution class (%s)." - .formatted(classInfo.name(), solutionDescriptor.getSolutionClass()), - e); - } - }).collect(Collectors.toCollection(LinkedHashSet::new)); - solutionSubclassSet.add(solutionDescriptor.getSolutionClass()); - - Map, GizmoSolutionOrEntityDescriptor> memoizedGizmoSolutionOrEntityDescriptorForClassMap = - new HashMap<>(); - - for (var solutionSubclass : solutionSubclassSet) { - getGizmoSolutionOrEntityDescriptorForEntity(solutionDescriptor, - solutionSubclass, - memoizedGizmoSolutionOrEntityDescriptorForClassMap, - transformers); - } - - for (var entityClass : solutionDescriptor.getEntityClassSet()) { - getGizmoSolutionOrEntityDescriptorForEntity(solutionDescriptor, - entityClass, - memoizedGizmoSolutionOrEntityDescriptorForClassMap, - transformers); - } - - var solutionAndEntitySubclassSet = new HashSet<>(solutionSubclassSet); - for (var entityClass : solutionDescriptor.getEntityClassSet()) { - Collection classInfoCollection; - - // getAllKnownSubclasses returns an empty collection for interfaces (silent failure); thus: - // for interfaces, we use getAllKnownImplementations; otherwise we use getAllKnownSubclasses - if (entityClass.isInterface()) { - classInfoCollection = indexView.getAllKnownImplementations(DotName.createSimple(entityClass.getName())); - } else { - classInfoCollection = indexView.getAllKnownSubclasses(DotName.createSimple(entityClass.getName())); - } - - classInfoCollection.stream().map(classInfo -> { - try { - return Class.forName(classInfo.name().toString(), false, - Thread.currentThread().getContextClassLoader()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Unable to find class (%s), which is a known subclass of the entity class (%s)." - .formatted(classInfo.name(), entityClass), - e); - } - }).forEach(solutionAndEntitySubclassSet::add); - } - var deepClonedClassSet = - GizmoCloningUtils.getDeepClonedClasses(solutionDescriptor, solutionAndEntitySubclassSet); - - for (var deepCloningClass : deepClonedClassSet) { - makeConstructorAccessible(deepCloningClass, transformers); - if (!memoizedGizmoSolutionOrEntityDescriptorForClassMap.containsKey(deepCloningClass)) { - getGizmoSolutionOrEntityDescriptorForEntity(solutionDescriptor, - deepCloningClass, - memoizedGizmoSolutionOrEntityDescriptorForClassMap, - transformers); - } - } - - GizmoSolutionClonerImplementor.defineClonerFor(QuarkusGizmoSolutionClonerImplementor::new, - classCreator, - solutionDescriptor, solutionSubclassSet, - memoizedGizmoSolutionOrEntityDescriptorForClassMap, deepClonedClassSet); - }); - - return generatedClassName; - } - private void makeConstructorAccessible(Class clazz, BuildProducer transformers) { try { if (clazz.isInterface() || Modifier.isAbstract(clazz.getModifiers())) { @@ -350,31 +251,8 @@ private void makeConstructorAccessible(Class clazz, BuildProducer void getGizmoSolutionOrEntityDescriptorForEntity( - SolutionDescriptor solutionDescriptor, Class entityClass, - Map, GizmoSolutionOrEntityDescriptor> memoizedMap, - BuildProducer transformers) { - Map solutionFieldToMemberDescriptor = new HashMap<>(); - - var currentClass = entityClass; - while (currentClass != null) { - for (var field : currentClass.getDeclaredFields()) { - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - // Do not enforce a setter; this is for cloning, not an annotation - solutionFieldToMemberDescriptor.put(field, createMemberDescriptorForField(field, transformers, true)); - } - currentClass = currentClass.getSuperclass(); - } - GizmoSolutionOrEntityDescriptor out = - new GizmoSolutionOrEntityDescriptor(solutionDescriptor, entityClass, solutionFieldToMemberDescriptor); - memoizedMap.put(entityClass, out); - } - private GizmoMemberDescriptor createMemberDescriptorForField(Field field, - BuildProducer transformers, - boolean isForCloning) { + BuildProducer transformers) { var isFinal = Modifier.isFinal(field.getModifiers()); if (isFinal) { makeFieldNonFinal(field, transformers); @@ -384,9 +262,7 @@ private GizmoMemberDescriptor createMemberDescriptorForField(Field field, var memberDescriptor = FieldDesc.of(field); var name = field.getName(); - // Not being recorded, so can use Type and annotated element directly - if ((ReflectionHelper.hasGetterMethod(declaringClass, name) || Modifier.isPublic(field.getModifiers())) - && !isForCloning) { + if (ReflectionHelper.hasGetterMethod(declaringClass, name) || Modifier.isPublic(field.getModifiers())) { return new GizmoMemberDescriptor(name, memberDescriptor, declaringClass, AccessorInfo.withReturnValueAndNoArguments()); } else { @@ -411,16 +287,6 @@ public static Map> getGeneratedGizmoMemberA return generatedGizmoMemberAccessorNameToInstanceMap; } - public static Map>> getGeneratedSolutionClonerMap( - RecorderContext recorderContext, - Set generatedSolutionClonersClassNames) { - Map>> generatedGizmoSolutionClonerNameToInstanceMap = new HashMap<>(); - for (var className : generatedSolutionClonersClassNames) { - generatedGizmoSolutionClonerNameToInstanceMap.put(className, recorderContext.newInstance(className)); - } - return generatedGizmoSolutionClonerNameToInstanceMap; - } - public void generateGizmoBeanFactory(ClassOutput classOutput, Set> beanClasses, BuildProducer transformers) { var generatedClassName = TimefoldGizmoBeanFactory.class.getName() + "$Implementation"; diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/QuarkusGizmoSolutionClonerImplementor.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/QuarkusGizmoSolutionClonerImplementor.java deleted file mode 100644 index eef3ab85037..00000000000 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/QuarkusGizmoSolutionClonerImplementor.java +++ /dev/null @@ -1,80 +0,0 @@ -package ai.timefold.solver.quarkus.deployment; - -import java.util.ArrayDeque; -import java.util.Map; -import java.util.function.Consumer; - -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionClonerImplementor; -import ai.timefold.solver.core.impl.domain.solution.cloner.gizmo.GizmoSolutionOrEntityDescriptor; -import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; - -import io.quarkus.gizmo2.Const; -import io.quarkus.gizmo2.Var; -import io.quarkus.gizmo2.creator.BlockCreator; -import io.quarkus.gizmo2.desc.ConstructorDesc; -import io.quarkus.gizmo2.desc.MethodDesc; - -class QuarkusGizmoSolutionClonerImplementor extends GizmoSolutionClonerImplementor { - @Override - protected ClonerDescriptor withFallbackClonerField(ClonerDescriptor clonerDescriptor) { - // do nothing, we don't need a shallow cloner - return clonerDescriptor; - } - - @Override - protected void createSetSolutionDescriptor(ClonerDescriptor clonerDescriptor) { - // do nothing, we don't need to create a shallow cloner - clonerDescriptor.classCreator().method("setSolutionDescriptor", methodMetadataCreator -> { - methodMetadataCreator.returning(void.class); - methodMetadataCreator.parameter("solutionDescriptor", SolutionDescriptor.class); - methodMetadataCreator.body(BlockCreator::return_); - }); - } - - @Override - protected void handleUnknownClass(ClonerDescriptor clonerDescriptor, - ClonerMethodDescriptor clonerMethodDescriptor, - Class entityClass, - Var toClone, - Consumer blockCreatorConsumer) { - // do nothing, since we cannot encounter unknown classes - blockCreatorConsumer.accept(clonerMethodDescriptor.blockCreator()); - } - - @Override - protected void createAbstractDeepCloneHelperMethod(ClonerDescriptor clonerDescriptor, - Class entityClass) { - clonerDescriptor.classCreator().staticMethod(getEntityHelperMethodName(entityClass), methodCreator -> { - var toClone = methodCreator.parameter("toClone", entityClass); - var cloneMap = methodCreator.parameter("cloneMap", Map.class); - var ignoredIsBottom = methodCreator.parameter("ignoredIsBottom", boolean.class); - var ignoredQueue = methodCreator.parameter("ignoredQueue", ArrayDeque.class); - - methodCreator.public_(); - methodCreator.returning(entityClass); - methodCreator.body(blockCreator -> { - clonerDescriptor.memoizedSolutionOrEntityDescriptorMap().computeIfAbsent(entityClass, - key -> new GizmoSolutionOrEntityDescriptor(clonerDescriptor.solutionDescriptor(), entityClass)); - - var maybeClone = blockCreator.localVar("existingClone", blockCreator.withMap(cloneMap).get(toClone)); - blockCreator.ifNotNull(maybeClone, hasCloneBranch -> hasCloneBranch.return_(maybeClone)); - - var errorMessageBuilder = blockCreator.localVar("messageBuilder", blockCreator.new_(StringBuilder.class)); - blockCreator.invokeVirtual( - MethodDesc.of(StringBuilder.class, "append", StringBuilder.class, String.class), - errorMessageBuilder, Const.of("Impossible state: encountered unknown subclass (")); - blockCreator.invokeVirtual( - MethodDesc.of(StringBuilder.class, "append", StringBuilder.class, Object.class), - errorMessageBuilder, - blockCreator.withObject(toClone).getClass_()); - blockCreator.invokeVirtual( - MethodDesc.of(StringBuilder.class, "append", StringBuilder.class, String.class), - errorMessageBuilder, Const.of(") of (" + entityClass + ") in Quarkus.")); - - var error = blockCreator.new_(ConstructorDesc.of(IllegalStateException.class, String.class), - blockCreator.withObject(errorMessageBuilder).toString_()); - blockCreator.throw_(error); - }); - }); - } -} diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java index 33e092fbded..9e3fd54bd04 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java @@ -2,9 +2,6 @@ import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -25,8 +22,7 @@ import ai.timefold.solver.core.api.domain.entity.PlanningEntity; import ai.timefold.solver.core.api.domain.solution.PlanningSolution; -import ai.timefold.solver.core.api.domain.variable.ShadowSources; -import ai.timefold.solver.core.api.domain.variable.ShadowVariable; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator; import ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator; import ai.timefold.solver.core.api.score.stream.ConstraintMetaModel; @@ -38,11 +34,7 @@ import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.SolverManagerConfig; import ai.timefold.solver.core.impl.domain.common.DomainAccessType; -import ai.timefold.solver.core.impl.domain.common.ReflectionHelper; -import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessorType; -import ai.timefold.solver.core.impl.domain.common.accessor.gizmo.AccessorInfo; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; -import ai.timefold.solver.core.impl.domain.variable.declarative.RootVariableSource; import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; import ai.timefold.solver.core.impl.score.stream.test.DefaultConstraintVerifier; import ai.timefold.solver.core.impl.solver.DefaultSolverFactory; @@ -66,9 +58,7 @@ import org.jboss.jandex.AnnotationTarget; import org.jboss.jandex.ClassInfo; import org.jboss.jandex.DotName; -import org.jboss.jandex.FieldInfo; import org.jboss.jandex.IndexView; -import org.jboss.jandex.MethodInfo; import org.jboss.jandex.ParameterizedType; import org.jboss.jandex.Type; import org.jboss.logging.Logger; @@ -78,7 +68,6 @@ import io.quarkus.arc.deployment.GeneratedBeanGizmo2Adaptor; import io.quarkus.arc.deployment.SyntheticBeanBuildItem; import io.quarkus.arc.deployment.UnremovableBeanBuildItem; -import io.quarkus.deployment.GeneratedClassGizmo2Adaptor; import io.quarkus.deployment.IsDevelopment; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; @@ -93,11 +82,9 @@ import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem; import io.quarkus.deployment.pkg.steps.NativeBuild; -import io.quarkus.deployment.recording.RecorderContext; import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; import io.quarkus.devui.spi.page.CardPageBuildItem; import io.quarkus.devui.spi.page.Page; -import io.quarkus.gizmo2.ClassOutput; import io.quarkus.gizmo2.Const; import io.quarkus.gizmo2.desc.ConstructorDesc; import io.quarkus.gizmo2.desc.MethodDesc; @@ -226,14 +213,22 @@ SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem com } } + // Check if there is a PlanningSpecification CDI producer bean + var hasPlanningSpecBean = indexView.getAnnotations( + DotName.createSimple("jakarta.enterprise.inject.Produces")).stream() + .filter(ai -> ai.target().kind() == AnnotationTarget.Kind.METHOD) + .anyMatch(ai -> ai.target().asMethod().returnType().name().equals(DotNames.PLANNING_SPECIFICATION)); + // Only skip this extension if everything is missing. Otherwise, if some parts are missing, fail fast later. - if (indexView.getAnnotations(DotNames.PLANNING_SOLUTION).isEmpty() - && indexView.getAnnotations(DotNames.PLANNING_ENTITY).isEmpty()) { + boolean hasAnnotations = !indexView.getAnnotations(DotNames.PLANNING_SOLUTION).isEmpty() + || !indexView.getAnnotations(DotNames.PLANNING_ENTITY).isEmpty(); + if (!hasAnnotations && !hasPlanningSpecBean) { LOGGER.warn( """ - Skipping Timefold extension because there are no @%s or @%s annotated classes. + Skipping Timefold extension because there are no @%s or @%s annotated classes \ + and no PlanningSpecification CDI bean was found. If your domain classes are located in a dependency of this project, maybe try generating the \ - Jandex index by using the jandex-maven-plugin in that dependency, or by addingapplication.properties entries \ + Jandex index by using the jandex-maven-plugin in that dependency, or by adding application.properties entries \ (quarkus.index-dependency..group-id and quarkus.index-dependency..artifact-id).""" .formatted(PlanningSolution.class.getSimpleName(), PlanningEntity.class.getSimpleName())); additionalBeans.produce(new AdditionalBeanBuildItem(UnavailableTimefoldBeanProvider.class)); @@ -258,35 +253,89 @@ SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem com createSolverConfig(classLoader, solverName))); } - // Step 2 - validate all SolverConfig definitions - assertNoMemberAnnotationWithoutClassAnnotation(indexView); + // Determine which solvers are spec-based (programmatic PlanningSpecification) + var specBasedSolverNames = new HashSet(); + if (hasPlanningSpecBean) { + for (var entry : solverConfigMap.entrySet()) { + // Solvers without a solutionClass (not configured via XML) will use the spec bean + if (entry.getValue().getSolutionClass() == null) { + specBasedSolverNames.add(entry.getKey()); + } + } + } + boolean allSpecBased = !solverConfigMap.isEmpty() + && specBasedSolverNames.equals(solverConfigMap.keySet()); + + // Apply score director factory properties for spec-based solvers + // (they need constraint provider discovery even without annotation scanning) + for (var entry : solverConfigMap.entrySet()) { + if (specBasedSolverNames.contains(entry.getKey())) { + applyScoreDirectorFactoryProperties(indexView, entry.getValue()); + } + } + + // Step 2 - validate all SolverConfig definitions (only for annotation-based solvers) + if (!allSpecBased) { + assertNoMemberAnnotationWithoutClassAnnotation(indexView); + assertSolverConfigSolutionClasses(indexView, solverConfigMap); + assertSolverConfigEntityClasses(indexView); + } assertNodeSharingDisabled(solverConfigMap); - assertSolverConfigSolutionClasses(indexView, solverConfigMap); - assertSolverConfigEntityClasses(indexView); assertSolverConfigConstraintClasses(indexView, solverConfigMap); // Step 3 - load all additional information per SolverConfig Set> reflectiveClassSet = new LinkedHashSet<>(); - solverConfigMap.forEach((solverName, solverConfig) -> loadSolverConfig(indexView, reflectiveHierarchyClass, - solverConfig, solverName, reflectiveClassSet)); - - // Register all annotated domain model classes - registerClassesFromAnnotations(indexView, reflectiveClassSet); + if (!allSpecBased) { + // Annotation path for non-spec solvers + solverConfigMap.forEach((solverName, solverConfig) -> { + if (!specBasedSolverNames.contains(solverName)) { + loadSolverConfig(indexView, reflectiveHierarchyClass, + solverConfig, solverName, reflectiveClassSet); + } + }); + // Register all annotated domain model classes + registerClassesFromAnnotations(indexView, reflectiveClassSet); + + // Validate domain model by building the solution descriptor + // (catches inheritance, duplicate variables, etc.) + for (var entry : solverConfigMap.entrySet()) { + if (!specBasedSolverNames.contains(entry.getKey())) { + var solverConfig = entry.getValue(); + if (solverConfig.getSolutionClass() != null + && solverConfig.getEntityClassList() != null) { + SolutionDescriptor.buildSolutionDescriptor( + solverConfig.getSolutionClass(), + solverConfig.getEntityClassList()); + } + } + } + } - // Register only distinct constraint providers - solverConfigMap.values() + // Register only distinct constraint providers (skip spec-based solvers as they lack solution/entity classes) + solverConfigMap.entrySet() .stream() - .filter(config -> config.getScoreDirectorFactoryConfig().getConstraintProviderClass() != null) + .filter(entry -> !specBasedSolverNames.contains(entry.getKey())) + .map(Map.Entry::getValue) + .filter(config -> config.getScoreDirectorFactoryConfig() != null + && config.getScoreDirectorFactoryConfig().getConstraintProviderClass() != null) .map(config -> config.getScoreDirectorFactoryConfig().getConstraintProviderClass().getName()) .distinct() .map(constraintProviderName -> solverConfigMap.entrySet().stream().filter(entryConfig -> entryConfig.getValue() - .getScoreDirectorFactoryConfig().getConstraintProviderClass().getName().equals(constraintProviderName)) + .getScoreDirectorFactoryConfig() != null + && entryConfig.getValue().getScoreDirectorFactoryConfig().getConstraintProviderClass() != null + && entryConfig.getValue().getScoreDirectorFactoryConfig().getConstraintProviderClass().getName() + .equals(constraintProviderName)) .findFirst().orElseThrow()) .forEach( entryConfig -> generateConstraintVerifier(entryConfig.getValue(), syntheticBeanBuildItemBuildProducer)); - GeneratedGizmoClasses generatedGizmoClasses = generateDomainAccessors(solverConfigMap, indexView, generatedBeans, - generatedClasses, generatedResources, transformers, reflectiveClassSet); + // Gizmo MemberAccessor/SolutionCloner generation is no longer needed; + // FORCE_REFLECTION domain access type is used instead (set by TimefoldRecorder). + // Only the bean factory for ConstraintProvider instantiation is still generated. + GeneratedGizmoClasses generatedGizmoClasses = new GeneratedGizmoClasses(Set.of()); + var beanClassOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); + new GizmoMemberAccessorEntityEnhancer() + .generateGizmoBeanFactory(beanClassOutput, reflectiveClassSet, transformers); additionalBeans.produce(new AdditionalBeanBuildItem(TimefoldSolverBannerBean.class)); if (solverConfigMap.size() <= 1) { @@ -295,6 +344,16 @@ SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem com } unremovableBeans.produce(UnremovableBeanBuildItem.beanTypes(TimefoldRuntimeConfig.class)); + if (hasPlanningSpecBean) { + unremovableBeans.produce(UnremovableBeanBuildItem.beanTypes(PlanningSpecification.class)); + // Also mark any class that produces a PlanningSpecification as unremovable + unremovableBeans.produce(new UnremovableBeanBuildItem( + beanInfo -> beanInfo.isProducerMethod() + && beanInfo.getTypes().stream() + .anyMatch(t -> t.name().equals(DotNames.PLANNING_SPECIFICATION)))); + } + + // Reflection registration needed for FORCE_REFLECTION MemberAccessors for (var reflectiveClass : reflectiveClassSet) { registerReflectiveClasses.produce(ReflectiveClassBuildItem.builder(reflectiveClass) .fields() @@ -303,7 +362,7 @@ SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem com .build()); } - return new SolverConfigBuildItem(solverConfigMap, generatedGizmoClasses); + return new SolverConfigBuildItem(solverConfigMap, generatedGizmoClasses, specBasedSolverNames); } private void assertNoMemberAnnotationWithoutClassAnnotation(IndexView indexView) { @@ -607,6 +666,9 @@ void buildConstraintMetaModel(SolverConfigBuildItem solverConfigBuildItem, var constraintMetaModelsBySolverNames = new HashMap(); solverConfigBuildItem.getSolverConfigMap().forEach((solverName, solverConfig) -> { + if (solverConfigBuildItem.isSpecBased(solverName)) { + return; // Spec-based solvers build constraint metamodel at runtime + } var solverFactory = new DefaultSolverFactory<>(solverConfig, DomainAccessType.FORCE_REFLECTION); var constraintMetaModel = BeanUtil.buildConstraintMetaModel(solverFactory); // Avoid changing the original solver config. @@ -618,7 +680,7 @@ void buildConstraintMetaModel(SolverConfigBuildItem solverConfigBuildItem, @BuildStep @Record(RUNTIME_INIT) - void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, RecorderContext recorderContext, + void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, BuildProducer syntheticBeanBuildItemBuildProducer, SolverConfigBuildItem solverConfigBuildItem) { // Skip this extension if everything is missing. @@ -632,20 +694,23 @@ void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, RecorderContext re // which can inject all resources to be retro-compatible. solverConfigBuildItem.getSolverConfigMap().forEach((key, value) -> { if (solverConfigBuildItem.isDefaultSolverConfig(key)) { - // The two configuration resources are required for DefaultTimefoldBeanProvider - // to produce all available managed beans for the default solver. - syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(SolverConfig.class) - .scope(Singleton.class) - .supplier(recorder.solverConfigSupplier(key, value, - GizmoMemberAccessorEntityEnhancer.getGeneratedGizmoMemberAccessorMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().memberAccessorClassSet()), - GizmoMemberAccessorEntityEnhancer.getGeneratedSolutionClonerMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().solutionClonerClassSet()))) - .setRuntimeInit() - .defaultBean() - .done()); + if (solverConfigBuildItem.isSpecBased(key)) { + // Spec path: CDI lookup of PlanningSpecification at runtime + syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(SolverConfig.class) + .scope(Singleton.class) + .supplier(recorder.solverConfigWithSpecSupplier(key, value)) + .setRuntimeInit() + .defaultBean() + .done()); + } else { + // Annotation path: FORCE_REFLECTION (no Gizmo maps needed) + syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(SolverConfig.class) + .scope(Singleton.class) + .supplier(recorder.solverConfigSupplier(key, value)) + .setRuntimeInit() + .defaultBean() + .done()); + } var solverManagerConfig = new SolverManagerConfig(); syntheticBeanBuildItemBuildProducer.produce(SyntheticBeanBuildItem.configure(SolverManagerConfig.class) @@ -656,24 +721,28 @@ void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, RecorderContext re .done()); } if (!TimefoldBuildTimeConfig.DEFAULT_SOLVER_NAME.equals(key)) { - // The default SolverManager instance is generated by DefaultTimefoldBeanProvider - syntheticBeanBuildItemBuildProducer.produce( - // We generate all required resources only to create a SolverManager and set it as managed bean - SyntheticBeanBuildItem.configure(SolverManager.class) - .scope(Singleton.class) - .addType(ParameterizedType.create(DotName.createSimple(SolverManager.class.getName()), - Type.create(DotName.createSimple(value.getSolutionClass().getName()), - Type.Kind.CLASS))) - .supplier(recorder.solverManager(key, value, - GizmoMemberAccessorEntityEnhancer.getGeneratedGizmoMemberAccessorMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().memberAccessorClassSet()), - GizmoMemberAccessorEntityEnhancer.getGeneratedSolutionClonerMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().solutionClonerClassSet()))) - .setRuntimeInit() - .named(key) - .done()); + if (solverConfigBuildItem.isSpecBased(key)) { + // Spec path: CDI lookup of PlanningSpecification at runtime + syntheticBeanBuildItemBuildProducer.produce( + SyntheticBeanBuildItem.configure(SolverManager.class) + .scope(Singleton.class) + .supplier(recorder.solverManagerWithSpec(key, value)) + .setRuntimeInit() + .named(key) + .done()); + } else { + // Annotation path: FORCE_REFLECTION (no Gizmo maps needed) + syntheticBeanBuildItemBuildProducer.produce( + SyntheticBeanBuildItem.configure(SolverManager.class) + .scope(Singleton.class) + .addType(ParameterizedType.create(DotName.createSimple(SolverManager.class.getName()), + Type.create(DotName.createSimple(value.getSolutionClass().getName()), + Type.Kind.CLASS))) + .supplier(recorder.solverManager(key, value)) + .setRuntimeInit() + .named(key) + .done()); + } } }); } @@ -682,16 +751,13 @@ void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, RecorderContext re @Record(RUNTIME_INIT) public void recordAndRegisterDevUIBean( TimefoldDevUIRecorder devUIRecorder, - RecorderContext recorderContext, SolverConfigBuildItem solverConfigBuildItem, BuildProducer syntheticBeans) { if (solverConfigBuildItem.getGeneratedGizmoClasses() == null) { // Extension was skipped, so no solver configs syntheticBeans.produce(SyntheticBeanBuildItem.configure(DevUISolverConfig.class) .scope(ApplicationScoped.class) - .supplier(devUIRecorder.solverConfigSupplier(Collections.emptyMap(), - Collections.emptyMap(), - Collections.emptyMap())) + .supplier(devUIRecorder.solverConfigSupplier(Collections.emptyMap())) .defaultBean() .setRuntimeInit() .done()); @@ -699,13 +765,7 @@ public void recordAndRegisterDevUIBean( } syntheticBeans.produce(SyntheticBeanBuildItem.configure(DevUISolverConfig.class) .scope(ApplicationScoped.class) - .supplier(devUIRecorder.solverConfigSupplier(solverConfigBuildItem.getSolverConfigMap(), - GizmoMemberAccessorEntityEnhancer.getGeneratedGizmoMemberAccessorMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().memberAccessorClassSet()), - GizmoMemberAccessorEntityEnhancer.getGeneratedSolutionClonerMap(recorderContext, - solverConfigBuildItem - .getGeneratedGizmoClasses().solutionClonerClassSet()))) + .supplier(devUIRecorder.solverConfigSupplier(solverConfigBuildItem.getSolverConfigMap())) .defaultBean() .setRuntimeInit() .done()); @@ -944,242 +1004,6 @@ private Class convertClassInfoToClass(ClassInfo classInfo) { } } - private GeneratedGizmoClasses generateDomainAccessors(Map solverConfigMap, IndexView indexView, - BuildProducer generatedBeans, - BuildProducer generatedClasses, - BuildProducer generatedResources, - BuildProducer transformers, - Set> reflectiveClassSet) { - // Use mvn quarkus:dev -Dquarkus.debug.generated-classes-dir=dump-classes - // to dump generated classes - var classOutput = new GeneratedClassGizmo2Adaptor(generatedClasses, generatedResources, true); - var beanClassOutput = new GeneratedBeanGizmo2Adaptor(generatedBeans); - - var generatedMemberAccessorsClassNameSet = new HashSet(); - var gizmoSolutionClonerClassNameSet = new HashSet(); - - /* - * TODO consistently change the name "entity" to something less confusing - * "entity" in this context means both "planning solution", - * "planning entity" and other things as well. - */ - var entityEnhancer = new GizmoMemberAccessorEntityEnhancer(); - var membersToGeneratedAccessorsForCollection = new ArrayList(); - - // Every entity and solution gets scanned for annotations. - // Annotated members get their accessors generated. - for (var dotName : DotNames.GIZMO_MEMBER_ACCESSOR_ANNOTATIONS) { - membersToGeneratedAccessorsForCollection.addAll(indexView.getAnnotationsWithRepeatable(dotName, indexView)); - } - generateDomainAccessorsForShadowSources(indexView, membersToGeneratedAccessorsForCollection); - membersToGeneratedAccessorsForCollection.removeIf(this::shouldIgnoreMember); - - // Fail fast on auto-discovery. - var planningSolutionAnnotationInstanceCollection = getAllConcreteSolutionClasses(indexView); - var unconfiguredSolverConfigList = solverConfigMap.entrySet().stream() - .filter(e -> e.getValue().getSolutionClass() == null) - .map(Map.Entry::getKey) - .toList(); - var unusedSolutionClassList = planningSolutionAnnotationInstanceCollection.stream() - .map(planningClass -> planningClass.target().asClass().name().toString()) - .filter(planningClassName -> reflectiveClassSet.stream() - .noneMatch(clazz -> clazz.getName().equals(planningClassName))) - .toList(); - if (planningSolutionAnnotationInstanceCollection.isEmpty()) { - throw new IllegalStateException( - "No classes found with a @%s annotation.".formatted(PlanningSolution.class.getSimpleName())); - } else if (planningSolutionAnnotationInstanceCollection.size() > 1 && !unconfiguredSolverConfigList.isEmpty() - && !unusedSolutionClassList.isEmpty()) { - throw new IllegalStateException( - "Unused classes (%s) found with a @%s annotation.".formatted(String.join(", ", unusedSolutionClassList), - PlanningSolution.class.getSimpleName())); - } - - var solutionClassInstance = planningSolutionAnnotationInstanceCollection.iterator().next(); - var solutionClassInfo = solutionClassInstance.target().asClass(); - var visited = new HashSet(); - for (var annotatedMember : membersToGeneratedAccessorsForCollection) { - ClassInfo classInfo = null; - String memberName = null; - if (!visited.add(annotatedMember.target())) { - continue; - } - switch (annotatedMember.target().kind()) { - case FIELD -> { - var fieldInfo = annotatedMember.target().asField(); - classInfo = fieldInfo.declaringClass(); - memberName = fieldInfo.name(); - buildFieldAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, - classInfo, fieldInfo, transformers); - } - case METHOD -> { - var methodInfo = annotatedMember.target().asMethod(); - classInfo = methodInfo.declaringClass(); - memberName = methodInfo.name(); - buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, - classInfo, methodInfo, - AccessorInfo.of((annotatedMember.name().equals(DotNames.VALUE_RANGE_PROVIDER)) - ? MemberAccessorType.FIELD_OR_READ_METHOD_WITH_OPTIONAL_PARAMETER - : MemberAccessorType.FIELD_OR_READ_METHOD), - transformers); - } - default -> throw new IllegalStateException( - "The member (%s) is not on a field or method.".formatted(annotatedMember)); - } - if (annotatedMember.name().equals(DotNames.CASCADING_UPDATE_SHADOW_VARIABLE)) { - // The source method name also must be included - // targetMethodName is a required field and is always present - var targetMethodName = annotatedMember.value("targetMethodName").asString(); - var methodInfo = classInfo.method(targetMethodName); - buildMethodAccessor(null, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, classInfo, - methodInfo, AccessorInfo.of(MemberAccessorType.VOID_METHOD), transformers); - } else if (annotatedMember.name().equals(DotNames.SHADOW_VARIABLE) - && annotatedMember.value("supplierName") != null) { - // The source method name also must be included - var targetMethodName = annotatedMember.value("supplierName") - .asString(); - var methodInfo = classInfo.method(targetMethodName); - if (methodInfo == null) { - // Retry with the solution class - var solutionType = Type.create(solutionClassInfo.name(), Type.Kind.CLASS); - methodInfo = classInfo.method(targetMethodName, solutionType); - } - if (methodInfo == null) { - throw new IllegalArgumentException(""" - @%s (%s) defines a supplierName (%s) that does not exist inside its declaring class (%s). - Maybe you included a parameter which is not a planning solution (%s)? - Maybe you misspelled the supplierName name?""" - .formatted(ShadowVariable.class.getSimpleName(), memberName, targetMethodName, - classInfo.name().toString(), solutionClassInfo.name().toString())); - } - buildMethodAccessor(annotatedMember, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, - classInfo, methodInfo, - AccessorInfo - .of(MemberAccessorType.FIELD_OR_READ_METHOD_WITH_OPTIONAL_PARAMETER), - transformers); - } - } - // The ConstraintWeightOverrides field is not annotated, but it needs a member accessor - var constraintFieldInfo = solutionClassInfo.fields().stream() - .filter(f -> f.type().name().equals(DotNames.CONSTRAINT_WEIGHT_OVERRIDES)) - .findFirst() - .orElse(null); - if (constraintFieldInfo != null) { - // Prefer method to field - var solutionClass = convertClassInfoToClass(solutionClassInfo); - var constraintMethod = - ReflectionHelper.getGetterMethod(solutionClass, constraintFieldInfo.name()); - var constraintMethodInfo = solutionClassInfo.methods().stream() - .filter(m -> constraintMethod != null && m.name().equals(constraintMethod.getName()) - && m.parametersCount() == 0) - .findFirst() - .orElse(null); - if (constraintMethodInfo != null) { - buildMethodAccessor(solutionClassInstance, generatedMemberAccessorsClassNameSet, entityEnhancer, - classOutput, solutionClassInfo, constraintMethodInfo, AccessorInfo.withReturnValueAndNoArguments(), - transformers); - } else { - buildFieldAccessor(solutionClassInstance, generatedMemberAccessorsClassNameSet, entityEnhancer, classOutput, - solutionClassInfo, constraintFieldInfo, transformers); - } - } - // Using REFLECTION domain access type so Timefold doesn't try to generate GIZMO code - solverConfigMap.values().forEach(c -> { - var solutionDescriptor = SolutionDescriptor.buildSolutionDescriptor( - c.getEnablePreviewFeatureSet(), DomainAccessType.FORCE_REFLECTION, - c.getSolutionClass(), null, null, c.getEntityClassList()); - gizmoSolutionClonerClassNameSet - .add(entityEnhancer.generateSolutionCloner(solutionDescriptor, classOutput, indexView, transformers)); - }); - - entityEnhancer.generateGizmoBeanFactory(beanClassOutput, reflectiveClassSet, transformers); - return new GeneratedGizmoClasses(generatedMemberAccessorsClassNameSet, gizmoSolutionClonerClassNameSet); - } - - private static void generateDomainAccessorsForShadowSources(IndexView indexView, - List membersToGeneratedAccessorsForCollection) { - for (var shadowSources : indexView.getAnnotations(DotNames.SHADOW_SOURCES)) { - Class rootType; - try { - rootType = Thread.currentThread().getContextClassLoader().loadClass( - shadowSources.target().asMethod().declaringClass().name().toString()); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("Unable to load class (%s) which has a @%s annotation." - .formatted(shadowSources.target().asMethod().declaringClass().name(), - ShadowSources.class.getSimpleName())); - } - var sources = shadowSources.value().asStringArray(); - var alignmentKey = shadowSources.value("alignmentKey"); - - if (alignmentKey != null && !alignmentKey.asString().isEmpty()) { - generateDomainAccessorsForSourcePath(indexView, rootType, alignmentKey.asString(), - membersToGeneratedAccessorsForCollection); - } - for (var source : sources) { - generateDomainAccessorsForSourcePath(indexView, rootType, source, membersToGeneratedAccessorsForCollection); - } - } - } - - private static void generateDomainAccessorsForSourcePath(IndexView indexView, Class rootType, String source, - List membersToGeneratedAccessorsForCollection) { - for (var iterator = RootVariableSource.pathIterator(rootType, source); iterator.hasNext();) { - var member = iterator.next().member(); - AnnotationTarget target; - - if (member instanceof Field field) { - target = indexView.getClassByName(field.getDeclaringClass()).field(field.getName()); - } else if (member instanceof Method method) { - target = indexView.getClassByName(method.getDeclaringClass()).method(method.getName()); - } else { - throw new IllegalStateException("Member (%s) is not on a field or method." - .formatted(member)); - } - // Create a fake annotation for it - membersToGeneratedAccessorsForCollection.add( - AnnotationInstance.builder(DotNames.SHADOW_SOURCES) - .value(source) - .buildWithTarget(target)); - } - } - - private static void buildFieldAccessor(AnnotationInstance annotatedMember, Set generatedMemberAccessorsClassNameSet, - GizmoMemberAccessorEntityEnhancer entityEnhancer, ClassOutput classOutput, ClassInfo classInfo, FieldInfo fieldInfo, - BuildProducer transformers) { - try { - generatedMemberAccessorsClassNameSet.add( - entityEnhancer.generateFieldAccessor(annotatedMember, classOutput, fieldInfo, - transformers)); - } catch (ClassNotFoundException | NoSuchFieldException e) { - throw new IllegalStateException("Fail to generate member accessor for field (%s) of the class(%s)." - .formatted(fieldInfo.name(), classInfo.name().toString()), e); - } - } - - private static void buildMethodAccessor(AnnotationInstance annotatedMember, - Set generatedMemberAccessorsClassNameSet, GizmoMemberAccessorEntityEnhancer entityEnhancer, - ClassOutput classOutput, ClassInfo classInfo, MethodInfo methodInfo, AccessorInfo accessorInfo, - BuildProducer transformers) { - try { - generatedMemberAccessorsClassNameSet.add(entityEnhancer.generateMethodAccessor(annotatedMember, - classOutput, classInfo, methodInfo, accessorInfo, transformers)); - } catch (ClassNotFoundException | NoSuchMethodException e) { - throw new IllegalStateException( - "Failed to generate member accessor for the method (%s) of the class (%s)." - .formatted(methodInfo.name(), classInfo.name()), - e); - } - } - - private boolean shouldIgnoreMember(AnnotationInstance annotationInstance) { - return switch (annotationInstance.target().kind()) { - case FIELD -> (annotationInstance.target().asField().flags() & Modifier.STATIC) != 0; - case METHOD -> (annotationInstance.target().asMethod().flags() & Modifier.STATIC) != 0; - default -> throw new IllegalArgumentException( - "Annotation (%s) can only be applied to methods and fields.".formatted(annotationInstance.name())); - }; - } - private void registerCustomClassesFromSolverConfig(SolverConfig solverConfig, Set> reflectiveClassSet) { solverConfig.visitReferencedClasses(clazz -> { if (clazz != null) { diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/GeneratedGizmoClasses.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/GeneratedGizmoClasses.java index 64ab926ad99..83cdf48c843 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/GeneratedGizmoClasses.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/GeneratedGizmoClasses.java @@ -2,11 +2,10 @@ import java.util.Set; -public record GeneratedGizmoClasses(Set memberAccessorClassSet, Set solutionClonerClassSet) { +public record GeneratedGizmoClasses(Set memberAccessorClassSet) { public GeneratedGizmoClasses { memberAccessorClassSet = Set.copyOf(memberAccessorClassSet); - solutionClonerClassSet = Set.copyOf(solutionClonerClassSet); } } diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/SolverConfigBuildItem.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/SolverConfigBuildItem.java index 388f6199909..ab854c3a763 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/SolverConfigBuildItem.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/api/SolverConfigBuildItem.java @@ -1,6 +1,7 @@ package ai.timefold.solver.quarkus.deployment.api; import java.util.Map; +import java.util.Set; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.quarkus.deployment.config.TimefoldBuildTimeConfig; @@ -10,14 +11,21 @@ public final class SolverConfigBuildItem extends SimpleBuildItem { private final Map solverConfigurations; private final GeneratedGizmoClasses generatedGizmoClasses; + private final Set specBasedSolverNames; /** * Constructor for multiple solver configurations. */ public SolverConfigBuildItem(Map solverConfig, GeneratedGizmoClasses generatedGizmoClasses) { + this(solverConfig, generatedGizmoClasses, Set.of()); + } + + public SolverConfigBuildItem(Map solverConfig, GeneratedGizmoClasses generatedGizmoClasses, + Set specBasedSolverNames) { // Defensive copy to avoid changing the map in dependent build items. this.solverConfigurations = Map.copyOf(solverConfig); this.generatedGizmoClasses = generatedGizmoClasses; + this.specBasedSolverNames = Set.copyOf(specBasedSolverNames); } public boolean isDefaultSolverConfig(String solverName) { @@ -31,4 +39,12 @@ public Map getSolverConfigMap() { public GeneratedGizmoClasses getGeneratedGizmoClasses() { return generatedGizmoClasses; } + + public boolean isSpecBased(String solverName) { + return specBasedSolverNames.contains(solverName); + } + + public boolean isAllSpecBased() { + return !solverConfigurations.isEmpty() && specBasedSolverNames.equals(solverConfigurations.keySet()); + } } diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorPlanningSpecificationSolveTest.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorPlanningSpecificationSolveTest.java new file mode 100644 index 00000000000..2bcd6506fed --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/TimefoldProcessorPlanningSpecificationSolveTest.java @@ -0,0 +1,59 @@ +package ai.timefold.solver.quarkus; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import jakarta.inject.Inject; + +import ai.timefold.solver.core.api.solver.SolverFactory; +import ai.timefold.solver.core.api.solver.SolverJob; +import ai.timefold.solver.core.api.solver.SolverManager; +import ai.timefold.solver.quarkus.testdomain.spec.TestdataSpecConstraintProvider; +import ai.timefold.solver.quarkus.testdomain.spec.TestdataSpecEntity; +import ai.timefold.solver.quarkus.testdomain.spec.TestdataSpecProducer; +import ai.timefold.solver.quarkus.testdomain.spec.TestdataSpecSolution; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class TimefoldProcessorPlanningSpecificationSolveTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .overrideConfigKey("quarkus.timefold.solver.termination.best-score-limit", "0") + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(TestdataSpecEntity.class, TestdataSpecSolution.class, + TestdataSpecConstraintProvider.class, TestdataSpecProducer.class)); + + @Inject + SolverFactory solverFactory; + @Inject + SolverManager solverManager; + + @Test + void solve() throws ExecutionException, InterruptedException { + assertNotNull(solverFactory); + assertNotNull(solverManager); + + TestdataSpecSolution problem = new TestdataSpecSolution(); + problem.setValueList(IntStream.range(1, 3) + .mapToObj(i -> "v" + i) + .collect(Collectors.toList())); + problem.setEntityList(IntStream.range(1, 3) + .mapToObj(i -> new TestdataSpecEntity()) + .collect(Collectors.toList())); + SolverJob solverJob = solverManager.solve(1L, problem); + TestdataSpecSolution solution = solverJob.getFinalBestSolution(); + assertNotNull(solution); + assertTrue(solution.getScore().score() >= 0); + } + +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecConstraintProvider.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecConstraintProvider.java new file mode 100644 index 00000000000..ddfa2777f95 --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecConstraintProvider.java @@ -0,0 +1,24 @@ +package ai.timefold.solver.quarkus.testdomain.spec; + +import ai.timefold.solver.core.api.score.SimpleScore; +import ai.timefold.solver.core.api.score.stream.Constraint; +import ai.timefold.solver.core.api.score.stream.ConstraintFactory; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; +import ai.timefold.solver.core.api.score.stream.Joiners; + +import org.jspecify.annotations.NonNull; + +public class TestdataSpecConstraintProvider implements ConstraintProvider { + + @Override + public Constraint @NonNull [] defineConstraints(@NonNull ConstraintFactory factory) { + return new Constraint[] { + factory.forEach(TestdataSpecEntity.class) + .join(TestdataSpecEntity.class, Joiners.equal(TestdataSpecEntity::getValue)) + .filter((a, b) -> a != b) + .penalize(SimpleScore.ONE) + .asConstraint("Don't assign 2 entities the same value.") + }; + } + +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecEntity.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecEntity.java new file mode 100644 index 00000000000..2ceb3b8bff7 --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecEntity.java @@ -0,0 +1,15 @@ +package ai.timefold.solver.quarkus.testdomain.spec; + +public class TestdataSpecEntity { + + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecProducer.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecProducer.java new file mode 100644 index 00000000000..1620ad65c01 --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecProducer.java @@ -0,0 +1,35 @@ +package ai.timefold.solver.quarkus.testdomain.spec; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; +import ai.timefold.solver.core.api.score.SimpleScore; + +@ApplicationScoped +public class TestdataSpecProducer { + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Produces + @Singleton + PlanningSpecification planningSpec() { + return PlanningSpecification.of(TestdataSpecSolution.class) + .score(SimpleScore.class, TestdataSpecSolution::getScore, TestdataSpecSolution::setScore) + .problemFacts("valueList", TestdataSpecSolution::getValueList, + (solution, value) -> solution.setValueList((java.util.List) value)) + .entityCollection("entityList", TestdataSpecSolution::getEntityList, + (solution, value) -> solution.setEntityList( + (java.util.List) value)) + .valueRange("valueRange", TestdataSpecSolution::getValueList) + .entity(TestdataSpecEntity.class, e -> e + .variable("value", String.class, v -> v + .accessors(TestdataSpecEntity::getValue, TestdataSpecEntity::setValue) + .valueRange("valueRange"))) + .cloning(c -> c + .solutionFactory(TestdataSpecSolution::new) + .entityFactory(TestdataSpecEntity.class, TestdataSpecEntity::new)) + .build(); + } + +} diff --git a/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecSolution.java b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecSolution.java new file mode 100644 index 00000000000..708ddf45c00 --- /dev/null +++ b/quarkus-integration/quarkus/deployment/src/test/java/ai/timefold/solver/quarkus/testdomain/spec/TestdataSpecSolution.java @@ -0,0 +1,37 @@ +package ai.timefold.solver.quarkus.testdomain.spec; + +import java.util.List; + +import ai.timefold.solver.core.api.score.SimpleScore; + +public class TestdataSpecSolution { + + private List valueList; + private List entityList; + private SimpleScore score; + + public List getValueList() { + return valueList; + } + + public void setValueList(List valueList) { + this.valueList = valueList; + } + + public List getEntityList() { + return entityList; + } + + public void setEntityList(List entityList) { + this.entityList = entityList; + } + + public SimpleScore getScore() { + return score; + } + + public void setScore(SimpleScore score) { + this.score = score; + } + +} diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java index 6a5f10d14fd..9128ab2f75e 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/TimefoldRecorder.java @@ -1,8 +1,6 @@ package ai.timefold.solver.quarkus; -import java.util.HashMap; import java.util.HashSet; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -10,14 +8,14 @@ import jakarta.inject.Named; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; +import ai.timefold.solver.core.api.domain.specification.PlanningSpecification; import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.api.solver.SolverManager; import ai.timefold.solver.core.config.solver.SolverConfig; import ai.timefold.solver.core.config.solver.SolverManagerConfig; import ai.timefold.solver.core.config.solver.termination.DiminishedReturnsTerminationConfig; import ai.timefold.solver.core.config.solver.termination.TerminationConfig; -import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; import ai.timefold.solver.quarkus.config.DiminishedReturnsRuntimeConfig; import ai.timefold.solver.quarkus.config.SolverRuntimeConfig; import ai.timefold.solver.quarkus.config.TimefoldRuntimeConfig; @@ -62,54 +60,53 @@ public void assertNoUnmatchedRuntimeProperties(Set names) { assertNoUnmatchedProperties(names, timefoldRuntimeConfig.getValue().solver().keySet()); } - public Supplier solverConfigSupplier(final String solverName, - final SolverConfig solverConfig, - Map> generatedGizmoMemberAccessorMap, - Map>> generatedGizmoSolutionClonerMap) { + public Supplier solverConfigWithSpecSupplier(String solverName, SolverConfig solverConfig) { return () -> { updateSolverConfigWithRuntimeProperties(solverName, solverConfig); - Map memberAccessorMap = new HashMap<>(); - Map> solutionClonerMap = new HashMap<>(); - generatedGizmoMemberAccessorMap - .forEach((className, runtimeValue) -> memberAccessorMap.put(className, runtimeValue.getValue())); - generatedGizmoSolutionClonerMap - .forEach((className, runtimeValue) -> solutionClonerMap.put(className, runtimeValue.getValue())); - - solverConfig.setGizmoMemberAccessorMap(memberAccessorMap); - solverConfig.setGizmoSolutionClonerMap((Map) solutionClonerMap); + var spec = io.quarkus.arc.Arc.container().instance(PlanningSpecification.class).get(); + solverConfig.setPlanningSpecification(spec); return solverConfig; }; } - public Supplier solverManagerConfig(final SolverManagerConfig solverManagerConfig) { + public Supplier solverConfigSupplier(String solverName, SolverConfig solverConfig) { return () -> { - updateSolverManagerConfigWithRuntimeProperties(solverManagerConfig); - return solverManagerConfig; + updateSolverConfigWithRuntimeProperties(solverName, solverConfig); + solverConfig.setDomainAccessType(DomainAccessType.FORCE_REFLECTION); + return solverConfig; }; } - public Supplier> solverManager(final String solverName, - final SolverConfig solverConfig, - Map> generatedGizmoMemberAccessorMap, - Map>> generatedGizmoSolutionClonerMap) { + public Supplier> solverManager( + String solverName, SolverConfig solverConfig) { return () -> { updateSolverConfigWithRuntimeProperties(solverName, solverConfig); - Map memberAccessorMap = new HashMap<>(); - Map solutionClonerMap = new HashMap<>(); - generatedGizmoMemberAccessorMap - .forEach((className, runtimeValue) -> memberAccessorMap.put(className, runtimeValue.getValue())); - generatedGizmoSolutionClonerMap - .forEach((className, runtimeValue) -> solutionClonerMap.put(className, runtimeValue.getValue())); + solverConfig.setDomainAccessType(DomainAccessType.FORCE_REFLECTION); + var solverManagerConfig = new SolverManagerConfig(); + updateSolverManagerConfigWithRuntimeProperties(solverManagerConfig); + return (SolverManager) SolverManager.create( + SolverFactory.create(solverConfig), solverManagerConfig); + }; + } - solverConfig.setGizmoMemberAccessorMap(memberAccessorMap); - solverConfig.setGizmoSolutionClonerMap(solutionClonerMap); + public Supplier> solverManagerWithSpec( + String solverName, SolverConfig solverConfig) { + return () -> { + updateSolverConfigWithRuntimeProperties(solverName, solverConfig); + var spec = io.quarkus.arc.Arc.container().instance(PlanningSpecification.class).get(); + solverConfig.setPlanningSpecification(spec); var solverManagerConfig = new SolverManagerConfig(); updateSolverManagerConfigWithRuntimeProperties(solverManagerConfig); - var solverFactory = SolverFactory.create(solverConfig); + return (SolverManager) SolverManager.create(SolverFactory.create(solverConfig), solverManagerConfig); + }; + } - return (SolverManager) SolverManager.create(solverFactory, solverManagerConfig); + public Supplier solverManagerConfig(final SolverManagerConfig solverManagerConfig) { + return () -> { + updateSolverManagerConfigWithRuntimeProperties(solverManagerConfig); + return solverManagerConfig; }; } diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/bean/UnavailableTimefoldBeanProvider.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/bean/UnavailableTimefoldBeanProvider.java index d0a9b603f4b..ae9579340c6 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/bean/UnavailableTimefoldBeanProvider.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/bean/UnavailableTimefoldBeanProvider.java @@ -99,10 +99,11 @@ SolutionManager solutionManager_ private RuntimeException createException(Class beanClass) { return new IllegalStateException("The " + beanClass.getName() + " is not available as there are no @" + PlanningSolution.class.getSimpleName() + " or @" + PlanningEntity.class.getSimpleName() - + " annotated classes." + + " annotated classes, and no PlanningSpecification CDI bean was found." + + "\nEither annotate your domain classes, or provide a PlanningSpecification CDI bean via a @Produces method." + "\nIf your domain classes are located in a dependency of this project, maybe try generating" + " the Jandex index by using the jandex-maven-plugin in that dependency, or by adding" - + "application.properties entries (quarkus.index-dependency..group-id" + + " application.properties entries (quarkus.index-dependency..group-id" + " and quarkus.index-dependency..artifact-id)."); } } diff --git a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/devui/TimefoldDevUIRecorder.java b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/devui/TimefoldDevUIRecorder.java index 6d1c5585e3e..04b5856d85f 100644 --- a/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/devui/TimefoldDevUIRecorder.java +++ b/quarkus-integration/quarkus/runtime/src/main/java/ai/timefold/solver/quarkus/devui/TimefoldDevUIRecorder.java @@ -1,14 +1,12 @@ package ai.timefold.solver.quarkus.devui; import java.io.StringWriter; -import java.util.HashMap; import java.util.Map; import java.util.function.Supplier; -import ai.timefold.solver.core.api.domain.solution.cloner.SolutionCloner; import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.config.solver.SolverConfig; -import ai.timefold.solver.core.impl.domain.common.accessor.MemberAccessor; +import ai.timefold.solver.core.impl.domain.common.DomainAccessType; import ai.timefold.solver.core.impl.io.jaxb.SolverConfigIO; import ai.timefold.solver.quarkus.TimefoldRecorder; import ai.timefold.solver.quarkus.config.TimefoldRuntimeConfig; @@ -24,22 +22,12 @@ public TimefoldDevUIRecorder(final RuntimeValue timefoldR this.timefoldRuntimeConfig = timefoldRuntimeConfig; } - public Supplier solverConfigSupplier(Map allSolverConfig, - Map> generatedGizmoMemberAccessorMap, - Map>> generatedGizmoSolutionClonerMap) { + public Supplier solverConfigSupplier(Map allSolverConfig) { return () -> { DevUISolverConfig uiSolverConfig = new DevUISolverConfig(); allSolverConfig.forEach((solverName, solverConfig) -> { updateSolverConfigWithRuntimeProperties(solverName, solverConfig); - Map memberAccessorMap = new HashMap<>(); - Map solutionClonerMap = new HashMap<>(); - generatedGizmoMemberAccessorMap - .forEach((className, runtimeValue) -> memberAccessorMap.put(className, runtimeValue.getValue())); - generatedGizmoSolutionClonerMap - .forEach((className, runtimeValue) -> solutionClonerMap.put(className, runtimeValue.getValue())); - - solverConfig.setGizmoMemberAccessorMap(memberAccessorMap); - solverConfig.setGizmoSolutionClonerMap(solutionClonerMap); + solverConfig.setDomainAccessType(DomainAccessType.FORCE_REFLECTION); StringWriter effectiveSolverConfigWriter = new StringWriter(); SolverConfigIO solverConfigIO = new SolverConfigIO(); @@ -50,7 +38,6 @@ public Supplier solverConfigSupplier(Map e + .variable("value", TestdataValue.class, v -> v + .accessors(TestdataEntity::getValue, TestdataEntity::setValue) + .valueRange("valueRange"))) + .build(); + + var termination = new TerminationConfig().withSecondsSpentLimit(30L); + + // Annotation-based solver config + var annotationConfig = new SolverConfig() + .withSolutionClass(TestdataSolution.class) + .withEntityClasses(TestdataEntity.class) + .withConstraintProviderClass(TestdataConstraintProvider.class) + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .withTerminationConfig(termination); + + // Programmatic solver config + var programmaticConfig = new SolverConfig() + .withPlanningSpecification(spec) + .withConstraintProviderClass(TestdataConstraintProvider.class) + .withEnvironmentMode(EnvironmentMode.NO_ASSERT) + .withTerminationConfig(termination); + + // Benchmark config with both solver benchmarks + var benchmarkConfig = new PlannerBenchmarkConfig(); + benchmarkConfig.setBenchmarkDirectory(new File("local/benchmarkReport")); + benchmarkConfig.setWarmUpSecondsSpentLimit(10L); + + var sb1 = new SolverBenchmarkConfig(); + sb1.setName("Annotation Path"); + sb1.setSolverConfig(annotationConfig); + sb1.setSubSingleCount(3); + + var sb2 = new SolverBenchmarkConfig(); + sb2.setName("Programmatic API"); + sb2.setSolverConfig(programmaticConfig); + sb2.setSubSingleCount(3); + + benchmarkConfig.setSolverBenchmarkConfigList(List.of(sb1, sb2)); + + // Generate a large problem: 200 values, 800 entities + var solution = TestdataSolution.generateSolution(200, 800); + + System.out.println("Starting benchmark: 200 values, 800 entities"); + System.out.println("Warm-up: 10s, Solving: 30s per config"); + System.out.println("Report will be at: local/benchmarkReport/"); + + // Run benchmark + var factory = PlannerBenchmarkFactory.create(benchmarkConfig); + var benchmark = factory.buildPlannerBenchmark(solution); + benchmark.benchmark(); + + System.out.println("Benchmark complete. Open local/benchmarkReport/ for HTML report."); + } +}