diff --git a/.gitignore b/.gitignore index 10feb0dc73..108c596311 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ itests/src/main dependency_tree.txt .mvn/.develocity/develocity-workspace-id /.cursor/ +/.claude/ /.local-notes/ itests/snapshots_repository/ itests/archives/ diff --git a/api/pom.xml b/api/pom.xml index 2d7a0c44fc..b1933fbed9 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -66,7 +66,6 @@ org.slf4j slf4j-api - ${slf4j.version} provided @@ -104,7 +103,6 @@ org.slf4j slf4j-simple - ${slf4j.version} test diff --git a/api/src/main/java/org/apache/unomi/api/Parameter.java b/api/src/main/java/org/apache/unomi/api/Parameter.java index 24c8bb3492..10cd6083db 100644 --- a/api/src/main/java/org/apache/unomi/api/Parameter.java +++ b/api/src/main/java/org/apache/unomi/api/Parameter.java @@ -17,6 +17,7 @@ package org.apache.unomi.api; +import org.apache.unomi.api.conditions.ConditionValidation; import org.apache.unomi.api.utils.YamlUtils; import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; @@ -24,6 +25,8 @@ import java.util.Map; import java.util.Set; +import static org.apache.unomi.api.utils.YamlUtils.toYamlValue; + /** * A representation of a condition parameter, to be used in the segment building UI to either select parameters from a * choicelist or to enter a specific value. @@ -39,6 +42,7 @@ public class Parameter implements Serializable, YamlConvertible { private String type; private boolean multivalued; private Object defaultValue; + private ConditionValidation validation; public Parameter() { } @@ -90,6 +94,14 @@ public void setDefaultValue(Object defaultValue) { this.defaultValue = defaultValue; } + public ConditionValidation getValidation() { + return validation; + } + + public void setValidation(ConditionValidation validation) { + this.validation = validation; + } + /** * Converts this parameter to a Map structure for YAML output. * Implements YamlConvertible interface. @@ -112,6 +124,7 @@ public Map toYaml(Set visited, int maxDepth) { .putIfNotNull("type", type) .putIf("multivalued", true, multivalued) .putIfNotNull("defaultValue", defaultValue) + .putIfNotNull("validation", validation != null ? toYamlValue(validation, visited, maxDepth - 1) : null) .build(); } diff --git a/api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java b/api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java new file mode 100644 index 0000000000..37c7acf37a --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.api.conditions; + +import org.apache.unomi.api.utils.YamlUtils; +import org.apache.unomi.api.utils.YamlUtils.YamlConvertible; + +import java.io.Serializable; +import java.util.Map; +import java.util.Set; + +import static org.apache.unomi.api.utils.YamlUtils.setToSortedList; + +/** + * Validation metadata for condition parameters + */ +public class ConditionValidation implements Serializable, YamlConvertible { + private static final long serialVersionUID = 1L; + + public enum Type { + STRING, + INTEGER, + LONG, + FLOAT, + DOUBLE, + BOOLEAN, + DATE, + CONDITION, + OBJECT + } + + private boolean required; + private Set allowedValues; + private Set allowedConditionTags; + private Set disallowedConditionTypes; + private boolean exclusive; // Only one of the exclusive parameters in a group can have a value + private String exclusiveGroup; // Name of the exclusive group this parameter belongs to + private boolean recommended; // Parameter is recommended but not required + private transient Class customType; + + public ConditionValidation() { + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public Set getAllowedValues() { + return allowedValues; + } + + public void setAllowedValues(Set allowedValues) { + this.allowedValues = allowedValues; + } + + public Set getAllowedConditionTags() { + return allowedConditionTags; + } + + public void setAllowedConditionTags(Set allowedConditionTags) { + this.allowedConditionTags = allowedConditionTags; + } + + public Set getDisallowedConditionTypes() { + return disallowedConditionTypes; + } + + public void setDisallowedConditionTypes(Set disallowedConditionTypes) { + this.disallowedConditionTypes = disallowedConditionTypes; + } + + public boolean isExclusive() { + return exclusive; + } + + public void setExclusive(boolean exclusive) { + this.exclusive = exclusive; + } + + public String getExclusiveGroup() { + return exclusiveGroup; + } + + public void setExclusiveGroup(String exclusiveGroup) { + this.exclusiveGroup = exclusiveGroup; + } + + public boolean isRecommended() { + return recommended; + } + + public void setRecommended(boolean recommended) { + this.recommended = recommended; + } + + public Class getCustomType() { + return customType; + } + + public void setCustomType(Class customType) { + this.customType = customType; + } + + /** + * Converts this validation to a Map structure for YAML output. + * Implements YamlConvertible interface. + * + * @param visited set of already visited objects to prevent infinite recursion (may be null) + * @return a Map representation of this validation + */ + @Override + public Map toYaml(Set visited, int maxDepth) { + return YamlUtils.YamlMapBuilder.create() + .putIf("required", true, required) + .putIf("recommended", true, recommended) + .putIfNotNull("allowedValues", setToSortedList(allowedValues)) + .putIfNotNull("allowedConditionTags", setToSortedList(allowedConditionTags)) + .putIfNotNull("disallowedConditionTypes", setToSortedList(disallowedConditionTypes)) + .putIf("exclusive", true, exclusive) + .putIfNotNull("exclusiveGroup", exclusiveGroup) + .putIfNotNull("customType", customType != null ? customType.getName() : null) + .build(); + } + + @Override + public String toString() { + return YamlUtils.format(toYaml()); + } +} diff --git a/api/src/main/java/org/apache/unomi/api/exceptions/BadSegmentConditionException.java b/api/src/main/java/org/apache/unomi/api/exceptions/BadSegmentConditionException.java index bed713154f..854d9a7a73 100644 --- a/api/src/main/java/org/apache/unomi/api/exceptions/BadSegmentConditionException.java +++ b/api/src/main/java/org/apache/unomi/api/exceptions/BadSegmentConditionException.java @@ -17,8 +17,20 @@ package org.apache.unomi.api.exceptions; +/** + * Exception thrown when a segment condition is invalid or cannot be used. + */ public class BadSegmentConditionException extends RuntimeException { + public BadSegmentConditionException() { super(); } + + public BadSegmentConditionException(String message) { + super(message); + } + + public BadSegmentConditionException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java b/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java new file mode 100644 index 0000000000..b9d73ea925 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.api.services; + +import org.apache.unomi.api.conditions.Condition; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A service to validate conditions against their type definitions + */ +public interface ConditionValidationService { + + /** + * Validates a condition against its type definition. + * Skips validation for parameters that contain references (`parameter::`) or script expressions (`script::`). + * Only validates parameters that are NOT parameter references or script expressions. + * @param condition the condition to validate + * @return a list of validation errors, empty if the condition is valid (for non-reference/script values) + */ + List validate(Condition condition); + + /** + * Represents a validation error with detailed context + */ + class ValidationError { + private final String parameterName; + private final String message; + private final ValidationErrorType type; + private final String conditionId; + private final String conditionTypeId; + private final Map context; + private final ValidationError parentError; + + public ValidationError(String parameterName, String message, ValidationErrorType type) { + this(parameterName, message, type, null, null, null, null); + } + + public ValidationError(String parameterName, String message, ValidationErrorType type, + String conditionId, String conditionTypeId, Map context, + ValidationError parentError) { + this.parameterName = parameterName; + this.message = message; + this.type = type; + this.conditionId = conditionId; + this.conditionTypeId = conditionTypeId; + this.context = context != null ? new HashMap<>(context) : new HashMap<>(); + this.parentError = parentError; + } + + public String getParameterName() { + return parameterName; + } + + public String getMessage() { + return message; + } + + public ValidationErrorType getType() { + return type; + } + + public String getConditionId() { + return conditionId; + } + + public String getConditionTypeId() { + return conditionTypeId; + } + + /** @deprecated Use {@link #getConditionTypeId()} instead. */ + @Deprecated + public String getConditionTypeName() { + return conditionTypeId; + } + + public Map getContext() { + return new HashMap<>(context); + } + + public ValidationError getParentError() { + return parentError; + } + + /** + * Returns a detailed error message including all context information + * @return A detailed error message + */ + public String getDetailedMessage() { + StringBuilder sb = new StringBuilder(); + + // Build location context + if (conditionTypeId != null) { + sb.append("In condition type '").append(conditionTypeId).append("'"); + if (conditionId != null) { + sb.append(" (ID: ").append(conditionId).append(")"); + } + if (parameterName != null) { + sb.append(", parameter '").append(parameterName).append("'"); + } + sb.append(": "); + } else if (parameterName != null) { + sb.append("In parameter '").append(parameterName).append("': "); + } + + // Add main error message + sb.append(message); + + // Add context information if available + if (!context.isEmpty()) { + sb.append(" (Context: "); + boolean first = true; + for (Map.Entry entry : context.entrySet()) { + if (!first) { + sb.append(", "); + } + sb.append(entry.getKey()).append("=").append(entry.getValue()); + first = false; + } + sb.append(")"); + } + + // Add parent error if available + if (parentError != null) { + sb.append("\nCaused by: ").append(parentError.getDetailedMessage()); + } + + return sb.toString(); + } + + @Override + public String toString() { + return getDetailedMessage(); + } + } + + /** + * Types of validation errors + */ + enum ValidationErrorType { + MISSING_REQUIRED_PARAMETER("Required parameter is missing"), + INVALID_VALUE("Invalid value provided"), + INVALID_CONDITION_TYPE("Invalid or unsupported condition type"), + EXCLUSIVE_PARAMETER_VIOLATION("Mutually exclusive parameters conflict"), + MISSING_RECOMMENDED_PARAMETER("Recommended parameter is missing"); + + private final String description; + + ValidationErrorType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } +} diff --git a/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java b/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java index 13a4943da1..03da8090bb 100644 --- a/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java +++ b/api/src/main/java/org/apache/unomi/api/services/DefinitionsService.java @@ -232,26 +232,55 @@ public interface DefinitionsService { Condition extractConditionBySystemTag(Condition rootCondition, String systemTag); /** - * Resolves (if possible) the {@link ConditionType}s for the specified condition and its sub-conditions (if any) from the type identifiers existing on the specified condition + * @deprecated Use {@link #getTypeResolutionService()} for resolution only, or + * {@link #getConditionValidationService()} for resolution + validation. + * This method will be removed in a future version. * - * TODO: remove from API and move to a different class? + *

For resolution only (query operations): + *

{@code
+     * definitionsService.getTypeResolutionService()
+     *     .resolveConditionType(condition, "query");
+     * }
+ * + *

For resolution + validation (save operations): + *

{@code
+     * List errors = definitionsService.getConditionValidationService()
+     *     .validate(condition);
+     * // Handle errors...
+     * }
* * @param rootCondition the condition for which we want to resolve the condition types from the existing condition type identifiers - * @return {@code true} + * @return {@code true} if resolution succeeded */ + @Deprecated boolean resolveConditionType(Condition rootCondition); /** - * Forces a refresh of the definitions from the persistence service. Warning: this may seriously impact performance - * so it is recommended to use this in specific cases such as for example in integration tests. + * Refreshes the definitions service, reloading all types from persistence. */ void refresh(); - /** - * Retrieves a new instance of a ConditionBuilder to help to build conditions. + * Gets the condition builder instance. * - * @return a new instance of a ConditionBuilder + * @return the condition builder instance */ ConditionBuilder getConditionBuilder(); + + /** + * Gets the TypeResolutionService instance. + * This service handles type resolution and invalid object tracking. + * + * @return the TypeResolutionService instance + */ + TypeResolutionService getTypeResolutionService(); + + /** + * Gets the ConditionValidationService instance. + * This service validates conditions against their type definitions. + * The service automatically resolves condition types if needed before validation. + * + * @return the ConditionValidationService instance + */ + ConditionValidationService getConditionValidationService(); } diff --git a/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java new file mode 100644 index 0000000000..7307fb4bd3 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.api.services; + +import java.util.*; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Information about an invalid object, including detailed reasons for invalidation. + */ +public class InvalidObjectInfo { + private final String objectType; + private final String objectId; + private final String reason; + private final long firstSeenTimestamp; + private final AtomicLong lastSeenTimestamp; + private final AtomicInteger encounterCount; + private final Set missingConditionTypeIds; // CopyOnWriteArraySet — atomic add-if-absent, no TOCTOU + private final Set missingActionTypeIds; + private final Set contextNames; + + public InvalidObjectInfo(String objectType, String objectId, String reason) { + this(objectType, objectId, reason, null, null, null); + } + + public InvalidObjectInfo(String objectType, String objectId, String reason, + List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { + this.objectType = objectType; + this.objectId = objectId; + this.reason = reason; + this.firstSeenTimestamp = System.currentTimeMillis(); + this.lastSeenTimestamp = new AtomicLong(this.firstSeenTimestamp); + this.encounterCount = new AtomicInteger(1); + this.missingConditionTypeIds = missingConditionTypeIds != null + ? new CopyOnWriteArraySet<>(missingConditionTypeIds) : new CopyOnWriteArraySet<>(); + this.missingActionTypeIds = missingActionTypeIds != null + ? new CopyOnWriteArraySet<>(missingActionTypeIds) : new CopyOnWriteArraySet<>(); + this.contextNames = new CopyOnWriteArraySet<>(); + if (contextName != null) { + this.contextNames.add(contextName); + } + } + + public String getObjectType() { + return objectType; + } + + public String getObjectId() { + return objectId; + } + + public String getReason() { + return reason; + } + + public long getFirstSeenTimestamp() { + return firstSeenTimestamp; + } + + public long getLastSeenTimestamp() { + return lastSeenTimestamp.get(); + } + + public int getEncounterCount() { + return encounterCount.get(); + } + + public List getMissingConditionTypeIds() { + return Collections.unmodifiableList(new ArrayList<>(missingConditionTypeIds)); + } + + public List getMissingActionTypeIds() { + return Collections.unmodifiableList(new ArrayList<>(missingActionTypeIds)); + } + + public Set getContextNames() { + return Collections.unmodifiableSet(contextNames); + } + + /** + * Updates tracking info when the object is encountered again during type resolution. + * Thread-safe: backed by CopyOnWriteArrayList/CopyOnWriteArraySet, so reads via + * {@code getMissingConditionTypeIds()}, {@code getMissingActionTypeIds()}, and + * {@code getContextNames()} are safe during concurrent writes. + * + * @param missingConditionTypeIds additional missing condition type IDs found in this encounter + * @param missingActionTypeIds additional missing action type IDs found in this encounter + * @param contextName context where this encounter occurred + */ + public void updateEncounter(List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { + this.lastSeenTimestamp.set(System.currentTimeMillis()); + this.encounterCount.incrementAndGet(); + + if (missingConditionTypeIds != null) { + this.missingConditionTypeIds.addAll(missingConditionTypeIds); + } + + if (missingActionTypeIds != null) { + this.missingActionTypeIds.addAll(missingActionTypeIds); + } + + if (contextName != null) { + this.contextNames.add(contextName); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + InvalidObjectInfo that = (InvalidObjectInfo) o; + return Objects.equals(objectType, that.objectType) && Objects.equals(objectId, that.objectId); + } + + @Override + public int hashCode() { + return Objects.hash(objectType, objectId); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("InvalidObjectInfo{"); + sb.append("objectType='").append(objectType).append('\''); + sb.append(", objectId='").append(objectId).append('\''); + sb.append(", reason='").append(reason).append('\''); + sb.append(", firstSeen=").append(firstSeenTimestamp); + sb.append(", lastSeen=").append(lastSeenTimestamp.get()); + sb.append(", encounters=").append(encounterCount.get()); + if (!missingConditionTypeIds.isEmpty()) { + sb.append(", missingConditionTypes=").append(missingConditionTypeIds); + } + if (!missingActionTypeIds.isEmpty()) { + sb.append(", missingActionTypes=").append(missingActionTypeIds); + } + if (!contextNames.isEmpty()) { + sb.append(", contexts=").append(contextNames); + } + sb.append('}'); + return sb.toString(); + } +} + diff --git a/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java b/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java new file mode 100644 index 0000000000..1ca1f7113f --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.api.services; + +import org.apache.unomi.api.MetadataItem; +import org.apache.unomi.api.PropertyType; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.rules.Rule; + +import java.util.Map; +import java.util.Set; + +/** + * Service for resolving condition types, action types, and value types, with automatic + * tracking of invalid objects that have unresolved types. + * + * This service centralizes type resolution logic and automatically tracks objects + * that fail to resolve, providing detailed error information. + */ +public interface TypeResolutionService { + + /** + * Resolve a condition type and all its nested conditions. + * This is a low-level method that only performs resolution - it does NOT track invalid objects + * or handle missingPlugins. Use {@link #resolveCondition(String, MetadataItem, Condition, String)} + * for validation contexts where tracking is needed. + * + * @param rootCondition the condition to resolve + * @param contextObjectName name/ID of the object containing this condition (for error messages) + * @return true if resolution succeeded, false otherwise + */ + boolean resolveConditionType(Condition rootCondition, String contextObjectName); + + /** + * Resolve all action types in a rule. + * This is a low-level method that only performs resolution - it does NOT track invalid objects + * or handle missingPlugins. + * + * @param rule the rule containing actions to resolve + * @param ignoreErrors if true, don't log warnings for missing actions + * @return true if all actions resolved successfully, false otherwise + */ + boolean resolveActionTypes(Rule rule, boolean ignoreErrors); + + /** + * Resolve a single action type. + * This is a low-level method that only performs resolution - it does NOT track invalid objects + * or handle missingPlugins. + * + * @param action the action to resolve + * @return true if resolution succeeded, false otherwise + */ + boolean resolveActionType(Action action); + + /** + * Resolve a value type for a property type. + * + * @param propertyType the property type to resolve + */ + void resolveValueType(PropertyType propertyType); + + /** + * Resolve condition types for a MetadataItem with automatic tracking and missingPlugins handling. + * + *

This method resolves condition types (needed for validation) but skips parameter value resolution. + * Parameter resolution happens on-demand in query builders and evaluators. + * + *

This method performs three operations: + *

    + *
  • Resolves the condition type (always)
  • + *
  • Tracks invalid objects in the tracking service
  • + *
  • Sets/clears the missingPlugins flag on the item's metadata
  • + *
+ * + *

This is the recommended method for save operations (e.g., when saving rules, segments, goals). + * + * @param objectType the type of object (e.g., "rules", "segments", "goals", "campaigns", "scoring") + * @param item the MetadataItem object (e.g., Rule, Segment, Goal, Campaign, Scoring) + * @param condition the condition to resolve (may be null) + * @param contextName context name for error messages + * @return true if condition type resolved successfully (or was null), false otherwise + */ + boolean resolveCondition(String objectType, MetadataItem item, Condition condition, String contextName); + + /** + * Resolve action types for a rule with automatic tracking and missingPlugins handling. + * + *

This method resolves action types (needed for validation) but skips parameter value resolution. + * Parameter resolution happens on-demand in query builders and evaluators. + * + *

This method performs three operations: + *

    + *
  • Resolves the action types
  • + *
  • Tracks invalid objects in the tracking service
  • + *
  • Sets/clears the missingPlugins flag on the rule's metadata
  • + *
+ * + *

Note: For complete rule validation (condition + actions), use {@link #resolveRule(String, Rule)} instead. + * + * @param objectType the type of object (e.g., "rules") + * @param rule the rule containing actions to resolve + * @return true if all action types resolved successfully, false otherwise + */ + boolean resolveActions(String objectType, Rule rule); + + /** + * Resolve both condition and actions for a rule with automatic tracking and missingPlugins handling. + * + *

This method resolves condition and action types (needed for validation) but skips parameter value resolution. + * Parameter resolution happens on-demand in query builders and evaluators. + * + *

This is a convenience method that handles both condition and action resolution together, + * ensuring missingPlugins is set correctly based on both resolutions. + * + * @param objectType the type of object (e.g., "rules") + * @param rule the rule to resolve + * @return true if both condition and action types resolved successfully, false otherwise + */ + boolean resolveRule(String objectType, Rule rule); + + // Invalid object tracking methods + + /** + * Mark an object as invalid with a reason. + * + * @param objectType the type of object (e.g., "rules", "segments", "goals", "campaigns", "scoring") + * @param objectId the ID of the object + * @param reason the reason why the object is invalid (e.g., "Unresolved condition type: xyz", "Unresolved action type: abc") + */ + void markInvalid(String objectType, String objectId, String reason); + + /** + * Mark an object as valid (remove it from invalid tracking). + * + * @param objectType the type of object + * @param objectId the ID of the object + */ + void markValid(String objectType, String objectId); + + /** + * Check if an object is invalid. + * + * @param objectType the type of object + * @param objectId the ID of the object + * @return true if the object is invalid, false otherwise + */ + boolean isInvalid(String objectType, String objectId); + + /** + * Get the invalidation reason for an object. + * + * @param objectType the type of object + * @param objectId the ID of the object + * @return the reason why the object is invalid, or null if the object is valid + */ + String getInvalidationReason(String objectType, String objectId); + + /** + * Get all invalid objects grouped by object type, with their reasons. + * + * @return a map where keys are object type names (e.g., "rules", "segments", "goals", "campaigns", "scoring") + * and values are maps of object ID to InvalidObjectInfo + */ + Map> getAllInvalidObjects(); + + /** + * Get invalid objects for a specific object type, with their reasons. + * + * @param objectType the object type (e.g., "rules", "segments", "goals", "campaigns", "scoring") + * @return map of object ID to InvalidObjectInfo for the specified type, or empty map if type is unknown + */ + Map getInvalidObjects(String objectType); + + /** + * Get all invalid object IDs grouped by object type (for backward compatibility). + * + * @return a map where keys are object type names and values are sets of invalid object IDs + */ + Map> getAllInvalidObjectIds(); + + /** + * Get invalid object IDs for a specific object type (for backward compatibility). + * + * @param objectType the object type + * @return set of invalid object IDs for the specified type, or empty set if type is unknown + */ + Set getInvalidObjectIds(String objectType); + + /** + * Get the total count of all invalid objects across all types. + * + * @return total count of invalid objects + */ + int getTotalInvalidObjectCount(); +} + diff --git a/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java b/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java new file mode 100644 index 0000000000..ec6a3e2558 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.api.services; + +/** + * A service interface for validating values against specific types. + */ +public interface ValueTypeValidator { + + /** + * @return The type identifier this validator handles + */ + String getValueTypeId(); + + /** + * Validates if a value matches the expected type + * @param value The value to validate + * @return true if the value is valid for this type, false otherwise + */ + boolean validate(Object value); + + /** + * @return A human readable description of what this type expects, used in error messages + */ + String getValueTypeDescription(); +} diff --git a/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java b/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java index 12043fc3d5..0ab6f08d6b 100644 --- a/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java +++ b/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java @@ -28,11 +28,13 @@ import org.apache.unomi.api.conditions.ConditionType; import org.apache.unomi.api.rules.Rule; import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.TypeResolutionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.lang.reflect.InvocationTargetException; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; /** * Helper class to resolve condition, action and values types when loading definitions from JSON files @@ -41,13 +43,17 @@ public class ParserHelper { private static final Logger LOGGER = LoggerFactory.getLogger(ParserHelper.class); - private static final Set unresolvedActionTypes = new HashSet<>(); - private static final Set unresolvedConditionTypes = new HashSet<>(); + private static final Set unresolvedActionTypes = ConcurrentHashMap.newKeySet(); + private static final Set unresolvedConditionTypes = ConcurrentHashMap.newKeySet(); + // Track rules that have already been warned about null/empty actions to avoid log spam + private static final Set warnedRulesWithNullActions = ConcurrentHashMap.newKeySet(); private static final String VALUE_NAME_SEPARATOR = "::"; private static final String PLACEHOLDER_PREFIX = "${"; private static final String PLACEHOLDER_SUFFIX = "}"; + private static final int MAX_RECURSION_DEPTH = 1000; + public interface ConditionVisitor { void visit(Condition condition); void postVisit(Condition condition); @@ -68,34 +74,88 @@ public interface ValueExtractor { } public static boolean resolveConditionType(final DefinitionsService definitionsService, Condition rootCondition, String contextObjectName) { + return resolveConditionType(definitionsService, rootCondition, contextObjectName, + new HashSet<>(), false, 0); + } + + private static boolean resolveConditionType(final DefinitionsService definitionsService, Condition rootCondition, + String contextObjectName, Set parentChainPath, boolean isGoingUp, int depth) { if (rootCondition == null) { LOGGER.warn("Couldn't resolve null condition for {}", contextObjectName); return false; } - final List result = new ArrayList(); - visitConditions(rootCondition, new ConditionVisitor() { - @Override - public void visit(Condition condition) { - if (condition.getConditionType() == null) { - ConditionType conditionType = definitionsService.getConditionType(condition.getConditionTypeId()); - if (conditionType != null) { - unresolvedConditionTypes.remove(condition.getConditionTypeId()); - condition.setConditionType(conditionType); - } else { - result.add(condition.getConditionTypeId()); - if (!unresolvedConditionTypes.contains(condition.getConditionTypeId())) { - unresolvedConditionTypes.add(condition.getConditionTypeId()); - LOGGER.warn("Couldn't resolve condition type: {} for {}", condition.getConditionTypeId(), contextObjectName); + + if (depth > MAX_RECURSION_DEPTH) { + LOGGER.error("Maximum recursion depth ({}) exceeded when resolving condition type {} in {}", + MAX_RECURSION_DEPTH, rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + + if (isGoingUp) { + if (!parentChainPath.add(rootCondition.getConditionTypeId())) { + LOGGER.warn("Detected circular reference for condition type {} in {}", rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + } + + try { + // Resolve current condition type if needed + if (rootCondition.getConditionType() == null) { + String conditionTypeId = rootCondition.getConditionTypeId(); + if (conditionTypeId == null) { + LOGGER.warn("Condition has no type ID for {}", contextObjectName); + return false; + } + ConditionType conditionType = definitionsService.getConditionType(conditionTypeId); + if (conditionType == null) { + if (!unresolvedConditionTypes.contains(conditionTypeId)) { + unresolvedConditionTypes.add(conditionTypeId); + LOGGER.warn("Couldn't resolve condition type: {} for {}", conditionTypeId, contextObjectName); + } + return false; + } + unresolvedConditionTypes.remove(rootCondition.getConditionTypeId()); + rootCondition.setConditionType(conditionType); + + if (conditionType.getParentCondition() != null) { + Set pathForParent = new HashSet<>(parentChainPath); + if (!isGoingUp) { + pathForParent.add(rootCondition.getConditionTypeId()); + } + if (!resolveConditionType(definitionsService, conditionType.getParentCondition(), contextObjectName, + pathForParent, true, depth + 1)) { + rootCondition.setConditionType(null); + LOGGER.warn("Failed to resolve parent condition for type: {} in {}", + rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + } + } + + for (Object value : rootCondition.getParameterValues().values()) { + if (value instanceof Condition) { + if (!resolveConditionType(definitionsService, (Condition) value, contextObjectName, + parentChainPath, false, depth + 1)) { + return false; + } + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item instanceof Condition) { + if (!resolveConditionType(definitionsService, (Condition) item, contextObjectName, + parentChainPath, false, depth + 1)) { + return false; + } } } } } - @Override - public void postVisit(Condition condition) { + return true; + } finally { + if (isGoingUp) { + parentChainPath.remove(rootCondition.getConditionTypeId()); } - }); - return result.isEmpty(); + } } public static List getConditionTypeIds(Condition rootCondition) { @@ -136,20 +196,33 @@ public static void visitConditions(Condition rootCondition, ConditionVisitor vis public static boolean resolveActionTypes(DefinitionsService definitionsService, Rule rule, boolean ignoreErrors) { boolean result = true; + String ruleId = rule.getItemId(); if (rule.getActions() == null) { if (!ignoreErrors) { - LOGGER.warn("Rule {}:{} has null actions", rule.getItemId(), rule.getMetadata().getName()); + // Only warn once per rule to avoid log spam + if (warnedRulesWithNullActions.add(ruleId)) { + LOGGER.warn("Rule {}:{} has null actions", ruleId, rule.getMetadata().getName()); + } } return false; } if (rule.getActions().isEmpty()) { if (!ignoreErrors) { - LOGGER.warn("Rule {}:{} has empty actions", rule.getItemId(), rule.getMetadata().getName()); + // Only warn once per rule to avoid log spam + if (warnedRulesWithNullActions.add(ruleId)) { + LOGGER.warn("Rule {}:{} has empty actions", ruleId, rule.getMetadata().getName()); + } } return false; } + TypeResolutionService typeResolutionService = definitionsService != null ? definitionsService.getTypeResolutionService() : null; for (Action action : rule.getActions()) { - result &= ParserHelper.resolveActionType(definitionsService, action); + if (typeResolutionService != null) { + result &= typeResolutionService.resolveActionType(action); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + result &= ParserHelper.resolveActionType(definitionsService, action); + } } return result; } @@ -175,6 +248,15 @@ public static boolean resolveActionType(DefinitionsService definitionsService, A } public static void resolveValueType(DefinitionsService definitionsService, PropertyType propertyType) { + if (definitionsService == null) { + return; + } + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveValueType(propertyType); + return; + } + // Fallback to direct resolution if TypeResolutionService is not available if (propertyType.getValueType() == null) { ValueType valueType = definitionsService.getValueType(propertyType.getValueTypeId()); if (valueType != null) { @@ -184,22 +266,55 @@ public static void resolveValueType(DefinitionsService definitionsService, Prope } + /** + * @deprecated Use {@link #resolveConditionEventTypes(Condition, DefinitionsService)} instead. + */ + @Deprecated public static Set resolveConditionEventTypes(Condition rootCondition) { + return resolveConditionEventTypes(rootCondition, null); + } + + public static Set resolveConditionEventTypes(Condition rootCondition, DefinitionsService definitionsService) { if (rootCondition == null) { return new HashSet<>(); } - EventTypeConditionVisitor eventTypeConditionVisitor = new EventTypeConditionVisitor(); + EventTypeConditionVisitor eventTypeConditionVisitor = new EventTypeConditionVisitor(definitionsService); visitConditions(rootCondition, eventTypeConditionVisitor); return eventTypeConditionVisitor.getEventTypeIds(); } public static class EventTypeConditionVisitor implements ConditionVisitor { + private final DefinitionsService definitionsService; private Set eventTypeIds = new HashSet<>(); private Stack conditionTypeStack = new Stack<>(); + public EventTypeConditionVisitor(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + public void visit(Condition condition) { conditionTypeStack.push(condition.getConditionTypeId()); + + // Ensure condition type is resolved before checking parent conditions + if (definitionsService != null && condition.getConditionType() == null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "eventTypeResolution"); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + String conditionTypeId = condition.getConditionTypeId(); + if (conditionTypeId != null) { + ConditionType conditionType = definitionsService.getConditionType(conditionTypeId); + if (conditionType != null) { + condition.setConditionType(conditionType); + } else { + LOGGER.warn("Condition type {} could not be resolved!", conditionTypeId); + } + } + } + } + if ("eventTypeCondition".equals(condition.getConditionTypeId())) { String eventTypeId = (String) condition.getParameter("eventTypeId"); if (eventTypeId == null) { @@ -214,7 +329,18 @@ public void visit(Condition condition) { } } } else if (condition.getConditionType() != null && condition.getConditionType().getParentCondition() != null) { - visitConditions(condition.getConditionType().getParentCondition(), this); + // Resolve parent condition type if needed before traversing + Condition parentCondition = condition.getConditionType().getParentCondition(); + if (definitionsService != null && parentCondition.getConditionType() == null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(parentCondition, "eventTypeResolution"); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + resolveConditionType(definitionsService, parentCondition, "eventTypeResolution"); + } + } + visitConditions(parentCondition, this); } } @@ -272,7 +398,12 @@ public static Object extractValue(String s, Event event, Map values, Map getParentChain( + ConditionType conditionType, + DefinitionsService definitionsService, + String contextName, + int maxDepth) { + + if (conditionType == null || definitionsService == null) { + return new ArrayList<>(); + } + + List chain = new ArrayList<>(); + Set visited = new HashSet<>(); + + ConditionType current = conditionType; + int depth = 0; + + while (current != null && current.getParentCondition() != null && depth < maxDepth) { + Condition parentCondition = current.getParentCondition(); + + // Resolve parent condition type if needed + if (parentCondition.getConditionType() == null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(parentCondition, contextName); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + resolveConditionType(definitionsService, parentCondition, contextName); + } + } + + ConditionType parentType = parentCondition.getConditionType(); + if (parentType == null) { + LOGGER.warn("Parent condition type could not be resolved for {} in {}", + current.getItemId(), contextName); + break; + } + + String parentId = parentType.getItemId(); + + // Check for circular reference + if (visited.contains(parentId)) { + LOGGER.warn("Circular reference detected in parent chain for {} in {}: {}", + conditionType.getItemId(), contextName, visited); + return null; + } + + visited.add(parentId); + chain.add(parentCondition); + + current = parentType; + depth++; + } + + if (depth >= maxDepth) { + LOGGER.warn("Maximum depth ({}) exceeded when traversing parent chain for {} in {}", + maxDepth, conditionType.getItemId(), contextName); + return null; + } + + return chain; + } + + /** + * Deep copies a parameter value, handling Condition objects and collections containing Conditions. + * This is a helper method to avoid code duplication when merging parameters. + * + * @param value the parameter value to deep copy + * @return a deep copy of the value, or the original value if it's not a Condition or collection + */ + private static Object deepCopyParameterValue(Object value) { + if (value instanceof Condition) { + return ((Condition) value).deepCopy(); + } else if (value instanceof Collection) { + Collection collection = (Collection) value; + Collection copiedCollection = new ArrayList<>(); + for (Object item : collection) { + if (item instanceof Condition) { + copiedCollection.add(((Condition) item).deepCopy()); + } else { + copiedCollection.add(item); + } + } + return copiedCollection; + } else { + return value; + } + } + + /** + * Resolves the effective condition to use, following the parent chain. + * + * This method traverses the parent condition chain and returns the condition + * at the end of the chain (the root parent). It merges parameters from all + * levels in the chain into the context and the effective condition. + * + * @param condition the condition to resolve + * @param definitionsService service to resolve condition types + * @param context context map for parameter merging (will be modified) + * @param contextName name for error messages + * @return the effective condition (may be from parent chain), or the original condition if no parent chain + */ + public static Condition resolveEffectiveCondition( + Condition condition, + DefinitionsService definitionsService, + Map context, + String contextName) { + return resolveEffectiveCondition(condition, definitionsService, context, + contextName, MAX_RECURSION_DEPTH); + } + + /** + * Resolves the effective condition to use, following the parent chain. + * + * @param condition the condition to resolve + * @param definitionsService service to resolve condition types + * @param context context map for parameter merging (will be modified) + * @param contextName name for error messages + * @param maxDepth maximum depth to traverse (prevents infinite loops) + * @return the effective condition (may be from parent chain), or the original condition if no parent chain + */ + public static Condition resolveEffectiveCondition( + Condition condition, + DefinitionsService definitionsService, + Map context, + String contextName, + int maxDepth) { + + if (condition == null || definitionsService == null) { + return condition; + } + + // Ensure condition type is resolved (this also resolves parent conditions) + // definitionsService is guaranteed non-null here (checked at line 575) + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (condition.getConditionType() == null) { + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, contextName); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + resolveConditionType(definitionsService, condition, contextName); + } + } else { + // Even if condition type is already resolved, ensure parent condition is also resolved + ConditionType type = condition.getConditionType(); + if (type != null && type.getParentCondition() != null && type.getParentCondition().getConditionType() == null) { + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(type.getParentCondition(), contextName); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + resolveConditionType(definitionsService, type.getParentCondition(), contextName); + } + } + } + + ConditionType type = condition.getConditionType(); + if (type == null) { + return condition; + } + if (type.getParentCondition() == null) { + return condition; + } + + List parentChain = getParentChain(type, definitionsService, + contextName, maxDepth); + if (parentChain == null) { + LOGGER.warn("Failed to build parent chain for condition type '{}' in '{}' (cycle or max depth exceeded), cannot resolve effective condition", + type.getItemId(), contextName); + return null; + } + if (parentChain.isEmpty()) { + return condition; + } + + // Use the last parent in the chain (root parent) + Condition rootParent = parentChain.get(parentChain.size() - 1); + + // Create new condition from root parent with deep copy + Condition effectiveCondition = rootParent.deepCopy(); + + // Merge all parameters from chain into context + // Start with condition's parameters + if (context != null) { + context.putAll(condition.getParameterValues()); + } + + // Merge parameters from all parents in the chain (skip rootParent as it's already copied) + for (Condition parent : parentChain) { + if (parent == rootParent) { + continue; // Already copied above + } + if (context != null) { + context.putAll(parent.getParameterValues()); + } + // Merge into effective condition (only add parameters not already present, deep copying if nested condition) + for (Map.Entry entry : parent.getParameterValues().entrySet()) { + if (!effectiveCondition.getParameterValues().containsKey(entry.getKey())) { + effectiveCondition.getParameterValues().put(entry.getKey(), deepCopyParameterValue(entry.getValue())); + } + } + } + + // Merge condition parameters into effective condition (highest priority) + // Deep copy nested conditions from condition as well + for (Map.Entry entry : condition.getParameterValues().entrySet()) { + effectiveCondition.getParameterValues().put(entry.getKey(), deepCopyParameterValue(entry.getValue())); + } + + // Resolve the effective condition's type to ensure nested conditions are resolved + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(effectiveCondition, contextName + " (effective condition)"); + } else { + // Fallback to direct resolution if TypeResolutionService is not available + resolveConditionType(definitionsService, effectiveCondition, contextName + " (effective condition)"); + } + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Resolved effective condition: original={}, effective={}, chainDepth={}", + condition.getConditionTypeId(), + effectiveCondition.getConditionTypeId(), + parentChain.size()); + } + + return effectiveCondition; + } + } diff --git a/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java b/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java new file mode 100644 index 0000000000..f5e3440d09 --- /dev/null +++ b/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java @@ -0,0 +1,1199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.api.utils; + +import org.apache.unomi.api.*; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.actions.ActionType; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.services.DefinitionsService; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class ParserHelperTest { + + @Mock + private DefinitionsService definitionsService; + + private Event event; + private Profile profile; + private Session session; + + @Before + public void setUp() { + // Create real objects instead of mocks + profile = new Profile(); + profile.setItemId("testProfile"); + Map properties = new HashMap<>(); + properties.put("property1", "profile value"); + properties.put("testProperty", "test value"); + profile.setProperties(properties); + + session = new Session(); + session.setItemId("testSession"); + Map sessionProperties = new HashMap<>(); + sessionProperties.put("property2", "session value"); + session.setProperties(sessionProperties); + + event = new Event(); + event.setItemId("testEvent"); + event.setProfile(profile); + event.setSession(session); + Map eventProperties = new HashMap<>(); + eventProperties.put("testEventProperty", "event value"); + event.setProperties(eventProperties); + } + + @Test + public void testResolveConditionType() { + // Create test condition + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + + // Create test condition type + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("testConditionType"); + + // Mock definitions service + when(definitionsService.getConditionType("testConditionType")).thenReturn(conditionType); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertTrue("Condition type should be resolved", result); + assertEquals("Condition type should be set", conditionType, condition.getConditionType()); + } + + @Test + public void testResolveConditionTypeWithParent() { + // Create test condition + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + + // Create parent condition + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + + // Create condition types + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + childType.setParentCondition(parentCondition); + + ConditionType parentType = new ConditionType(); + parentType.setItemId("parentConditionType"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertTrue("Condition type should be resolved", result); + assertEquals("Child condition type should be set", childType, condition.getConditionType()); + assertEquals("Parent condition type should be set", parentType, + condition.getConditionType().getParentCondition().getConditionType()); + } + + @Test + public void testResolveActionType() { + // Create test action + Action action = new Action(); + action.setActionTypeId("testActionType"); + + // Create test action type + ActionType actionType = new ActionType(); + actionType.setItemId("testActionType"); + + // Mock definitions service + when(definitionsService.getActionType("testActionType")).thenReturn(actionType); + + // Test resolution + boolean result = ParserHelper.resolveActionType(definitionsService, action); + assertTrue("Action type should be resolved", result); + assertEquals("Action type should be set", actionType, action.getActionType()); + } + + @Test + public void testResolveActionTypes() { + // Create test rule with actions + Rule rule = new Rule(); + Action action1 = new Action(); + action1.setActionTypeId("action1"); + Action action2 = new Action(); + action2.setActionTypeId("action2"); + rule.setActions(Arrays.asList(action1, action2)); + + // Create action types + ActionType actionType1 = new ActionType(); + actionType1.setItemId("action1"); + ActionType actionType2 = new ActionType(); + actionType2.setItemId("action2"); + + // Mock definitions service + when(definitionsService.getActionType("action1")).thenReturn(actionType1); + when(definitionsService.getActionType("action2")).thenReturn(actionType2); + + // Test resolution + boolean result = ParserHelper.resolveActionTypes(definitionsService, rule, false); + assertTrue("Action types should be resolved", result); + assertEquals("Action type 1 should be set", actionType1, action1.getActionType()); + assertEquals("Action type 2 should be set", actionType2, action2.getActionType()); + } + + @Test + public void testResolveValueType() { + // Create test property type + PropertyType propertyType = new PropertyType(); + propertyType.setValueTypeId("testValueType"); + + // Create test value type + ValueType valueType = new ValueType(); + valueType.setId("testValueType"); + + // Mock definitions service + when(definitionsService.getValueType("testValueType")).thenReturn(valueType); + + // Test resolution + ParserHelper.resolveValueType(definitionsService, propertyType); + assertEquals("Value type should be set", valueType, propertyType.getValueType()); + } + + @Test + public void testParseMapWithPlaceholders() { + // Set up test data + Map inputMap = new HashMap<>(); + inputMap.put("key1", "${profileProperty::property1}"); + inputMap.put("key2", "static value"); + inputMap.put("key3", "${sessionProperty::property2}"); + + // Test parsing + Map result = ParserHelper.parseMap(event, inputMap, ParserHelper.DEFAULT_VALUE_EXTRACTORS); + + assertEquals("Profile property should be resolved", "profile value", result.get("key1")); + assertEquals("Static value should remain unchanged", "static value", result.get("key2")); + assertEquals("Session property should be resolved", "session value", result.get("key3")); + } + + @Test + public void testExtractValue() throws Exception { + // Test value extraction + Object result = ParserHelper.extractValue("simpleProfileProperty::testProperty", + event, ParserHelper.DEFAULT_VALUE_EXTRACTORS); + + assertEquals("Property value should be extracted", "test value", result); + } + + @Test + public void testExtractNestedValue() throws Exception { + // Test nested property extraction + Object result = ParserHelper.extractValue("profileProperty::property1", + event, ParserHelper.DEFAULT_VALUE_EXTRACTORS); + + assertEquals("Nested property value should be extracted", "profile value", result); + } + + @Test + public void testExtractSessionValue() throws Exception { + // Test session property extraction + Object result = ParserHelper.extractValue("sessionProperty::property2", + event, ParserHelper.DEFAULT_VALUE_EXTRACTORS); + + assertEquals("Session property value should be extracted", "session value", result); + } + + @Test + public void testExtractEventValue() throws Exception { + // Test event property extraction + Object result = ParserHelper.extractValue("eventProperty::properties.testEventProperty", + event, ParserHelper.DEFAULT_VALUE_EXTRACTORS); + + assertEquals("Event property value should be extracted", "event value", result); + } + + @Test + public void testHasContextualParameter() { + // Set up test data + Map values = new HashMap<>(); + values.put("key1", "profileProperty::property1"); + values.put("key2", "static value"); + values.put("nested", Collections.singletonMap("key3", "sessionProperty::property2")); + + // Test contextual parameter detection + assertTrue("Should detect contextual parameter in root", + ParserHelper.hasContextualParameter(values, ParserHelper.DEFAULT_VALUE_EXTRACTORS)); + assertTrue("Should detect contextual parameter in nested map", + ParserHelper.hasContextualParameter(values, ParserHelper.DEFAULT_VALUE_EXTRACTORS)); + } + + @Test + public void testVisitConditions() { + // Create test conditions + Condition rootCondition = new Condition(); + rootCondition.setConditionTypeId("root"); + + Condition childCondition = new Condition(); + childCondition.setConditionTypeId("child"); + + rootCondition.setParameter("subCondition", childCondition); + rootCondition.setParameter("subConditions", Collections.singletonList(childCondition)); + + // Create visitor to track visits + final List visitedTypes = new ArrayList<>(); + ParserHelper.ConditionVisitor visitor = new ParserHelper.ConditionVisitor() { + @Override + public void visit(Condition condition) { + visitedTypes.add(condition.getConditionTypeId()); + } + + @Override + public void postVisit(Condition condition) { + // Not testing post-visit in this test + } + }; + + // Test visiting + ParserHelper.visitConditions(rootCondition, visitor); + + assertEquals("Should visit all conditions", Arrays.asList("root", "child", "child"), visitedTypes); + } + + @Test + public void testResolveConditionEventTypes() { + // Create test condition structure + Condition rootCondition = new Condition(); + rootCondition.setConditionTypeId("eventTypeCondition"); + rootCondition.setParameter("eventTypeId", "testEvent"); + + // Mock definitionsService to return null for condition types (not needed for this simple parameter extraction test) + when(definitionsService.getConditionType(anyString())).thenReturn(null); + + // Test event type resolution + Set eventTypes = ParserHelper.resolveConditionEventTypes(rootCondition, definitionsService); + + assertTrue("Should contain event type", eventTypes.contains("testEvent")); + assertEquals("Should only contain one event type", 1, eventTypes.size()); + } + + @Test + public void testResolveConditionEventTypesWithNegation() { + // Create test condition structure with negation + Condition notCondition = new Condition(); + notCondition.setConditionTypeId("notCondition"); + + Condition eventCondition = new Condition(); + eventCondition.setConditionTypeId("eventTypeCondition"); + eventCondition.setParameter("eventTypeId", "testEvent"); + + notCondition.setParameter("subCondition", eventCondition); + + // Mock definitionsService to return null for condition types (not needed for this simple parameter extraction test) + when(definitionsService.getConditionType(anyString())).thenReturn(null); + + // Test event type resolution + Set eventTypes = ParserHelper.resolveConditionEventTypes(notCondition, definitionsService); + + assertTrue("Should use wildcard for negated event condition", eventTypes.contains("*")); + assertEquals("Should only contain wildcard", 1, eventTypes.size()); + } + + @Test + public void testResolveConditionTypeWithEventTypeCondition() { + // Create test condition based on eventTypeCondition.json + Condition condition = new Condition(); + condition.setConditionTypeId("eventTypeCondition"); + condition.setParameter("eventTypeId", "testEvent"); + + // Create parent condition type (eventPropertyCondition) + ConditionType parentType = new ConditionType(); + parentType.setItemId("eventPropertyCondition"); + + // Create child condition type (eventTypeCondition) + ConditionType childType = new ConditionType(); + childType.setItemId("eventTypeCondition"); + + // Create parent condition as defined in eventTypeCondition.json + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("eventPropertyCondition"); + Map parameterValues = new HashMap<>(); + parameterValues.put("propertyName", "eventType"); + parameterValues.put("propertyValue", "parameter::eventTypeId"); + parameterValues.put("comparisonOperator", "equals"); + parentCondition.setParameterValues(parameterValues); + + // Set parent condition + childType.setParentCondition(parentCondition); + + // Mock definitions service + when(definitionsService.getConditionType("eventTypeCondition")).thenReturn(childType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(parentType); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertTrue("Condition type should be resolved", result); + assertEquals("Child condition type should be set", childType, condition.getConditionType()); + + // Verify parent condition is properly resolved + Condition resolvedParentCondition = condition.getConditionType().getParentCondition(); + assertNotNull("Parent condition should be set", resolvedParentCondition); + assertEquals("Parent condition type should be set", parentType, resolvedParentCondition.getConditionType()); + assertEquals("Parent condition propertyName should match", "eventType", resolvedParentCondition.getParameter("propertyName")); + assertEquals("Parent condition propertyValue should match", "parameter::eventTypeId", resolvedParentCondition.getParameter("propertyValue")); + assertEquals("Parent condition comparisonOperator should match", "equals", resolvedParentCondition.getParameter("comparisonOperator")); + } + + @Test + public void testResolveConditionTypeWithMultiLevelParents() { + // Create test condition + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + + // Create parent conditions chain: child -> parent1 -> parent2 -> parent3 + Condition parent1Condition = new Condition(); + parent1Condition.setConditionTypeId("parent1Type"); + Map parent1Params = new HashMap<>(); + parent1Params.put("parent1Param", "parent1Value"); + parent1Condition.setParameterValues(parent1Params); + + Condition parent2Condition = new Condition(); + parent2Condition.setConditionTypeId("parent2Type"); + Map parent2Params = new HashMap<>(); + parent2Params.put("parent2Param", "parent2Value"); + parent2Condition.setParameterValues(parent2Params); + + Condition parent3Condition = new Condition(); + parent3Condition.setConditionTypeId("parent3Type"); + Map parent3Params = new HashMap<>(); + parent3Params.put("parent3Param", "parent3Value"); + parent3Condition.setParameterValues(parent3Params); + + // Create condition types and link them + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + childType.setParentCondition(parent1Condition); + + ConditionType parent1Type = new ConditionType(); + parent1Type.setItemId("parent1Type"); + parent1Type.setParentCondition(parent2Condition); + + ConditionType parent2Type = new ConditionType(); + parent2Type.setItemId("parent2Type"); + parent2Type.setParentCondition(parent3Condition); + + ConditionType parent3Type = new ConditionType(); + parent3Type.setItemId("parent3Type"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("parent1Type")).thenReturn(parent1Type); + when(definitionsService.getConditionType("parent2Type")).thenReturn(parent2Type); + when(definitionsService.getConditionType("parent3Type")).thenReturn(parent3Type); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertTrue("Condition type should be resolved", result); + assertEquals("Child condition type should be set", childType, condition.getConditionType()); + + // Verify parent conditions are properly resolved + Condition resolvedParent1 = condition.getConditionType().getParentCondition(); + assertNotNull("Parent1 condition should be set", resolvedParent1); + assertEquals("Parent1 condition type should be set", parent1Type, resolvedParent1.getConditionType()); + assertEquals("Parent1 param should match", "parent1Value", resolvedParent1.getParameter("parent1Param")); + + Condition resolvedParent2 = resolvedParent1.getConditionType().getParentCondition(); + assertNotNull("Parent2 condition should be set", resolvedParent2); + assertEquals("Parent2 condition type should be set", parent2Type, resolvedParent2.getConditionType()); + assertEquals("Parent2 param should match", "parent2Value", resolvedParent2.getParameter("parent2Param")); + + Condition resolvedParent3 = resolvedParent2.getConditionType().getParentCondition(); + assertNotNull("Parent3 condition should be set", resolvedParent3); + assertEquals("Parent3 condition type should be set", parent3Type, resolvedParent3.getConditionType()); + assertEquals("Parent3 param should match", "parent3Value", resolvedParent3.getParameter("parent3Param")); + } + + @Test + public void testResolveConditionTypeWithCircularParentReference() { + // Create test condition + Condition condition = new Condition(); + condition.setConditionTypeId("conditionA"); + + // Create circular parent conditions: A -> B -> C -> A + Condition conditionB = new Condition(); + conditionB.setConditionTypeId("conditionB"); + Map paramsB = new HashMap<>(); + paramsB.put("paramB", "valueB"); + conditionB.setParameterValues(paramsB); + + Condition conditionC = new Condition(); + conditionC.setConditionTypeId("conditionC"); + Map paramsC = new HashMap<>(); + paramsC.put("paramC", "valueC"); + conditionC.setParameterValues(paramsC); + + Condition circularConditionA = new Condition(); + circularConditionA.setConditionTypeId("conditionA"); + Map paramsA = new HashMap<>(); + paramsA.put("paramA", "valueA"); + circularConditionA.setParameterValues(paramsA); + + // Create condition types with circular references + ConditionType typeA = new ConditionType(); + typeA.setItemId("conditionA"); + typeA.setParentCondition(conditionB); + + ConditionType typeB = new ConditionType(); + typeB.setItemId("conditionB"); + typeB.setParentCondition(conditionC); + + ConditionType typeC = new ConditionType(); + typeC.setItemId("conditionC"); + typeC.setParentCondition(circularConditionA); // Creates circular reference back to A + + // Mock definitions service with proper verification + when(definitionsService.getConditionType("conditionA")).thenReturn(typeA); + when(definitionsService.getConditionType("conditionB")).thenReturn(typeB); + when(definitionsService.getConditionType("conditionC")).thenReturn(typeC); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertFalse("Condition type resolution should fail due to circular reference", result); + + // Verify that the condition type is not set to prevent infinite loops + assertNull("Condition type should not be set for circular reference", condition.getConditionType()); + + // Verify that the definitions service was called for each condition type + // We expect multiple calls for conditionA due to the circular reference + verify(definitionsService, atLeast(1)).getConditionType("conditionA"); + verify(definitionsService, times(1)).getConditionType("conditionB"); + verify(definitionsService, times(1)).getConditionType("conditionC"); + } + + @Test + public void testResolveConditionTypeWithParentSubConditions() { + // Create test condition + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + + // Create parent condition with subConditions + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + + // Create subConditions for parent + Condition subCondition1 = new Condition(); + subCondition1.setConditionTypeId("subCondition1Type"); + + Condition subCondition2 = new Condition(); + subCondition2.setConditionTypeId("subCondition2Type"); + + // Set up parent condition's parameters including subConditions + parentCondition.setParameter("operator", "and"); + parentCondition.setParameter("subConditions", Arrays.asList(subCondition1, subCondition2)); + + // Create condition types + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + childType.setParentCondition(parentCondition); + + ConditionType parentType = new ConditionType(); + parentType.setItemId("parentConditionType"); + + ConditionType subCondition1Type = new ConditionType(); + subCondition1Type.setItemId("subCondition1Type"); + + ConditionType subCondition2Type = new ConditionType(); + subCondition2Type.setItemId("subCondition2Type"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + when(definitionsService.getConditionType("subCondition1Type")).thenReturn(subCondition1Type); + when(definitionsService.getConditionType("subCondition2Type")).thenReturn(subCondition2Type); + + // Test resolution + boolean result = ParserHelper.resolveConditionType(definitionsService, condition, "testContext"); + assertTrue("Condition type should be resolved", result); + + // Verify child condition type is set + assertEquals("Child condition type should be set", childType, condition.getConditionType()); + + // Verify parent condition is properly resolved + Condition resolvedParent = condition.getConditionType().getParentCondition(); + assertNotNull("Parent condition should be set", resolvedParent); + assertEquals("Parent condition type should be set", parentType, resolvedParent.getConditionType()); + + // Verify subConditions are properly resolved + @SuppressWarnings("unchecked") + List resolvedSubConditions = (List) resolvedParent.getParameter("subConditions"); + assertNotNull("SubConditions should be present", resolvedSubConditions); + assertEquals("Should have two subConditions", 2, resolvedSubConditions.size()); + assertEquals("First subCondition type should be set", subCondition1Type, resolvedSubConditions.get(0).getConditionType()); + assertEquals("Second subCondition type should be set", subCondition2Type, resolvedSubConditions.get(1).getConditionType()); + } + + @Test + public void testResolveBooleanConditionWithParentConditionUsingBooleanCondition() { + // Test scenario: booleanCondition A has subCondition B + // subCondition B has parentCondition which is booleanCondition C + // This should NOT be detected as a circular reference because they are different instances + Condition booleanConditionA = new Condition(); + booleanConditionA.setConditionTypeId("booleanCondition"); + booleanConditionA.setParameter("operator", "and"); + + Condition subConditionB = new Condition(); + subConditionB.setConditionTypeId("eventPropertyCondition"); + Map subConditionBParams = new HashMap<>(); + subConditionBParams.put("propertyName", "testProperty"); + subConditionBParams.put("propertyValue", "testValue"); + subConditionB.setParameterValues(subConditionBParams); + booleanConditionA.setParameter("subConditions", Arrays.asList(subConditionB)); + + Condition booleanConditionC = new Condition(); + booleanConditionC.setConditionTypeId("booleanCondition"); + booleanConditionC.setParameter("operator", "or"); + + Condition subConditionD = new Condition(); + subConditionD.setConditionTypeId("profilePropertyCondition"); + booleanConditionC.setParameter("subConditions", Arrays.asList(subConditionD)); + + ConditionType booleanConditionType = new ConditionType(); + booleanConditionType.setItemId("booleanCondition"); + + ConditionType eventPropertyConditionType = new ConditionType(); + eventPropertyConditionType.setItemId("eventPropertyCondition"); + eventPropertyConditionType.setParentCondition(booleanConditionC); + + ConditionType profilePropertyConditionType = new ConditionType(); + profilePropertyConditionType.setItemId("profilePropertyCondition"); + + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanConditionType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(profilePropertyConditionType); + + boolean result = ParserHelper.resolveConditionType(definitionsService, booleanConditionA, "testContext"); + assertTrue("BooleanCondition with parent condition using booleanCondition should resolve successfully", result); + assertEquals("Root booleanCondition type should be set", booleanConditionType, booleanConditionA.getConditionType()); + } + + @Test + public void testSelfReferencingCycle() { + // Test self-referencing condition hits depth limit + Condition a = new Condition(); + a.setConditionTypeId("booleanCondition"); + a.setParameter("subConditions", Arrays.asList(a)); + + ConditionType booleanConditionType = new ConditionType(); + booleanConditionType.setItemId("booleanCondition"); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanConditionType); + + boolean result = ParserHelper.resolveConditionType(definitionsService, a, "testContext"); + assertFalse("Self-referencing condition should hit depth limit", result); + } + + @Test + public void testMultipleBranchesWithSameConditionType() { + // Test that multiple branches can use the same condition type without false positives + Condition root = new Condition(); + root.setConditionTypeId("booleanCondition"); + + Condition branch1 = new Condition(); + branch1.setConditionTypeId("booleanCondition"); + + Condition branch2 = new Condition(); + branch2.setConditionTypeId("booleanCondition"); + + root.setParameter("subConditions", Arrays.asList(branch1, branch2)); + + ConditionType booleanConditionType = new ConditionType(); + booleanConditionType.setItemId("booleanCondition"); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanConditionType); + + boolean result = ParserHelper.resolveConditionType(definitionsService, root, "testContext"); + assertTrue("Multiple branches using the same condition type should not be a false positive", result); + } + + @Test + public void testCycleInParentConditionChain() { + // Test cycle in parent condition chain: A has parent B, B has parent C, C has parent B + Condition root = new Condition(); + root.setConditionTypeId("conditionA"); + + Condition parentB = new Condition(); + parentB.setConditionTypeId("conditionB"); + + ConditionType typeA = new ConditionType(); + typeA.setItemId("conditionA"); + typeA.setParentCondition(parentB); + + ConditionType typeB = new ConditionType(); + typeB.setItemId("conditionB"); + + Condition parentC = new Condition(); + parentC.setConditionTypeId("conditionC"); + + ConditionType typeC = new ConditionType(); + typeC.setItemId("conditionC"); + + typeB.setParentCondition(parentC); + typeC.setParentCondition(parentB); + + when(definitionsService.getConditionType("conditionA")).thenReturn(typeA); + when(definitionsService.getConditionType("conditionB")).thenReturn(typeB); + when(definitionsService.getConditionType("conditionC")).thenReturn(typeC); + + boolean result = ParserHelper.resolveConditionType(definitionsService, root, "testContext"); + assertFalse("Cycle in parent condition chain (B->C->B) should be detected", result); + } + + @Test + public void testNestedBooleanConditionsWithoutCycle() { + // Test deeply nested booleanConditions that don't form a cycle + Condition root = new Condition(); + root.setConditionTypeId("booleanCondition"); + + Condition b1 = new Condition(); + b1.setConditionTypeId("booleanCondition"); + + Condition b2 = new Condition(); + b2.setConditionTypeId("booleanCondition"); + + Condition b3 = new Condition(); + b3.setConditionTypeId("booleanCondition"); + + root.setParameter("subConditions", Arrays.asList(b1)); + b1.setParameter("subConditions", Arrays.asList(b2)); + b2.setParameter("subConditions", Arrays.asList(b3)); + + ConditionType booleanConditionType = new ConditionType(); + booleanConditionType.setItemId("booleanCondition"); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanConditionType); + + boolean result = ParserHelper.resolveConditionType(definitionsService, root, "testContext"); + assertTrue("Deeply nested booleanConditions without cycle should succeed", result); + } + + @Test + public void testUpDownBackUpCycle() { + // Test up → down → back up cycle: Root A -> parameter B -> B's parent C -> C's parent B (cycle in parent chain) + // This creates a cycle in the parent chain that should be detected + Condition rootA = new Condition(); + rootA.setConditionTypeId("typeA"); + + Condition paramB = new Condition(); + paramB.setConditionTypeId("typeB"); + rootA.setParameter("subConditions", Arrays.asList(paramB)); + + Condition parentC = new Condition(); + parentC.setConditionTypeId("typeC"); + + Condition parentB = new Condition(); + parentB.setConditionTypeId("typeB"); + + ConditionType typeA = new ConditionType(); + typeA.setItemId("typeA"); + + ConditionType typeB = new ConditionType(); + typeB.setItemId("typeB"); + typeB.setParentCondition(parentC); + + ConditionType typeC = new ConditionType(); + typeC.setItemId("typeC"); + typeC.setParentCondition(parentB); + + when(definitionsService.getConditionType("typeA")).thenReturn(typeA); + when(definitionsService.getConditionType("typeB")).thenReturn(typeB); + when(definitionsService.getConditionType("typeC")).thenReturn(typeC); + + boolean result = ParserHelper.resolveConditionType(definitionsService, rootA, "testContext"); + assertFalse("Up → down → back up cycle (B->C->B) should be detected", result); + } + + @Test + public void testResolveEffectiveConditionDeepCopyNestedConditions() { + // Create a condition type with parent condition containing nested conditions + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + + // Create parent condition with nested conditions + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("booleanCondition"); + parentCondition.setParameter("operator", "and"); + + // Create nested conditions + Condition nested1 = new Condition(); + nested1.setConditionTypeId("eventTypeCondition"); + nested1.setParameter("eventTypeId", "view"); + + Condition nested2 = new Condition(); + nested2.setConditionTypeId("eventPropertyCondition"); + nested2.setParameter("propertyName", "testProperty"); + nested2.setParameter("propertyValue", "testValue"); + + parentCondition.setParameter("subConditions", Arrays.asList(nested1, nested2)); + childType.setParentCondition(parentCondition); + + // Create condition types + ConditionType booleanType = new ConditionType(); + booleanType.setItemId("booleanCondition"); + + ConditionType eventTypeConditionType = new ConditionType(); + eventTypeConditionType.setItemId("eventTypeCondition"); + + ConditionType eventPropertyConditionType = new ConditionType(); + eventPropertyConditionType.setItemId("eventPropertyCondition"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanType); + when(definitionsService.getConditionType("eventTypeCondition")).thenReturn(eventTypeConditionType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + + // Create condition to resolve + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + condition.setParameter("customParam", "customValue"); + + // Resolve effective condition + Map context = new HashMap<>(); + Condition effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "test context"); + + // Verify effective condition is the parent (booleanCondition) + assertNotNull("Effective condition should not be null", effectiveCondition); + assertEquals("Effective condition should be booleanCondition", + "booleanCondition", effectiveCondition.getConditionTypeId()); + + // Verify nested conditions are deep copied (not shared references) + @SuppressWarnings("unchecked") + List effectiveSubConditions = (List) effectiveCondition.getParameter("subConditions"); + assertNotNull("SubConditions should be present", effectiveSubConditions); + assertEquals("Should have two subConditions", 2, effectiveSubConditions.size()); + + // Verify nested conditions are independent (modifying copy doesn't affect original) + Condition originalNested1 = nested1; + Condition copiedNested1 = effectiveSubConditions.get(0); + + // Modify the copied nested condition + copiedNested1.setParameter("eventTypeId", "modified"); + + // Verify original is not affected + assertEquals("Original nested condition should not be modified", + "view", originalNested1.getParameter("eventTypeId")); + + // Verify copied nested condition is modified + assertEquals("Copied nested condition should be modified", + "modified", copiedNested1.getParameter("eventTypeId")); + + // Verify nested conditions have their types resolved + assertNotNull("First nested condition type should be resolved", + effectiveSubConditions.get(0).getConditionType()); + assertNotNull("Second nested condition type should be resolved", + effectiveSubConditions.get(1).getConditionType()); + + // Verify nested condition parameters are preserved + assertEquals("First nested condition should have eventTypeId parameter", + "modified", effectiveSubConditions.get(0).getParameter("eventTypeId")); + assertEquals("Second nested condition should have propertyName parameter", + "testProperty", effectiveSubConditions.get(1).getParameter("propertyName")); + assertEquals("Second nested condition should have propertyValue parameter", + "testValue", effectiveSubConditions.get(1).getParameter("propertyValue")); + } + + @Test + public void testResolveEffectiveConditionDeepCopySingleNestedCondition() { + // Test deep copy with a single nested condition (not in a collection) + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + + // Create parent condition with single nested condition + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + + Condition nested = new Condition(); + nested.setConditionTypeId("nestedConditionType"); + nested.setParameter("nestedParam", "nestedValue"); + + parentCondition.setParameter("subCondition", nested); + childType.setParentCondition(parentCondition); + + // Create condition types + ConditionType parentType = new ConditionType(); + parentType.setItemId("parentConditionType"); + + ConditionType nestedType = new ConditionType(); + nestedType.setItemId("nestedConditionType"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + when(definitionsService.getConditionType("nestedConditionType")).thenReturn(nestedType); + + // Create condition to resolve + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + + // Resolve effective condition + Map context = new HashMap<>(); + Condition effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "test context"); + + // Verify nested condition is deep copied + Condition effectiveNested = (Condition) effectiveCondition.getParameter("subCondition"); + assertNotNull("Nested condition should be present", effectiveNested); + + // Verify it's a deep copy (not the same reference) + assertNotSame("Nested condition should be a copy, not the same reference", + nested, effectiveNested); + + // Modify the copied nested condition + effectiveNested.setParameter("nestedParam", "modifiedValue"); + + // Verify original is not affected + assertEquals("Original nested condition should not be modified", + "nestedValue", nested.getParameter("nestedParam")); + + // Verify copied nested condition is modified + assertEquals("Copied nested condition should be modified", + "modifiedValue", effectiveNested.getParameter("nestedParam")); + } + + @Test + public void testResolveEffectiveConditionDeepCopyRecursiveNesting() { + // Test deep copy with recursively nested conditions (nested condition contains another nested condition) + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + + // Create parent condition with nested condition that itself has a nested condition + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("booleanCondition"); + parentCondition.setParameter("operator", "and"); + + Condition nested1 = new Condition(); + nested1.setConditionTypeId("booleanCondition"); + nested1.setParameter("operator", "or"); + + Condition nested2 = new Condition(); + nested2.setConditionTypeId("eventTypeCondition"); + nested2.setParameter("eventTypeId", "view"); + + nested1.setParameter("subConditions", Arrays.asList(nested2)); + parentCondition.setParameter("subConditions", Arrays.asList(nested1)); + childType.setParentCondition(parentCondition); + + // Create condition types + ConditionType booleanType = new ConditionType(); + booleanType.setItemId("booleanCondition"); + + ConditionType eventTypeConditionType = new ConditionType(); + eventTypeConditionType.setItemId("eventTypeCondition"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanType); + when(definitionsService.getConditionType("eventTypeCondition")).thenReturn(eventTypeConditionType); + + // Create condition to resolve + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + + // Resolve effective condition + Map context = new HashMap<>(); + Condition effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "test context"); + + // Verify all levels are deep copied + @SuppressWarnings("unchecked") + List level1SubConditions = (List) effectiveCondition.getParameter("subConditions"); + assertNotNull("Level 1 subConditions should be present", level1SubConditions); + assertEquals("Should have one level 1 subCondition", 1, level1SubConditions.size()); + + Condition level1Nested = level1SubConditions.get(0); + assertNotSame("Level 1 nested condition should be a copy", nested1, level1Nested); + + @SuppressWarnings("unchecked") + List level2SubConditions = (List) level1Nested.getParameter("subConditions"); + assertNotNull("Level 2 subConditions should be present", level2SubConditions); + assertEquals("Should have one level 2 subCondition", 1, level2SubConditions.size()); + + Condition level2Nested = level2SubConditions.get(0); + assertNotSame("Level 2 nested condition should be a copy", nested2, level2Nested); + + // Verify modifying deeply nested condition doesn't affect original + level2Nested.setParameter("eventTypeId", "modified"); + assertEquals("Original level 2 nested condition should not be modified", + "view", nested2.getParameter("eventTypeId")); + assertEquals("Copied level 2 nested condition should be modified", + "modified", level2Nested.getParameter("eventTypeId")); + } + + @Test + public void testResolveEffectiveConditionPreservesParameters() { + // Test that all parameters are preserved in the deep copy + ConditionType childType = new ConditionType(); + childType.setItemId("childConditionType"); + + // Create parent condition with various parameter types + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + parentCondition.setParameter("stringParam", "stringValue"); + parentCondition.setParameter("intParam", 42); + parentCondition.setParameter("boolParam", true); + parentCondition.setParameter("listParam", Arrays.asList("item1", "item2")); + + // Create nested condition + Condition nested = new Condition(); + nested.setConditionTypeId("nestedConditionType"); + nested.setParameter("nestedString", "nestedValue"); + parentCondition.setParameter("nestedCondition", nested); + + childType.setParentCondition(parentCondition); + + // Create condition types + ConditionType parentType = new ConditionType(); + parentType.setItemId("parentConditionType"); + + ConditionType nestedType = new ConditionType(); + nestedType.setItemId("nestedConditionType"); + + // Mock definitions service + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + when(definitionsService.getConditionType("nestedConditionType")).thenReturn(nestedType); + + // Create condition to resolve with additional parameters + Condition condition = new Condition(); + condition.setConditionTypeId("childConditionType"); + condition.setParameter("customParam", "customValue"); + + // Resolve effective condition + Map context = new HashMap<>(); + Condition effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "test context"); + + // Verify all parameter types are preserved + assertEquals("String parameter should be preserved", + "stringValue", effectiveCondition.getParameter("stringParam")); + assertEquals("Integer parameter should be preserved", + 42, effectiveCondition.getParameter("intParam")); + assertEquals("Boolean parameter should be preserved", + true, effectiveCondition.getParameter("boolParam")); + assertEquals("List parameter should be preserved", + Arrays.asList("item1", "item2"), effectiveCondition.getParameter("listParam")); + + // Verify nested condition parameter is preserved + Condition effectiveNested = (Condition) effectiveCondition.getParameter("nestedCondition"); + assertNotNull("Nested condition should be present", effectiveNested); + assertEquals("Nested condition parameter should be preserved", + "nestedValue", effectiveNested.getParameter("nestedString")); + + // Verify condition's custom parameter is merged (highest priority) + assertEquals("Condition parameter should override parent parameter if same key", + "customValue", effectiveCondition.getParameter("customParam")); + } + + @Test + public void testResolveEffectiveConditionWithNullCondition() { + // Test that null condition is handled gracefully + Map context = new HashMap<>(); + Condition result = ParserHelper.resolveEffectiveCondition( + null, definitionsService, context, "test context"); + + assertNull("Null condition should return null", result); + } + + @Test + public void testResolveEffectiveConditionWithNoParent() { + // Test condition without parent condition + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("testConditionType"); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + condition.setParameter("param1", "value1"); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(conditionType); + + Map context = new HashMap<>(); + Condition result = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "test context"); + + // Should return the original condition (no parent to resolve) + assertNotNull("Result should not be null", result); + assertEquals("Should return original condition when no parent", + condition, result); + assertEquals("Parameter should be preserved", + "value1", result.getParameter("param1")); + } + + @Test + public void testConditionDeepCopy() { + // Test direct deep copy method on Condition + Condition original = new Condition(); + original.setConditionTypeId("testConditionType"); + original.setParameter("stringParam", "stringValue"); + original.setParameter("intParam", 42); + original.setParameter("boolParam", true); + + // Create nested condition + Condition nested = new Condition(); + nested.setConditionTypeId("nestedConditionType"); + nested.setParameter("nestedParam", "nestedValue"); + original.setParameter("nestedCondition", nested); + + // Create nested condition in collection + Condition nestedInList = new Condition(); + nestedInList.setConditionTypeId("nestedInListType"); + nestedInList.setParameter("listParam", "listValue"); + original.setParameter("nestedList", Arrays.asList(nestedInList)); + + // Perform deep copy + Condition copied = original.deepCopy(); + + // Verify it's a copy, not the same reference + assertNotSame("Copied condition should be a different object", original, copied); + + // Verify basic properties are copied + assertEquals("Condition type ID should be copied", + "testConditionType", copied.getConditionTypeId()); + assertEquals("String parameter should be copied", + "stringValue", copied.getParameter("stringParam")); + assertEquals("Integer parameter should be copied", + 42, copied.getParameter("intParam")); + assertEquals("Boolean parameter should be copied", + true, copied.getParameter("boolParam")); + + // Verify nested condition is deep copied + Condition copiedNested = (Condition) copied.getParameter("nestedCondition"); + assertNotNull("Nested condition should be present", copiedNested); + assertNotSame("Nested condition should be a different object", nested, copiedNested); + assertEquals("Nested condition type ID should be copied", + "nestedConditionType", copiedNested.getConditionTypeId()); + assertEquals("Nested condition parameter should be copied", + "nestedValue", copiedNested.getParameter("nestedParam")); + + // Verify nested condition in list is deep copied + @SuppressWarnings("unchecked") + List copiedList = (List) copied.getParameter("nestedList"); + assertNotNull("Nested list should be present", copiedList); + assertEquals("Nested list should have one item", 1, copiedList.size()); + assertNotSame("Nested condition in list should be a different object", + nestedInList, copiedList.get(0)); + assertEquals("Nested condition in list type ID should be copied", + "nestedInListType", copiedList.get(0).getConditionTypeId()); + assertEquals("Nested condition in list parameter should be copied", + "listValue", copiedList.get(0).getParameter("listParam")); + + // Verify modifying copied condition doesn't affect original + copied.setParameter("stringParam", "modified"); + assertEquals("Original parameter should not be modified", + "stringValue", original.getParameter("stringParam")); + assertEquals("Copied parameter should be modified", + "modified", copied.getParameter("stringParam")); + + // Verify modifying nested condition doesn't affect original + copiedNested.setParameter("nestedParam", "modifiedNested"); + assertEquals("Original nested parameter should not be modified", + "nestedValue", nested.getParameter("nestedParam")); + assertEquals("Copied nested parameter should be modified", + "modifiedNested", copiedNested.getParameter("nestedParam")); + + // Verify modifying nested condition in list doesn't affect original + copiedList.get(0).setParameter("listParam", "modifiedList"); + assertEquals("Original nested list parameter should not be modified", + "listValue", nestedInList.getParameter("listParam")); + assertEquals("Copied nested list parameter should be modified", + "modifiedList", copiedList.get(0).getParameter("listParam")); + } + + @Test + public void testConditionDeepCopyWithNullValues() { + // Test deep copy handles null values gracefully + Condition original = new Condition(); + original.setConditionTypeId("testConditionType"); + original.setParameter("nullParam", null); + original.setParameter("stringParam", "value"); + + Condition copied = original.deepCopy(); + + assertNotNull("Copied condition should not be null", copied); + assertNull("Null parameter should remain null", copied.getParameter("nullParam")); + assertEquals("String parameter should be copied", + "value", copied.getParameter("stringParam")); + } + + @Test + public void testConditionDeepCopyWithEmptyParameters() { + // Test deep copy with empty parameter map + Condition original = new Condition(); + original.setConditionTypeId("testConditionType"); + + Condition copied = original.deepCopy(); + + assertNotNull("Copied condition should not be null", copied); + assertEquals("Condition type ID should be copied", + "testConditionType", copied.getConditionTypeId()); + assertNotNull("Parameter values map should exist", copied.getParameterValues()); + assertTrue("Parameter values map should be empty", copied.getParameterValues().isEmpty()); + } + + /** + * Guards against regression where putAll is replaced with putIfAbsent in + * resolveEffectiveCondition. Child condition parameters MUST be written into the + * shared context map unconditionally so that parent conditions containing + * "parameter::X" references can resolve them via ConditionContextHelper. + * + * Simulates the eventTypeCondition pattern: + * child sets eventTypeId = "view" + * parent uses propertyValue = "parameter::eventTypeId" + */ + @Test + public void testResolveEffectiveConditionPopulatesContextWithChildParameters() { + ConditionType childType = new ConditionType(); + childType.setItemId("eventTypeCondition"); + + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("eventPropertyCondition"); + parentCondition.setParameter("propertyName", "eventType"); + parentCondition.setParameter("propertyValue", "parameter::eventTypeId"); + parentCondition.setParameter("comparisonOperator", "equals"); + childType.setParentCondition(parentCondition); + + ConditionType parentType = new ConditionType(); + parentType.setItemId("eventPropertyCondition"); + + when(definitionsService.getConditionType("eventTypeCondition")).thenReturn(childType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(parentType); + + Condition child = new Condition(); + child.setConditionTypeId("eventTypeCondition"); + child.setParameter("eventTypeId", "view"); + + Map context = new HashMap<>(); + ParserHelper.resolveEffectiveCondition(child, definitionsService, context, "test"); + + assertEquals("context must contain child parameter so parent parameter:: refs can be resolved", + "view", context.get("eventTypeId")); + } +} diff --git a/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java b/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java index 6fcd48c864..0ad1fb5a20 100644 --- a/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java +++ b/itests/src/test/java/org/apache/unomi/itests/tools/LogCheckerTest.java @@ -296,13 +296,19 @@ public void testPerformanceWithManySubstrings() { logChecker.addIgnoredSubstring("pattern" + i); } - // Should still match quickly + // Warm up the JVM to avoid measuring class-loading and JIT compilation costs, + // which inflate the first-call time on cold CI runners. + for (int i = 0; i < 5; i++) { + shouldInclude("This message contains pattern50 in it"); + } + + // Now measure with a warmed-up JVM long start = System.nanoTime(); assertFalse("Should match pattern50", shouldInclude("This message contains pattern50 in it")); long duration = System.nanoTime() - start; - // Should complete in reasonable time (< 1ms for this test) - assertTrue("Matching should be fast: " + duration + " ns", duration < 1_000_000); + // 50ms threshold: generous enough for loaded CI runners, tight enough to catch O(N²) regressions + assertTrue("Matching should be fast: " + duration + " ns", duration < 50_000_000); } @Test diff --git a/persistence-elasticsearch/conditions/src/main/java/org/apache/unomi/persistence/elasticsearch/querybuilders/advanced/PastEventConditionESQueryBuilder.java b/persistence-elasticsearch/conditions/src/main/java/org/apache/unomi/persistence/elasticsearch/querybuilders/advanced/PastEventConditionESQueryBuilder.java index 77e083d24a..cb22ce9620 100644 --- a/persistence-elasticsearch/conditions/src/main/java/org/apache/unomi/persistence/elasticsearch/querybuilders/advanced/PastEventConditionESQueryBuilder.java +++ b/persistence-elasticsearch/conditions/src/main/java/org/apache/unomi/persistence/elasticsearch/querybuilders/advanced/PastEventConditionESQueryBuilder.java @@ -226,7 +226,11 @@ public Condition getEventCondition(Condition condition, Map cont andCondition.setParameter("operator", "and"); andCondition.setParameter("subConditions", l); - l.add(ConditionContextHelper.getContextualCondition(eventCondition, context, scriptExecutor)); + Condition contextualEventCondition = ConditionContextHelper.getContextualCondition(eventCondition, context, scriptExecutor); + if (contextualEventCondition == null) { + throw new IllegalArgumentException("Could not resolve event condition context for past event query"); + } + l.add(contextualEventCondition); if (profileId != null) { Condition profileCondition = new Condition(); diff --git a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java index b7386e804e..ba91d1fe8b 100644 --- a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java +++ b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java @@ -19,6 +19,9 @@ import co.elastic.clients.elasticsearch._types.query_dsl.Query; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.TypeResolutionService; +import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; import org.apache.unomi.persistence.spi.conditions.dispatcher.ConditionQueryBuilderDispatcher; import org.apache.unomi.scripting.ScriptExecutor; @@ -49,6 +52,7 @@ public class ConditionESQueryBuilderDispatcher extends ConditionQueryBuilderDisp private Map queryBuilders = new ConcurrentHashMap<>(); private ScriptExecutor scriptExecutor; + private DefinitionsService definitionsService; public ConditionESQueryBuilderDispatcher() { } @@ -57,6 +61,22 @@ public void setScriptExecutor(ScriptExecutor scriptExecutor) { this.scriptExecutor = scriptExecutor; } + public void setDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + + public void bindDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + LOGGER.debug("DefinitionsService bound to ConditionESQueryBuilderDispatcher"); + } + + public void unbindDefinitionsService(DefinitionsService definitionsService) { + if (this.definitionsService == definitionsService) { + this.definitionsService = null; + LOGGER.debug("DefinitionsService unbound from ConditionESQueryBuilderDispatcher"); + } + } + /** * Registers a query builder implementation under the provided ID. * @@ -85,39 +105,78 @@ public Query buildFilter(Condition condition) { } public Query buildFilter(Condition condition, Map context) { - if (condition == null || condition.getConditionType() == null) { - throw new IllegalArgumentException("Condition is null or doesn't have type, impossible to build filter"); + if (condition == null) { + throw new IllegalArgumentException("Condition is null, impossible to build filter"); + } + + // Resolve condition type if needed - try to resolve first if definitionsService is available + if (condition.getConditionType() == null) { + if (definitionsService != null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "query builder"); + } + } else { + LOGGER.debug("DefinitionsService not available, cannot resolve condition type for condition typeID={}", condition.getConditionTypeId()); + } + // If still null after attempting resolution (or definitionsService was null), return match-none + if (condition.getConditionType() == null) { + LOGGER.warn("Condition type is null for condition typeID={}, returning match-none query", condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(m -> m)); + } } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return buildFilter(condition.getConditionType().getParentCondition(), context); + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + Condition resolved = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); + if (resolved == null) { + LOGGER.warn("Could not resolve effective condition for typeID={} (cycle or max depth), returning match-none query", + condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(m -> m)); + } + effectiveCondition = resolved; } + // Check if effective condition has a type - if not, return match-none query + if (effectiveCondition.getConditionType() == null) { + LOGGER.warn("Effective condition type is null for condition typeID={}, returning match-none query", + effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchNone(m -> m)); + } + + String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { - throw new UnsupportedOperationException("No query builder defined for : " + condition.getConditionTypeId()); + if (effectiveCondition.getConditionType().getParentCondition() != null) { + context.putAll(effectiveCondition.getParameterValues()); + return buildFilter(effectiveCondition.getConditionType().getParentCondition(), context); + } + LOGGER.warn("No query builder defined for condition type: {}, returning match-none query", effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchNone(m -> m)); } // Find the appropriate query builder key (new or legacy) String finalQueryBuilderKey = findQueryBuilderKey( queryBuilderKey, - condition.getConditionTypeId(), + effectiveCondition.getConditionTypeId(), queryBuilders::containsKey); if (finalQueryBuilderKey != null) { ConditionESQueryBuilder queryBuilder = queryBuilders.get(finalQueryBuilderKey); - Condition contextualCondition = ConditionContextHelper.getContextualCondition(condition, context, scriptExecutor); + Condition contextualCondition = ConditionContextHelper.getContextualCondition(effectiveCondition, context, scriptExecutor); if (contextualCondition != null) { return queryBuilder.buildQuery(contextualCondition, context, this); } + LOGGER.warn("getContextualCondition returned null for conditionTypeId={}, returning match-none query", + effectiveCondition.getConditionTypeId()); } else { - // if no matching - LOGGER.warn("No matching query builder. See debug log level for more information"); - LOGGER.debug("No matching query builder for condition {} and context {}", condition, context); + LOGGER.warn("No matching query builder for conditionTypeId={} (queryBuilderKey={})", + effectiveCondition.getConditionTypeId(), queryBuilderKey); + LOGGER.debug("No matching query builder for condition {} and context {}", effectiveCondition, context); } - return Query.of(q -> q.matchAll(m -> m)); + return Query.of(q -> q.matchNone(m -> m)); } public long count(Condition condition) { @@ -125,29 +184,67 @@ public long count(Condition condition) { } public long count(Condition condition, Map context) { - if (condition == null || condition.getConditionType() == null) { - throw new IllegalArgumentException("Condition is null or doesn't have type, impossible to build filter"); + if (condition == null) { + throw new IllegalArgumentException("Condition is null, impossible to build filter"); } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return count(condition.getConditionType().getParentCondition(), context); + // Resolve condition type if needed - try to resolve first if definitionsService is available + if (condition.getConditionType() == null) { + if (definitionsService != null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "query builder"); + } + } else { + LOGGER.debug("DefinitionsService not available, cannot resolve condition type for condition typeID={}", condition.getConditionTypeId()); + } + // If still null after attempting resolution (or definitionsService was null), throw exception + // (count operations require a valid condition type) + if (condition.getConditionType() == null) { + LOGGER.warn("Condition type is null for condition typeID={}, cannot perform count operation", condition.getConditionTypeId()); + throw new IllegalArgumentException("Condition doesn't have type, impossible to build filter for count"); + } } + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + Condition resolved = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); + if (resolved == null) { + throw new IllegalArgumentException( + "Could not resolve effective condition for typeID=" + condition.getConditionTypeId() + + " (cycle or max depth exceeded)"); + } + effectiveCondition = resolved; + } + + // Check if effective condition has a type - if not, throw exception for count + if (effectiveCondition.getConditionType() == null) { + LOGGER.warn("Effective condition type is null for condition typeID={}, cannot perform count operation", + effectiveCondition.getConditionTypeId()); + throw new IllegalArgumentException("Effective condition type not resolved for : " + effectiveCondition.getConditionTypeId()); + } + + String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { - throw new UnsupportedOperationException("No query builder defined for : " + condition.getConditionTypeId()); + if (effectiveCondition.getConditionType().getParentCondition() != null) { + context.putAll(effectiveCondition.getParameterValues()); + return count(effectiveCondition.getConditionType().getParentCondition(), context); + } + LOGGER.warn("No query builder defined for condition type: {}, cannot perform count operation", effectiveCondition.getConditionTypeId()); + throw new UnsupportedOperationException("No query builder defined for : " + effectiveCondition.getConditionTypeId()); } // Find the appropriate query builder key (new or legacy) String finalQueryBuilderKey = findQueryBuilderKey( queryBuilderKey, - condition.getConditionTypeId(), + effectiveCondition.getConditionTypeId(), queryBuilders::containsKey); if (finalQueryBuilderKey != null) { ConditionESQueryBuilder queryBuilder = queryBuilders.get(finalQueryBuilderKey); - Condition contextualCondition = ConditionContextHelper.getContextualCondition(condition, context, scriptExecutor); + Condition contextualCondition = ConditionContextHelper.getContextualCondition(effectiveCondition, context, scriptExecutor); if (contextualCondition != null) { return queryBuilder.count(contextualCondition, context, this); } diff --git a/persistence-elasticsearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/persistence-elasticsearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml index 5c80e50ca7..a71474bb7b 100644 --- a/persistence-elasticsearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/persistence-elasticsearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -92,6 +92,16 @@ + + + + + + + cont andCondition.setParameter("operator", "and"); andCondition.setParameter("subConditions", l); - l.add(ConditionContextHelper.getContextualCondition(eventCondition, context, scriptExecutor)); + Condition contextualEventCondition = ConditionContextHelper.getContextualCondition(eventCondition, context, scriptExecutor); + if (contextualEventCondition == null) { + throw new IllegalArgumentException("Could not resolve event condition context for past event query"); + } + l.add(contextualEventCondition); if (profileId != null) { Condition profileCondition = new Condition(); diff --git a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java index 01ed4f9737..4e5b22b181 100644 --- a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java +++ b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java @@ -18,6 +18,9 @@ package org.apache.unomi.persistence.opensearch; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.TypeResolutionService; +import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; import org.apache.unomi.persistence.spi.conditions.dispatcher.ConditionQueryBuilderDispatcher; import org.apache.unomi.scripting.ScriptExecutor; @@ -49,6 +52,7 @@ public class ConditionOSQueryBuilderDispatcher extends ConditionQueryBuilderDisp private Map queryBuilders = new ConcurrentHashMap<>(); private ScriptExecutor scriptExecutor; + private DefinitionsService definitionsService; public ConditionOSQueryBuilderDispatcher() { } @@ -57,6 +61,22 @@ public void setScriptExecutor(ScriptExecutor scriptExecutor) { this.scriptExecutor = scriptExecutor; } + public void setDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + + public void bindDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + LOGGER.debug("DefinitionsService bound to ConditionOSQueryBuilderDispatcher"); + } + + public void unbindDefinitionsService(DefinitionsService definitionsService) { + if (this.definitionsService == definitionsService) { + this.definitionsService = null; + LOGGER.debug("DefinitionsService unbound from ConditionOSQueryBuilderDispatcher"); + } + } + /** * Registers a query builder implementation under the provided ID. * @@ -84,41 +104,78 @@ public Query buildFilter(Condition condition) { } public Query buildFilter(Condition condition, Map context) { - if (condition == null || condition.getConditionType() == null) { - throw new IllegalArgumentException("Condition is null or doesn't have type, impossible to build filter"); + if (condition == null) { + throw new IllegalArgumentException("Condition is null, impossible to build filter"); + } + + // Resolve condition type if needed - try to resolve first if definitionsService is available + if (condition.getConditionType() == null) { + if (definitionsService != null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "query builder"); + } + } else { + LOGGER.debug("DefinitionsService not available, cannot resolve condition type for condition typeID={}", condition.getConditionTypeId()); + } + // If still null after attempting resolution (or definitionsService was null), return match-none + if (condition.getConditionType() == null) { + LOGGER.warn("Condition type is null for condition typeID={}, returning match-none query", condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(t -> t)); + } + } + + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + Condition resolved = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); + if (resolved == null) { + LOGGER.warn("Could not resolve effective condition for typeID={} (cycle or max depth), returning match-none query", + condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(t -> t)); + } + effectiveCondition = resolved; } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return buildFilter(condition.getConditionType().getParentCondition(), context); + // Check if effective condition has a type - if not, return match-none query + if (effectiveCondition.getConditionType() == null) { + LOGGER.warn("Effective condition type is null for condition typeID={}, returning match-none query", + effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchNone(t -> t)); } + String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { - throw new UnsupportedOperationException("No query builder defined for : " + condition.getConditionTypeId()); + if (effectiveCondition.getConditionType().getParentCondition() != null) { + context.putAll(effectiveCondition.getParameterValues()); + return buildFilter(effectiveCondition.getConditionType().getParentCondition(), context); + } + LOGGER.warn("No query builder defined for condition type: {}, returning match-none query", effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchNone(t -> t)); } // Find the appropriate query builder key (new or legacy) String finalQueryBuilderKey = findQueryBuilderKey( queryBuilderKey, - condition.getConditionTypeId(), + effectiveCondition.getConditionTypeId(), queryBuilders::containsKey); if (finalQueryBuilderKey != null) { ConditionOSQueryBuilder queryBuilder = queryBuilders.get(finalQueryBuilderKey); - Condition contextualCondition = ConditionContextHelper.getContextualCondition(condition, context, scriptExecutor); + Condition contextualCondition = ConditionContextHelper.getContextualCondition(effectiveCondition, context, scriptExecutor); if (contextualCondition != null) { return queryBuilder.buildQuery(contextualCondition, context, this); } + LOGGER.warn("getContextualCondition returned null for conditionTypeId={}, returning match-none query", + effectiveCondition.getConditionTypeId()); } else { - // if no matching - LOGGER.warn("No matching query builder. See debug log level for more information"); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("No matching query builder for condition {} and context {}", condition, context); - } + LOGGER.warn("No matching query builder for conditionTypeId={} (queryBuilderKey={})", + effectiveCondition.getConditionTypeId(), queryBuilderKey); + LOGGER.debug("No matching query builder for condition {} and context {}", effectiveCondition, context); } - return Query.of(q -> q.matchAll(t->t)); + return Query.of(q -> q.matchNone(t -> t)); } public long count(Condition condition) { @@ -126,29 +183,67 @@ public long count(Condition condition) { } public long count(Condition condition, Map context) { - if (condition == null || condition.getConditionType() == null) { - throw new IllegalArgumentException("Condition is null or doesn't have type, impossible to build filter"); + if (condition == null) { + throw new IllegalArgumentException("Condition is null, impossible to build filter"); + } + + // Resolve condition type if needed - try to resolve first if definitionsService is available + if (condition.getConditionType() == null) { + if (definitionsService != null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "query builder"); + } + } else { + LOGGER.debug("DefinitionsService not available, cannot resolve condition type for condition typeID={}", condition.getConditionTypeId()); + } + // If still null after attempting resolution (or definitionsService was null), throw exception + // (count operations require a valid condition type) + if (condition.getConditionType() == null) { + LOGGER.warn("Condition type is null for condition typeID={}, cannot perform count operation", condition.getConditionTypeId()); + throw new IllegalArgumentException("Condition doesn't have type, impossible to build filter for count"); + } + } + + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + Condition resolved = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); + if (resolved == null) { + throw new IllegalArgumentException( + "Could not resolve effective condition for typeID=" + condition.getConditionTypeId() + + " (cycle or max depth exceeded)"); + } + effectiveCondition = resolved; } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return count(condition.getConditionType().getParentCondition(), context); + // Check if effective condition has a type - if not, throw exception for count + if (effectiveCondition.getConditionType() == null) { + LOGGER.warn("Effective condition type is null for condition typeID={}, cannot perform count operation", + effectiveCondition.getConditionTypeId()); + throw new IllegalArgumentException("Effective condition type not resolved for : " + effectiveCondition.getConditionTypeId()); } + String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { - throw new UnsupportedOperationException("No query builder defined for : " + condition.getConditionTypeId()); + if (effectiveCondition.getConditionType().getParentCondition() != null) { + context.putAll(effectiveCondition.getParameterValues()); + return count(effectiveCondition.getConditionType().getParentCondition(), context); + } + LOGGER.warn("No query builder defined for condition type: {}, cannot perform count operation", effectiveCondition.getConditionTypeId()); + throw new UnsupportedOperationException("No query builder defined for : " + effectiveCondition.getConditionTypeId()); } // Find the appropriate query builder key (new or legacy) String finalQueryBuilderKey = findQueryBuilderKey( queryBuilderKey, - condition.getConditionTypeId(), + effectiveCondition.getConditionTypeId(), queryBuilders::containsKey); if (finalQueryBuilderKey != null) { ConditionOSQueryBuilder queryBuilder = queryBuilders.get(finalQueryBuilderKey); - Condition contextualCondition = ConditionContextHelper.getContextualCondition(condition, context, scriptExecutor); + Condition contextualCondition = ConditionContextHelper.getContextualCondition(effectiveCondition, context, scriptExecutor); if (contextualCondition != null) { return queryBuilder.count(contextualCondition, context, this); } diff --git a/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml index f9ee0b8416..dd08748a7b 100644 --- a/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/persistence-opensearch/core/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -94,6 +94,16 @@ + + + + + + + FOLD_MAPPING = new HashMap<>(); @@ -43,13 +71,16 @@ public class ConditionContextHelper { try { loadMappingFile(); } catch (IOException e) { - LOGGER.error("Erreur lors du chargement du fichier de mapping", e); + throw new ExceptionInInitializerError(e); } } private static void loadMappingFile() throws IOException { - try (InputStream is = ConditionContextHelper.class.getClassLoader().getResourceAsStream("mapping-FoldToASCII.txt"); - BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + InputStream is = ConditionContextHelper.class.getClassLoader().getResourceAsStream("mapping-FoldToASCII.txt"); + if (is == null) { + throw new IOException("Classpath resource 'mapping-FoldToASCII.txt' not found on classpath"); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { @@ -83,58 +114,353 @@ private static void loadMappingFile() throws IOException { } public static Condition getContextualCondition(Condition condition, Map context, ScriptExecutor scriptExecutor) { + return getContextualCondition(condition, context, scriptExecutor, null); + } + + /** + * Resolves parameter references and script expressions in a condition, + * with optional type validation. + * + * @param condition the condition to resolve + * @param context context map for parameter resolution + * @param scriptExecutor executor for script expressions + * @param definitionsService optional service for parameter type information + * @return resolved condition with all parameter references resolved + */ + public static Condition getContextualCondition( + Condition condition, + Map context, + ScriptExecutor scriptExecutor, + DefinitionsService definitionsService) { + + // Debug logging + if (!hasContextualParameter(condition.getParameterValues())) { return condition; } - @SuppressWarnings("unchecked") - Map values = (Map) parseParameter(context, condition.getParameterValues(), scriptExecutor); - if (values == null) { + + // Ensure context is not null and merge condition's non-reference parameters into it + // This allows parameter references to resolve from the condition's own parameters + if (context == null) { + context = new HashMap<>(); + } + // Merge condition's parameters into context (for parameter reference resolution) + // Only merge non-reference/script values to avoid overwriting resolved values + for (Map.Entry entry : condition.getParameterValues().entrySet()) { + Object value = entry.getValue(); + if (!isParameterReference(value) && !(value instanceof String && ((String) value).startsWith(SCRIPT_EXPRESSION_PREFIX))) { + if (!context.containsKey(entry.getKey())) { + context.put(entry.getKey(), value); + } + } + } + + ConditionType conditionType = condition.getConditionType(); + Map parameterDefs = null; + if (conditionType != null && definitionsService != null) { + parameterDefs = new HashMap<>(); + for (Parameter param : conditionType.getParameters()) { + parameterDefs.put(param.getId(), param); + } + } + + Object rawValues = parseParameterWithValidation( + context, condition.getParameterValues(), scriptExecutor, + parameterDefs, condition.getConditionTypeId()); + + if (rawValues == null || rawValues == RESOLUTION_ERROR) { return null; } + @SuppressWarnings("unchecked") + Map values = (Map) rawValues; Condition n = new Condition(condition.getConditionType()); n.setParameterValues(values); + return n; } @SuppressWarnings("unchecked") private static Object parseParameter(Map context, Object value, ScriptExecutor scriptExecutor) { - if (value instanceof String) { - if (((String) value).startsWith("parameter::") || ((String) value).startsWith("script::")) { - String s = (String) value; - if (s.startsWith("parameter::")) { - return context.get(StringUtils.substringAfter(s, "parameter::")); - } else if (s.startsWith("script::")) { - String script = StringUtils.substringAfter(s, "script::"); - return scriptExecutor.execute(script, context); + return parseParameterWithValidation(context, value, scriptExecutor, null, null); + } + + @SuppressWarnings("unchecked") + private static Object parseParameterWithValidation( + Map context, + Object value, + ScriptExecutor scriptExecutor, + Map parameterDefs, + String conditionTypeId) { + + return parseParameterWithValidationRecursive( + context, value, scriptExecutor, parameterDefs, conditionTypeId, new HashSet<>(), 0); + } + + /** + * Recursively resolves parameter references and script expressions, + * handling chains of references and detecting cycles. + * + * @param context context map for parameter resolution + * @param value the value to resolve + * @param scriptExecutor executor for script expressions + * @param parameterDefs optional parameter definitions for validation + * @param conditionTypeId condition type ID for context + * @param resolutionChain set of parameter keys/scripts already being resolved (for cycle detection) + * @param depth current resolution depth + * @return resolved value, or {@code RESOLUTION_ERROR} sentinel if cycle detected or max depth exceeded + */ + @SuppressWarnings("unchecked") + private static Object parseParameterWithValidationRecursive( + Map context, + Object value, + ScriptExecutor scriptExecutor, + Map parameterDefs, + String conditionTypeId, + Set resolutionChain, + int depth) { + + // Check maximum depth to prevent stack overflow + if (depth >= MAX_RESOLUTION_DEPTH) { + String message = String.format( + "Maximum resolution depth (%d) exceeded when resolving parameter reference. " + + "Possible infinite chain or very deep nesting. Resolution chain: %s", + MAX_RESOLUTION_DEPTH, resolutionChain); + LOGGER.error(message); + return RESOLUTION_ERROR; + } + + if (isParameterReference(value)) { + String s = (String) value; + String referenceKey = s; // Use full string as key for cycle detection + + // Check for cyclic reference + if (resolutionChain.contains(referenceKey)) { + String message = String.format( + "Circular parameter reference detected: %s. Resolution chain: %s", + referenceKey, resolutionChain); + LOGGER.warn(message); + // Return a special marker to indicate cycle (we'll check for this in Map processing) + return RESOLUTION_ERROR; + } + + // Add to resolution chain + resolutionChain.add(referenceKey); + + try { + Object resolvedValue = null; + + if (s.startsWith(PARAMETER_REFERENCE_PREFIX)) { + String paramKey = StringUtils.substringAfter(s, PARAMETER_REFERENCE_PREFIX); + resolvedValue = context.get(paramKey); + + if (resolvedValue == null) { + LOGGER.debug("Parameter reference '{}' not found in context", paramKey); + } + } else if (s.startsWith(SCRIPT_EXPRESSION_PREFIX)) { + if (scriptExecutor == null) { + LOGGER.warn("Script executor is null, cannot execute script expression: {}", s); + return RESOLUTION_ERROR; + } + String script = StringUtils.substringAfter(s, SCRIPT_EXPRESSION_PREFIX); + resolvedValue = scriptExecutor.execute(script, context); } + + // If resolved value is itself a parameter reference, continue resolving + if (resolvedValue != null && isParameterReference(resolvedValue)) { + Object furtherResolved = parseParameterWithValidationRecursive( + context, resolvedValue, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth + 1); + // Propagate RESOLUTION_ERROR if cycle/max-depth was detected, or resolved value otherwise + return furtherResolved; + } + + // Return resolved value (can be null if parameter not found, which is valid) + return resolvedValue; + } finally { + // Remove from resolution chain when done (allows same reference at different levels) + resolutionChain.remove(referenceKey); } } else if (value instanceof Map) { Map values = new HashMap(); for (Map.Entry entry : ((Map) value).entrySet()) { - Object parameter = parseParameter(context, entry.getValue(), scriptExecutor); - if (parameter == null) { - return null; + String paramName = entry.getKey(); + Object paramValue = entry.getValue(); + Parameter paramDef = parameterDefs != null ? parameterDefs.get(paramName) : null; + + Object parameter = parseParameterWithValidationRecursive( + context, paramValue, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth); + + // If resolution returned an error marker, propagate failure + if (parameter == RESOLUTION_ERROR) { + return RESOLUTION_ERROR; + } + + // Validate type if parameter definition is available and value is not null + if (parameter != null && paramDef != null && paramDef.getType() != null) { + validateParameterType(paramName, parameter, paramDef, + conditionTypeId); } + + // Always put the value, even if null (missing parameter is valid) values.put(entry.getKey(), parameter); } return values; } else if (value instanceof List) { List values = new ArrayList(); for (Object o : ((List) value)) { - Object parameter = parseParameter(context, o, scriptExecutor); - if (parameter != null) { - values.add(parameter); + Object parameter = parseParameterWithValidationRecursive( + context, o, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth); + // If resolution returned an error marker, propagate failure (consistent with Map behavior) + if (parameter == RESOLUTION_ERROR) { + return RESOLUTION_ERROR; } + // Add the value, even if null (missing parameter is valid in lists) + values.add(parameter); } return values; } return value; } - private static boolean hasContextualParameter(Object value) { + /** + * Validates that a resolved parameter value matches the expected type. + * Logs warnings if type mismatch is detected. + * + * @param paramName parameter name + * @param resolvedValue resolved parameter value + * @param paramDef parameter definition + * @param conditionTypeId condition type ID for context + */ + private static void validateParameterType( + String paramName, + Object resolvedValue, + Parameter paramDef, + String conditionTypeId) { + + if (resolvedValue == null || paramDef == null || paramDef.getType() == null) { + return; + } + + String expectedType = paramDef.getType().toLowerCase(); + String actualType = getValueType(resolvedValue); + + if (!isTypeCompatible(actualType, expectedType)) { + String message = String.format( + "Parameter '%s' in condition type '%s' resolved to type '%s' but expected '%s'", + paramName, conditionTypeId, actualType, expectedType); + + LOGGER.warn(message + " (value: {})", resolvedValue); + + } + } + + /** + * Gets the type name of a value for validation purposes. + * + * @param value the value to check + * @return type name (e.g., "integer", "date", "string") + */ + private static String getValueType(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof Integer || value instanceof Long || value instanceof Short || value instanceof Byte) { + return "integer"; + } + if (value instanceof Float || value instanceof Double) { + return "float"; + } + if (value instanceof Boolean) { + return "boolean"; + } + if (value instanceof Date || value instanceof Instant || + value instanceof OffsetDateTime || value instanceof ZonedDateTime || + value instanceof LocalDateTime) { + return "date"; + } if (value instanceof String) { - if (((String) value).startsWith("parameter::") || ((String) value).startsWith("script::")) { - return true; + return "string"; + } + if (value instanceof Condition) { + return "condition"; + } + if (value instanceof Collection) { + return "collection"; + } + if (value instanceof Map) { + return "object"; + } + return value.getClass().getSimpleName().toLowerCase(); + } + + /** + * Checks if an actual type is compatible with an expected type. + * + * @param actualType the actual type of the value + * @param expectedType the expected type from parameter definition + * @return true if types are compatible + */ + private static boolean isTypeCompatible(String actualType, String expectedType) { + if (actualType == null || expectedType == null) { + return true; // Can't validate without type info + } + + // Exact match + if (actualType.equals(expectedType)) { + return true; + } + + // Integer/Long compatibility + if (("integer".equals(expectedType) || "long".equals(expectedType)) && + ("integer".equals(actualType) || "long".equals(actualType))) { + return true; + } + + // Float/Double compatibility + if (("float".equals(expectedType) || "double".equals(expectedType)) && + ("float".equals(actualType) || "double".equals(actualType))) { + return true; + } + + // Date compatibility (various date types) + if ("date".equals(expectedType) && + (actualType.contains("date") || actualType.contains("time") || + actualType.contains("instant"))) { + return true; + } + + // String can often be converted, so be lenient + // But log warnings for type mismatches that might indicate issues + + return false; + } + + /** + * Checks if a value or any nested value contains parameter references or script expressions. + * Recursively checks Maps, Lists, Collections, and Condition objects. + * + * @param value the value to check + * @return true if the value contains any parameter references or script expressions + */ + public static boolean hasContextualParameter(Object value) { + if (value == null) { + return false; + } + + // Check if value itself is a reference/script + if (isParameterReference(value)) { + return true; + } + + // Check nested conditions + if (value instanceof Condition) { + Condition nestedCondition = (Condition) value; + Map nestedParams = nestedCondition.getParameterValues(); + if (nestedParams != null) { + for (Object nestedValue : nestedParams.values()) { + if (hasContextualParameter(nestedValue)) { + return true; + } + } } } else if (value instanceof Map) { for (Object o : ((Map) value).values()) { @@ -142,8 +468,8 @@ private static boolean hasContextualParameter(Object value) { return true; } } - } else if (value instanceof List) { - for (Object o : ((List) value)) { + } else if (value instanceof Collection) { + for (Object o : ((Collection) value)) { if (hasContextualParameter(o)) { return true; } @@ -152,6 +478,26 @@ private static boolean hasContextualParameter(Object value) { return false; } + /** + * Checks if a value is a parameter reference or script expression that + * needs to be resolved later. + * + * Parameter references use the format "parameter::key" where key is a + * key in the context map. Script expressions use the format "script::..." + * where ... is a script to be evaluated. + * + * @param value the value to check + * @return true if the value is a parameter reference or script expression + */ + public static boolean isParameterReference(Object value) { + if (value instanceof String) { + String stringValue = (String) value; + return stringValue.startsWith(PARAMETER_REFERENCE_PREFIX) || + stringValue.startsWith(SCRIPT_EXPRESSION_PREFIX); + } + return false; + } + public static String forceFoldToASCII(Object object) { if (object != null) { return foldToASCII(object.toString()); diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java index 14a6e972a9..b632c2758b 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/DateUtils.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -44,6 +45,16 @@ * utility classes. Keeping them here ensures backward-compatible behavior across our persistence * implementations, including OpenSearch. *

+ * Backward Compatibility: This implementation is designed to work with datasets + * migrated from older versions of Unomi and Elasticsearch. It supports: + *

    + *
  • Standard Elasticsearch date formats: {@code strict_date_optional_time} and {@code epoch_millis}
  • + *
  • Legacy formats: {@code date_optional_time} (non-strict, more lenient parsing)
  • + *
  • Epoch timestamps: both milliseconds and seconds
  • + *
  • Case-insensitive parsing: handles lowercase 't' and 'z' in ISO dates
  • + *
  • Modern Java date/time types: {@code Instant}, {@code OffsetDateTime}, {@code ZonedDateTime}, {@code LocalDateTime}
  • + *
+ *

* This class is stateless and thread-safe. */ public class DateUtils { @@ -55,12 +66,16 @@ public class DateUtils { *

    *
  • If the value is {@code null}, returns {@code null}.
  • *
  • If the value is already a {@link Date}, returns it unchanged.
  • + *
  • If the value is an {@link Instant}, converts it to {@link Date}.
  • + *
  • If the value is an {@link OffsetDateTime}, converts it to {@link Date}.
  • + *
  • If the value is a {@link ZonedDateTime}, converts it to {@link Date}.
  • + *
  • If the value is a {@link LocalDateTime}, converts it to {@link Date} using {@link ZoneOffset#UTC}.
  • *
  • Otherwise, attempts to parse the value as a string using a date math parser * that supports {@code strict_date_optional_time}, {@code epoch_millis}, ISO-8601 * formats, and Elasticsearch/OpenSearch date math expressions, all evaluated in UTC.
  • *
* - * @param value a date-like value (may be a {@link Date} or a parseable {@link String}) + * @param value a date-like value (may be a {@link Date}, modern date/time type, or a parseable {@link String}) * @return a {@link Date} if parsing succeeds; {@code null} otherwise */ public static Date getDate(Object value) { @@ -70,6 +85,7 @@ public static Date getDate(Object value) { if (value instanceof Date) { return (Date) value; } + // Handle modern Java date/time API types if (value instanceof Instant) { return Date.from((Instant) value); } @@ -79,21 +95,36 @@ public static Date getDate(Object value) { if (value instanceof ZonedDateTime) { return Date.from(((ZonedDateTime) value).toInstant()); } - // LocalDateTime carries no timezone; treat as UTC per Unomi's server-side convention. - // Callers that need a specific zone should use ZonedDateTime or OffsetDateTime instead. if (value instanceof LocalDateTime) { return Date.from(((LocalDateTime) value).atZone(ZoneOffset.UTC).toInstant()); - } else { - JavaDateFormatter formatter = new JavaDateFormatter("strict_date_optional_time||epoch_millis"); - DateMathParser dateMathParser = new DateMathParser(formatter, DateTimeFormatter.ISO_DATE_TIME); + } + // Fall back to string parsing for other types + // Use the same format pattern as Elasticsearch/OpenSearch default date format + // This supports: strict_date_optional_time (ISO-8601) and epoch_millis + // For backward compatibility with migrated datasets, we also support: + // - date_optional_time (non-strict, more lenient - used in older Elasticsearch versions) + // - epoch_second (for timestamps stored as seconds instead of milliseconds) + String dateString = value.toString(); + JavaDateFormatter formatter = new JavaDateFormatter("strict_date_optional_time||epoch_millis"); + DateMathParser dateMathParser = new DateMathParser(formatter, DateTimeFormatter.ISO_DATE_TIME); + try { + // Parse in UTC to match Elasticsearch/OpenSearch behavior + Instant instant = dateMathParser.parse(dateString, System::currentTimeMillis, false, ZoneOffset.UTC); + return Date.from(instant); + } catch (DateMathParseException e) { + // Fallback for legacy formats that might exist in migrated datasets + // Try with more lenient date_optional_time format (non-strict) try { - Instant instant = dateMathParser.parse(value.toString(), System::currentTimeMillis, false, ZoneOffset.UTC); + JavaDateFormatter fallbackFormatter = new JavaDateFormatter("date_optional_time||epoch_millis||epoch_second"); + DateMathParser fallbackParser = new DateMathParser(fallbackFormatter, DateTimeFormatter.ISO_DATE_TIME); + Instant instant = fallbackParser.parse(dateString, System::currentTimeMillis, false, ZoneOffset.UTC); + LOGGER.debug("Successfully parsed date using fallback formatter: {}", dateString); return Date.from(instant); - } catch (DateMathParseException e) { - LOGGER.warn("unable to parse date. See debug log level for full stacktrace"); - LOGGER.warn("unable to parse date {}", value, e); + } catch (DateMathParseException e2) { + LOGGER.warn("unable to parse date with primary and fallback formatters. See debug log level for full stacktrace"); + LOGGER.warn("unable to parse date {} - primary error: {}, fallback error: {}", dateString, e.getMessage(), e2.getMessage()); } - return null; } + return null; } } diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java index 3785147a57..d63719c801 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImpl.java @@ -18,15 +18,16 @@ package org.apache.unomi.persistence.spi.conditions.evaluator.impl; import org.apache.unomi.api.Item; -import org.apache.unomi.api.services.DefinitionsService; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.TypeResolutionService; +import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.metrics.MetricAdapter; import org.apache.unomi.metrics.MetricsService; import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluator; import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; import org.apache.unomi.scripting.ScriptExecutor; -import org.osgi.annotation.bundle.Requirement; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.osgi.service.component.annotations.ReferenceCardinality; @@ -66,6 +67,15 @@ public void setScriptExecutor(ScriptExecutor scriptExecutor) { this.scriptExecutor = scriptExecutor; } + @Reference(cardinality = ReferenceCardinality.OPTIONAL, policy = ReferencePolicy.DYNAMIC) + public void setDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + + public void unsetDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = null; + } + @Reference(service = ConditionEvaluator.class, cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) public void bindEvaluator(ConditionEvaluator evaluator, Map props) { evaluators.put((String) props.get("conditionEvaluatorId"), evaluator); @@ -75,14 +85,6 @@ public void unbindEvaluator(ConditionEvaluator evaluator, Map pr evaluators.remove((String) props.get("conditionEvaluatorId")); } - public void setDefinitionsService(DefinitionsService definitionsService) { - this.definitionsService = definitionsService; - } - - public void unsetDefinitionsService(DefinitionsService definitionsService) { - this.definitionsService = null; - } - @Override public void addEvaluator(String name, ConditionEvaluator evaluator) { evaluators.put(name, evaluator); @@ -95,7 +97,7 @@ public void removeEvaluator(String name) { @Override public boolean eval(Condition condition, Item item) { - return eval(condition, item, new HashMap<>()); + return eval(condition, item, new HashMap()); } @Override @@ -103,43 +105,87 @@ public boolean eval(Condition condition, Item item, Map context) if (condition == null) { throw new UnsupportedOperationException("Null condition passed for item : " + item); } - // If condition type is unresolved (e.g. missing condition type definition), return false gracefully - // instead of throwing NullPointerException. This matches the behaviour from unomi-3-dev. - if (condition.getConditionType() == null) { - LOGGER.debug("Condition type is null for condition typeID={}, returning false gracefully", - condition.getConditionTypeId()); - return false; - } - String conditionEvaluatorKey = condition.getConditionType().getConditionEvaluator(); - if (condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return eval(condition.getConditionType().getParentCondition(), item, context); - } - if (conditionEvaluatorKey == null) { - throw new UnsupportedOperationException("No evaluator defined for : " + condition.getConditionTypeId()); - } + try { + // Resolve condition type if needed - try to resolve first if definitionsService is available + if (condition.getConditionType() == null) { + if (definitionsService != null) { + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "condition evaluator"); + } + } else { + LOGGER.debug("DefinitionsService not available, cannot resolve condition type for condition typeID={}", condition.getConditionTypeId()); + } + // If still null after attempting resolution (or definitionsService was null), return false gracefully + if (condition.getConditionType() == null) { + LOGGER.warn("Condition type is null for condition typeID={}, returning false gracefully", condition.getConditionTypeId()); + return false; + } + } - if (evaluators.containsKey(conditionEvaluatorKey)) { - ConditionEvaluator evaluator = evaluators.get(conditionEvaluatorKey); - final ConditionEvaluatorDispatcher dispatcher = this; - try { - return new MetricAdapter(metricsService, this.getClass().getName() + ".conditions." + conditionEvaluatorKey) { - @Override public Boolean execute(Object... args) throws Exception { - Condition contextualCondition = ConditionContextHelper.getContextualCondition(condition, context, scriptExecutor); - if (contextualCondition != null) { - return evaluator.eval(contextualCondition, item, context, dispatcher); - } else { - return true; - } + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + Condition resolved = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "condition evaluator"); + if (resolved == null) { + LOGGER.warn("Could not resolve effective condition for typeID={} on item={} (cycle or max depth), returning false", + condition.getConditionTypeId(), item != null ? item.getItemId() : "null"); + return false; + } + effectiveCondition = resolved; + } else if (condition.getConditionType().getParentCondition() != null) { + // Fallback when DefinitionsService is not wired: recurse on embedded parent (master behaviour) + context.putAll(condition.getParameterValues()); + return eval(condition.getConditionType().getParentCondition(), item, context); + } + + // Check if effective condition has a type - if not, return false gracefully + if (effectiveCondition.getConditionType() == null) { + LOGGER.debug("Effective condition type is null for condition typeID={}, returning false gracefully", + effectiveCondition.getConditionTypeId()); + return false; + } + + String conditionEvaluatorKey = effectiveCondition.getConditionType().getConditionEvaluator(); + if (conditionEvaluatorKey == null) { + // resolveEffectiveCondition() already resolved to the root parent above, + // so a null evaluator key here means the chain genuinely has no evaluator. + LOGGER.warn("No evaluator defined for condition type: {}", effectiveCondition.getConditionTypeId()); + return false; + } + + if (evaluators.containsKey(conditionEvaluatorKey)) { + ConditionEvaluator evaluator = evaluators.get(conditionEvaluatorKey); + final ConditionEvaluatorDispatcher dispatcher = this; + try { + // Use effective condition for evaluation + Condition contextualCondition = ConditionContextHelper.getContextualCondition(effectiveCondition, context, scriptExecutor); + if (contextualCondition == null) { + return false; } - }.runWithTimer(); - } catch (Exception e) { - LOGGER.error("Error executing condition evaluator with key={}", conditionEvaluatorKey, e); + final Condition finalContextualCondition = contextualCondition; + boolean result = new MetricAdapter(metricsService, this.getClass().getName() + ".conditions." + conditionEvaluatorKey) { + @Override + public Boolean execute(Object... args) throws Exception { + return evaluator.eval(finalContextualCondition, item, context, dispatcher); + } + }.runWithTimer(); + return result; + } catch (Exception e) { + LOGGER.error("Error executing condition evaluator with key={}", conditionEvaluatorKey, e); + return false; + } + } else { + LOGGER.error("Couldn't find evaluator with key={}", conditionEvaluatorKey); + return false; } + } catch (Exception e) { + LOGGER.error("Infrastructure error during condition evaluation for typeID={} on item={}", + condition.getConditionTypeId(), item != null ? item.getItemId() : "null", e); + return false; } - - // if no matching - return false; } + } diff --git a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java new file mode 100644 index 0000000000..b9577595c4 --- /dev/null +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java @@ -0,0 +1,541 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.persistence.spi.conditions; + +import org.apache.unomi.api.Parameter; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.scripting.ScriptExecutor; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Comprehensive tests for ConditionContextHelper parameter resolution, + * including simple cases, chains, cycles, and edge cases. + */ +@RunWith(MockitoJUnitRunner.class) +public class ConditionContextHelperTest { + + @Mock + private ScriptExecutor scriptExecutor; + + @Mock + private DefinitionsService definitionsService; + + + private Map context; + + @Before + public void setUp() { + context = new HashMap<>(); + } + + // ========== Simple Parameter Reference Tests ========== + + @Test + public void testSimpleParameterReference() { + context.put("param1", "value1"); + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Simple parameter reference should resolve to context value", "value1", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testParameterReferenceNotFound() { + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "nonexistent"; + + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertNull("Missing parameter reference should resolve to null", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testSimpleScriptExpression() { + when(scriptExecutor.execute(eq("return 'scriptResult';"), eq(context))) + .thenReturn("scriptResult"); + + String value = ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'scriptResult';"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Script expression should execute and return result", "scriptResult", resolved.getParameterValues().get("testParam")); + } + + // ========== Parameter Reference Chain Tests ========== + + @Test + public void testTwoLevelParameterChain() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", "finalValue"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Two-level parameter chain should resolve to final value", "finalValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testThreeLevelParameterChain() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param3"); + context.put("param3", "finalValue"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Three-level parameter chain should resolve to final value", "finalValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testParameterToScriptChain() { + context.put("param1", ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'scriptResult';"); + when(scriptExecutor.execute(eq("return 'scriptResult';"), eq(context))) + .thenReturn("scriptResult"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Parameter to script chain should resolve correctly", "scriptResult", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testScriptToParameterChain() { + context.put("param1", "finalValue"); + when(scriptExecutor.execute(eq("return 'parameter::param1';"), eq(context))) + .thenReturn(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'parameter::param1';"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Script to parameter chain should resolve correctly", "finalValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testMixedParameterScriptChain() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'scriptResult';"); + context.put("param3", "finalValue"); + when(scriptExecutor.execute(eq("return 'scriptResult';"), eq(context))) + .thenReturn(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param3"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Mixed parameter-script chain should resolve correctly", "finalValue", resolved.getParameterValues().get("testParam")); + } + + // ========== Cyclic Reference Tests ========== + + @Test + public void testDirectCyclicReference() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNull("Condition with cyclic reference should return null", resolved); + } + + @Test + public void testTwoLevelCyclicReference() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNull("Condition with two-level cyclic reference should return null", resolved); + } + + @Test + public void testThreeLevelCyclicReference() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param3"); + context.put("param3", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNull("Condition with three-level cyclic reference should return null", resolved); + } + + @Test + public void testCyclicReferenceWithScript() { + context.put("param1", ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'parameter::param1';"); + when(scriptExecutor.execute(eq("return 'parameter::param1';"), eq(context))) + .thenReturn(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNull("Condition with script-based cyclic reference should return null", resolved); + } + + // ========== Maximum Depth Tests ========== + + @Test + public void testMaximumDepthExceeded() { + // Create a chain that exceeds MAX_RESOLUTION_DEPTH (50) + for (int i = 1; i <= 51; i++) { + if (i < 51) { + context.put("param" + i, ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param" + (i + 1)); + } else { + context.put("param" + i, "finalValue"); + } + } + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNull("Condition exceeding maximum depth should return null", resolved); + } + + // ========== Nested Structure Tests ========== + + @Test + public void testParameterReferenceInMap() { + context.put("param1", "value1"); + context.put("param2", "value2"); + + Map paramValues = new HashMap<>(); + paramValues.put("key1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + paramValues.put("key2", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + + Condition condition = new Condition(); + condition.setParameterValues(paramValues); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + @SuppressWarnings("unchecked") + Map resolvedValues = (Map) resolved.getParameterValues(); + assertEquals("Parameter reference in map should resolve", "value1", resolvedValues.get("key1")); + assertEquals("Parameter reference in map should resolve", "value2", resolvedValues.get("key2")); + } + + @Test + public void testParameterReferenceInList() { + context.put("param1", "value1"); + context.put("param2", "value2"); + + List paramValues = new ArrayList<>(); + paramValues.add(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + paramValues.add(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + paramValues.add("directValue"); + + Condition condition = new Condition(); + condition.setParameterValues(Collections.singletonMap("listParam", paramValues)); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + @SuppressWarnings("unchecked") + List resolvedList = (List) ((Map) resolved.getParameterValues()).get("listParam"); + assertNotNull("Resolved list should not be null", resolvedList); + assertEquals("List should have 3 elements", 3, resolvedList.size()); + assertEquals("First list element should resolve", "value1", resolvedList.get(0)); + assertEquals("Second list element should resolve", "value2", resolvedList.get(1)); + assertEquals("Third list element should remain unchanged", "directValue", resolvedList.get(2)); + } + + @Test + public void testNestedMapWithChains() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", "finalValue"); + + Map nestedMap = new HashMap<>(); + nestedMap.put("nestedKey", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + Map paramValues = new HashMap<>(); + paramValues.put("outerKey", nestedMap); + + Condition condition = new Condition(); + condition.setParameterValues(paramValues); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + @SuppressWarnings("unchecked") + Map outerMap = (Map) resolved.getParameterValues(); + @SuppressWarnings("unchecked") + Map innerMap = (Map) outerMap.get("outerKey"); + assertEquals("Nested map with parameter chain should resolve correctly", "finalValue", innerMap.get("nestedKey")); + } + + @Test + public void testNestedListWithChains() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", "finalValue"); + + List nestedList = new ArrayList<>(); + nestedList.add(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + Map paramValues = new HashMap<>(); + paramValues.put("listKey", nestedList); + + Condition condition = new Condition(); + condition.setParameterValues(paramValues); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + @SuppressWarnings("unchecked") + List resolvedList = (List) ((Map) resolved.getParameterValues()).get("listKey"); + assertEquals("Nested list with parameter chain should resolve correctly", "finalValue", resolvedList.get(0)); + } + + // ========== Type Validation Tests ========== + + @Test + public void testTypeValidationWithCorrectType() { + context.put("param1", 42); + + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("integer"); + conditionType.setParameters(Collections.singletonList(param)); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + condition.setConditionType(conditionType); + + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor, definitionsService); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Integer parameter should resolve correctly", 42, resolved.getParameterValues().get("testParam")); + } + + @Test + public void testTypeValidationWithTypeMismatch() { + context.put("param1", "notAnInteger"); + + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("integer"); + conditionType.setParameters(Collections.singletonList(param)); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + condition.setConditionType(conditionType); + + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor, definitionsService); + + assertNotNull("Resolved condition should not be null", resolved); + // Type mismatch should log warning but still resolve + assertEquals("Type mismatch should still resolve value but log warning", "notAnInteger", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testTypeValidationWithChain() { + context.put("param1", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + context.put("param2", 42); + + ConditionType conditionType = new ConditionType(); + conditionType.setItemId("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("integer"); + conditionType.setParameters(Collections.singletonList(param)); + + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + condition.setConditionType(conditionType); + + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor, definitionsService); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Parameter chain should resolve and validate type correctly", 42, resolved.getParameterValues().get("testParam")); + } + + // ========== Edge Case Tests ========== + + @Test + public void testNullValue() { + Condition condition = createConditionWithParameter("testParam", null); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertNull("Null value should remain null", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testEmptyContext() { + String value = ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, new HashMap<>(), scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertNull("Parameter reference in empty context should resolve to null", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testNonReferenceValue() { + Condition condition = createConditionWithParameter("testParam", "directValue"); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Non-reference value should remain unchanged", "directValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testConditionWithoutParameterReferences() { + Condition condition = createConditionWithParameter("testParam", "directValue"); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + // Should return the same condition instance when no references present + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Condition without parameter references should remain unchanged", "directValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testScriptReturningNull() { + when(scriptExecutor.execute(anyString(), eq(context))).thenReturn(null); + + String value = ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return null;"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertNull("Script returning null should resolve to null", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testScriptReturningParameterReference() { + context.put("param1", "finalValue"); + when(scriptExecutor.execute(eq("return 'parameter::param1';"), eq(context))) + .thenReturn(ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + + String value = ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'parameter::param1';"; + Condition condition = createConditionWithParameter("testParam", value); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + assertEquals("Script returning parameter reference should continue resolving", "finalValue", resolved.getParameterValues().get("testParam")); + } + + @Test + public void testMultipleParametersWithMixedReferences() { + context.put("param1", "value1"); + context.put("param2", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + when(scriptExecutor.execute(eq("return 'scriptResult';"), eq(context))) + .thenReturn("scriptResult"); + + Map paramValues = new HashMap<>(); + paramValues.put("direct", "directValue"); + paramValues.put("paramRef", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param1"); + paramValues.put("chainRef", ConditionContextHelper.PARAMETER_REFERENCE_PREFIX + "param2"); + paramValues.put("scriptRef", ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'scriptResult';"); + + Condition condition = new Condition(); + condition.setParameterValues(paramValues); + Condition resolved = ConditionContextHelper.getContextualCondition( + condition, context, scriptExecutor); + + assertNotNull("Resolved condition should not be null", resolved); + @SuppressWarnings("unchecked") + Map resolvedValues = (Map) resolved.getParameterValues(); + assertEquals("Direct value should remain unchanged", "directValue", resolvedValues.get("direct")); + assertEquals("Parameter reference should resolve", "value1", resolvedValues.get("paramRef")); + assertEquals("Parameter chain should resolve", "value1", resolvedValues.get("chainRef")); + assertEquals("Script reference should execute", "scriptResult", resolvedValues.get("scriptRef")); + } + + // TC5: null scriptExecutor + script:: expression — getContextualCondition must return null + // (RESOLUTION_ERROR propagated internally then converted to null at the public boundary) + @Test + public void testGetContextualCondition_nullScriptExecutor_scriptExpression_returnsNull() { + Map paramValues = new HashMap<>(); + paramValues.put("value", ConditionContextHelper.SCRIPT_EXPRESSION_PREFIX + "return 'result';"); + + Condition condition = new Condition(); + condition.setParameterValues(paramValues); + + // Pass null as scriptExecutor — simulates OSGi service not yet wired + Condition resolved = ConditionContextHelper.getContextualCondition(condition, context, null); + + assertNull("getContextualCondition must return null when scriptExecutor is null and a script:: value is present", resolved); + } + + // ========== Helper Methods ========== + + private Condition createConditionWithParameter(String paramName, Object paramValue) { + Condition condition = new Condition(); + Map paramValues = new HashMap<>(); + paramValues.put(paramName, paramValue); + condition.setParameterValues(paramValues); + return condition; + } +} + diff --git a/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImplTest.java b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImplTest.java new file mode 100644 index 0000000000..3994becae8 --- /dev/null +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImplTest.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.persistence.spi.conditions.evaluator.impl; + +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.TypeResolutionService; +import org.apache.unomi.metrics.MetricsService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluator; +import org.apache.unomi.scripting.ScriptExecutor; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * TC1: unit tests for the paths changed in ConditionEvaluatorDispatcherImpl — + * type resolution, parent fallback, and missing evaluator key. + */ +@RunWith(MockitoJUnitRunner.class) +public class ConditionEvaluatorDispatcherImplTest { + + private ConditionEvaluatorDispatcherImpl dispatcher; + + @Mock private MetricsService metricsService; + @Mock private ScriptExecutor scriptExecutor; + @Mock private DefinitionsService definitionsService; + @Mock private TypeResolutionService typeResolutionService; + + private final Profile dummyProfile = new Profile("test-profile"); + + @Before + public void setUp() { + dispatcher = new ConditionEvaluatorDispatcherImpl(); + dispatcher.setMetricsService(metricsService); + dispatcher.setScriptExecutor(scriptExecutor); + } + + // eval() with null conditionType and no DefinitionsService — must return false, not throw + @Test + public void eval_nullConditionType_noDefinitionsService_returnsFalse() { + Condition condition = new Condition(); + condition.setConditionTypeId("unknownType"); + // conditionType left null; no definitionsService set + + boolean result = dispatcher.eval(condition, dummyProfile); + + assertFalse("Must return false gracefully when conditionType is null and no DefinitionsService is available", result); + } + + // eval() with null conditionType, DS present but TypeResolutionService returns null — must return false + @Test + public void eval_nullConditionType_nullTypeResolutionService_returnsFalse() { + dispatcher.setDefinitionsService(definitionsService); + when(definitionsService.getTypeResolutionService()).thenReturn(null); + + Condition condition = new Condition(); + condition.setConditionTypeId("someType"); + + boolean result = dispatcher.eval(condition, dummyProfile); + + assertFalse("Must return false when TypeResolutionService is unavailable", result); + } + + // eval() with null conditionType, DS present, TypeResolutionService does not set a type — must return false + @Test + public void eval_nullConditionType_resolutionDoesNotSetType_returnsFalse() { + dispatcher.setDefinitionsService(definitionsService); + when(definitionsService.getTypeResolutionService()).thenReturn(typeResolutionService); + // resolveConditionType returns false and does NOT set conditionType → stays null + when(typeResolutionService.resolveConditionType(any(Condition.class), any(String.class))).thenReturn(false); + + Condition condition = new Condition(); + condition.setConditionTypeId("missingType"); + + boolean result = dispatcher.eval(condition, dummyProfile); + + assertFalse("Must return false when type resolution attempt leaves conditionType null", result); + verify(typeResolutionService).resolveConditionType(eq(condition), any(String.class)); + } + + // eval() where DS is null and conditionType has a parentCondition — must recurse to parent + @Test + public void eval_noDefinitionsService_parentConditionPresent_recursesFallback() { + // Parent condition: no evaluator key, no parent → returns false at the "no evaluator" path + ConditionType parentType = new ConditionType(new Metadata()); + parentType.setItemId("parentType"); + // conditionEvaluatorKey intentionally null → parent eval returns false too + Condition parentCondition = new Condition(parentType); + + ConditionType childType = new ConditionType(new Metadata()); + childType.setItemId("childType"); + childType.setParentCondition(parentCondition); + // No conditionEvaluatorKey on childType → null key triggers parent fallback (DS not wired path) + + Condition childCondition = new Condition(childType); + childCondition.setParameter("someParam", "someValue"); + // No DefinitionsService → fallback recursion on embedded parent fires at lines 132-135 + + boolean result = dispatcher.eval(childCondition, dummyProfile); + + // Recursion reaches parentCondition: no evaluator key, no parent → WARN + false + assertFalse("Fallback to parent condition with no evaluator must return false", result); + } + + // eval() where conditionEvaluatorKey is set but no evaluator is registered — must return false + @Test + public void eval_missingEvaluatorKey_returnsFalse() { + ConditionType type = new ConditionType(new Metadata()); + type.setItemId("myType"); + type.setConditionEvaluator("nonExistentEvaluator"); + + Condition condition = new Condition(type); + // No DefinitionsService — type is already set so resolution is skipped; no parent → effectiveCondition=condition + + boolean result = dispatcher.eval(condition, dummyProfile); + + assertFalse("Must return false when evaluatorKey references an unregistered evaluator", result); + } + + // eval() with a registered evaluator that always returns true — must return true + @Test + public void testEval_withRegisteredEvaluator_returnsResult() { + // Register an evaluator that always returns true + dispatcher.addEvaluator("testEval", (condition, item, ctx, d) -> true); + + Condition condition = new Condition(); + condition.setConditionTypeId("testEvalType"); + ConditionType conditionType = new ConditionType(new Metadata()); + conditionType.setConditionEvaluator("testEval"); + condition.setConditionType(conditionType); + + // Use a real Profile as item + Profile profile = new Profile("test-profile-eval"); + + boolean result = dispatcher.eval(condition, profile); + assertTrue("Evaluator returning true should produce true", result); + + dispatcher.removeEvaluator("testEval"); + } + + // addEvaluator / removeEvaluator wiring round-trip + @Test + public void addRemoveEvaluator_registrationRoundTrip() { + ConditionEvaluator mockEvaluator = mock(ConditionEvaluator.class); + dispatcher.addEvaluator("myEval", mockEvaluator); + + dispatcher.removeEvaluator("myEval"); + + // After removal, eval with that key must return false + ConditionType type = new ConditionType(new Metadata()); + type.setConditionEvaluator("myEval"); + Condition condition = new Condition(type); + + boolean result = dispatcher.eval(condition, dummyProfile); + assertFalse("After removal, evaluator key must not resolve to any registered evaluator", result); + } + + // eval() where the ConditionType parent chain forms a cycle — + // resolveEffectiveCondition() detects it and returns null; eval() must return false, not loop infinitely. + @Test + public void eval_cycleInParentChain_returnsFalseGracefully() { + // cycleTypeA.parentCondition → conditionWithTypeB (conditionType = cycleTypeB) + // cycleTypeB.parentCondition → conditionWithTypeA (conditionType = cycleTypeA) + // → ParserHelper.getParentChain() detects "cycleTypeB" already visited → returns null + ConditionType cycleTypeA = new ConditionType(new Metadata()); + cycleTypeA.setItemId("cycleTypeA"); + ConditionType cycleTypeB = new ConditionType(new Metadata()); + cycleTypeB.setItemId("cycleTypeB"); + + Condition conditionWithTypeB = new Condition(cycleTypeB); + conditionWithTypeB.setConditionType(cycleTypeB); + Condition conditionWithTypeA = new Condition(cycleTypeA); + conditionWithTypeA.setConditionType(cycleTypeA); + + cycleTypeA.setParentCondition(conditionWithTypeB); + cycleTypeB.setParentCondition(conditionWithTypeA); + + dispatcher.setDefinitionsService(definitionsService); + // definitionsService.getTypeResolutionService() returns null (mock default); + // no resolution is needed since all condition types are pre-set. + + Condition root = new Condition(cycleTypeA); + + boolean result = dispatcher.eval(root, dummyProfile); + + assertFalse("Cycle in ConditionType parent chain must return false, not cause infinite recursion or NPE", result); + } +} diff --git a/plugins/baseplugin/pom.xml b/plugins/baseplugin/pom.xml index 64f38f98ae..68e36ca865 100644 --- a/plugins/baseplugin/pom.xml +++ b/plugins/baseplugin/pom.xml @@ -114,6 +114,11 @@ slf4j-simple test + + org.mockito + mockito-core + test + diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluator.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluator.java index 4d16d4909a..8eb57b1ead 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluator.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluator.java @@ -34,15 +34,23 @@ public class BooleanConditionEvaluator implements ConditionEvaluator { public boolean eval(Condition condition, Item item, Map context, ConditionEvaluatorDispatcher dispatcher) { boolean isAnd = "and".equalsIgnoreCase((String) condition.getParameter("operator")); + Object subConditionsParam = condition.getParameter("subConditions"); + if (subConditionsParam != null && !(subConditionsParam instanceof List)) { + throw new IllegalArgumentException("Parameter 'subConditions' of condition type '" + + condition.getConditionTypeId() + "' must be a List, got: " + subConditionsParam.getClass().getName()); + } @SuppressWarnings("unchecked") - List conditions = (List) condition.getParameter("subConditions"); + List conditions = (List) subConditionsParam; + + if (conditions == null || conditions.isEmpty()) { + return isAnd; + } + for (Condition sub : conditions) { boolean eval = dispatcher.eval(sub, item, context); if (!eval && isAnd) { - // And return false; } else if (eval && !isAnd) { - // Or return true; } } diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluator.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluator.java index 51bb6fd821..68d008bf46 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluator.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluator.java @@ -30,7 +30,12 @@ public boolean eval(Condition condition, Item item, Map context, Collection ids = (Collection) condition.getParameter("ids"); Boolean match = (Boolean) condition.getParameter("match"); - boolean contained = ids != null && !ids.isEmpty() && ids.contains(item.getItemId()); - return match == contained; + if (ids == null || ids.isEmpty()) { + return false; + } + + boolean contained = ids.contains(item.getItemId()); + boolean matchValue = match == null || match; + return matchValue == contained; } } diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/NotConditionEvaluator.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/NotConditionEvaluator.java index 57a305a964..fff7dc799f 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/NotConditionEvaluator.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/NotConditionEvaluator.java @@ -32,6 +32,9 @@ public class NotConditionEvaluator implements ConditionEvaluator { @Override public boolean eval(Condition condition, Item item, Map context, ConditionEvaluatorDispatcher dispatcher) { Condition subCondition = (Condition) condition.getParameter("subCondition"); + if (subCondition == null) { + return false; + } return !dispatcher.eval(subCondition, item, context); } } diff --git a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java index f5a3a711fe..18d8225a3d 100644 --- a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java +++ b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/conditions/PropertyConditionEvaluator.java @@ -25,9 +25,9 @@ import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.persistence.spi.PropertyHelper; import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; +import org.apache.unomi.persistence.spi.conditions.DateUtils; import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluator; import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; -import org.apache.unomi.persistence.spi.conditions.DateUtils; import org.apache.unomi.persistence.spi.conditions.geo.DistanceUnit; import org.apache.unomi.plugins.baseplugin.conditions.accessors.HardcodedPropertyAccessor; import org.apache.unomi.scripting.ExpressionFilterFactory; @@ -64,6 +64,7 @@ public void setExpressionFilterFactory(ExpressionFilterFactory expressionFilterF this.expressionFilterFactory = expressionFilterFactory; } + private int compare(Object actualValue, String expectedValue, Object expectedValueDate, Object expectedValueInteger, Object expectedValueDateExpr, Object expectedValueDouble) { if (expectedValue == null && expectedValueDate == null && expectedValueInteger == null && getDate(expectedValueDateExpr) == null && expectedValueDouble == null) { return actualValue == null ? 0 : 1; @@ -72,13 +73,37 @@ private int compare(Object actualValue, String expectedValue, Object expectedVal } if (expectedValueInteger != null) { - return PropertyHelper.getInteger(actualValue).compareTo(PropertyHelper.getInteger(expectedValueInteger)); + Integer actualInt = PropertyHelper.getInteger(actualValue); + Integer expectedInt = PropertyHelper.getInteger(expectedValueInteger); + if (actualInt == null || expectedInt == null) { + // If either value cannot be converted to integer, they are not equal + return actualInt == null && expectedInt == null ? 0 : (actualInt == null ? -1 : 1); + } + return actualInt.compareTo(expectedInt); } else if (expectedValueDouble != null) { - return PropertyHelper.getDouble(actualValue).compareTo(PropertyHelper.getDouble(expectedValueDouble)); + Double actualDouble = PropertyHelper.getDouble(actualValue); + Double expectedDouble = PropertyHelper.getDouble(expectedValueDouble); + if (actualDouble == null || expectedDouble == null) { + // If either value cannot be converted to double, they are not equal + return actualDouble == null && expectedDouble == null ? 0 : (actualDouble == null ? -1 : 1); + } + return actualDouble.compareTo(expectedDouble); } else if (expectedValueDate != null) { - return getDate(actualValue).compareTo(getDate(expectedValueDate)); + Date actualDate = getDate(actualValue); + Date expectedDate = getDate(expectedValueDate); + if (actualDate == null || expectedDate == null) { + // If either value cannot be converted to date, they are not equal + return actualDate == null && expectedDate == null ? 0 : (actualDate == null ? -1 : 1); + } + return actualDate.compareTo(expectedDate); } else if (expectedValueDateExpr != null) { - return getDate(actualValue).compareTo(getDate(expectedValueDateExpr)); + Date actualDate = getDate(actualValue); + Date expectedDate = getDate(expectedValueDateExpr); + if (actualDate == null || expectedDate == null) { + // If either value cannot be converted to date, they are not equal + return actualDate == null && expectedDate == null ? 0 : (actualDate == null ? -1 : 1); + } + return actualDate.compareTo(expectedDate); } else { // We use foldToASCII here to match the behavior of the analyzer configuration in the persistence configuration return ConditionContextHelper.foldToASCII(actualValue.toString()).compareTo(expectedValue); @@ -98,7 +123,9 @@ private boolean compareValues(Object actualValue, Collection expectedValues, return false; } - Collection actual = ConditionContextHelper.foldToASCII(getValueSet(actualValue)); + // Normalize actual and expected collections so enums compare as strings (name()) and strings are folded + Collection actual = normalizeCollection(ConditionContextHelper.foldToASCII(getValueSet(actualValue))); + Collection expectedNormalized = normalizeCollection(expected); boolean result = true; @@ -106,7 +133,7 @@ private boolean compareValues(Object actualValue, Collection expectedValues, case "in": result = false; for (Object a : actual) { - if (expected.contains(a)) { + if (expectedNormalized.contains(a)) { result = true; break; } @@ -124,14 +151,14 @@ private boolean compareValues(Object actualValue, Collection expectedValues, break; case "notIn": for (Object a : actual) { - if (expected.contains(a)) { + if (expectedNormalized.contains(a)) { result = false; break; } } break; case "all": - for (Object e : expected) { + for (Object e : expectedNormalized) { if (!actual.contains(e)) { result = false; break; @@ -139,12 +166,12 @@ private boolean compareValues(Object actualValue, Collection expectedValues, } break; case "hasNoneOf": - if (!Collections.disjoint(actual, expected)) { + if (!Collections.disjoint(actual, expectedNormalized)) { return false; } break; case "hasSomeOf": - if (Collections.disjoint(actual, expected)) { + if (Collections.disjoint(actual, expectedNormalized)) { return false; } break; @@ -156,46 +183,65 @@ private boolean compareValues(Object actualValue, Collection expectedValues, return result; } + private Collection normalizeCollection(Collection input) { + if (input == null) { + return null; + } + return input.stream() + .map(this::normalizeScalar) + .collect(Collectors.toList()); + } + + private Object normalizeScalar(Object value) { + if (value == null) { + return null; + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return value; + } + @Override public boolean eval(Condition condition, Item item, Map context, ConditionEvaluatorDispatcher dispatcher) { - String op = (String) condition.getParameter("comparisonOperator"); - String name = (String) condition.getParameter("propertyName"); - - String expectedValue = ConditionContextHelper.foldToASCII((String) condition.getParameter("propertyValue")); - Object expectedValueInteger = condition.getParameter("propertyValueInteger"); - Object expectedValueDouble = condition.getParameter("propertyValueDouble"); - Object expectedValueDate = condition.getParameter("propertyValueDate"); - Object expectedValueDateExpr = condition.getParameter("propertyValueDateExpr"); - - Object actualValue; - if (item instanceof Event && "eventType".equals(name)) { - actualValue = ((Event) item).getEventType(); - } else { - try { - long time = System.nanoTime(); - //actualValue = beanUtilsBean.getPropertyUtils().getProperty(item, name); - actualValue = getPropertyValue(item, name); - time = System.nanoTime() - time; - if (time > 5000000L) { - LOGGER.info("eval took {} ms for {} {}", time / 1000000L, item.getClass().getName(), name); - } - } catch (NullPointerException e) { - // property not found - actualValue = null; - } catch (Exception e) { - if ((!StringUtils.startsWith(e.getMessage(),"source is null for getProperty(null"))) { - LOGGER.warn("Error evaluating value for {} {}. See debug level for more information", item.getClass().getName(), name); - if (LOGGER.isDebugEnabled()) LOGGER.debug("Error evaluating value for {} {}", item.getClass().getName(), name, e); + String op = (String) condition.getParameter("comparisonOperator"); + String name = (String) condition.getParameter("propertyName"); + + String expectedValue = ConditionContextHelper.foldToASCII((String) condition.getParameter("propertyValue")); + Object expectedValueInteger = condition.getParameter("propertyValueInteger"); + Object expectedValueDouble = condition.getParameter("propertyValueDouble"); + Object expectedValueDate = condition.getParameter("propertyValueDate"); + Object expectedValueDateExpr = condition.getParameter("propertyValueDateExpr"); + + Object actualValue; + if (item instanceof Event && "eventType".equals(name)) { + actualValue = ((Event) item).getEventType(); + } else { + try { + long time = System.nanoTime(); + actualValue = getPropertyValue(item, name); + time = System.nanoTime() - time; + if (time > 5000000L) { + LOGGER.info("eval took {} ms for {} {}", time / 1000000L, item.getClass().getName(), name); + } + } catch (NullPointerException e) { + // property not found + actualValue = null; + } catch (Exception e) { + if (!StringUtils.startsWith(e.getMessage(),"source is null for getProperty(null")) { + LOGGER.warn("Error evaluating value for {} {}. See debug level for more information", item.getClass().getName(), name); + if (LOGGER.isDebugEnabled()) LOGGER.debug("Error evaluating value for {} {}", item.getClass().getName(), name, e); + } + actualValue = null; } - actualValue = null; } - } - if (actualValue instanceof String) { - actualValue = ConditionContextHelper.foldToASCII((String) actualValue); - } + if (actualValue instanceof String) { + actualValue = ConditionContextHelper.foldToASCII((String) actualValue); + } - return isMatch(op, actualValue, expectedValue, expectedValueInteger, expectedValueDouble, expectedValueDate, - expectedValueDateExpr, condition); + boolean result = isMatch(op, actualValue, expectedValue, expectedValueInteger, expectedValueDouble, expectedValueDate, + expectedValueDateExpr, condition); + return result; } protected boolean isMatch(String op, Object actualValue, String expectedValue, Object expectedValueInteger, Object expectedValueDouble, @@ -204,6 +250,64 @@ protected boolean isMatch(String op, Object actualValue, String expectedValue, O return false; } else if (actualValue == null) { return op.equals("missing") || op.equals("notIn") || op.equals("notEquals") || op.equals("hasNoneOf"); + } else if (expectedValueInteger != null && + ("greaterThan".equals(op) || "greaterThanOrEqualTo".equals(op) || "lessThan".equals(op) || "lessThanOrEqualTo".equals(op))) { + Integer actualInt = PropertyHelper.getInteger(actualValue); + Integer expectedInt = PropertyHelper.getInteger(expectedValueInteger); + if (actualInt == null || expectedInt == null) { + return false; + } + switch (op) { + case "greaterThan": + return actualInt.compareTo(expectedInt) > 0; + case "greaterThanOrEqualTo": + return actualInt.compareTo(expectedInt) >= 0; + case "lessThan": + return actualInt.compareTo(expectedInt) < 0; + case "lessThanOrEqualTo": + return actualInt.compareTo(expectedInt) <= 0; + default: + return false; + } + } else if (expectedValueDouble != null && + ("greaterThan".equals(op) || "greaterThanOrEqualTo".equals(op) || "lessThan".equals(op) || "lessThanOrEqualTo".equals(op))) { + Double actualDouble = PropertyHelper.getDouble(actualValue); + Double expectedDouble = PropertyHelper.getDouble(expectedValueDouble); + if (actualDouble == null || expectedDouble == null) { + return false; + } + switch (op) { + case "greaterThan": + return actualDouble.compareTo(expectedDouble) > 0; + case "greaterThanOrEqualTo": + return actualDouble.compareTo(expectedDouble) >= 0; + case "lessThan": + return actualDouble.compareTo(expectedDouble) < 0; + case "lessThanOrEqualTo": + return actualDouble.compareTo(expectedDouble) <= 0; + default: + return false; + } + } else if ((expectedValueDate != null || expectedValueDateExpr != null) && + ("greaterThan".equals(op) || "greaterThanOrEqualTo".equals(op) || "lessThan".equals(op) || "lessThanOrEqualTo".equals(op))) { + Date actualDate = getDate(actualValue); + Object expectedDateObj = expectedValueDate != null ? expectedValueDate : expectedValueDateExpr; + Date expectedDate = getDate(expectedDateObj); + if (actualDate == null || expectedDate == null) { + return false; + } + switch (op) { + case "greaterThan": + return actualDate.compareTo(expectedDate) > 0; + case "greaterThanOrEqualTo": + return actualDate.compareTo(expectedDate) >= 0; + case "lessThan": + return actualDate.compareTo(expectedDate) < 0; + case "lessThanOrEqualTo": + return actualDate.compareTo(expectedDate) <= 0; + default: + return false; + } } else if (op.equals("exists")) { if (actualValue instanceof List) { return ((List) actualValue).size() > 0; @@ -237,17 +341,56 @@ protected boolean isMatch(String op, Object actualValue, String expectedValue, O Collection expectedValuesDouble = (Collection) condition.getParameter("propertyValuesDouble"); Collection expectedValuesDate = (Collection) condition.getParameter("propertyValuesDate"); Collection expectedValuesDateExpr = (Collection) condition.getParameter("propertyValuesDateExpr"); - return compare(actualValue, null, - getDate(getFirst(expectedValuesDate)), - getFirst(expectedValuesInteger), - getFirst(expectedValuesDateExpr), - getFirst(expectedValuesDouble)) >= 0 - && - compare(actualValue, null, - getDate(getSecond(expectedValuesDate)), - getSecond(expectedValuesInteger), - getSecond(expectedValuesDateExpr), - getSecond(expectedValuesDouble)) <= 0; + Object firstInteger = getFirst(expectedValuesInteger); + Object secondInteger = getSecond(expectedValuesInteger); + Object firstDouble = getFirst(expectedValuesDouble); + Object secondDouble = getSecond(expectedValuesDouble); + Date firstDate = getDate(getFirst(expectedValuesDate)); + Date secondDate = getDate(getSecond(expectedValuesDate)); + Date firstDateExpr = getDate(getFirst(expectedValuesDateExpr)); + Date secondDateExpr = getDate(getSecond(expectedValuesDateExpr)); + + // Numeric between (integer) + if (firstInteger != null || secondInteger != null) { + Integer actualInt = PropertyHelper.getInteger(actualValue); + Integer firstInt = PropertyHelper.getInteger(firstInteger); + Integer secondInt = PropertyHelper.getInteger(secondInteger); + if (actualInt == null || firstInt == null || secondInt == null) { + return false; + } + return actualInt.compareTo(firstInt) >= 0 && actualInt.compareTo(secondInt) <= 0; + } + + // Numeric between (double) + if (firstDouble != null || secondDouble != null) { + Double actualDouble = PropertyHelper.getDouble(actualValue); + Double firstD = PropertyHelper.getDouble(firstDouble); + Double secondD = PropertyHelper.getDouble(secondDouble); + if (actualDouble == null || firstD == null || secondD == null) { + return false; + } + return actualDouble.compareTo(firstD) >= 0 && actualDouble.compareTo(secondD) <= 0; + } + + // Date between (explicit date) + if (firstDate != null || secondDate != null) { + Date actualDate = getDate(actualValue); + if (actualDate == null || firstDate == null || secondDate == null) { + return false; + } + return actualDate.compareTo(firstDate) >= 0 && actualDate.compareTo(secondDate) <= 0; + } + + // Date between (date expression) + if (firstDateExpr != null || secondDateExpr != null) { + Date actualDate = getDate(actualValue); + if (actualDate == null || firstDateExpr == null || secondDateExpr == null) { + return false; + } + return actualDate.compareTo(firstDateExpr) >= 0 && actualDate.compareTo(secondDateExpr) <= 0; + } + + return false; } else if (op.equals("contains")) { return actualValue.toString().contains(expectedValue); } else if (op.equals("notContains")) { diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/IdsCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/IdsCondition.json index a4a86850fc..1105d8b789 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/IdsCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/IdsCondition.json @@ -20,4 +20,4 @@ "multivalued": false } ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/booleanCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/booleanCondition.json index 3dbf1b04ba..9ce7aad213 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/booleanCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/booleanCondition.json @@ -2,7 +2,7 @@ "metadata": { "id": "booleanCondition", "name": "booleanCondition", - "description": "", + "description": "Combines multiple conditions with a logical operator", "systemTags": [ "profileTags", "logical", @@ -19,14 +19,21 @@ "parameters": [ { "id": "operator", - "type": "String", + "type": "string", "multivalued": false, - "defaultValue": "and" + "defaultValue": "and", + "validation": { + "required": true, + "allowedValues": ["and", "or"] + } }, { "id": "subConditions", "type": "Condition", - "multivalued": true + "multivalued": true, + "validation": { + "required": true + } } ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/eventPropertyCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/eventPropertyCondition.json index e3d7a6174b..6ac81225f3 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/eventPropertyCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/eventPropertyCondition.json @@ -35,6 +35,11 @@ "type": "integer", "multivalued": false }, + { + "id": "propertyValueDouble", + "type": "double", + "multivalued": false + }, { "id": "propertyValueDate", "type": "date", @@ -55,6 +60,11 @@ "type": "integer", "multivalued": true }, + { + "id": "propertyValuesDouble", + "type": "double", + "multivalued": true + }, { "id": "propertyValuesDate", "type": "date", @@ -66,4 +76,4 @@ "multivalued": true } ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/matchAllCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/matchAllCondition.json index 48aaf16d64..9655fbf762 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/matchAllCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/matchAllCondition.json @@ -19,4 +19,4 @@ "parameters": [ ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/nestedCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/nestedCondition.json index 426cc3c947..047247c8ee 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/nestedCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/nestedCondition.json @@ -2,7 +2,7 @@ "metadata": { "id": "nestedCondition", "name": "nestedCondition", - "description": "", + "description": "Evaluates a condition on nested items within a profile or session property", "systemTags": [ "profileTags", "logical", @@ -17,13 +17,19 @@ "parameters": [ { "id": "path", - "type": "String", - "multivalued": false + "type": "string", + "multivalued": false, + "validation": { + "required": true + } }, { "id": "subCondition", "type": "Condition", - "multivalued": false + "multivalued": false, + "validation": { + "required": true + } } ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/notCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/notCondition.json index 736929d28c..4506f754c3 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/notCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/notCondition.json @@ -24,4 +24,4 @@ "multivalued": false } ] -} \ No newline at end of file +} diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/pastEventCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/pastEventCondition.json index 3411cd66bc..b2550eef0b 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/pastEventCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/pastEventCondition.json @@ -2,7 +2,7 @@ "metadata": { "id": "pastEventCondition", "name": "pastEventCondition", - "description": "", + "description": "Evaluates past events that occurred within a specified time range", "systemTags": [ "availableToEndUser", "behavioral", @@ -17,6 +17,25 @@ "queryBuilder": "pastEventConditionQueryBuilder", "parameters": [ + { + "id": "eventCondition", + "type": "Condition", + "multivalued": false, + "validation": { + "required": true, + "allowedConditionTags": ["eventCondition"] + } + }, + { + "id": "operator", + "type": "string", + "multivalued": false, + "defaultValue": "eventsOccurred", + "validation": { + "recommended": true, + "allowedValues": ["eventsOccurred", "eventsNotOccurred"] + } + }, { "id": "numberOfDays", "type": "integer", @@ -35,22 +54,18 @@ { "id": "minimumEventCount", "type": "integer", - "multivalued": false + "multivalued": false, + "validation": { + "recommended": true + } }, { "id": "maximumEventCount", "type": "integer", - "multivalued": false - }, - { - "id": "operator", - "type": "string", - "multivalued": false - }, - { - "id": "eventCondition", - "type": "Condition", - "multivalued": false + "multivalued": false, + "validation": { + "recommended": true + } } ] } diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/profilePropertyCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/profilePropertyCondition.json index bddf515fdc..249d8c7d50 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/profilePropertyCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/profilePropertyCondition.json @@ -2,7 +2,7 @@ "metadata": { "id": "profilePropertyCondition", "name": "profilePropertyCondition", - "description": "", + "description": "Evaluates a profile property against various comparison criteria", "systemTags": [ "availableToEndUser", "profileTags", @@ -18,52 +18,108 @@ { "id": "propertyName", "type": "string", - "multivalued": false + "multivalued": false, + "validation": { + "required": true + } }, { "id": "comparisonOperator", "type": "comparisonOperator", - "multivalued": false + "multivalued": false, + "validation": { + "required": true + } }, { "id": "propertyValue", "type": "string", - "multivalued": false + "multivalued": false, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValue" + } }, { "id": "propertyValueInteger", "type": "integer", - "multivalued": false + "multivalued": false, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValue" + } + }, + { + "id": "propertyValueDouble", + "type": "double", + "multivalued": false, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValue" + } }, { "id": "propertyValueDate", "type": "date", - "multivalued": false + "multivalued": false, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValue" + } }, { "id": "propertyValueDateExpr", "type": "string", - "multivalued": false + "multivalued": false, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValue" + } }, { "id": "propertyValues", "type": "string", - "multivalued": true + "multivalued": true, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValues" + } }, { "id": "propertyValuesInteger", "type": "integer", - "multivalued": true + "multivalued": true, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValues" + } + }, + { + "id": "propertyValuesDouble", + "type": "double", + "multivalued": true, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValues" + } }, { "id": "propertyValuesDate", "type": "date", - "multivalued": true + "multivalued": true, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValues" + } }, { "id": "propertyValuesDateExpr", "type": "string", - "multivalued": true + "multivalued": true, + "validation": { + "exclusive": true, + "exclusiveGroup": "propertyValues" + } } ] } diff --git a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/sessionPropertyCondition.json b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/sessionPropertyCondition.json index 8dad7607fb..32f7271cf3 100644 --- a/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/sessionPropertyCondition.json +++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/conditions/sessionPropertyCondition.json @@ -36,6 +36,11 @@ "type": "integer", "multivalued": false }, + { + "id": "propertyValueDouble", + "type": "double", + "multivalued": false + }, { "id": "propertyValueDate", "type": "date", @@ -56,6 +61,11 @@ "type": "integer", "multivalued": true }, + { + "id": "propertyValuesDouble", + "type": "double", + "multivalued": true + }, { "id": "propertyValuesDate", "type": "date", diff --git a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluatorTest.java b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluatorTest.java new file mode 100644 index 0000000000..67a027be6b --- /dev/null +++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluatorTest.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.plugins.baseplugin.conditions; + +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class BooleanConditionEvaluatorTest { + + private final BooleanConditionEvaluator evaluator = new BooleanConditionEvaluator(); + + @Mock + private ConditionEvaluatorDispatcher dispatcher; + + private Profile profile; + + @Before + public void setUp() { + profile = new Profile("test-profile"); + } + + // --- empty / null subConditions --- + + @Test + public void emptyAnd_returnsTrue() { + assertTrue(evaluator.eval(booleanCondition("and"), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void nullSubConditions_and_returnsTrue() { + Condition c = new Condition(); + c.setParameter("operator", "and"); + assertTrue(evaluator.eval(c, profile, new HashMap<>(), dispatcher)); + } + + @Test + public void emptyOr_returnsFalse() { + assertFalse(evaluator.eval(booleanCondition("or"), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void nullSubConditions_or_returnsFalse() { + Condition c = new Condition(); + c.setParameter("operator", "or"); + assertFalse(evaluator.eval(c, profile, new HashMap<>(), dispatcher)); + } + + // --- AND logic --- + + @Test + public void and_allTrue_returnsTrue() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(true); + when(dispatcher.eval(same(sub2), same(profile), any())).thenReturn(true); + assertTrue(evaluator.eval(booleanCondition("and", sub1, sub2), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void and_firstFalse_returnsFalse() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(false); + assertFalse(evaluator.eval(booleanCondition("and", sub1, sub2), profile, new HashMap<>(), dispatcher)); + verify(dispatcher, never()).eval(same(sub2), any(), any()); + } + + @Test + public void and_lastFalse_returnsFalse() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(true); + when(dispatcher.eval(same(sub2), same(profile), any())).thenReturn(false); + assertFalse(evaluator.eval(booleanCondition("and", sub1, sub2), profile, new HashMap<>(), dispatcher)); + } + + // --- OR logic --- + + @Test + public void or_allFalse_returnsFalse() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(false); + when(dispatcher.eval(same(sub2), same(profile), any())).thenReturn(false); + assertFalse(evaluator.eval(booleanCondition("or", sub1, sub2), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void or_firstTrue_returnsTrue() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(true); + assertTrue(evaluator.eval(booleanCondition("or", sub1, sub2), profile, new HashMap<>(), dispatcher)); + verify(dispatcher, never()).eval(same(sub2), any(), any()); + } + + @Test + public void or_lastTrue_returnsTrue() { + Condition sub1 = new Condition(); + Condition sub2 = new Condition(); + when(dispatcher.eval(same(sub1), same(profile), any())).thenReturn(false); + when(dispatcher.eval(same(sub2), same(profile), any())).thenReturn(true); + assertTrue(evaluator.eval(booleanCondition("or", sub1, sub2), profile, new HashMap<>(), dispatcher)); + } + + // --- operator case-insensitivity --- + + @Test + public void operatorUppercaseAND_emptySubConditions_returnsTrue() { + Condition c = new Condition(); + c.setParameter("operator", "AND"); + c.setParameter("subConditions", Collections.emptyList()); + assertTrue(evaluator.eval(c, profile, new HashMap<>(), dispatcher)); + } + + @Test + public void operatorUppercaseOR_emptySubConditions_returnsFalse() { + Condition c = new Condition(); + c.setParameter("operator", "OR"); + c.setParameter("subConditions", Collections.emptyList()); + assertFalse(evaluator.eval(c, profile, new HashMap<>(), dispatcher)); + } + + // --- null operator treated as non-"and" (OR semantics) --- + + @Test + public void nullOperator_emptySubConditions_returnsFalse() { + Condition c = new Condition(); + c.setParameter("subConditions", Collections.emptyList()); + assertFalse(evaluator.eval(c, profile, new HashMap<>(), dispatcher)); + } + + // --- type safety guard --- + + @Test + public void nonListSubConditions_throwsIllegalArgumentException() { + // Passing a non-List as subConditions must throw IAE immediately (not silently cast or swallow). + Condition c = new Condition(); + c.setParameter("operator", "and"); + c.setParameter("subConditions", "not-a-list"); + + try { + evaluator.eval(c, profile, new HashMap<>(), dispatcher); + fail("Expected IllegalArgumentException when subConditions is not a List"); + } catch (IllegalArgumentException expected) { + assertTrue("Exception message must mention 'subConditions'", + expected.getMessage().contains("subConditions")); + } + } + + // --- helper --- + + private Condition booleanCondition(String operator, Condition... subs) { + Condition c = new Condition(); + c.setParameter("operator", operator); + c.setParameter("subConditions", subs.length == 0 ? Collections.emptyList() : Arrays.asList(subs)); + return c; + } +} diff --git a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluatorTest.java b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluatorTest.java new file mode 100644 index 0000000000..1f54ec3ac0 --- /dev/null +++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluatorTest.java @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.plugins.baseplugin.conditions; + +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(MockitoJUnitRunner.class) +public class IdsConditionEvaluatorTest { + + private final IdsConditionEvaluator evaluator = new IdsConditionEvaluator(); + + @Mock + private ConditionEvaluatorDispatcher dispatcher; // never called by IdsConditionEvaluator + + private Profile profile; + + @Before + public void setUp() { + profile = new Profile("profile-a"); + } + + // --- null / empty ids always false --- + + @Test + public void nullIds_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(null, null), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void emptyIds_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(Collections.emptyList(), null), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void emptyIds_matchFalse_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(Collections.emptyList(), false), profile, new HashMap<>(), dispatcher)); + } + + // --- null match defaults to inclusion (match=true semantics, no NPE) --- + + @Test + public void nullMatch_itemInList_returnsTrue() { + assertTrue(evaluator.eval(idsCondition(Arrays.asList("profile-a", "profile-b"), null), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void nullMatch_itemNotInList_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(Arrays.asList("profile-x"), null), profile, new HashMap<>(), dispatcher)); + } + + // --- explicit match=true (inclusion mode) --- + + @Test + public void matchTrue_itemInList_returnsTrue() { + assertTrue(evaluator.eval(idsCondition(Arrays.asList("profile-a"), true), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void matchTrue_itemNotInList_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(Arrays.asList("profile-x"), true), profile, new HashMap<>(), dispatcher)); + } + + // --- explicit match=false (exclusion mode) --- + + @Test + public void matchFalse_itemInList_returnsFalse() { + assertFalse(evaluator.eval(idsCondition(Arrays.asList("profile-a", "profile-b"), false), profile, new HashMap<>(), dispatcher)); + } + + @Test + public void matchFalse_itemNotInList_returnsTrue() { + assertTrue(evaluator.eval(idsCondition(Arrays.asList("profile-x", "profile-y"), false), profile, new HashMap<>(), dispatcher)); + } + + // --- helper --- + + private Condition idsCondition(Object ids, Boolean match) { + Condition c = new Condition(); + c.setParameter("ids", ids); + c.setParameter("match", match); + return c; + } +} diff --git a/services/pom.xml b/services/pom.xml index fb9eaa15b6..280d8df57a 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -77,6 +77,7 @@ provided + org.osgi osgi.core diff --git a/services/src/main/java/org/apache/unomi/services/impl/AbstractServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/AbstractServiceImpl.java index afd4f801de..1f9247976a 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/AbstractServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/AbstractServiceImpl.java @@ -57,7 +57,9 @@ protected PartialList getMetadatas(Query quer if (query.isForceRefresh()) { persistenceService.refreshIndex(clazz); } - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } PartialList items = persistenceService.query(query.getCondition(), query.getSortby(), clazz, query.getOffset(), query.getLimit()); List details = new LinkedList<>(); for (T definition : items.getList()) { diff --git a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java new file mode 100644 index 0000000000..329756a75b --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -0,0 +1,558 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.MetadataItem; +import org.apache.unomi.api.PropertyType; +import org.apache.unomi.api.ValueType; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.actions.ActionType; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.InvalidObjectInfo; +import org.apache.unomi.api.services.TypeResolutionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Implementation of TypeResolutionService that resolves condition types, action types, and value types, + * with automatic tracking of invalid objects that have unresolved types. + * + * Requires a non-null DefinitionsService; the constructor enforces this. + */ +public class TypeResolutionServiceImpl implements TypeResolutionService { + + private static final Logger LOGGER = LoggerFactory.getLogger(TypeResolutionServiceImpl.class.getName()); + + private static final int MAX_RECURSION_DEPTH = 1000; + + private volatile DefinitionsService definitionsService; + + // Map of object type -> Map of object ID -> InvalidObjectInfo + private final Map> invalidObjects = new ConcurrentHashMap<>(); + + // Track unresolved types to avoid log spam — ConcurrentHashMap.newKeySet() makes add() atomic + private final Set unresolvedActionTypes = ConcurrentHashMap.newKeySet(); + private final Set unresolvedConditionTypes = ConcurrentHashMap.newKeySet(); + private final Set warnedRulesWithNullActions = ConcurrentHashMap.newKeySet(); + + /** + * Constructor that requires DefinitionsService. + * TypeResolutionService cannot function without DefinitionsService. + * + * @param definitionsService the definitions service to use for resolution (required, cannot be null) + * @throws IllegalArgumentException if definitionsService is null + */ + public TypeResolutionServiceImpl(DefinitionsService definitionsService) { + if (definitionsService == null) { + throw new IllegalArgumentException("DefinitionsService is required for TypeResolutionServiceImpl"); + } + this.definitionsService = definitionsService; + } + + /** + * Sets the DefinitionsService. This method allows setting the service after construction, + * which is useful for circular dependency scenarios or when the service becomes available later. + * + * @param definitionsService the definitions service to use for resolution + */ + public void setDefinitionsService(DefinitionsService definitionsService) { + if (definitionsService == null) { + throw new IllegalArgumentException("DefinitionsService cannot be null"); + } + this.definitionsService = definitionsService; + } + + @Override + public boolean resolveConditionType(Condition rootCondition, String contextObjectName) { + if (definitionsService == null) { + LOGGER.warn("DefinitionsService not available, cannot resolve condition type for {}", contextObjectName); + return false; + } + return resolveConditionTypeInternal(rootCondition, contextObjectName, new HashSet<>(), false, 0); + } + + /** + * Internal recursive method for resolving condition types. + * Handles parent conditions, nested conditions, and circular reference detection. + */ + private boolean resolveConditionTypeInternal(Condition rootCondition, String contextObjectName, + Set parentChainPath, boolean isGoingUp, int depth) { + if (rootCondition == null) { + LOGGER.warn("Couldn't resolve null condition for {}", contextObjectName); + return false; + } + + if (depth > MAX_RECURSION_DEPTH) { + LOGGER.error("Maximum recursion depth ({}) exceeded when resolving condition type {} in {}", + MAX_RECURSION_DEPTH, rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + + if (isGoingUp) { + if (!parentChainPath.add(rootCondition.getConditionTypeId())) { + LOGGER.warn("Detected circular reference for condition type {} in {}", rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + } + + try { + // Track whether we set the type in this call so we can roll back on child failure + final boolean conditionTypeWasNull = rootCondition.getConditionType() == null; + if (conditionTypeWasNull) { + String conditionTypeId = rootCondition.getConditionTypeId(); + if (conditionTypeId == null) { + LOGGER.warn("Condition has no type ID for {}", contextObjectName); + return false; + } + ConditionType conditionType = definitionsService.getConditionType(conditionTypeId); + if (conditionType == null) { + if (unresolvedConditionTypes.add(conditionTypeId)) { + LOGGER.warn("Couldn't resolve condition type: {} for {}", conditionTypeId, contextObjectName); + } + return false; + } + unresolvedConditionTypes.remove(rootCondition.getConditionTypeId()); + rootCondition.setConditionType(conditionType); + + if (conditionType.getParentCondition() != null) { + Set pathForParent = new HashSet<>(parentChainPath); + if (!isGoingUp) { + pathForParent.add(rootCondition.getConditionTypeId()); + } + if (!resolveConditionTypeInternal(conditionType.getParentCondition(), contextObjectName, + pathForParent, true, depth + 1)) { + rootCondition.setConditionType(null); + LOGGER.warn("Failed to resolve parent condition for type: {} in {}", + rootCondition.getConditionTypeId(), contextObjectName); + return false; + } + } + } + + // Track which child conditions we newly resolve so we can roll them back on sibling failure + List resolvedInThisCall = new ArrayList<>(); + for (Object value : rootCondition.getParameterValues().values()) { + if (value instanceof Condition) { + Condition child = (Condition) value; + boolean childWasNull = child.getConditionType() == null; + if (!resolveConditionTypeInternal(child, contextObjectName, parentChainPath, false, depth + 1)) { + rollback(resolvedInThisCall, conditionTypeWasNull ? rootCondition : null); + return false; + } + if (childWasNull && child.getConditionType() != null) { + resolvedInThisCall.add(child); + } + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item instanceof Condition) { + Condition child = (Condition) item; + boolean childWasNull = child.getConditionType() == null; + if (!resolveConditionTypeInternal(child, contextObjectName, parentChainPath, false, depth + 1)) { + rollback(resolvedInThisCall, conditionTypeWasNull ? rootCondition : null); + return false; + } + if (childWasNull && child.getConditionType() != null) { + resolvedInThisCall.add(child); + } + } + } + } + } + + return true; + } finally { + if (isGoingUp) { + parentChainPath.remove(rootCondition.getConditionTypeId()); + } + } + } + + /** Walks the condition tree and returns the first node whose conditionType is still null. */ + private String findUnresolvedConditionTypeId(Condition condition) { + if (condition == null) return null; + if (condition.getConditionType() == null) return condition.getConditionTypeId(); + for (Object value : condition.getParameterValues().values()) { + if (value instanceof Condition) { + String id = findUnresolvedConditionTypeId((Condition) value); + if (id != null) return id; + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item instanceof Condition) { + String id = findUnresolvedConditionTypeId((Condition) item); + if (id != null) return id; + } + } + } + } + return null; + } + + private void rollback(List resolvedInThisCall, Condition rootToRollBack) { + for (Condition c : resolvedInThisCall) { + c.setConditionType(null); + } + if (rootToRollBack != null) { + rootToRollBack.setConditionType(null); + } + } + + @Override + public boolean resolveActionTypes(Rule rule, boolean ignoreErrors) { + if (definitionsService == null) { + return false; + } + + boolean result = true; + String ruleId = rule.getItemId(); + if (rule.getActions() == null) { + if (!ignoreErrors) { + // Only warn once per rule to avoid log spam + if (warnedRulesWithNullActions.add(ruleId)) { + LOGGER.warn("Rule {}:{} has null actions", ruleId, rule.getMetadata() != null ? rule.getMetadata().getName() : "unknown"); + } + } + return false; + } + if (rule.getActions().isEmpty()) { + if (!ignoreErrors) { + // Only warn once per rule to avoid log spam + if (warnedRulesWithNullActions.add(ruleId)) { + LOGGER.warn("Rule {}:{} has empty actions", ruleId, rule.getMetadata() != null ? rule.getMetadata().getName() : "unknown"); + } + } + return false; + } + for (Action action : rule.getActions()) { + result &= resolveActionType(action); + } + return result; + } + + @Override + public boolean resolveActionType(Action action) { + if (definitionsService == null) { + LOGGER.warn("DefinitionsService not available, cannot resolve action type for {}", + action != null ? action.getActionTypeId() : "null"); + return false; + } + if (action.getActionType() == null) { + ActionType actionType = definitionsService.getActionType(action.getActionTypeId()); + if (actionType != null) { + unresolvedActionTypes.remove(action.getActionTypeId()); + action.setActionType(actionType); + } else { + if (unresolvedActionTypes.add(action.getActionTypeId())) { + LOGGER.warn("Couldn't resolve action type : {}", action.getActionTypeId()); + } + return false; + } + } + return true; + } + + @Override + public void resolveValueType(PropertyType propertyType) { + if (definitionsService == null || propertyType == null) { + return; + } + if (propertyType.getValueType() == null) { + ValueType valueType = definitionsService.getValueType(propertyType.getValueTypeId()); + if (valueType != null) { + propertyType.setValueType(valueType); + } else { + LOGGER.warn("Couldn't resolve value type '{}' for property '{}'", + propertyType.getValueTypeId(), propertyType.getItemId()); + } + } + } + + @Override + public boolean resolveCondition(String objectType, MetadataItem item, Condition condition, String contextName) { + if (condition == null) { + // Null condition is valid, clear missingPlugins if set + if (item != null && item.getMetadata() != null && item.getMetadata().isMissingPlugins()) { + item.getMetadata().setMissingPlugins(false); + } + return true; + } + + boolean resolved = resolveConditionType(condition, contextName); + String objectId = item != null ? item.getItemId() : null; + + if (!resolved) { + // Walk the tree to find the actual failing leaf, not just the root + String unresolvedTypeId = findUnresolvedConditionTypeId(condition); + List missingConditionTypeIds = unresolvedTypeId != null + ? Collections.singletonList(unresolvedTypeId) : Collections.emptyList(); + String reason = unresolvedTypeId != null + ? "Unresolved condition type: " + unresolvedTypeId + : "Unresolved condition type"; + if (objectId != null) { + markInvalid(objectType, objectId, reason, missingConditionTypeIds, null, contextName); + } + // Set missingPlugins flag when types can't be resolved + if (item != null && item.getMetadata() != null) { + item.getMetadata().setMissingPlugins(true); + } + } else { + if (objectId != null) { + markValid(objectType, objectId); + } + // Clear missingPlugins flag when types are successfully resolved + if (item != null && item.getMetadata() != null) { + item.getMetadata().setMissingPlugins(false); + } + } + return resolved; + } + + @Override + public boolean resolveActions(String objectType, Rule rule) { + if (rule == null) { + return true; + } + + boolean resolved = resolveActionTypes(rule, false); + String objectId = rule.getItemId(); + + if (!resolved) { + // Collect all unresolved action type IDs + List unresolvedActionIds = new ArrayList<>(); + if (rule.getActions() != null) { + for (Action action : rule.getActions()) { + if (action.getActionType() == null && action.getActionTypeId() != null) { + unresolvedActionIds.add(action.getActionTypeId()); + } + } + } + boolean structuralError = rule.getActions() == null || rule.getActions().isEmpty(); + String reason = structuralError + ? (rule.getActions() == null ? "Rule has null actions" : "Rule has no actions") + : "Unresolved action type(s): " + String.join(", ", unresolvedActionIds); + if (objectId != null) { + markInvalid(objectType, objectId, reason, null, unresolvedActionIds, "rule " + objectId); + } + // Only set missingPlugins when action plugin types are genuinely missing, not for structural errors + if (!structuralError && rule.getMetadata() != null) { + rule.getMetadata().setMissingPlugins(true); + } + } else { + if (objectId != null) { + markValid(objectType, objectId); + } + } + return resolved; + } + + @Override + public boolean resolveRule(String objectType, Rule rule) { + if (rule == null) { + return true; + } + + String objectId = rule.getItemId(); + String contextName = "rule " + objectId; + List reasons = new ArrayList<>(); + List missingConditionTypeIds = new ArrayList<>(); + List missingActionTypeIds = new ArrayList<>(); + + boolean conditionResolved = true; + if (rule.getCondition() != null) { + conditionResolved = resolveConditionType(rule.getCondition(), contextName); + if (!conditionResolved) { + // Walk tree to find the actual failing leaf, not just root + String unresolvedTypeId = findUnresolvedConditionTypeId(rule.getCondition()); + if (unresolvedTypeId != null) { + missingConditionTypeIds.add(unresolvedTypeId); + } + reasons.add("Unresolved condition type" + (unresolvedTypeId != null ? ": " + unresolvedTypeId : "")); + } + } + + boolean structuralActionsError = rule.getActions() == null || rule.getActions().isEmpty(); + boolean actionsResolved = structuralActionsError ? false : resolveActionTypes(rule, false); + if (!actionsResolved) { + if (!structuralActionsError && rule.getActions() != null) { + for (Action action : rule.getActions()) { + if (action.getActionType() == null && action.getActionTypeId() != null) { + missingActionTypeIds.add(action.getActionTypeId()); + } + } + } + String actionMsg = structuralActionsError + ? (rule.getActions() == null ? "Rule has null actions" : "Rule has no actions") + : "Unresolved action type(s): " + (missingActionTypeIds.isEmpty() ? "unknown" : String.join(", ", missingActionTypeIds)); + reasons.add(actionMsg); + } + + boolean allResolved = conditionResolved && actionsResolved; + // missingPlugins reflects actual missing plugin types, not structural config errors + boolean hasMissingPlugins = !conditionResolved || (!actionsResolved && !structuralActionsError); + if (rule.getMetadata() != null) { + rule.getMetadata().setMissingPlugins(hasMissingPlugins); + } + + // Track invalid objects + if (!allResolved && objectId != null) { + markInvalid(objectType, objectId, String.join("; ", reasons), + missingConditionTypeIds.isEmpty() ? null : missingConditionTypeIds, + missingActionTypeIds.isEmpty() ? null : missingActionTypeIds, + contextName); + } else if (allResolved && objectId != null) { + markValid(objectType, objectId); + } + + return allResolved; + } + + // Invalid object tracking methods + + @Override + public void markInvalid(String objectType, String objectId, String reason) { + markInvalid(objectType, objectId, reason, null, null, null); + } + + /** + * Mark an object as invalid with detailed information. + * Only logs the first time an object is encountered. + * + * @param objectType the type of object + * @param objectId the ID of the object + * @param reason the reason for invalidation + * @param missingConditionTypeIds list of missing condition type IDs + * @param missingActionTypeIds list of missing action type IDs + * @param contextName context where the object was encountered + */ + private void markInvalid(String objectType, String objectId, String reason, + List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { + if (objectType == null || objectId == null) { + LOGGER.warn("Cannot mark object as invalid: objectType or objectId is null"); + return; + } + + Map typeMap = invalidObjects.computeIfAbsent(objectType, k -> new ConcurrentHashMap<>()); + + InvalidObjectInfo newInfo = new InvalidObjectInfo( + objectType, + objectId, + reason != null ? reason : "Unknown reason", + missingConditionTypeIds, + missingActionTypeIds, + contextName + ); + InvalidObjectInfo existingInfo = typeMap.putIfAbsent(objectId, newInfo); + if (existingInfo != null) { + // Object already marked invalid - update encounter info but don't log again + existingInfo.updateEncounter(missingConditionTypeIds, missingActionTypeIds, contextName); + } else { + // First time encountering this invalid object — log with structured parameters + String missingConditions = (missingConditionTypeIds != null && !missingConditionTypeIds.isEmpty()) + ? " (missing condition types: " + String.join(", ", missingConditionTypeIds) + ")" : ""; + String missingActions = (missingActionTypeIds != null && !missingActionTypeIds.isEmpty()) + ? " (missing action types: " + String.join(", ", missingActionTypeIds) + ")" : ""; + String context = (contextName != null) ? " [context: " + contextName + "]" : ""; + LOGGER.warn("Marked {} {} as invalid: {}{}{}{}", objectType, objectId, + reason != null ? reason : "Unknown reason", missingConditions, missingActions, context); + } + } + + @Override + public void markValid(String objectType, String objectId) { + if (objectType == null || objectId == null) { + return; + } + final boolean[] wasRemoved = {false}; + // compute() is atomic per key — avoids TOCTOU between isEmpty() and remove() + invalidObjects.compute(objectType, (k, typeMap) -> { + if (typeMap == null) return null; + wasRemoved[0] = typeMap.remove(objectId) != null; + return typeMap.isEmpty() ? null : typeMap; + }); + if (wasRemoved[0]) { + LOGGER.debug("Marked {} {} as valid (removed from invalid tracking)", objectType, objectId); + } + } + + @Override + public boolean isInvalid(String objectType, String objectId) { + if (objectType == null || objectId == null) { + return false; + } + Map typeMap = invalidObjects.get(objectType); + return typeMap != null && typeMap.containsKey(objectId); + } + + @Override + public String getInvalidationReason(String objectType, String objectId) { + if (objectType == null || objectId == null) { + return null; + } + Map typeMap = invalidObjects.get(objectType); + if (typeMap != null) { + InvalidObjectInfo info = typeMap.get(objectId); + return info != null ? info.getReason() : null; + } + return null; + } + + @Override + public Map> getAllInvalidObjects() { + Map> result = new HashMap<>(); + for (Map.Entry> entry : invalidObjects.entrySet()) { + result.put(entry.getKey(), new HashMap<>(entry.getValue())); + } + return result; + } + + @Override + public Map getInvalidObjects(String objectType) { + Map typeMap = invalidObjects.get(objectType); + return typeMap != null ? new HashMap<>(typeMap) : Collections.emptyMap(); + } + + @Override + public Map> getAllInvalidObjectIds() { + Map> result = new HashMap<>(); + for (Map.Entry> entry : invalidObjects.entrySet()) { + result.put(entry.getKey(), new HashSet<>(entry.getValue().keySet())); + } + return result; + } + + @Override + public Set getInvalidObjectIds(String objectType) { + Map typeMap = invalidObjects.get(objectType); + return typeMap != null ? new HashSet<>(typeMap.keySet()) : Collections.emptySet(); + } + + @Override + public int getTotalInvalidObjectCount() { + return invalidObjects.values().stream() + .mapToInt(Map::size) + .sum(); + } +} + diff --git a/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java index ce04423cb4..65fb93abec 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/cluster/ClusterServiceImpl.java @@ -256,7 +256,7 @@ public void destroy() { LOGGER.info("Cluster service shutdown."); } - private void cancelScheduledTasks() { + void cancelScheduledTasks() { // Cancel scheduled tasks if (schedulerService != null) { try { @@ -355,7 +355,7 @@ private void updateSystemStatsForNode(ClusterNode node) { /** * Updates the system statistics for this node and stores them in the persistence service */ - private void updateSystemStats() { + void updateSystemStats() { if (shutdownNow) { return; } @@ -413,7 +413,7 @@ private void updateSystemStats() { /** * Removes stale nodes from the cluster */ - private void cleanupStaleNodes() { + void cleanupStaleNodes() { if (shutdownNow) { return; } diff --git a/services/src/main/java/org/apache/unomi/services/impl/definitions/DefinitionsServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/definitions/DefinitionsServiceImpl.java index 24ccca6aba..c8584ef278 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/definitions/DefinitionsServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/definitions/DefinitionsServiceImpl.java @@ -25,11 +25,14 @@ import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.conditions.ConditionType; import org.apache.unomi.api.services.*; +import org.apache.unomi.api.services.ConditionValidationService.ValidationError; +import org.apache.unomi.api.services.ConditionValidationService.ValidationErrorType; import org.apache.unomi.api.services.cache.CacheableTypeConfig; import org.apache.unomi.api.services.cache.MultiTypeCacheService; import org.apache.unomi.api.utils.ConditionBuilder; -import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.services.common.cache.AbstractMultiTypeCachingService; +import org.apache.unomi.services.impl.TypeResolutionServiceImpl; +import org.apache.unomi.services.impl.validation.ConditionValidationServiceImpl; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.framework.SynchronousBundleListener; @@ -64,7 +67,9 @@ public class DefinitionsServiceImpl extends AbstractMultiTypeCachingService impl private static final long TASK_TIMEOUT_MS = 60000; // 1 minute timeout for tasks + private ConditionValidationServiceImpl conditionValidationService; private EventAdmin eventAdmin; + private TypeResolutionServiceImpl typeResolutionService; // OSGi Event Admin topic constants for type change events private static final String TOPIC_CONDITION_TYPE_ADDED = "org/apache/unomi/definitions/conditionType/ADDED"; @@ -89,6 +94,12 @@ public void setEventAdmin(EventAdmin eventAdmin) { public DefinitionsServiceImpl() { // Initialize other components conditionBuilder = new ConditionBuilder(this); + // Create TypeResolutionService internally - it will get DefinitionsService reference in postConstruct + typeResolutionService = new TypeResolutionServiceImpl(this); + // Create ConditionValidationService internally + conditionValidationService = new ConditionValidationServiceImpl(); + // Pass TypeResolutionService to validation service for auto-resolution + conditionValidationService.setTypeResolutionService(typeResolutionService); } @Override @@ -98,6 +109,46 @@ public void postConstruct() { LOGGER.debug("Definitions service initialized."); } + /** + * Sets the built-in validators for the ConditionValidationService. + * This is called by Blueprint after the service is created. + * + * @param builtInValidators the list of built-in validators + */ + public void setConditionValidationServiceBuiltInValidators(List builtInValidators) { + conditionValidationService.setBuiltInValidators(builtInValidators); + } + + /** + * Binds a validator to the ConditionValidationService. + * Called by OSGi reference listener. + * + * @param validator the validator to bind + */ + public void bindValidator(ValueTypeValidator validator) { + conditionValidationService.bindValidator(validator); + } + + /** + * Unbinds a validator from the ConditionValidationService. + * Called by OSGi reference listener. + * + * @param validator the validator to unbind + */ + public void unbindValidator(ValueTypeValidator validator) { + conditionValidationService.unbindValidator(validator); + } + + @Override + public TypeResolutionService getTypeResolutionService() { + return typeResolutionService; + } + + @Override + public ConditionValidationService getConditionValidationService() { + return conditionValidationService; + } + public void setDefinitionsRefreshInterval(long definitionsRefreshInterval) { this.definitionsRefreshInterval = definitionsRefreshInterval; } @@ -164,42 +215,22 @@ public void preDestroy() { @Override public Collection getAllConditionTypes() { - Collection all = getAllItems(ConditionType.class, true); - for (ConditionType type : all) { - resolveParentCondition(type); - } - return all; + return getAllItems(ConditionType.class, true); } @Override public Set getConditionTypesByTag(String tag) { - Set types = getItemsByTag(ConditionType.class, tag); - for (ConditionType type : types) { - resolveParentCondition(type); - } - return types; + return getItemsByTag(ConditionType.class, tag); } @Override public Set getConditionTypesBySystemTag(String tag) { - Set types = getItemsBySystemTag(ConditionType.class, tag); - for (ConditionType type : types) { - resolveParentCondition(type); - } - return types; + return getItemsBySystemTag(ConditionType.class, tag); } @Override public ConditionType getConditionType(String id) { - ConditionType type = getItem(id, ConditionType.class); - resolveParentCondition(type); - return type; - } - - private void resolveParentCondition(ConditionType type) { - if (type != null && type.getParentCondition() != null) { - ParserHelper.resolveConditionType(this, type.getParentCondition(), "condition type " + type.getItemId()); - } + return getItem(id, ConditionType.class); } @Override @@ -533,9 +564,47 @@ private Condition createBooleanCondition(List conditions) { return res; } + @Deprecated @Override public boolean resolveConditionType(Condition rootCondition) { - return ParserHelper.resolveConditionType(this, rootCondition, (rootCondition != null ? "condition type " + rootCondition.getConditionTypeId() : "unknown")); + if (rootCondition == null) { + return false; + } + // Delegate to TypeResolutionService for resolution + boolean resolved = typeResolutionService.resolveConditionType(rootCondition, "condition type " + rootCondition.getConditionTypeId()); + if (resolved) { + // Validate the condition after resolving its type (validation service will auto-resolve if needed) + List validationErrors = conditionValidationService.validate(rootCondition); + + + // Separate errors and warnings + List errors = validationErrors.stream() + .filter(error -> error.getType() != ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + List warnings = validationErrors.stream() + .filter(error -> error.getType() == ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + // Log warnings but don't block the operation + if (!warnings.isEmpty()) { + StringBuilder warningMessage = new StringBuilder("Condition has warnings:"); + for (ValidationError warning : warnings) { + warningMessage.append("\n- ").append(warning.getDetailedMessage()); + } + LOGGER.warn(warningMessage.toString()); + } + + // Only throw exception for actual errors + if (!errors.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Invalid condition:"); + for (ValidationError error : errors) { + errorMessage.append("\n- ").append(error.getDetailedMessage()); + } + throw new IllegalArgumentException(errorMessage.toString()); + } + } + return resolved; } @Override @@ -713,5 +782,4 @@ private void publishTypeChangeEvent(String topic, String typeId, String tenantId public ConditionBuilder getConditionBuilder() { return conditionBuilder; } - } diff --git a/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java index cfeeb1ab0c..7711fb6b2f 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/goals/GoalsServiceImpl.java @@ -151,7 +151,15 @@ public Set getGoalMetadatas() { } public Set getGoalMetadatas(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "goal metadata query"); + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); + if (!validationErrors.isEmpty()) { + LOGGER.warn("Goal query condition has {} validation issue(s)", validationErrors.size()); + } + } + } Set descriptions = new LinkedHashSet<>(); List goals = persistenceService.query(query.getCondition(), query.getSortby(), Goal.class, query.getOffset(), query.getLimit()).getList(); @@ -262,7 +270,15 @@ public Set getCampaignMetadatas() { } public Set getCampaignMetadatas(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "campaign metadata query"); + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); + if (!validationErrors.isEmpty()) { + LOGGER.warn("Campaign query condition has {} validation issue(s)", validationErrors.size()); + } + } + } Set descriptions = new HashSet(); for (Campaign definition : persistenceService.query(query.getCondition(), query.getSortby(), Campaign.class, query.getOffset(), query.getLimit()).getList()) { descriptions.add(definition.getMetadata()); @@ -271,7 +287,15 @@ public Set getCampaignMetadatas(Query query) { } public PartialList getCampaignDetails(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "campaign details query"); + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); + if (!validationErrors.isEmpty()) { + LOGGER.warn("Campaign details query condition has {} validation issue(s)", validationErrors.size()); + } + } + } PartialList campaigns = persistenceService.query(query.getCondition(), query.getSortby(), Campaign.class, query.getOffset(), query.getLimit()); List details = new LinkedList<>(); for (Campaign definition : campaigns.getList()) { @@ -382,7 +406,13 @@ public GoalReport getGoalReport(String goalId, AggregateQuery query) { } if (query != null && query.getCondition() != null) { - ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "goal " + goalId + " report"); + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "goal report query"); + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); + if (!validationErrors.isEmpty()) { + LOGGER.warn("Events query condition has {} validation issue(s)", validationErrors.size()); + } + } list.add(query.getCondition()); } @@ -464,7 +494,15 @@ public PartialList getEvents(Query query) { if(query.isForceRefresh()){ persistenceService.refreshIndex(CampaignEvent.class); } - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "campaign events query"); + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); + if (!validationErrors.isEmpty()) { + LOGGER.warn("Campaign events query condition has {} validation issue(s)", validationErrors.size()); + } + } + } return persistenceService.query(query.getCondition(), query.getSortby(), CampaignEvent.class, query.getOffset(), query.getLimit()); } diff --git a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java index 26cf6fd90f..352849cb87 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java @@ -429,7 +429,15 @@ private PartialList doSearch(Query query, Class clazz) { if (query.getScrollIdentifier() != null) { return persistenceService.continueScrollQuery(clazz, query.getScrollIdentifier(), query.getScrollTimeValidity()); } - if (query.getCondition() != null && definitionsService.resolveConditionType(query.getCondition())) { + if (query.getCondition() != null) { + ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "profile search"); + if (definitionsService.getConditionValidationService() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } + if (query.getCondition().getConditionType() == null) { + LOGGER.warn("Cannot execute query: condition type '{}' could not be resolved", query.getCondition().getConditionTypeId()); + return new PartialList<>(); + } if (StringUtils.isNotBlank(query.getText())) { return persistenceService.queryFullText(query.getText(), query.getCondition(), query.getSortby(), clazz, query.getOffset(), query.getLimit()); } else { diff --git a/services/src/main/java/org/apache/unomi/services/impl/queries/QueryServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/queries/QueryServiceImpl.java index e2e8930ed5..7c820d3980 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/queries/QueryServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/queries/QueryServiceImpl.java @@ -90,7 +90,7 @@ public long getQueryCount(String itemType, Condition condition) { private Map getAggregate(String itemType, String property, AggregateQuery query, boolean optimizedQuery) { if (query != null) { // resolve condition - ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "aggregate on property " + property + " for type " + itemType); + definitionsService.getConditionValidationService().validate(query.getCondition()); // resolve aggregate BaseAggregate baseAggregate = null; diff --git a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java index 8227b51b4a..bfe2ad07c5 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/rules/RulesServiceImpl.java @@ -31,6 +31,9 @@ import org.apache.unomi.api.services.cache.CacheableTypeConfig; import org.apache.unomi.api.tasks.ScheduledTask; import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.services.ConditionValidationService.ValidationError; +import org.apache.unomi.api.services.ConditionValidationService.ValidationErrorType; +import org.apache.unomi.api.services.TypeResolutionService; import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.persistence.spi.config.ConfigurationUpdateHelper; import org.apache.unomi.services.actions.ActionExecutorDispatcher; @@ -64,8 +67,6 @@ public class RulesServiceImpl extends AbstractMultiTypeCachingService implements private final List ruleListeners = new CopyOnWriteArrayList<>(); - private final Set invalidRulesId = new HashSet<>(); - private final Object cacheLock = new Object(); private final Map>> rulesByEventTypeByTenant = new ConcurrentHashMap<>(); private final Map> ruleStatisticsByTenant = new ConcurrentHashMap<>(); @@ -112,6 +113,14 @@ public void setOptimizedRulesActivated(Boolean optimizedRulesActivated) { this.optimizedRulesActivated = optimizedRulesActivated; } + /** + * Helper method to get TypeResolutionService from DefinitionsService. + * Returns null if DefinitionsService is not available or does not expose TypeResolutionService. + */ + private TypeResolutionService getTypeResolutionService() { + return definitionsService != null ? definitionsService.getTypeResolutionService() : null; + } + @Override public void updated(Dictionary properties) { Map propertyMappings = new HashMap<>(); @@ -581,7 +590,9 @@ public PartialList getRuleMetadatas(Query query) { if (query.isForceRefresh()) { persistenceService.refreshIndex(Rule.class); } - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } List descriptions = new LinkedList<>(); PartialList rules = persistenceService.query(query.getCondition(), query.getSortby(), Rule.class, query.getOffset(), query.getLimit()); for (Rule definition : rules.getList()) { @@ -594,7 +605,9 @@ public PartialList getRuleDetails(Query query) { if (query.isForceRefresh()) { persistenceService.refreshIndex(Rule.class); } - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } PartialList rules = persistenceService.query(query.getCondition(), query.getSortby(), Rule.class, query.getOffset(), query.getLimit()); List details = new LinkedList<>(); details.addAll(rules.getList()); @@ -654,6 +667,49 @@ protected void setRule(Rule rule, boolean allowInvalidRules) { throw e; } else { LOGGER.warn("Invalid rule condition for rule {} : ", rule, e); + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.markInvalid("rules", rule.getItemId(), + "Missing eventCondition: " + e.getMessage()); + } + } + } + } + } + + + if (rule.getCondition() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(rule.getCondition()); + + List errors = validationErrors.stream() + .filter(error -> error.getType() != ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + List warnings = validationErrors.stream() + .filter(error -> error.getType() == ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + if (!warnings.isEmpty()) { + StringBuilder warningMessage = new StringBuilder("Rule condition has warnings:"); + for (ValidationError warning : warnings) { + warningMessage.append("\n- ").append(warning.getMessage()); + } + LOGGER.warn(warningMessage.toString()); + } + + if (!errors.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Invalid rule condition:"); + for (ValidationError error : errors) { + errorMessage.append("\n- ").append(error.getMessage()); + } + if (!effectiveAllowInvalidRules) { + throw new IllegalArgumentException(errorMessage.toString()); + } else { + LOGGER.warn("Invalid rule condition for rule {} : {}", rule, errorMessage.toString()); + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.markInvalid("rules", rule.getItemId(), + "Condition validation errors: " + errorMessage.toString()); } } } @@ -790,10 +846,11 @@ private boolean shouldExcludeRuleFromEventTypeIndex(Rule rule) { return true; } - // Check if rule has missing plugins or is invalid (set by resolveRule) boolean hasMissingPlugins = rule.getMetadata().isMissingPlugins(); + TypeResolutionService typeResolutionService = getTypeResolutionService(); + boolean isInvalid = typeResolutionService != null && typeResolutionService.isInvalid("rules", rule.getItemId()); - if (hasMissingPlugins) { + if (hasMissingPlugins || isInvalid) { String ruleName = getRuleName(rule); String ruleId = rule.getItemId(); String reason = hasMissingPlugins ? "missing plugins" : "invalid rule"; @@ -849,7 +906,7 @@ private void removeRuleFromEventTypeIndex(Map> rulesByEventTyp * @return the set of event type IDs, which may include "*" for wildcard matching, or empty set if condition has unresolved types */ private Set resolveEventTypesWithWarnings(Rule rule) { - Set eventTypeIds = ParserHelper.resolveConditionEventTypes(rule.getCondition()); + Set eventTypeIds = ParserHelper.resolveConditionEventTypes(rule.getCondition(), definitionsService); boolean hasWildcard = eventTypeIds.contains("*"); boolean defaultingToWildcard = false; @@ -861,7 +918,9 @@ private Set resolveEventTypesWithWarnings(Rule rule) { // Check if rule has unresolved types by checking resolution status // Note: shouldExcludeRuleFromEventTypeIndex() also checks disabled, so we need to check specifically boolean hasMissingPlugins = rule.getMetadata() != null && rule.getMetadata().isMissingPlugins(); - boolean hasUnresolvedTypes = hasMissingPlugins; + TypeResolutionService typeResolutionService = getTypeResolutionService(); + boolean isInvalid = typeResolutionService != null && typeResolutionService.isInvalid("rules", rule.getItemId()); + boolean hasUnresolvedTypes = hasMissingPlugins || isInvalid; if (hasUnresolvedTypes) { // Rule has unresolved types - return empty set to exclude rule @@ -934,14 +993,20 @@ private boolean ensureRuleResolved(Rule rule) { if (rule == null) { return false; } - boolean isValid = ParserHelper.resolveConditionType(definitionsService, rule.getCondition(), "rule " + rule.getItemId()); - isValid = isValid && ParserHelper.resolveActionTypes(definitionsService, rule, invalidRulesId.contains(rule.getItemId())); - if (!isValid) { - invalidRulesId.add(rule.getItemId()); - } else { - invalidRulesId.remove(rule.getItemId()); + + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService == null) { + return false; } - return isValid; + + boolean wasInvalid = typeResolutionService.isInvalid("rules", rule.getItemId()); + boolean hadMissingPlugins = rule.getMetadata() != null && rule.getMetadata().isMissingPlugins(); + + if (!wasInvalid && !hadMissingPlugins) { + return true; + } + + return typeResolutionService.resolveRule("rules", rule); } /** @@ -953,7 +1018,16 @@ private boolean ensureRuleResolved(Rule rule) { * @return true if the rule is now valid, false if it's still invalid */ private boolean ensureRuleResolvedForIndexing(Rule rule) { - return ensureRuleResolved(rule); + if (rule == null) { + return false; + } + + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService == null) { + return false; + } + + return typeResolutionService.resolveRule("rules", rule); } /** @@ -969,7 +1043,11 @@ private boolean reEvaluateRuleResolution(Rule rule) { return false; } - boolean wasInvalid = invalidRulesId.contains(rule.getItemId()); + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService == null) { + return false; + } + boolean wasInvalid = typeResolutionService.isInvalid("rules", rule.getItemId()); boolean hadMissingPlugins = rule.getMetadata() != null && rule.getMetadata().isMissingPlugins(); // Ensure rule is resolved (idempotent - only resolves if needed) @@ -1065,8 +1143,14 @@ public Set getTrackedConditions(Item source) { if (persistenceService.testMatch(evalCondition, source)) { trackedConditions.add(trackedCondition); } - } else if ( - trackedCondition.getConditionType() != null && + } else { + if (trackedCondition.getConditionType() == null) { + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveConditionType(trackedCondition, "tracked conditions"); + } + } + if (trackedCondition.getConditionType() != null && trackedCondition.getConditionType().getParameters() != null && !trackedCondition.getConditionType() .getParameters().isEmpty() ) { @@ -1102,6 +1186,7 @@ public Set getTrackedConditions(Item source) { } else { trackedConditions.add(trackedCondition); } + } } } } diff --git a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java index 64024b22c8..fd8f400024 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java @@ -39,6 +39,8 @@ import org.apache.unomi.persistence.spi.aggregate.TermsAggregate; import org.apache.unomi.services.common.cache.AbstractMultiTypeCachingService; import org.apache.unomi.services.impl.scheduler.SchedulerServiceImpl; +import org.apache.unomi.api.services.ConditionValidationService.ValidationError; +import org.apache.unomi.api.services.ConditionValidationService.ValidationErrorType; import org.apache.unomi.api.utils.ParserHelper; import org.apache.unomi.api.exceptions.BadSegmentConditionException; import org.osgi.framework.Bundle; @@ -100,6 +102,10 @@ public void setDefinitionsService(DefinitionsService definitionsService) { this.definitionsService = definitionsService; } + private TypeResolutionService getTypeResolutionService() { + return definitionsService != null ? definitionsService.getTypeResolutionService() : null; + } + public void setSegmentUpdateBatchSize(int segmentUpdateBatchSize) { this.segmentUpdateBatchSize = segmentUpdateBatchSize; } @@ -377,19 +383,57 @@ public void setSegmentDefinition(Segment segment) { throw new IllegalArgumentException("Segment metadata cannot be null"); } - if (segment.getMetadata().isEnabled()) { - ParserHelper.resolveConditionType(definitionsService, segment.getCondition(), "segment " + segment.getItemId()); - if (!persistenceService.isValidCondition(segment.getCondition(), new Profile(VALIDATION_PROFILE_ID))) { + if (segment.getCondition() == null) { + if (segment.getMetadata().isEnabled()) { throw new BadSegmentConditionException(); } - if (!segment.getMetadata().isMissingPlugins()) { - updateAutoGeneratedRules(segment.getMetadata(), segment.getCondition()); + } else { + if (segment.getMetadata().isEnabled()) { + TypeResolutionService typeResolutionService = getTypeResolutionService(); + if (typeResolutionService != null) { + typeResolutionService.resolveCondition("segments", segment, segment.getCondition(), "segment " + segment.getItemId()); + } + + if (!persistenceService.isValidCondition(segment.getCondition(), new Profile(VALIDATION_PROFILE_ID))) { + throw new BadSegmentConditionException(); + } + + if (definitionsService.getConditionValidationService() != null) { + List validationErrors = definitionsService.getConditionValidationService().validate(segment.getCondition()); + + List errors = validationErrors.stream() + .filter(error -> error.getType() != ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + List warnings = validationErrors.stream() + .filter(error -> error.getType() == ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) + .collect(Collectors.toList()); + + if (!warnings.isEmpty()) { + StringBuilder warningMessage = new StringBuilder("Segment condition has warnings:"); + for (ValidationError warning : warnings) { + warningMessage.append("\n- ").append(warning.getDetailedMessage()); + } + LOGGER.warn(warningMessage.toString()); + } + + if (!errors.isEmpty()) { + StringBuilder errorMessage = new StringBuilder("Invalid segment condition:"); + for (ValidationError error : errors) { + errorMessage.append("\n- ").append(error.getDetailedMessage()); + } + throw new BadSegmentConditionException(errorMessage.toString()); + } + } } } + if (segment.getMetadata().isEnabled() && !segment.getMetadata().isMissingPlugins()) { + updateAutoGeneratedRules(segment.getMetadata(), segment.getCondition()); + } + segment.setTenantId(contextManager.getCurrentContext().getTenantId()); - // Save segment and update cache persistenceService.save(segment, null, true); cacheService.put(Segment.ITEM_TYPE, segment.getItemId(), segment.getTenantId(), segment); updateExistingProfilesForSegment(segment); diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImpl.java new file mode 100644 index 0000000000..810e9386e6 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImpl.java @@ -0,0 +1,517 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation; + +import org.apache.unomi.api.Parameter; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.conditions.ConditionValidation; +import org.apache.unomi.api.services.ConditionValidationService; +import org.apache.unomi.api.services.TypeResolutionService; +import org.apache.unomi.api.services.ValueTypeValidator; +import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; +import org.apache.unomi.services.impl.validation.validators.ConditionValueTypeValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class ConditionValidationServiceImpl implements ConditionValidationService { + private static final Logger LOGGER = LoggerFactory.getLogger(ConditionValidationServiceImpl.class); + + private final Map validators = new ConcurrentHashMap<>(); + private List builtInValidators; + private TypeResolutionService typeResolutionService; + + public void setBuiltInValidators(List builtInValidators) { + this.builtInValidators = builtInValidators; + if (builtInValidators == null) { + return; + } + for (ValueTypeValidator validator : builtInValidators) { + validators.put(validator.getValueTypeId().toLowerCase(), validator); + } + LOGGER.debug("Initialized with {} built-in validators", builtInValidators.size()); + } + + public void bindValidator(ValueTypeValidator validator) { + validators.put(validator.getValueTypeId().toLowerCase(), validator); + LOGGER.debug("Added custom validator for type: {}", validator.getValueTypeId()); + } + + public void unbindValidator(ValueTypeValidator validator) { + if (validator == null) { + return; + } + String rawTypeId = validator.getValueTypeId(); + if (rawTypeId == null) { + return; + } + String typeId = rawTypeId.toLowerCase(); + if (builtInValidators == null || builtInValidators.stream().noneMatch(v -> v.getValueTypeId() != null && v.getValueTypeId().equalsIgnoreCase(typeId))) { + validators.remove(typeId); + LOGGER.debug("Removed custom validator for type: {}", rawTypeId); + } + } + + /** + * Sets the TypeResolutionService for automatic type resolution during validation. + * This allows the validation service to automatically resolve condition types + * if they haven't been resolved yet, including nested conditions. + * + * @param typeResolutionService the type resolution service to use + */ + public void setTypeResolutionService(TypeResolutionService typeResolutionService) { + this.typeResolutionService = typeResolutionService; + } + + private Map buildValidationContext(String paramName, Object value, Parameter param, + String location, Map additionalContext) { + Map context = new HashMap<>(); + + // Always include location information + context.put("location", location); + + // Add parameter type information + if (param != null && param.getType() != null) { + context.put("parameterType", param.getType().toLowerCase()); + } + + // Add value information if present + if (value != null) { + context.put("actualValue", value); + context.put("valueType", value.getClass().getSimpleName()); + } + + // Add any additional context + if (additionalContext != null) { + context.putAll(additionalContext); + } + + return context; + } + + @Override + public List validate(Condition condition) { + if (builtInValidators == null) { + LOGGER.warn("ConditionValidationService not properly initialized (no built-in validators), skipping validation"); + return Collections.emptyList(); + } + List errors = new ArrayList<>(); + + if (condition == null) { + Map context = buildValidationContext(null, null, null, "root condition", null); + errors.add(new ValidationError(null, "Condition cannot be null", + ValidationErrorType.MISSING_REQUIRED_PARAMETER, null, null, context, null)); + return errors; + } + + // Auto-resolve condition type if needed (including nested conditions) + // This ensures types are resolved before validation, preventing validation failures + if (condition.getConditionType() == null && typeResolutionService != null) { + typeResolutionService.resolveConditionType(condition, "validation"); + } + + ConditionType type = condition.getConditionType(); + if (type == null) { + // Condition without type is invalid (could not be resolved) + String location = "condition[" + condition.getConditionTypeId() + "]"; + Map context = buildValidationContext(null, null, null, location, null); + errors.add(new ValidationError(null, + "Condition type is missing or could not be resolved", + ValidationErrorType.INVALID_CONDITION_TYPE, + condition.getConditionTypeId(), + null, + context, + null)); + return errors; + } + + // Group parameters by exclusive group (only for parameters with validation) + Map> exclusiveGroups = new HashMap<>(); + List typeParameters = type.getParameters() != null ? type.getParameters() : Collections.emptyList(); + for (Parameter param : typeParameters) { + if (param.getValidation() != null && + param.getValidation().isExclusive() && + param.getValidation().getExclusiveGroup() != null) { + String group = param.getValidation().getExclusiveGroup(); + exclusiveGroups.computeIfAbsent(group, k -> new ArrayList<>()).add(param); + } + } + + // Check each parameter, skipping those with references/scripts (partial validation) + for (Parameter param : typeParameters) { + String paramName = param.getId(); + Object value = condition.getParameter(paramName); + String location = "condition[" + condition.getConditionTypeId() + "]." + paramName; + + // Skip most validation for parameters with references/scripts (resolved at runtime). + // Still emit an advisory for required parameters so operators know static checks + // were skipped and the reference must resolve at evaluation time. + if (ConditionContextHelper.hasContextualParameter(value)) { + if (param.getValidation() != null && param.getValidation().isRequired()) { + Map ctx = buildValidationContext(paramName, value, param, location, + Collections.singletonMap("referenceValue", String.valueOf(value))); + errors.add(new ValidationError(paramName, + "Required parameter uses a contextual reference; ensure the reference resolves at runtime.", + ValidationErrorType.MISSING_RECOMMENDED_PARAMETER, + condition.getConditionTypeId(), + type.getItemId(), + ctx, + null)); + } + continue; + } + + // Validate basic type and multivalued constraints for non-reference values + if (value != null) { + errors.addAll(validateParameterType(paramName, value, param, condition, type, location)); + } + + // Only apply additional validation rules if they are present + if (param.getValidation() != null) { + errors.addAll(validateAdditionalRules(paramName, value, param, condition, type, location)); + } + } + + // Check exclusive parameter groups (only for non-reference/script values) + for (Map.Entry> entry : exclusiveGroups.entrySet()) { + List group = entry.getValue(); + long valuesCount = group.stream() + .map(p -> { + Object val = condition.getParameter(p.getId()); + // Only count non-reference/script values + return val != null && !ConditionContextHelper.hasContextualParameter(val) ? val : null; + }) + .filter(Objects::nonNull) + .count(); + + if (valuesCount > 1) { + String paramNames = group.stream() + .map(Parameter::getId) + .collect(Collectors.joining(", ")); + String location = "condition[" + condition.getConditionTypeId() + "].exclusiveGroup[" + entry.getKey() + "]"; + Map context = buildValidationContext(null, null, null, location, + Map.of("exclusiveGroup", entry.getKey(), "conflictingParameters", paramNames)); + + errors.add(new ValidationError(null, + "Only one of these parameters can have a value: " + paramNames, + ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } + } + + // Recursively validate nested conditions not already validated by the typed parameter loop above. + // Typed 'condition' parameters are already validated inline via validateSingleParameterValue → + // ConditionValueTypeValidator → validate(). The bottom loop only catches legacy/untyped parameters. + Set alreadyValidatedConditionParams = new HashSet<>(); + for (Parameter param : typeParameters) { + if ("condition".equalsIgnoreCase(param.getType())) { + alreadyValidatedConditionParams.add(param.getId()); + } + } + for (Map.Entry entry : condition.getParameterValues().entrySet()) { + if (alreadyValidatedConditionParams.contains(entry.getKey())) { + continue; + } + Object value = entry.getValue(); + if (value instanceof Condition) { + errors.addAll(validate((Condition) value)); + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item instanceof Condition) { + errors.addAll(validate((Condition) item)); + } + } + } + } + + return errors; + } + + + private List validateAdditionalRules(String paramName, Object value, Parameter param, + Condition condition, ConditionType type, String parentLocation) { + List errors = new ArrayList<>(); + ConditionValidation validation = param.getValidation(); + + Map context = buildValidationContext(paramName, value, param, parentLocation, null); + + // Check required parameters (skip if value is a parameter reference/script) + if (validation.isRequired() && value == null) { + errors.add(new ValidationError(paramName, + "Required parameter is missing", + ValidationErrorType.MISSING_REQUIRED_PARAMETER, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + return errors; // Skip other validations if required parameter is missing + } + + // Check recommended parameters (skip if value is a parameter reference/script) + if (validation.isRecommended() && value == null) { + errors.add(new ValidationError(paramName, + "Parameter is recommended for optimal functionality", + ValidationErrorType.MISSING_RECOMMENDED_PARAMETER, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } + + if (value != null) { + // Check allowed values (only enforced for String values; non-String types cannot be + // meaningfully compared to a Set via toString()) + if (validation.getAllowedValues() != null && !validation.getAllowedValues().isEmpty()) { + if (value instanceof String) { + if (!validation.getAllowedValues().contains((String) value)) { + Map allowedContext = new HashMap<>(context); + allowedContext.put("allowedValues", validation.getAllowedValues()); + errors.add(new ValidationError(paramName, + "Value must be one of: " + String.join(", ", validation.getAllowedValues()), + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + allowedContext, + null)); + } + } else { + LOGGER.warn("allowedValues check skipped for parameter '{}': value type {} cannot be compared to String set", + paramName, value.getClass().getSimpleName()); + } + } + + // Check condition type parameters + if (value instanceof Condition) { + Condition subCondition = (Condition) value; + ConditionType subConditionType = subCondition.getConditionType(); + String subLocation = parentLocation + ".condition[" + + (subCondition.getConditionTypeId() != null ? subCondition.getConditionTypeId() : "unknown") + "]"; + + if (subConditionType == null) { + Map typeContext = buildValidationContext(paramName, value, param, subLocation, null); + errors.add(new ValidationError(paramName, + "Nested condition type could not be resolved: " + subCondition.getConditionTypeId(), + ValidationErrorType.INVALID_CONDITION_TYPE, + condition.getConditionTypeId(), + type.getItemId(), + typeContext, + null)); + return errors; + } + + // Check allowed condition tags + if (validation.getAllowedConditionTags() != null && !validation.getAllowedConditionTags().isEmpty()) { + Set conditionTags = subConditionType.getMetadata().getSystemTags(); + if (conditionTags == null || conditionTags.isEmpty() || + Collections.disjoint(conditionTags, validation.getAllowedConditionTags())) { + Map tagContext = buildValidationContext(paramName, value, param, subLocation, + Map.of("allowedTags", validation.getAllowedConditionTags(), + "actualTags", conditionTags != null ? conditionTags : Collections.emptySet())); + errors.add(new ValidationError(paramName, + "Condition must have one of these tags: " + + String.join(", ", validation.getAllowedConditionTags()), + ValidationErrorType.INVALID_CONDITION_TYPE, + condition.getConditionTypeId(), + type.getItemId(), + tagContext, + null)); + } + } + + // Check disallowed condition types + if (validation.getDisallowedConditionTypes() != null && !validation.getDisallowedConditionTypes().isEmpty()) { + if (validation.getDisallowedConditionTypes().contains(subConditionType.getItemId())) { + Map typeContext = buildValidationContext(paramName, value, param, subLocation, + Collections.singletonMap("disallowedTypes", validation.getDisallowedConditionTypes())); + errors.add(new ValidationError(paramName, + "Condition type " + subConditionType.getItemId() + " is not allowed", + ValidationErrorType.INVALID_CONDITION_TYPE, + condition.getConditionTypeId(), + type.getItemId(), + typeContext, + null)); + } + } + } + } + + return errors; + } + + private List validateParameterType(String paramName, Object value, Parameter param, + Condition condition, ConditionType type, String parentLocation) { + List errors = new ArrayList<>(); + + Map context = buildValidationContext(paramName, value, param, parentLocation, null); + + // Skip type validation if type is not specified (for backward compatibility) + if (param.getType() == null) { + return errors; + } + + // Handle multivalued parameters + if (param.isMultivalued()) { + if (!(value instanceof Collection)) { + errors.add(new ValidationError(paramName, + "Value must be a collection for multivalued parameter", + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + return errors; + } + Collection values = (Collection) value; + + // Add validation for empty collections when parameter is required + if (param.getValidation() != null && param.getValidation().isRequired() && values.isEmpty()) { + errors.add(new ValidationError(paramName, + "Required parameter cannot be an empty collection", + ValidationErrorType.MISSING_REQUIRED_PARAMETER, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + return errors; + } + + int index = 0; + for (Object item : values) { + String itemLocation = parentLocation + "[" + index + "]"; + Map itemContext = new HashMap<>(context); + itemContext.put("collectionIndex", index++); + errors.addAll(validateSingleParameterValue(paramName, item, param, condition, type, itemContext, itemLocation)); + } + } else { + if (value instanceof Collection) { + errors.add(new ValidationError(paramName, + "Value cannot be a collection for non-multivalued parameter", + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + return errors; + } + errors.addAll(validateSingleParameterValue(paramName, value, param, condition, type, context, parentLocation)); + } + + return errors; + } + + private List validateSingleParameterValue(String paramName, Object value, Parameter param, + Condition condition, ConditionType type, Map parentContext, String location) { + List errors = new ArrayList<>(); + + String paramType = param.getType() != null ? param.getType().toLowerCase() : null; + Map context = buildValidationContext(paramName, value, param, location, parentContext); + + // Add collection index to error message if present + String parameterDescription = paramName; + if (context.containsKey("collectionIndex")) { + parameterDescription += "[" + context.get("collectionIndex") + "]"; + } + + // Skip type validation if type is not specified (for backward compatibility) + if (paramType == null) { + return errors; + } + + // Skip type validation for parameter references and script expressions + // These will be resolved later (via ConditionContextHelper.getContextualCondition) + // and validated at that point. Type validation here would fail incorrectly + // since parameter references appear as Strings but will resolve to the correct type. + if (ConditionContextHelper.isParameterReference(value)) { + // Parameter reference or script expression - skip all type and value validation; + // validation happens when the reference is resolved at evaluation time + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Skipping type validation for parameter reference: {}={}", + paramName, value); + } + return errors; + } + + // Special handling for object type with custom validation + if ("object".equals(paramType)) { + if (param.getValidation() != null && param.getValidation().getCustomType() != null) { + Class expectedType = param.getValidation().getCustomType(); + if (!expectedType.isInstance(value)) { + context.put("expectedType", expectedType.getName()); + errors.add(new ValidationError(parameterDescription, + "Value must be of type " + expectedType.getSimpleName(), + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } + } + return errors; + } + + // Use registered validator if available + ValueTypeValidator validator = validators.get(paramType); + if (validator != null) { + boolean isValid; + if (validator instanceof ConditionValueTypeValidator) { + // Pass the validation service for recursive validation + ConditionValueTypeValidator conditionValidator = (ConditionValueTypeValidator) validator; + if (!conditionValidator.validate(value)) { + // Basic condition structure validation failed + context.put("validatorType", validator.getClass().getSimpleName()); + errors.add(new ValidationError(parameterDescription, + validator.getValueTypeDescription(), + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } else if (value instanceof Condition) { + // Always validate the complete condition tree and report all errors + List nestedErrors = validate((Condition) value); + errors.addAll(nestedErrors); + } + return errors; + } else { + isValid = validator.validate(value); + } + + if (!isValid) { + context.put("validatorType", validator.getClass().getSimpleName()); + errors.add(new ValidationError(parameterDescription, + validator.getValueTypeDescription(), + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } + } else { + LOGGER.warn("No ValueTypeValidator for type '{}' (param '{}') — plugin MUST register one, skipping type check", + paramType, paramName); + } + + return errors; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/AbstractNumericValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/AbstractNumericValueTypeValidator.java new file mode 100644 index 0000000000..6dccca891a --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/AbstractNumericValueTypeValidator.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.services.ValueTypeValidator; + +public abstract class AbstractNumericValueTypeValidator implements ValueTypeValidator { + private final String valueTypeId; + private final Class expectedClass; + + protected AbstractNumericValueTypeValidator(String valueTypeId, Class expectedClass) { + this.valueTypeId = valueTypeId; + this.expectedClass = expectedClass; + } + + @Override + public String getValueTypeId() { + return valueTypeId; + } + + @Override + public boolean validate(Object value) { + return value == null || expectedClass.isInstance(value); + } + + @Override + public String getValueTypeDescription() { + return "Value must be a " + valueTypeId; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/BooleanValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/BooleanValueTypeValidator.java new file mode 100644 index 0000000000..af5a986b30 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/BooleanValueTypeValidator.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.services.ValueTypeValidator; + +public class BooleanValueTypeValidator implements ValueTypeValidator { + @Override + public String getValueTypeId() { + return "boolean"; + } + + @Override + public boolean validate(Object value) { + return value == null || value instanceof Boolean; + } + + @Override + public String getValueTypeDescription() { + return "Value must be a boolean"; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ComparisonOperatorValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ComparisonOperatorValueTypeValidator.java new file mode 100644 index 0000000000..14311e379e --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ComparisonOperatorValueTypeValidator.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.services.ValueTypeValidator; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +public class ComparisonOperatorValueTypeValidator implements ValueTypeValidator { + private static final Set VALID_OPERATORS = new LinkedHashSet<>(Arrays.asList( + // Equality operators + "equals", "notEquals", + // Comparison operators + "lessThan", "greaterThan", "lessThanOrEqualTo", "greaterThanOrEqualTo", + // Range operator + "between", + // Existence operators + "exists", "missing", + // Content operators + "contains", "notContains", "startsWith", "endsWith", "matchesRegex", + // Collection operators + "in", "notIn", "all", "inContains", "hasSomeOf", "hasNoneOf", + // Date operators + "isDay", "isNotDay", + // Geographic operator + "distance" + )); + + @Override + public String getValueTypeId() { + return "comparisonOperator"; + } + + @Override + public boolean validate(Object value) { + return value == null || (value instanceof String && VALID_OPERATORS.contains(value)); + } + + @Override + public String getValueTypeDescription() { + return "Value must be a valid comparison operator: " + String.join(", ", VALID_OPERATORS); + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ConditionValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ConditionValueTypeValidator.java new file mode 100644 index 0000000000..f77c008cc9 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/ConditionValueTypeValidator.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.ConditionValidationService; +import org.apache.unomi.api.services.ValueTypeValidator; + +import java.util.Collection; + +public class ConditionValueTypeValidator implements ValueTypeValidator { + @Override + public String getValueTypeId() { + return "condition"; + } + + @Override + public boolean validate(Object value) { + return validate(value, null); + } + + public boolean validate(Object value, ConditionValidationService validationService) { + if (value == null) { + return true; + } + // Handle collections for multivalued parameters + if (value instanceof Collection) { + Collection collection = (Collection) value; + // Empty collections are considered valid, parameter required validation is handled separately + if (collection.isEmpty()) { + return true; + } + // Check each element in the collection + return collection.stream().allMatch(element -> element == null || validateSingleCondition(element, validationService)); + } + return validateSingleCondition(value, validationService); + } + + private boolean validateSingleCondition(Object value, ConditionValidationService validationService) { + if (!(value instanceof Condition)) { + return false; + } + Condition condition = (Condition) value; + + // Note: This validator performs basic structure validation. + // Condition type resolution should happen before validation in ConditionValidationServiceImpl. + // If the type is not resolved here, it will be caught by the main validation. + // Basic validation: must have type and metadata + ConditionType type = condition.getConditionType(); + if (type == null || type.getMetadata() == null) { + return false; + } + + // For basic structure validation, we only check if it's a valid condition + // Let the parent validator handle the nested validation + return true; + } + + @Override + public String getValueTypeDescription() { + return "Value must be a valid condition with a condition type and metadata"; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DateValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DateValueTypeValidator.java new file mode 100644 index 0000000000..e8f3cf5b09 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DateValueTypeValidator.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.services.ValueTypeValidator; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Date; + +public class DateValueTypeValidator implements ValueTypeValidator { + @Override + public String getValueTypeId() { + return "date"; + } + + @Override + public boolean validate(Object value) { + if (value == null) { + return true; + } + // Accept Date and modern date/time types + return value instanceof Date + || value instanceof OffsetDateTime + || value instanceof ZonedDateTime + || value instanceof LocalDateTime + || value instanceof Instant; + } + + @Override + public String getValueTypeDescription() { + return "Value must be a date (Date, OffsetDateTime, ZonedDateTime, LocalDateTime, or Instant)"; + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DoubleValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DoubleValueTypeValidator.java new file mode 100644 index 0000000000..cfe2c76c85 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/DoubleValueTypeValidator.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +public class DoubleValueTypeValidator extends AbstractNumericValueTypeValidator { + public DoubleValueTypeValidator() { + super("double", Double.class); + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/FloatValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/FloatValueTypeValidator.java new file mode 100644 index 0000000000..f0aa3aa5be --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/FloatValueTypeValidator.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +public class FloatValueTypeValidator extends AbstractNumericValueTypeValidator { + public FloatValueTypeValidator() { + super("float", Float.class); + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/IntegerValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/IntegerValueTypeValidator.java new file mode 100644 index 0000000000..65eaaacb44 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/IntegerValueTypeValidator.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +public class IntegerValueTypeValidator extends AbstractNumericValueTypeValidator { + public IntegerValueTypeValidator() { + super("integer", Integer.class); + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/LongValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/LongValueTypeValidator.java new file mode 100644 index 0000000000..f0f93a4a63 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/LongValueTypeValidator.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +public class LongValueTypeValidator extends AbstractNumericValueTypeValidator { + public LongValueTypeValidator() { + super("long", Long.class); + } +} diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/StringValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/StringValueTypeValidator.java new file mode 100644 index 0000000000..b0329ce96c --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/StringValueTypeValidator.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation.validators; + +import org.apache.unomi.api.services.ValueTypeValidator; + +public class StringValueTypeValidator implements ValueTypeValidator { + @Override + public String getValueTypeId() { + return "string"; + } + + @Override + public boolean validate(Object value) { + return value instanceof String; + } + + @Override + public String getValueTypeDescription() { + return "Value must be a string"; + } +} diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml index 46082c898e..5a2881f985 100644 --- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -35,6 +35,16 @@ + + + + + + + + + + + + + + + + + + + @@ -452,6 +475,18 @@ + + + + + + + + + + + + @@ -642,4 +677,16 @@ + + + + + + + + + + + + diff --git a/services/src/test/java/org/apache/unomi/services/TestHelper.java b/services/src/test/java/org/apache/unomi/services/TestHelper.java index a23cbce556..fa44fd4199 100644 --- a/services/src/test/java/org/apache/unomi/services/TestHelper.java +++ b/services/src/test/java/org/apache/unomi/services/TestHelper.java @@ -37,6 +37,8 @@ import org.apache.unomi.services.impl.cluster.ClusterServiceImpl; import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; import org.apache.unomi.services.impl.scheduler.*; +import org.apache.unomi.services.impl.validation.ConditionValidationServiceImpl; +import org.apache.unomi.services.impl.validation.validators.*; import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; import org.osgi.service.event.EventAdmin; @@ -119,6 +121,19 @@ public static DefinitionsServiceImpl createDefinitionService( definitionsService.setTenantService(tenantService); definitionsService.setEventAdmin(eventAdmin); + // Configure built-in validators for the ConditionValidationService created internally + List validators = new ArrayList<>(); + validators.add(new StringValueTypeValidator()); + validators.add(new IntegerValueTypeValidator()); + validators.add(new LongValueTypeValidator()); + validators.add(new FloatValueTypeValidator()); + validators.add(new DoubleValueTypeValidator()); + validators.add(new BooleanValueTypeValidator()); + validators.add(new DateValueTypeValidator()); + validators.add(new ComparisonOperatorValueTypeValidator()); + validators.add(new ConditionValueTypeValidator()); + definitionsService.setConditionValidationServiceBuiltInValidators(validators); + definitionsService.postConstruct(); return definitionsService; } @@ -384,6 +399,22 @@ public static SchedulerServiceImpl createSchedulerServiceWithoutPersistenceProvi + public static ConditionValidationService createConditionValidationService() { + ConditionValidationServiceImpl conditionValidationService = new ConditionValidationServiceImpl(); + List validators = new ArrayList<>(); + validators.add(new StringValueTypeValidator()); + validators.add(new IntegerValueTypeValidator()); + validators.add(new LongValueTypeValidator()); + validators.add(new FloatValueTypeValidator()); + validators.add(new DoubleValueTypeValidator()); + validators.add(new BooleanValueTypeValidator()); + validators.add(new DateValueTypeValidator()); + validators.add(new ComparisonOperatorValueTypeValidator()); + validators.add(new ConditionValueTypeValidator()); + conditionValidationService.setBuiltInValidators(validators); + return conditionValidationService; + } + /** * Creates and wires a new ClusterServiceImpl with the specified persistence service and node ID. * Callers must invoke postConstruct() themselves if initialization behaviour is needed. @@ -404,6 +435,7 @@ public static ClusterServiceImpl createClusterService(PersistenceService persist } + /** * Creates and wires a new ClusterServiceImpl with custom addresses and bundle context. * Callers must invoke postConstruct() themselves if initialization behaviour is needed. diff --git a/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java b/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java index 9d09d3c130..25abd34393 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TestConditionEvaluators.java @@ -20,6 +20,7 @@ import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.conditions.ConditionValidation; import org.apache.unomi.api.services.EventService; import org.apache.unomi.persistence.spi.PropertyHelper; import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper; @@ -873,6 +874,20 @@ private static Parameter createParameter(String name, String type, boolean requi parameter.setType(type); parameter.setMultivalued(multivalued); + // Create validation settings + ConditionValidation validation = new ConditionValidation(); + + // Set required flag + validation.setRequired(required); + + // Set exclusive group if provided + if (exclusiveGroup != null) { + validation.setExclusive(true); + validation.setExclusiveGroup(exclusiveGroup); + } + + parameter.setValidation(validation); + return parameter; } @@ -882,6 +897,21 @@ private static Parameter createParameterRecommended(String name, String type, St parameter.setType(type); parameter.setMultivalued(multivalued); + // Create validation settings + ConditionValidation validation = new ConditionValidation(); + + // Set recommended flag instead of required + validation.setRequired(false); + validation.setRecommended(true); + + // Set exclusive group if provided + if (exclusiveGroup != null) { + validation.setExclusive(true); + validation.setExclusiveGroup(exclusiveGroup); + } + + parameter.setValidation(validation); + return parameter; } diff --git a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java new file mode 100644 index 0000000000..86e3a00517 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -0,0 +1,1380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl; + +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.actions.Action; +import org.apache.unomi.api.actions.ActionType; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.InvalidObjectInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class TypeResolutionServiceImplTest { + + private TypeResolutionServiceImpl typeResolutionService; + + @Mock + private DefinitionsService definitionsService; + + private ConditionType testConditionType; + private ActionType testActionType; + + @BeforeEach + public void setUp() { + typeResolutionService = new TypeResolutionServiceImpl(definitionsService); + + // Create test condition type + testConditionType = new ConditionType(new Metadata()); + testConditionType.setItemId("testConditionType"); + testConditionType.getMetadata().setName("Test Condition Type"); + + // Create test action type + testActionType = new ActionType(new Metadata()); + testActionType.setItemId("testActionType"); + testActionType.getMetadata().setName("Test Action Type"); + testActionType.setActionExecutor("testActionExecutor"); + } + + @Test + public void testResolveConditionType_Success() { + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + + boolean resolved = typeResolutionService.resolveConditionType(condition, "test context"); + + assertTrue(resolved, "Condition type should resolve successfully when definition exists"); + assertNotNull(condition.getConditionType(), "Condition should have its type set after resolution"); + assertEquals(testConditionType, condition.getConditionType(), "Condition type should match the definition"); + } + + @Test + public void testResolveConditionType_NotFound() { + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentType"); + + when(definitionsService.getConditionType("nonExistentType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveConditionType(condition, "test context"); + + assertFalse(resolved, "Condition type should not resolve when definition doesn't exist"); + assertNull(condition.getConditionType(), "Condition should not have its type set when resolution fails"); + } + + @Test + public void testResolveConditionType_NullCondition() { + boolean resolved = typeResolutionService.resolveConditionType(null, "test context"); + + assertFalse(resolved, "Null condition should return false"); + } + + @Test + public void testResolveConditionType_WithNestedConditions() { + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + + Condition childCondition = new Condition(); + childCondition.setConditionTypeId("childConditionType"); + + ConditionType parentType = new ConditionType(new Metadata()); + parentType.setItemId("parentConditionType"); + parentCondition.setParameter("subCondition", childCondition); + + ConditionType childType = new ConditionType(new Metadata()); + childType.setItemId("childConditionType"); + + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + when(definitionsService.getConditionType("childConditionType")).thenReturn(childType); + + boolean resolved = typeResolutionService.resolveConditionType(parentCondition, "test context"); + + assertTrue(resolved, "Parent condition with nested child should resolve successfully"); + assertNotNull(parentCondition.getConditionType(), "Parent condition should have its type set"); + assertNotNull(childCondition.getConditionType(), "Child condition should have its type set"); + } + + @Test + public void testResolveActionType_Success() { + Action action = new Action(); + action.setActionTypeId("testActionType"); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveActionType(action); + + assertTrue(resolved, "Action type should resolve successfully when definition exists"); + assertNotNull(action.getActionType(), "Action should have its type set after resolution"); + assertEquals(testActionType, action.getActionType(), "Action type should match the definition"); + } + + @Test + public void testResolveActionType_NotFound() { + Action action = new Action(); + action.setActionTypeId("nonExistentActionType"); + + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveActionType(action); + + assertFalse(resolved, "Action type should not resolve when definition doesn't exist"); + assertNull(action.getActionType(), "Action should not have its type set when resolution fails"); + } + + @Test + public void testResolveActionTypes_WithMultipleActions() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action1 = new Action(); + action1.setActionTypeId("testActionType"); + Action action2 = new Action(); + action2.setActionTypeId("testActionType"); + + rule.setActions(Arrays.asList(action1, action2)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveActionTypes(rule, false); + + assertTrue(resolved, "All actions should resolve successfully"); + assertNotNull(action1.getActionType(), "First action should have its type set"); + assertNotNull(action2.getActionType(), "Second action should have its type set"); + } + + @Test + public void testResolveActionTypes_WithUnresolvedAction() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action1 = new Action(); + action1.setActionTypeId("testActionType"); + Action action2 = new Action(); + action2.setActionTypeId("nonExistentActionType"); + + rule.setActions(Arrays.asList(action1, action2)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveActionTypes(rule, false); + + assertFalse(resolved, "Should return false when any action fails to resolve"); + assertNotNull(action1.getActionType(), "First action should have its type set"); + assertNull(action2.getActionType(), "Second action should not have its type set"); + } + + @Test + public void testResolveCondition_WithMetadataItem_Success() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + + boolean resolved = typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertTrue(resolved, "Condition should resolve successfully"); + assertFalse(segment.getMetadata().isMissingPlugins(), "missingPlugins should be false when resolution succeeds"); + assertFalse(typeResolutionService.isInvalid("segments", "testSegment"), "Segment should not be marked as invalid"); + } + + @Test + public void testResolveCondition_WithMetadataItem_Failure() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("nonExistentType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertFalse(resolved, "Condition should not resolve when type doesn't exist"); + assertTrue(segment.getMetadata().isMissingPlugins(), "missingPlugins should be true when resolution fails"); + assertTrue(typeResolutionService.isInvalid("segments", "testSegment"), "Segment should be marked as invalid"); + assertNotNull(typeResolutionService.getInvalidationReason("segments", "testSegment"), "Should have invalidation reason"); + } + + @Test + public void testResolveCondition_WithMetadataItem_NullCondition() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + segment.getMetadata().setMissingPlugins(true); // Set to true initially + + boolean resolved = typeResolutionService.resolveCondition("segments", segment, null, "test context"); + + assertTrue(resolved, "Null condition should be considered valid"); + assertFalse(segment.getMetadata().isMissingPlugins(), "missingPlugins should be cleared for null condition"); + } + + @Test + public void testResolveRule_Success() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertTrue(resolved, "Rule should resolve successfully when both condition and actions resolve"); + assertFalse(rule.getMetadata().isMissingPlugins(), "missingPlugins should be false when all types resolve"); + assertFalse(typeResolutionService.isInvalid("rules", "testRule"), "Rule should not be marked as invalid"); + } + + @Test + public void testResolveRule_WithUnresolvedCondition() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("nonExistentConditionType")).thenReturn(null); + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertFalse(resolved, "Rule should not resolve when condition fails"); + assertTrue(rule.getMetadata().isMissingPlugins(), "missingPlugins should be true when condition fails"); + assertTrue(typeResolutionService.isInvalid("rules", "testRule"), "Rule should be marked as invalid"); + } + + @Test + public void testResolveRule_WithUnresolvedActions() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("nonExistentActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertFalse(resolved, "Rule should not resolve when actions fail"); + assertTrue(rule.getMetadata().isMissingPlugins(), "missingPlugins should be true when actions fail"); + assertTrue(typeResolutionService.isInvalid("rules", "testRule"), "Rule should be marked as invalid"); + } + + @Test + public void testResolveRule_WithNullCondition() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + rule.setCondition(null); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertTrue(resolved, "Rule should resolve when condition is null but actions resolve"); + assertFalse(rule.getMetadata().isMissingPlugins(), "missingPlugins should be false"); + } + + @Test + public void testResolveActions_WithRule() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveActions("rules", rule); + + assertTrue(resolved, "Actions should resolve successfully"); + assertNotNull(action.getActionType(), "Action should have its type set"); + // Note: missingPlugins is only cleared in resolveRule, not in resolveActions alone + } + + @Test + public void testMarkInvalid_AndMarkValid() { + typeResolutionService.markInvalid("rules", "rule1", "Test reason"); + + assertTrue(typeResolutionService.isInvalid("rules", "rule1"), "Rule should be marked as invalid"); + assertEquals("Test reason", typeResolutionService.getInvalidationReason("rules", "rule1"), "Should return the invalidation reason"); + + typeResolutionService.markValid("rules", "rule1"); + + assertFalse(typeResolutionService.isInvalid("rules", "rule1"), "Rule should be marked as valid after markValid"); + assertNull(typeResolutionService.getInvalidationReason("rules", "rule1"), "Should return null for valid objects"); + } + + @Test + public void testGetAllInvalidObjects() { + typeResolutionService.markInvalid("rules", "rule1", "Reason 1"); + typeResolutionService.markInvalid("rules", "rule2", "Reason 2"); + typeResolutionService.markInvalid("segments", "segment1", "Reason 3"); + + Map> allInvalid = typeResolutionService.getAllInvalidObjects(); + + assertEquals(2, allInvalid.size(), "Should have two object types"); + assertTrue(allInvalid.containsKey("rules"), "Should contain rules"); + assertTrue(allInvalid.containsKey("segments"), "Should contain segments"); + assertEquals(2, allInvalid.get("rules").size(), "Should have 2 invalid rules"); + assertEquals(1, allInvalid.get("segments").size(), "Should have 1 invalid segment"); + } + + @Test + public void testGetInvalidObjects_ByType() { + typeResolutionService.markInvalid("rules", "rule1", "Reason 1"); + typeResolutionService.markInvalid("rules", "rule2", "Reason 2"); + typeResolutionService.markInvalid("segments", "segment1", "Reason 3"); + + Map invalidRules = typeResolutionService.getInvalidObjects("rules"); + + assertEquals(2, invalidRules.size(), "Should have 2 invalid rules"); + assertTrue(invalidRules.containsKey("rule1"), "Should contain rule1"); + assertTrue(invalidRules.containsKey("rule2"), "Should contain rule2"); + } + + @Test + public void testGetTotalInvalidObjectCount() { + typeResolutionService.markInvalid("rules", "rule1", "Reason 1"); + typeResolutionService.markInvalid("rules", "rule2", "Reason 2"); + typeResolutionService.markInvalid("segments", "segment1", "Reason 3"); + + int total = typeResolutionService.getTotalInvalidObjectCount(); + + assertEquals(3, total, "Should have 3 total invalid objects"); + } + + @Test + public void testGetInvalidObjectIds() { + typeResolutionService.markInvalid("rules", "rule1", "Reason 1"); + typeResolutionService.markInvalid("rules", "rule2", "Reason 2"); + + Set invalidIds = typeResolutionService.getInvalidObjectIds("rules"); + + assertEquals(2, invalidIds.size(), "Should have 2 invalid rule IDs"); + assertTrue(invalidIds.contains("rule1"), "Should contain rule1"); + assertTrue(invalidIds.contains("rule2"), "Should contain rule2"); + } + + @Test + public void testResolveCondition_ClearsMissingPlugins_WhenResolved() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + segment.getMetadata().setMissingPlugins(true); // Initially set to true + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + + typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertFalse(segment.getMetadata().isMissingPlugins(), "missingPlugins should be cleared when condition resolves successfully"); + } + + @Test + public void testResolveRule_ClearsMissingPlugins_WhenBothResolve() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + rule.getMetadata().setMissingPlugins(true); // Initially set to true + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + typeResolutionService.resolveRule("rules", rule); + + assertFalse(rule.getMetadata().isMissingPlugins(), "missingPlugins should be cleared when both condition and actions resolve"); + } + + @Test + public void testResolveConditionType_WithParentCondition() { + ConditionType parentType = new ConditionType(new Metadata()); + parentType.setItemId("parentConditionType"); + + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("parentConditionType"); + + testConditionType.setParentCondition(parentCondition); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getConditionType("parentConditionType")).thenReturn(parentType); + + boolean resolved = typeResolutionService.resolveConditionType(condition, "test context"); + + assertTrue(resolved, "Condition with parent should resolve successfully"); + assertNotNull(condition.getConditionType(), "Condition should have its type set"); + assertNotNull(testConditionType.getParentCondition().getConditionType(), "Parent condition should have its type set"); + } + + @Test + public void testResolveConditionType_WithUnresolvedParent() { + Condition parentCondition = new Condition(); + parentCondition.setConditionTypeId("nonExistentParentType"); + + testConditionType.setParentCondition(parentCondition); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getConditionType("nonExistentParentType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveConditionType(condition, "test context"); + + assertFalse(resolved, "Condition should not resolve when parent condition fails"); + assertNull(condition.getConditionType(), "Condition should not have its type set when parent fails"); + } + + @Test + public void testResolveValueType_Null() { + assertDoesNotThrow(() -> typeResolutionService.resolveValueType(null), "resolveValueType should handle null gracefully"); + } + + @Test + public void testResolveValueType_SetsValueTypeOnPropertyType() { + org.apache.unomi.api.PropertyType propertyType = new org.apache.unomi.api.PropertyType(); + propertyType.setMetadata(new Metadata("testProp")); + propertyType.setValueTypeId("string"); + + org.apache.unomi.api.ValueType valueType = new org.apache.unomi.api.ValueType("string"); + when(definitionsService.getValueType("string")).thenReturn(valueType); + + typeResolutionService.resolveValueType(propertyType); + + assertNotNull(propertyType.getValueType(), "resolveValueType should set the valueType on the PropertyType"); + assertEquals("string", propertyType.getValueType().getId(), "ValueType should match the looked-up type"); + } + + @Test + public void testResolveValueType_MissingType_LeavesNull() { + org.apache.unomi.api.PropertyType propertyType = new org.apache.unomi.api.PropertyType(); + propertyType.setMetadata(new Metadata("testProp")); + propertyType.setValueTypeId("nonExistent"); + + when(definitionsService.getValueType("nonExistent")).thenReturn(null); + + typeResolutionService.resolveValueType(propertyType); + + assertNull(propertyType.getValueType(), "resolveValueType should leave valueType null when type is not found"); + } + + @Test + public void testResolveActionTypes_IgnoreErrors_StillReturnsFalseOnPartialFailure() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action1 = new Action(); + action1.setActionTypeId("testActionType"); + Action action2 = new Action(); + action2.setActionTypeId("nonExistentActionType"); + rule.setActions(Arrays.asList(action1, action2)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + // ignoreErrors only suppresses the warning log; return value still reflects resolution outcome + boolean resolved = typeResolutionService.resolveActionTypes(rule, true); + + assertFalse(resolved, "resolveActionTypes should return false when any action fails, regardless of ignoreErrors"); + assertNotNull(action1.getActionType(), "Successfully resolved action should have its type set"); + } + + @Test + public void testResolveActionTypes_NullActions_IgnoreErrors_SuppressesWarning() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + rule.setActions(null); + + // ignoreErrors=true suppresses the null-actions warning; method still returns false + boolean resolved = typeResolutionService.resolveActionTypes(rule, true); + + assertFalse(resolved, "resolveActionTypes should return false when actions are null, even with ignoreErrors=true"); + } + + @Test + public void testIsInvalid_WithNullParameters() { + assertFalse(typeResolutionService.isInvalid(null, "rule1"), "Should return false for null objectType"); + assertFalse(typeResolutionService.isInvalid("rules", null), "Should return false for null objectId"); + assertFalse(typeResolutionService.isInvalid(null, null), "Should return false for both null"); + } + + @Test + public void testMarkInvalid_WithNullParameters() { + assertDoesNotThrow(() -> typeResolutionService.markInvalid(null, "rule1", "reason"), "Should handle null objectType"); + assertDoesNotThrow(() -> typeResolutionService.markInvalid("rules", null, "reason"), "Should handle null objectId"); + assertDoesNotThrow(() -> typeResolutionService.markInvalid("rules", "rule1", null), "Should handle null reason"); + } + + @Test + public void testResolveCondition_UpdatesInvalidTracking() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + segment.setCondition(condition); + + // First, mark as invalid + typeResolutionService.markInvalid("segments", "testSegment", "Previous reason"); + assertTrue(typeResolutionService.isInvalid("segments", "testSegment"), "Should be invalid initially"); + + // Then resolve successfully + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertFalse(typeResolutionService.isInvalid("segments", "testSegment"), "Should be marked as valid after successful resolution"); + } + + // --- Regression tests for review findings --- + + @Test + public void setDefinitionsService_null_throwsIllegalArgumentException() { + assertThrows(IllegalArgumentException.class, + () -> typeResolutionService.setDefinitionsService(null), + "setDefinitionsService(null) must throw IllegalArgumentException"); + } + + @Test + public void resolveValueType_unknownTypeId_doesNotThrow() { + org.apache.unomi.api.PropertyType propertyType = new org.apache.unomi.api.PropertyType(); + propertyType.setMetadata(new Metadata("testProp")); + propertyType.setValueTypeId("nonExistentType"); + when(definitionsService.getValueType("nonExistentType")).thenReturn(null); + + assertDoesNotThrow(() -> typeResolutionService.resolveValueType(propertyType), + "resolveValueType must not throw when type lookup returns null"); + assertNull(propertyType.getValueType(), "ValueType must remain null when lookup fails"); + } + + @Test + public void updateEncounter_concurrentAccess_isThreadSafe() throws InterruptedException { + InvalidObjectInfo info = new InvalidObjectInfo("rules", "rule1", "test"); + int threads = 10; + int iterationsPerThread = 100; + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(threads); + java.util.concurrent.ExecutorService executor = + java.util.concurrent.Executors.newFixedThreadPool(threads); + + for (int i = 0; i < threads; i++) { + executor.submit(() -> { + for (int j = 0; j < iterationsPerThread; j++) { + info.updateEncounter(null, null, "ctx-" + j); + } + latch.countDown(); + }); + } + latch.await(); + executor.shutdown(); + + assertEquals(1 + threads * iterationsPerThread, info.getEncounterCount(), + "All concurrent increments must be counted without loss"); + } + + @Test + public void updateEncounter_readDuringConcurrentWrite_neverObservesCorruptCount() throws InterruptedException { + InvalidObjectInfo info = new InvalidObjectInfo("rules", "rule1", "test"); + int writerThreads = 4; + int readerThreads = 4; + int iterations = 200; + java.util.concurrent.CountDownLatch startLatch = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch doneLatch = new java.util.concurrent.CountDownLatch(writerThreads + readerThreads); + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(writerThreads + readerThreads); + java.util.concurrent.atomic.AtomicBoolean corrupt = new java.util.concurrent.atomic.AtomicBoolean(false); + + for (int i = 0; i < writerThreads; i++) { + executor.submit(() -> { + try { startLatch.await(); } catch (InterruptedException ignored) {} + for (int j = 0; j < iterations; j++) { + info.updateEncounter(null, null, "ctx"); + } + doneLatch.countDown(); + }); + } + for (int i = 0; i < readerThreads; i++) { + executor.submit(() -> { + try { startLatch.await(); } catch (InterruptedException ignored) {} + for (int j = 0; j < iterations; j++) { + if (info.getEncounterCount() < 1) { + corrupt.set(true); + } + } + doneLatch.countDown(); + }); + } + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + assertFalse(corrupt.get(), "Concurrent reads must never observe a corrupted (< 1) encounter count"); + } + + // Tests for enhanced InvalidObjectInfo functionality + + @Test + public void testInvalidObjectInfo_WithDetailedInformation_FromResolveCondition() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("missingCondType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("missingCondType")).thenReturn(null); + + typeResolutionService.resolveCondition("segments", segment, condition, "segment testSegment"); + + Map invalidSegments = typeResolutionService.getInvalidObjects("segments"); + InvalidObjectInfo info = invalidSegments.get("testSegment"); + + assertNotNull(info, "InvalidObjectInfo should be created"); + assertEquals("segments", info.getObjectType(), "Object type should match"); + assertEquals("testSegment", info.getObjectId(), "Object ID should match"); + assertTrue(info.getReason().contains("Unresolved condition type"), "Reason should mention condition type"); + assertEquals(1, info.getMissingConditionTypeIds().size(), "Should have 1 missing condition type"); + assertEquals("missingCondType", info.getMissingConditionTypeIds().get(0), "Should contain missing condition type"); + assertTrue(info.getMissingActionTypeIds().isEmpty(), "Should have no missing action types"); + assertEquals(1, info.getContextNames().size(), "Should have 1 context"); + assertTrue(info.getContextNames().contains("segment testSegment"), "Should contain context name"); + assertEquals(1, info.getEncounterCount(), "Should have encounter count of 1"); + assertTrue(info.getFirstSeenTimestamp() > 0, "Should have first seen timestamp"); + assertEquals(info.getFirstSeenTimestamp(), info.getLastSeenTimestamp(), "First and last seen should be equal on first encounter"); + } + + @Test + public void testInvalidObjectInfo_UpdateEncounter_AccumulatesInformation() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition1 = new Condition(); + condition1.setConditionTypeId("missingCond1"); + Condition condition2 = new Condition(); + condition2.setConditionTypeId("missingCond2"); + + when(definitionsService.getConditionType("missingCond1")).thenReturn(null); + when(definitionsService.getConditionType("missingCond2")).thenReturn(null); + + // First encounter + typeResolutionService.resolveCondition("segments", segment, condition1, "context1"); + + InvalidObjectInfo info1 = typeResolutionService.getInvalidObjects("segments").get("testSegment"); + long firstSeen = info1.getFirstSeenTimestamp(); + int firstEncounterCount = info1.getEncounterCount(); + + assertEquals(1, firstEncounterCount, "First encounter should have count of 1"); + assertEquals(1, info1.getMissingConditionTypeIds().size(), "Should have 1 missing condition type initially"); + + // Wait a bit to ensure timestamp difference + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Second encounter with different missing type + typeResolutionService.resolveCondition("segments", segment, condition2, "context2"); + + InvalidObjectInfo info2 = typeResolutionService.getInvalidObjects("segments").get("testSegment"); + + assertEquals(firstSeen, info2.getFirstSeenTimestamp(), "First seen timestamp should not change"); + assertTrue(info2.getLastSeenTimestamp() >= firstSeen, "Last seen timestamp should be updated"); + assertEquals(2, info2.getEncounterCount(), "Encounter count should be incremented"); + assertEquals(2, info2.getMissingConditionTypeIds().size(), "Should accumulate all missing condition types"); + assertTrue(info2.getMissingConditionTypeIds().contains("missingCond1"), "Should contain initial condition type"); + assertTrue(info2.getMissingConditionTypeIds().contains("missingCond2"), "Should contain new condition type"); + assertEquals(2, info2.getContextNames().size(), "Should accumulate all contexts"); + assertTrue(info2.getContextNames().contains("context1"), "Should contain initial context"); + assertTrue(info2.getContextNames().contains("context2"), "Should contain additional context"); + } + + @Test + public void testResolveCondition_CollectsMissingConditionTypeIds() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("nonExistentType")).thenReturn(null); + + typeResolutionService.resolveCondition("segments", segment, condition, "segment testSegment"); + + Map invalidSegments = typeResolutionService.getInvalidObjects("segments"); + InvalidObjectInfo info = invalidSegments.get("testSegment"); + + assertNotNull(info, "InvalidObjectInfo should be created"); + assertEquals(1, info.getMissingConditionTypeIds().size(), "Should have 1 missing condition type"); + assertEquals("nonExistentType", info.getMissingConditionTypeIds().get(0), "Should contain the missing condition type ID"); + assertEquals(1, info.getContextNames().size(), "Should have context name"); + assertTrue(info.getContextNames().contains("segment testSegment"), "Should contain the context name"); + } + + @Test + public void testResolveActions_CollectsMissingActionTypeIds() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action1 = new Action(); + action1.setActionTypeId("nonExistentAction1"); + Action action2 = new Action(); + action2.setActionTypeId("nonExistentAction2"); + rule.setActions(Arrays.asList(action1, action2)); + + when(definitionsService.getActionType("nonExistentAction1")).thenReturn(null); + when(definitionsService.getActionType("nonExistentAction2")).thenReturn(null); + + typeResolutionService.resolveActions("rules", rule); + + Map invalidRules = typeResolutionService.getInvalidObjects("rules"); + InvalidObjectInfo info = invalidRules.get("testRule"); + + assertNotNull(info, "InvalidObjectInfo should be created"); + assertEquals(2, info.getMissingActionTypeIds().size(), "Should have 2 missing action types"); + assertTrue(info.getMissingActionTypeIds().contains("nonExistentAction1"), "Should contain first missing action type"); + assertTrue(info.getMissingActionTypeIds().contains("nonExistentAction2"), "Should contain second missing action type"); + assertEquals(1, info.getContextNames().size(), "Should have context name"); + assertTrue(info.getContextNames().contains("rule testRule"), "Should contain the context name"); + } + + @Test + public void testResolveRule_CollectsBothMissingConditionAndActionTypes() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("nonExistentActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("nonExistentConditionType")).thenReturn(null); + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + typeResolutionService.resolveRule("rules", rule); + + Map invalidRules = typeResolutionService.getInvalidObjects("rules"); + InvalidObjectInfo info = invalidRules.get("testRule"); + + assertNotNull(info, "InvalidObjectInfo should be created"); + assertEquals(1, info.getMissingConditionTypeIds().size(), "Should have 1 missing condition type"); + assertEquals("nonExistentConditionType", info.getMissingConditionTypeIds().get(0), "Should contain missing condition type"); + assertEquals(1, info.getMissingActionTypeIds().size(), "Should have 1 missing action type"); + assertEquals("nonExistentActionType", info.getMissingActionTypeIds().get(0), "Should contain missing action type"); + assertTrue(info.getReason().contains("Unresolved condition type"), "Reason should mention condition type"); + assertTrue(info.getReason().contains("Unresolved action type"), "Reason should mention action type"); + } + + @Test + public void testInvalidObjectInfo_TimestampFields() { + typeResolutionService.markInvalid("rules", "rule1", "Test reason"); + + InvalidObjectInfo info1 = typeResolutionService.getInvalidObjects("rules").get("rule1"); + long firstSeen = info1.getFirstSeenTimestamp(); + long lastSeen = info1.getLastSeenTimestamp(); + + assertTrue(firstSeen > 0, "First seen timestamp should be set"); + assertEquals(firstSeen, lastSeen, "First and last seen should be equal on first encounter"); + + // Wait a bit + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Second encounter + typeResolutionService.markInvalid("rules", "rule1", "Updated reason"); + + InvalidObjectInfo info2 = typeResolutionService.getInvalidObjects("rules").get("rule1"); + + assertEquals(firstSeen, info2.getFirstSeenTimestamp(), "First seen timestamp should not change"); + assertTrue(info2.getLastSeenTimestamp() >= firstSeen, "Last seen timestamp should be updated"); + assertTrue(info2.getLastSeenTimestamp() >= lastSeen, "Last seen should be greater than or equal to previous last seen"); + } + + @Test + public void testInvalidObjectInfo_BackwardCompatibility() { + // Test that the basic markInvalid(String, String, String) still works + typeResolutionService.markInvalid("rules", "rule1", "Simple reason"); + + InvalidObjectInfo info = typeResolutionService.getInvalidObjects("rules").get("rule1"); + + assertNotNull(info, "InvalidObjectInfo should be created"); + assertEquals("rules", info.getObjectType(), "Object type should match"); + assertEquals("rule1", info.getObjectId(), "Object ID should match"); + assertEquals("Simple reason", info.getReason(), "Reason should match"); + assertTrue(info.getMissingConditionTypeIds().isEmpty(), "Missing condition types should be empty when not provided"); + assertTrue(info.getMissingActionTypeIds().isEmpty(), "Missing action types should be empty when not provided"); + assertTrue(info.getContextNames().isEmpty(), "Context names should be empty when not provided"); + assertEquals(1, info.getEncounterCount(), "Encounter count should be 1"); + } + + @Test + public void testInvalidObjectInfo_ToString() { + Rule rule = new Rule(); + rule.setItemId("rule1"); + rule.setMetadata(new Metadata("rule1")); + + Condition condition = new Condition(); + condition.setConditionTypeId("missingCond1"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("missingAction1"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("missingCond1")).thenReturn(null); + when(definitionsService.getActionType("missingAction1")).thenReturn(null); + + typeResolutionService.resolveRule("rules", rule); + + InvalidObjectInfo info = typeResolutionService.getInvalidObjects("rules").get("rule1"); + String toString = info.toString(); + + assertTrue(toString.contains("rules"), "toString should contain object type"); + assertTrue(toString.contains("rule1"), "toString should contain object ID"); + assertTrue(toString.contains("Unresolved"), "toString should contain reason"); + assertTrue(toString.contains("missingConditionTypes"), "toString should contain missing condition types"); + assertTrue(toString.contains("missingActionTypes"), "toString should contain missing action types"); + assertTrue(toString.contains("contexts"), "toString should contain contexts"); + } + + @Test + public void testMarkInvalid_OnlyLogsFirstEncounter() { + // This test verifies that logging only happens on first encounter + // We can't easily test logging without a logging framework, but we can verify + // that the encounter count increases and information accumulates + + typeResolutionService.markInvalid("rules", "rule1", "First reason"); + + InvalidObjectInfo info1 = typeResolutionService.getInvalidObjects("rules").get("rule1"); + assertEquals(1, info1.getEncounterCount(), "First encounter should have count of 1"); + + typeResolutionService.markInvalid("rules", "rule1", "Second reason"); + + InvalidObjectInfo info2 = typeResolutionService.getInvalidObjects("rules").get("rule1"); + assertEquals(2, info2.getEncounterCount(), "Second encounter should have count of 2"); + // The reason should still be the first one (not updated) + assertEquals("First reason", info2.getReason(), "Reason should remain the first one"); + assertSame(info1, info2, "Second markInvalid call must update the existing entry, not replace it"); + } + + @Test + public void testResolveCondition_WithMultipleEncounters() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition1 = new Condition(); + condition1.setConditionTypeId("missingType1"); + Condition condition2 = new Condition(); + condition2.setConditionTypeId("missingType2"); + + when(definitionsService.getConditionType("missingType1")).thenReturn(null); + when(definitionsService.getConditionType("missingType2")).thenReturn(null); + + // First encounter with one missing type + typeResolutionService.resolveCondition("segments", segment, condition1, "context1"); + + InvalidObjectInfo info1 = typeResolutionService.getInvalidObjects("segments").get("testSegment"); + assertEquals(1, info1.getEncounterCount(), "Should have 1 encounter"); + assertEquals(1, info1.getMissingConditionTypeIds().size(), "Should have 1 missing condition type"); + + // Second encounter with different missing type + typeResolutionService.resolveCondition("segments", segment, condition2, "context2"); + + InvalidObjectInfo info2 = typeResolutionService.getInvalidObjects("segments").get("testSegment"); + assertEquals(2, info2.getEncounterCount(), "Should have 2 encounters"); + assertEquals(2, info2.getMissingConditionTypeIds().size(), "Should accumulate both missing condition types"); + assertTrue(info2.getMissingConditionTypeIds().contains("missingType1"), "Should contain first missing type"); + assertTrue(info2.getMissingConditionTypeIds().contains("missingType2"), "Should contain second missing type"); + assertEquals(2, info2.getContextNames().size(), "Should have both contexts"); + } + + // TC6: resolveActions(null) must return true, consistent with resolveCondition(null) and resolveRule(null) + @Test + public void resolveActions_nullRule_returnsTrue() { + assertTrue(typeResolutionService.resolveActions("rules", null), + "resolveActions with null rule must return true (consistent with resolveCondition and resolveRule)"); + } + + // TC4: markValid on the last object for a type removes the outer key from getAllInvalidObjects() + @Test + public void markValid_lastObjectForType_removesOuterKey() { + typeResolutionService.markInvalid("rules", "rule1", "reason"); + assertTrue(typeResolutionService.getAllInvalidObjects().containsKey("rules"), + "Outer key must exist after markInvalid"); + + typeResolutionService.markValid("rules", "rule1"); + assertFalse(typeResolutionService.getAllInvalidObjects().containsKey("rules"), + "Outer key must be removed when the last invalid object for a type is marked valid"); + } + + // TC3: pre-resolved root (conditionType != null) with an unresolvable child returns false; + // the root type must NOT be rolled back since it was set before this call + @Test + public void resolveConditionType_preResolvedRootWithUnresolvedChild_returnsFalse() { + ConditionType rootType = new ConditionType(new Metadata()); + rootType.setItemId("rootType"); + + Condition root = new Condition(); + root.setConditionType(rootType); // already resolved — type lookup is skipped + root.setConditionTypeId("rootType"); + + Condition child = new Condition(); + child.setConditionTypeId("unresolvedChildType"); + root.setParameter("subCondition", child); + + when(definitionsService.getConditionType("unresolvedChildType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveConditionType(root, "test"); + + assertFalse(resolved, "Should return false when pre-resolved root has an unresolvable child"); + assertNotNull(root.getConditionType(), + "Pre-resolved root type must not be rolled back — it was set before this call"); + assertNull(child.getConditionType(), "Unresolvable child must remain null"); + } + + // Test A: resolveRule with a null rule must return true + @Test + public void testResolveRule_NullRule_ReturnsTrue() { + assertTrue(typeResolutionService.resolveRule("rules", null), "resolveRule(null) must return true"); + } + + // Test B: resolveActionTypes with an empty (non-null) actions list returns false; + // missingPlugins is NOT set on the rule (structural error, not a missing plugin) + @Test + public void testResolveActionTypes_emptyActions_returnsFalse_doesNotSetMissingPlugins() { + Rule rule = new Rule(); + rule.setItemId("emptyActionsRule"); + rule.setMetadata(new Metadata("emptyActionsRule")); + rule.setActions(Collections.emptyList()); + + boolean resolved = typeResolutionService.resolveActionTypes(rule, false); + + assertFalse(resolved, "resolveActionTypes with an empty list must return false"); + assertFalse(rule.getMetadata().isMissingPlugins(), + "missingPlugins must NOT be set for a structurally-empty actions list (no plugins are actually missing)"); + } + + // Test C: resolveConditionType with null conditionTypeId returns false + @Test + public void testResolveConditionType_withNullConditionTypeId_returnsFalse() { + Condition condition = new Condition(); + // conditionTypeId is null by default — no ID to look up + boolean resolved = typeResolutionService.resolveConditionType(condition, "test"); + assertFalse(resolved, "resolveConditionType must return false when conditionTypeId is null"); + assertNull(condition.getConditionType(), "conditionType must remain null when conditionTypeId is null"); + } + + // Test D: getAllInvalidObjectIds returns a map covering all object types with their IDs + @Test + public void testGetAllInvalidObjectIds_returnsAllTrackedIds() { + typeResolutionService.markInvalid("rules", "rule1", "reason A"); + typeResolutionService.markInvalid("segments", "seg1", "reason B"); + + Map> allIds = typeResolutionService.getAllInvalidObjectIds(); + + assertTrue(allIds.containsKey("rules"), "Map must contain 'rules' key"); + assertTrue(allIds.containsKey("segments"), "Map must contain 'segments' key"); + assertTrue(allIds.get("rules").contains("rule1"), "rules set must contain rule1"); + assertTrue(allIds.get("segments").contains("seg1"), "segments set must contain seg1"); + } + + @Nested + class ResolutionTests { + @Test + public void testResolveCondition_Success() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + + boolean resolved = typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertTrue(resolved, "Condition type should resolve successfully"); + assertFalse(segment.getMetadata().isMissingPlugins(), "missingPlugins should be false when resolution succeeds"); + assertFalse(typeResolutionService.isInvalid("segments", "testSegment"), "Segment should not be marked as invalid"); + } + + @Test + public void testResolveCondition_WithUnresolvedType() { + Segment segment = new Segment(); + segment.setItemId("testSegment"); + segment.setMetadata(new Metadata("testSegment")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentType"); + segment.setCondition(condition); + + when(definitionsService.getConditionType("nonExistentType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveCondition("segments", segment, condition, "test context"); + + assertFalse(resolved, "Should return false when condition type cannot be resolved"); + assertTrue(segment.getMetadata().isMissingPlugins(), "missingPlugins should be true when resolution fails"); + assertTrue(typeResolutionService.isInvalid("segments", "testSegment"), "Segment should be marked as invalid"); + } + + @Test + public void testResolveActions_Success() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveActions("rules", rule); + + assertTrue(resolved, "Action types should resolve successfully"); + assertFalse(rule.getMetadata().isMissingPlugins(), "missingPlugins should be false when resolution succeeds"); + } + + @Test + public void testResolveActions_WithUnresolvedType() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Action action = new Action(); + action.setActionTypeId("nonExistentActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getActionType("nonExistentActionType")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveActions("rules", rule); + + assertFalse(resolved, "Should return false when action type cannot be resolved"); + assertTrue(rule.getMetadata().isMissingPlugins(), "missingPlugins should be true when resolution fails"); + } + + @Test + public void testResolveRule_Success() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("testConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("testConditionType")).thenReturn(testConditionType); + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertTrue(resolved, "Rule should resolve successfully when both condition and actions resolve"); + assertFalse(rule.getMetadata().isMissingPlugins(), "missingPlugins should be false when all types resolve"); + assertFalse(typeResolutionService.isInvalid("rules", "testRule"), "Rule should not be marked as invalid"); + } + + @Test + public void testResolveRule_WithUnresolvedCondition() { + Rule rule = new Rule(); + rule.setItemId("testRule"); + rule.setMetadata(new Metadata("testRule")); + + Condition condition = new Condition(); + condition.setConditionTypeId("nonExistentConditionType"); + rule.setCondition(condition); + + Action action = new Action(); + action.setActionTypeId("testActionType"); + rule.setActions(Collections.singletonList(action)); + + when(definitionsService.getConditionType("nonExistentConditionType")).thenReturn(null); + when(definitionsService.getActionType("testActionType")).thenReturn(testActionType); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertFalse(resolved, "Should return false when condition type cannot be resolved"); + assertTrue(rule.getMetadata().isMissingPlugins(), "missingPlugins should be true when resolution fails"); + assertTrue(typeResolutionService.isInvalid("rules", "testRule"), "Rule should be marked as invalid"); + } + } + + // ----------------------------------------------------------------------- + // Regression tests for review findings: cycle, max-depth, sibling rollback + // ----------------------------------------------------------------------- + + @Test + public void resolveConditionType_circularParentChain_returnsFalse() { + // typeA's parentCondition points to typeB, typeB's parentCondition points back to typeA + ConditionType typeA = new ConditionType(new Metadata()); + typeA.setItemId("cycleTypeA"); + ConditionType typeB = new ConditionType(new Metadata()); + typeB.setItemId("cycleTypeB"); + + Condition parentOfA = new Condition(); + parentOfA.setConditionTypeId("cycleTypeB"); + typeA.setParentCondition(parentOfA); + + Condition parentOfB = new Condition(); + parentOfB.setConditionTypeId("cycleTypeA"); + typeB.setParentCondition(parentOfB); + + when(definitionsService.getConditionType("cycleTypeA")).thenReturn(typeA); + when(definitionsService.getConditionType("cycleTypeB")).thenReturn(typeB); + + Condition root = new Condition(); + root.setConditionTypeId("cycleTypeA"); + + boolean resolved = typeResolutionService.resolveConditionType(root, "cycle test"); + + assertFalse(resolved, "Must return false for a circular parent chain"); + assertNull(root.getConditionType(), "Condition type must be rolled back on cycle detection"); + } + + @Test + public void resolveConditionType_exceedsMaxDepth_returnsFalse() { + // Build a chain of 1002 conditions as nested sub-condition parameters. + // Each depth increment passes through resolveConditionTypeInternal, so depth > 1000 triggers the guard. + int chainLength = 1002; + Condition[] conditions = new Condition[chainLength]; + for (int i = 0; i < chainLength; i++) { + conditions[i] = new Condition(); + conditions[i].setConditionTypeId("depthType" + i); + } + for (int i = 0; i < chainLength - 1; i++) { + conditions[i].setParameter("sub", conditions[i + 1]); + } + + when(definitionsService.getConditionType(startsWith("depthType"))) + .thenAnswer(inv -> { + String id = inv.getArgument(0); + ConditionType ct = new ConditionType(new Metadata()); + ct.setItemId(id); + return ct; + }); + + boolean resolved = typeResolutionService.resolveConditionType(conditions[0], "depth test"); + + assertFalse(resolved, "Must return false when recursion depth exceeds MAX_RECURSION_DEPTH"); + } + + @Test + public void resolveConditionType_threeSiblings_thirdFailsRollsBackAll() { + // parent has three sub-conditions as a list; child1 and child2 resolve, child3 does not. + // On failure, all resolved siblings and the parent must be rolled back. + ConditionType parentType = new ConditionType(new Metadata()); + parentType.setItemId("rollbackParent"); + ConditionType child1Type = new ConditionType(new Metadata()); + child1Type.setItemId("rollbackChild1"); + ConditionType child2Type = new ConditionType(new Metadata()); + child2Type.setItemId("rollbackChild2"); + + Condition parent = new Condition(); + parent.setConditionTypeId("rollbackParent"); + Condition child1 = new Condition(); + child1.setConditionTypeId("rollbackChild1"); + Condition child2 = new Condition(); + child2.setConditionTypeId("rollbackChild2"); + Condition child3 = new Condition(); + child3.setConditionTypeId("rollbackMissing"); + + parent.setParameter("subConditions", Arrays.asList(child1, child2, child3)); + + when(definitionsService.getConditionType("rollbackParent")).thenReturn(parentType); + when(definitionsService.getConditionType("rollbackChild1")).thenReturn(child1Type); + when(definitionsService.getConditionType("rollbackChild2")).thenReturn(child2Type); + when(definitionsService.getConditionType("rollbackMissing")).thenReturn(null); + + boolean resolved = typeResolutionService.resolveConditionType(parent, "rollback test"); + + assertFalse(resolved, "Must return false when any sibling fails to resolve"); + assertNull(parent.getConditionType(), "Parent type must be rolled back"); + assertNull(child1.getConditionType(), "child1 type must be rolled back after third sibling failure"); + assertNull(child2.getConditionType(), "child2 type must be rolled back after third sibling failure"); + assertNull(child3.getConditionType(), "child3 type must remain null (was never resolved)"); + } + + @Test + public void updateEncounter_concurrentUpdates_noDuplicateMissingTypeIds() throws InterruptedException { + // CopyOnWriteArraySet deduplication: concurrent threads adding the same type ID + // must not produce duplicates in the returned list. + InvalidObjectInfo info = new InvalidObjectInfo("rules", "rule1", "test"); + int threads = 10; + CountDownLatch latch = new CountDownLatch(threads); + ExecutorService executor = Executors.newFixedThreadPool(threads); + + List singletonList = Collections.singletonList("sameConditionType"); + for (int i = 0; i < threads; i++) { + executor.submit(() -> { + info.updateEncounter(singletonList, null, "ctx"); + latch.countDown(); + }); + } + latch.await(); + executor.shutdown(); + + List ids = info.getMissingConditionTypeIds(); + assertEquals(1, ids.size(), + "Concurrent adds of the same type ID must not produce duplicates"); + assertEquals("sameConditionType", ids.get(0)); + } + + @Test + public void resolveRule_nullActions_doesNotSetMissingPlugins() { + // Structural config error (null actions) must not set missingPlugins — + // that flag is reserved for genuinely missing plugin types. + Rule rule = new Rule(); + rule.setItemId("emptyRule"); + rule.setMetadata(new Metadata("emptyRule")); + rule.setCondition(null); + rule.setActions(null); + + boolean resolved = typeResolutionService.resolveRule("rules", rule); + + assertFalse(resolved, "Must return false when actions are null"); + assertFalse(rule.getMetadata().isMissingPlugins(), + "missingPlugins must NOT be set for structural config errors like null actions"); + } + + @Test + public void resolveCondition_nestedChildFails_reportsLeafTypeIdNotRoot() { + // When a nested sub-condition fails, InvalidObjectInfo must name the leaf type, + // not the root booleanCondition wrapper. + ConditionType boolType = new ConditionType(new Metadata()); + boolType.setItemId("booleanCondition"); + + Condition root = new Condition(); + root.setConditionTypeId("booleanCondition"); + // Pre-set root's conditionType so that rollback on leaf failure leaves root resolved; + // findUnresolvedConditionTypeId() will then drill into children to find the leaf. + root.setConditionType(boolType); + Condition leaf = new Condition(); + leaf.setConditionTypeId("missingLeafType"); + root.setParameter("subConditions", Collections.singletonList(leaf)); + + Segment segment = new Segment(); + segment.setItemId("seg1"); + segment.setMetadata(new Metadata("seg1")); + + when(definitionsService.getConditionType("booleanCondition")).thenReturn(boolType); + when(definitionsService.getConditionType("missingLeafType")).thenReturn(null); + + typeResolutionService.resolveCondition("segments", segment, root, "test"); + + Map invalids = typeResolutionService.getInvalidObjects("segments"); + InvalidObjectInfo info = invalids.get("seg1"); + assertNotNull(info, "Segment must be recorded as invalid"); + assertEquals(1, info.getMissingConditionTypeIds().size()); + assertEquals("missingLeafType", info.getMissingConditionTypeIds().get(0), + "Reported missing type must be the failing leaf, not the root wrapper"); + assertTrue(info.getReason().contains("missingLeafType"), + "Reason text must name the actual failing leaf type"); + } +} + diff --git a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java index 4836f6a81f..432bf165f1 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java @@ -38,7 +38,6 @@ import org.osgi.framework.BundleContext; import java.io.Serializable; -import java.lang.reflect.Method; import java.util.*; import java.util.concurrent.TimeUnit; @@ -267,13 +266,7 @@ public void testGetClusterNodesReturnsAllNodes() { // Force a cache refresh: updateSystemStats() queries persistence and populates // cachedClusterNodes. The scheduled task runs every 10 s, so we invoke it directly. - try { - Method updateSystemStats = ClusterServiceImpl.class.getDeclaredMethod("updateSystemStats"); - updateSystemStats.setAccessible(true); - updateSystemStats.invoke(clusterService); - } catch (ReflectiveOperationException e) { - throw new RuntimeException("Failed to invoke updateSystemStats via reflection", e); - } + clusterService.updateSystemStats(); List result = clusterService.getClusterNodes(); @@ -319,6 +312,12 @@ public void testUpdateSystemStats() { @Test public void testCleanupStaleNodes() { + // Cancel the background cluster tasks to prevent a race: cleanupStaleNodes() has + // initialDelay=0, so on a loaded CI machine the thread pool may defer its first + // execution until after we save the stale node — at which point it would delete the + // node before our precondition assertions run. + clusterService.cancelScheduledTasks(); + // Setup - create a stale node long cutoffTime = System.currentTimeMillis() - (NODE_STATISTICS_UPDATE_FREQUENCY * 3); @@ -343,31 +342,18 @@ public void testCleanupStaleNodes() { return null; }); - // Refresh persistence to ensure nodes are available for querying (handles refresh delay) - persistenceService.refresh(); - - // Verify both nodes exist - use retry to handle potential race conditions with scheduled cleanup task - ClusterNode staleNodeBeforeCleanup = TestHelper.retryUntil( - () -> persistenceService.load("stale-node", ClusterNode.class), - node -> node != null - ); - assertNotNull(staleNodeBeforeCleanup, "Stale node should exist before cleanup, nodeId=stale-node"); - - ClusterNode freshNodeBeforeCleanup = TestHelper.retryUntil( - () -> persistenceService.load("fresh-node", ClusterNode.class), - node -> node != null - ); - assertNotNull(freshNodeBeforeCleanup, "Fresh node should exist before cleanup, nodeId=fresh-node"); - - // Trigger the cleanup by running the scheduled task's logic directly. - // The cleanup scheduled task runs every 60 s (hardcoded) which is impractical in a unit test; - // we therefore invoke the private method synchronously to observe the same end-to-end - // persistence behaviour (query by lastHeartbeat, remove stale, keep fresh) without the wait. - assertDoesNotThrow(() -> { - Method cleanupMethod = ClusterServiceImpl.class.getDeclaredMethod("cleanupStaleNodes"); - cleanupMethod.setAccessible(true); - cleanupMethod.invoke(clusterService); - }, "cleanupStaleNodes should run without throwing"); + // Verify both nodes exist — in-memory persistence with simulateRefreshDelay=false is + // synchronous, so no retry loop is needed here. + assertNotNull(persistenceService.load("stale-node", ClusterNode.class), + "Stale node should exist before cleanup, nodeId=stale-node"); + assertNotNull(persistenceService.load("fresh-node", ClusterNode.class), + "Fresh node should exist before cleanup, nodeId=fresh-node"); + + // Trigger the cleanup synchronously. The scheduled task runs every 60 s which is + // impractical in a unit test; calling the method directly exercises the same end-to-end + // persistence behaviour (query by lastHeartbeat, remove stale, keep fresh). + assertDoesNotThrow(() -> clusterService.cleanupStaleNodes(), + "cleanupStaleNodes should run without throwing"); // Verify stale node was removed but fresh node remains assertNull(persistenceService.load("stale-node", ClusterNode.class), "Stale node should be removed"); diff --git a/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java b/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java index d239a86718..5745be9de1 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java +++ b/services/src/test/java/org/apache/unomi/services/impl/rules/TestSetEventOccurrenceCountAction.java @@ -53,6 +53,9 @@ public int execute(Action action, Event event) { ArrayList conditions = new ArrayList(); Condition eventCondition = (Condition) pastEventCondition.getParameter("eventCondition"); + if (eventCondition != null) { + definitionsService.getConditionValidationService().validate(eventCondition); + } conditions.add(eventCondition); Condition c = new Condition(definitionsService.getConditionType("eventPropertyCondition")); diff --git a/services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java new file mode 100644 index 0000000000..bad1b1c2a4 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java @@ -0,0 +1,1477 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.validation; + +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Parameter; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.conditions.ConditionValidation; +import org.apache.unomi.api.services.ConditionValidationService.ValidationError; +import org.apache.unomi.api.services.ConditionValidationService.ValidationErrorType; +import org.apache.unomi.api.services.SchedulerService; +import org.apache.unomi.api.services.ValueTypeValidator; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl; +import org.apache.unomi.services.impl.TestConditionEvaluators; +import org.apache.unomi.services.impl.TestTenantService; +import org.apache.unomi.services.impl.cache.MultiTypeCacheServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.osgi.framework.BundleContext; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class ConditionValidationServiceImplTest { + + private ConditionValidationServiceImpl conditionValidationService; + + private TenantService tenantService; + private KarafSecurityService securityService; + private ExecutionContextManagerImpl executionContextManager; + private MultiTypeCacheServiceImpl multiTypeCacheService; + private PersistenceService persistenceService; + private SchedulerService schedulerService; + + @Mock + private BundleContext bundleContext; + + private static final String EVENT_CONDITION_TAG = "eventCondition"; + private static final String PROFILE_CONDITION_TAG = "profileCondition"; + private static final String SESSION_CONDITION_TAG = "sessionCondition"; + + // Helper methods for creating common condition types + private ConditionType createBooleanConditionType() { + ConditionType type = new ConditionType(new Metadata()); + type.setItemId("booleanCondition"); + type.getMetadata().setSystemTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG, PROFILE_CONDITION_TAG))); + + // Add subConditions parameter with validation + List parameters = new ArrayList<>(); + Parameter subConditionsParam = new Parameter("subConditions", "Condition", true); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + // Allow both event and profile conditions in the subConditions + validation.setAllowedConditionTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG, PROFILE_CONDITION_TAG))); + subConditionsParam.setValidation(validation); + parameters.add(subConditionsParam); + type.setParameters(parameters); + + return type; + } + + private ConditionType createProfilePropertyConditionType() { + ConditionType type = new ConditionType(new Metadata()); + type.setItemId("profilePropertyCondition"); + type.getMetadata().setSystemTags(new HashSet<>(Collections.singletonList(PROFILE_CONDITION_TAG))); + List parameters = new ArrayList<>(); + + // Add parameters with proper validation + Parameter propertyNameParam = new Parameter("propertyName", "string", false); + ConditionValidation propertyNameValidation = new ConditionValidation(); + propertyNameValidation.setRequired(true); + propertyNameParam.setValidation(propertyNameValidation); + parameters.add(propertyNameParam); + + Parameter operatorParam = new Parameter("comparisonOperator", "comparisonOperator", false); + ConditionValidation operatorValidation = new ConditionValidation(); + operatorValidation.setRequired(true); + operatorParam.setValidation(operatorValidation); + parameters.add(operatorParam); + + Parameter valueParam = new Parameter("propertyValue", "string", false); + ConditionValidation valueValidation = new ConditionValidation(); + valueValidation.setRequired(true); + valueParam.setValidation(valueValidation); + parameters.add(valueParam); + + type.setParameters(parameters); + return type; + } + + private ConditionType createEventPropertyConditionType() { + ConditionType type = new ConditionType(new Metadata()); + type.setItemId("eventPropertyCondition"); + type.getMetadata().setSystemTags(new HashSet<>(Collections.singletonList(EVENT_CONDITION_TAG))); + List parameters = new ArrayList<>(); + + // Add parameters with proper validation + Parameter propertyNameParam = new Parameter("propertyName", "string", false); + ConditionValidation propertyNameValidation = new ConditionValidation(); + propertyNameValidation.setRequired(true); + propertyNameParam.setValidation(propertyNameValidation); + parameters.add(propertyNameParam); + + Parameter operatorParam = new Parameter("comparisonOperator", "comparisonOperator", false); + ConditionValidation operatorValidation = new ConditionValidation(); + operatorValidation.setRequired(true); + operatorParam.setValidation(operatorValidation); + parameters.add(operatorParam); + + Parameter valueParam = new Parameter("propertyValue", "string", false); + ConditionValidation valueValidation = new ConditionValidation(); + valueValidation.setRequired(true); + valueParam.setValidation(valueValidation); + parameters.add(valueParam); + + type.setParameters(parameters); + return type; + } + + @BeforeEach + public void setUp() { + + tenantService = new TestTenantService(); + + // Create tenants using TestHelper + TestHelper.setupCommonTestData(tenantService); + + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + + // Set up condition evaluator dispatcher + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + + // Set up bundle context using TestHelper + bundleContext = TestHelper.createMockBundleContext(); + + multiTypeCacheService = new MultiTypeCacheServiceImpl(); + + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher); + + // Create scheduler service using TestHelper + schedulerService = TestHelper.createSchedulerService("condition-validation-service-scheduler-node", persistenceService, executionContextManager, bundleContext, null, -1, true, true); + + conditionValidationService = (ConditionValidationServiceImpl) TestHelper.createConditionValidationService(); + + } + + @AfterEach + public void tearDown() throws Exception { + // Use the common tearDown method from TestHelper + TestHelper.tearDown( + schedulerService, + multiTypeCacheService, + persistenceService, + tenantService + ); + + // Clean up references using the helper method + TestHelper.cleanupReferences( + tenantService, securityService, executionContextManager, conditionValidationService, + persistenceService, schedulerService, multiTypeCacheService, bundleContext + ); + } + + private ConditionType createConditionType(String id, String... systemTags) { + ConditionType type = new ConditionType(new Metadata()); + type.setItemId(id); + type.getMetadata().setSystemTags(new HashSet<>(Arrays.asList(systemTags))); + return type; + } + + // Helper methods for test setup + private ConditionType createConditionTypeWithParameter(String paramName, String paramType, boolean multivalued) { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + Parameter param = new Parameter(paramName, paramType, multivalued); + parameters.add(param); + type.setParameters(parameters); + return type; + } + + private ConditionType createConditionTypeWithValidation(String paramName, String paramType, ConditionValidation validation) { + ConditionType type = createConditionTypeWithParameter(paramName, paramType, false); + type.getParameters().get(0).setValidation(validation); + return type; + } + + private void assertSingleError(List errors, String expectedParameterName) { + assertEquals(1, errors.size(), "Validation should produce exactly one error (param=" + expectedParameterName + ")"); + assertEquals(expectedParameterName, errors.get(0).getParameterName(), "Error should point to expected parameter (param=" + expectedParameterName + ")"); + } + + private void assertSingleErrorWithContext(List errors, String expectedConditionId, + String expectedParameterName, String expectedConditionTypeName) { + assertEquals(1, errors.size(), "Validation should produce exactly one error (conditionId=" + expectedConditionId + ")"); + ValidationError error = errors.get(0); + assertEquals(expectedConditionId, error.getConditionId(), "Error should reference expected condition id"); + assertEquals(expectedParameterName, error.getParameterName(), "Error should reference expected parameter"); + assertEquals(expectedConditionTypeName, error.getConditionTypeName(), "Error should reference expected condition type"); + } + + private void assertNoErrors(List errors) { + if (!errors.isEmpty()) { + System.out.println("Validation errors found:"); + for (ValidationError error : errors) { + System.out.println("Detailed error: " + error.getDetailedMessage()); + System.out.println("---"); + } + } + assertTrue(errors.isEmpty(), "Validation should pass without errors for constructed condition"); + } + + private Condition createConditionWithValue(ConditionType type, String paramName, Object value) { + Condition condition = new Condition(type); + condition.setParameter(paramName, value); + return condition; + } + + @Test + public void testNullCondition() { + List errors = conditionValidationService.validate(null); + assertEquals(1, errors.size(), "Null condition should yield one error"); + assertEquals(ValidationErrorType.MISSING_REQUIRED_PARAMETER, errors.get(0).getType(), "Null condition should report missing required parameter"); + } + + @Test + public void testNullConditionType() { + Condition condition = new Condition(); + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size(), "Condition without type should yield one error"); + assertEquals(ValidationErrorType.INVALID_CONDITION_TYPE, errors.get(0).getType(), "Condition without type should report invalid condition type"); + } + + @Test + public void testBasicTypeValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + parameters.add(new Parameter("stringParam", "string", false)); + parameters.add(new Parameter("intParam", "integer", false)); + type.setParameters(parameters); + + Condition condition = new Condition(type); + condition.setParameter("stringParam", "test"); + condition.setParameter("intParam", 42); + + assertNoErrors(conditionValidationService.validate(condition)); + + condition.setParameter("stringParam", 123); + condition.setParameter("intParam", "not a number"); + + List errors = conditionValidationService.validate(condition); + assertEquals(2, errors.size(), "Two parameters with wrong types should produce two errors"); + assertTrue(errors.stream().allMatch(e -> e.getType() == ValidationErrorType.INVALID_VALUE), "All errors should be INVALID_VALUE for type mismatch"); + } + + @Test + public void testMultivaluedValidation() { + ConditionType type = createConditionTypeWithParameter("tags", "string", true); + + // Test valid collection + Condition condition = createConditionWithValue(type, "tags", Arrays.asList("tag1", "tag2")); + assertNoErrors(conditionValidationService.validate(condition)); + + // Test non-collection value + condition = createConditionWithValue(type, "tags", "single value"); + assertSingleError(conditionValidationService.validate(condition), "tags"); + + // Test invalid type in collection + condition = createConditionWithValue(type, "tags", Arrays.asList("tag1", 123, "tag3")); + assertSingleError(conditionValidationService.validate(condition), "tags[1]"); + } + + @Test + public void testRequiredParameterValidation() { + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + ConditionType type = createConditionTypeWithValidation("required", "string", validation); + + // Test missing required parameter + Condition condition = new Condition(type); + assertSingleError(conditionValidationService.validate(condition), "required"); + + // Test with required parameter + condition = createConditionWithValue(type, "required", "value"); + assertNoErrors(conditionValidationService.validate(condition)); + } + + @Test + public void testRecommendedParameterValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("recommended", "string", false); + ConditionValidation validation = new ConditionValidation(); + validation.setRecommended(true); + param.setValidation(validation); + parameters.add(param); + type.setParameters(parameters); + + // Test missing recommended parameter + Condition condition = new Condition(type); + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.MISSING_RECOMMENDED_PARAMETER, errors.get(0).getType()); + } + + @Test + public void testAllowedValuesValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("operator", "string", false); + ConditionValidation validation = new ConditionValidation(); + validation.setAllowedValues(new HashSet<>(Arrays.asList("and", "or"))); + param.setValidation(validation); + parameters.add(param); + type.setParameters(parameters); + + // Test invalid value + Condition condition = new Condition(type); + condition.setParameter("operator", "not"); + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + + // Test valid value + condition.setParameter("operator", "and"); + errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + } + + @Test + public void testConditionTagValidation() { + // Create parent condition type that accepts only event conditions + ConditionType parentType = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("subCondition", "Condition", false); + ConditionValidation validation = new ConditionValidation(); + validation.setAllowedConditionTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG))); + param.setValidation(validation); + parameters.add(param); + parentType.setParameters(parameters); + + // Create sub-condition with wrong tag (profile condition) + ConditionType profileType = createConditionType("profilePropertyCondition", PROFILE_CONDITION_TAG); + Condition profileCondition = new Condition(profileType); + + // Test invalid condition tag + Condition condition = new Condition(parentType); + condition.setParameter("subCondition", profileCondition); + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_CONDITION_TYPE, errors.get(0).getType()); + + // Test valid condition tag (event condition) + ConditionType eventType = createConditionType("eventPropertyCondition", EVENT_CONDITION_TAG); + Condition eventCondition = new Condition(eventType); + condition.setParameter("subCondition", eventCondition); + errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + + // Test condition with multiple valid tags + ConditionType booleanType = createConditionType("booleanCondition", EVENT_CONDITION_TAG, PROFILE_CONDITION_TAG); + Condition booleanCondition = new Condition(booleanType); + condition.setParameter("subCondition", booleanCondition); + errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + } + + @Test + public void testDisallowedConditionTypesValidation() { + // Create parent condition type that disallows certain condition types + ConditionType parentType = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("subCondition", "Condition", false); + ConditionValidation validation = new ConditionValidation(); + validation.setDisallowedConditionTypes(new HashSet<>(Arrays.asList("booleanCondition"))); + param.setValidation(validation); + parameters.add(param); + parentType.setParameters(parameters); + + // Create allowed condition type + ConditionType eventType = createEventPropertyConditionType(); + Condition eventCondition = new Condition(eventType); + eventCondition.setParameter("propertyName", "test"); + eventCondition.setParameter("comparisonOperator", "equals"); + eventCondition.setParameter("propertyValue", "value"); + + // Test with allowed condition type + Condition parentCondition = new Condition(parentType); + parentCondition.setParameter("subCondition", eventCondition); + List errors = conditionValidationService.validate(parentCondition); + assertNoErrors(errors); + + // Test with disallowed condition type + ConditionType booleanType = createBooleanConditionType(); + Condition booleanCondition = new Condition(booleanType); + // Set required parameters for the boolean condition + booleanCondition.setParameter("subConditions", Collections.singletonList(eventCondition)); + parentCondition.setParameter("subCondition", booleanCondition); + errors = conditionValidationService.validate(parentCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_CONDITION_TYPE, errors.get(0).getType()); + } + + @Test + public void testExclusiveParameterValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + // Create exclusive parameters + Parameter stringParam = new Parameter("stringValue", "string", false); + Parameter intParam = new Parameter("intValue", "integer", false); + + ConditionValidation validation1 = new ConditionValidation(); + validation1.setExclusive(true); + validation1.setExclusiveGroup("value"); + stringParam.setValidation(validation1); + + ConditionValidation validation2 = new ConditionValidation(); + validation2.setExclusive(true); + validation2.setExclusiveGroup("value"); + intParam.setValidation(validation2); + + parameters.add(stringParam); + parameters.add(intParam); + type.setParameters(parameters); + + // Test exclusive violation + Condition condition = new Condition(type); + condition.setParameter("stringValue", "test"); + condition.setParameter("intValue", 42); + + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION, errors.get(0).getType()); + + // Test valid exclusive parameters + condition.setParameter("intValue", null); + errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + } + + @Test + public void testBackwardCompatibility() { + // Create condition type without any validation rules + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("value", "string", false); + parameters.add(param); + type.setParameters(parameters); + + // Test with valid value + Condition condition = new Condition(type); + condition.setParameter("value", "test"); + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + + // Test with invalid value type + condition.setParameter("value", 123); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + } + + @Test + public void testNestedConditionValidation() { + // Create parent condition type that accepts event conditions + ConditionType parentType = new ConditionType(new Metadata()); + List parentParams = new ArrayList<>(); + + Parameter subCondParam = new Parameter("subCondition", "Condition", false); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + validation.setAllowedConditionTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG))); + subCondParam.setValidation(validation); + parentParams.add(subCondParam); + parentType.setParameters(parentParams); + + // Create child condition type with event tag + ConditionType childType = createEventPropertyConditionType(); + + // Create nested conditions + Condition childCondition = new Condition(childType); + childCondition.setParameter("propertyName", "test"); + childCondition.setParameter("comparisonOperator", "equals"); + childCondition.setParameter("propertyValue", "value"); + + Condition parentCondition = new Condition(parentType); + parentCondition.setParameter("subCondition", childCondition); + + // Test valid nested conditions + List errors = conditionValidationService.validate(parentCondition); + assertTrue(errors.isEmpty()); + + // Test missing required parameter in child + childCondition.setParameter("propertyName", null); + errors = conditionValidationService.validate(parentCondition); + // Should get error from child condition (nested conditions are validated recursively) + assertTrue(errors.size() >= 1, "Should have at least one error from child condition"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER && + e.getParameterName() != null && e.getParameterName().equals("propertyName")), + "Should have error for missing propertyName in child condition"); + + // Test invalid condition tag + ConditionType profileType = createProfilePropertyConditionType(); + Condition profileCondition = new Condition(profileType); + profileCondition.setParameter("propertyName", "test"); + profileCondition.setParameter("comparisonOperator", "equals"); + profileCondition.setParameter("propertyValue", "value"); + parentCondition.setParameter("subCondition", profileCondition); + errors = conditionValidationService.validate(parentCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_CONDITION_TYPE, errors.get(0).getType()); + } + + @Test + public void testCustomTypeValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("customObject", "object", false); + ConditionValidation validation = new ConditionValidation(); + validation.setCustomType(String.class); + param.setValidation(validation); + parameters.add(param); + type.setParameters(parameters); + + // Test with valid custom type value + Condition condition = new Condition(type); + condition.setParameter("customObject", "test"); + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + + // Test with invalid custom type value + condition.setParameter("customObject", 123); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + } + + @Test + public void testSpecificTypeValidations() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter floatParam = new Parameter("floatValue", "float", false); + Parameter doubleParam = new Parameter("doubleValue", "double", false); + Parameter dateParam = new Parameter("dateValue", "Date", false); + parameters.add(floatParam); + parameters.add(doubleParam); + parameters.add(dateParam); + type.setParameters(parameters); + + Condition condition = new Condition(type); + + // Test valid values + condition.setParameter("floatValue", 1.5f); + condition.setParameter("doubleValue", 2.5d); + condition.setParameter("dateValue", new Date()); + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + + // Test invalid float + condition.setParameter("floatValue", "not a float"); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + + // Test invalid double + condition.setParameter("floatValue", 1.5f); + condition.setParameter("doubleValue", "not a double"); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + + // Test invalid date + condition.setParameter("doubleValue", 2.5d); + condition.setParameter("dateValue", "not a date"); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + } + + @Test + public void testInvalidConditionMetadata() { + ConditionType parentType = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("subCondition", "condition", false); + ConditionValidation validation = new ConditionValidation(); + param.setValidation(validation); + parameters.add(param); + parentType.setParameters(parameters); + + // Create sub-condition with null metadata + ConditionType subType = new ConditionType(null); + Condition subCondition = new Condition(subType); + + // Test condition with null metadata + Condition condition = new Condition(parentType); + condition.setParameter("subCondition", subCondition); + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + assertEquals("Value must be a valid condition with a condition type and metadata", errors.get(0).getMessage()); + } + + @Test + public void testContextInformation() { + // Create condition with context-specific validation + ConditionType conditionType = createProfilePropertyConditionType(); + Condition condition = new Condition(conditionType); + // Only set propertyName, missing required comparisonOperator and propertyValue + condition.setParameter("propertyName", "someValue"); + + // Validate and check results + List errors = conditionValidationService.validate(condition); + + assertFalse(errors.isEmpty(), "Should have validation errors"); + assertTrue(errors.size() >= 1, "Should have at least one error"); + ValidationError error = errors.get(0); + assertEquals(ValidationErrorType.MISSING_REQUIRED_PARAMETER, error.getType()); + assertNotNull(error.getContext(), "Error should have context"); + assertTrue( + error.getContext().containsKey("location") && error.getContext().containsKey("parameterType"), + "Context should contain both location and parameterType keys"); + } + + @Test + public void testValidatorBindingAndUnbinding() { + // Create a test validator + ValueTypeValidator testValidator = new ValueTypeValidator() { + @Override + public String getValueTypeId() { + return "test"; + } + + @Override + public boolean validate(Object value) { + return value instanceof String; + } + + @Override + public String getValueTypeDescription() { + return "Value must be a test string"; + } + }; + + // Test binding + conditionValidationService.bindValidator(testValidator); + + // Create a condition using the test type + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + Parameter param = new Parameter("testValue", "test", false); + parameters.add(param); + type.setParameters(parameters); + + Condition condition = new Condition(type); + condition.setParameter("testValue", "valid string"); + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); + + // Test invalid value + condition.setParameter("testValue", 123); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + assertEquals("Value must be a test string", errors.get(0).getMessage()); + + // Test unbinding — after removal, unknown type is now permissive (WARN+skip) + conditionValidationService.unbindValidator(testValidator); + errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty(), "Unknown type after unbind should produce no errors (WARN+skip)"); + } + + @Test + public void testAllBuiltInValidators() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + // Add parameters for all built-in types + Map validValues = new HashMap<>(); + validValues.put("stringValue", "test"); + validValues.put("integerValue", 42); + validValues.put("longValue", 123L); + validValues.put("floatValue", 3.14f); + validValues.put("doubleValue", 2.718); + validValues.put("booleanValue", true); + validValues.put("dateValue", new Date()); + validValues.put("operatorValue", "equals"); + + // Add parameters and set valid values + validValues.forEach((name, value) -> { + String paramType = name.replace("Value", "").toLowerCase(); + // Special case for operator to use comparisonOperator type + if (paramType.equals("operator")) { + paramType = "comparisonOperator"; + } + parameters.add(new Parameter(name, paramType, false)); + }); + type.setParameters(parameters); + + // Test valid values + Condition condition = new Condition(type); + validValues.forEach(condition::setParameter); + assertNoErrors(conditionValidationService.validate(condition)); + + // Test invalid values + Map invalidValues = new HashMap<>(); + invalidValues.put("stringValue", 123); + invalidValues.put("integerValue", "not a number"); + invalidValues.put("longValue", 3.14); + invalidValues.put("floatValue", "not a float"); + invalidValues.put("doubleValue", "not a double"); + invalidValues.put("booleanValue", "not a boolean"); + invalidValues.put("dateValue", "not a date"); + invalidValues.put("operatorValue", "invalid"); + + condition = new Condition(type); + invalidValues.forEach(condition::setParameter); + List errors = conditionValidationService.validate(condition); + assertEquals(invalidValues.size(), errors.size()); + assertTrue(errors.stream().allMatch(e -> e.getType() == ValidationErrorType.INVALID_VALUE), "All errors should be INVALID_VALUE for wrong element types (param=tags)"); + } + + @Test + public void testUnknownTypeValidation_isPermissive() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("value", "unknown_type", false); + parameters.add(param); + type.setParameters(parameters); + + Condition condition = new Condition(type); + condition.setParameter("value", "any value"); + + // Unknown validator types produce a WARN log and are skipped (backward-compatible, permissive) + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty(), "Unknown parameter types should produce no errors — WARN+skip for backward compatibility"); + } + + @Test + public void testMultivaluedWithMixedTypes() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("values", "integer", true); + parameters.add(param); + type.setParameters(parameters); + + // Test with mixed valid and invalid types + Condition condition = new Condition(type); + condition.setParameter("values", Arrays.asList(1, "not a number", 3, true)); + + List errors = conditionValidationService.validate(condition); + assertEquals(2, errors.size()); + assertTrue(errors.stream().allMatch(e -> e.getType() == ValidationErrorType.INVALID_VALUE)); + } + + @Test + public void testObjectTypeWithoutCustomType() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("object", "object", false); + // Note: no customType set in validation + parameters.add(param); + type.setParameters(parameters); + + Condition condition = new Condition(type); + condition.setParameter("object", new Object()); + + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); // Should pass without customType validation + } + + @Test + public void testNullValueValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + Parameter param = new Parameter("value", "string", false); + parameters.add(param); + type.setParameters(parameters); + + Condition condition = new Condition(type); + condition.setParameter("value", null); + + List errors = conditionValidationService.validate(condition); + assertTrue(errors.isEmpty()); // Null values should be allowed unless required + } + + @Test + public void testDetailedErrorMessages() { + // Create a condition with invalid parameter + ConditionType conditionType = createProfilePropertyConditionType(); + Condition condition = new Condition(conditionType); + condition.setParameter("propertyName", 123); // Integer when string expected + + // Validate and check results + List errors = conditionValidationService.validate(condition); + + assertFalse(errors.isEmpty(), "Should have validation errors"); + ValidationError error = errors.get(0); + assertEquals(ValidationErrorType.INVALID_VALUE, error.getType()); + assertNotNull(error.getContext(), "Should have context information"); + assertTrue(error.getContext().containsKey("parameterType"), "Context should contain parameter type"); + } + + @Test + public void testNestedErrorReporting() { + // Create parent condition + ConditionType booleanType = createBooleanConditionType(); + Condition parentCondition = new Condition(booleanType); + parentCondition.setConditionType(booleanType); + + // Create child condition with error + ConditionType profileType = createProfilePropertyConditionType(); + Condition childCondition = new Condition(profileType); + childCondition.setConditionType(profileType); + + // Set an invalid type for propertyName (integer instead of string) + childCondition.setParameter("propertyName", 123); + // Set other required parameters to avoid additional validation errors + childCondition.setParameter("comparisonOperator", "equals"); + childCondition.setParameter("propertyValue", "test"); + + // Set the child condition in the parent's subConditions parameter + parentCondition.setParameter("subConditions", Collections.singletonList(childCondition)); + + // Validate and check results + List errors = conditionValidationService.validate(parentCondition); + + // Should have at least one error for invalid value in child condition + assertTrue(errors.size() >= 1, "Should have at least one validation error"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.INVALID_VALUE), + "Should have error for invalid value in child condition"); + ValidationError error = errors.stream().filter(e -> e.getType() == ValidationErrorType.INVALID_VALUE).findFirst().orElse(null); + assertNotNull(error, "Should have invalid value error"); + assertNotNull(error.getContext(), "Error should have context"); + assertTrue(error.getContext().containsKey("location"), "Context should contain location info"); + } + + @Test + public void testMultivaluedConditionValidation() { + // Create parent condition type that accepts multiple sub-conditions + ConditionType parentType = createBooleanConditionType(); + Condition parentCondition = new Condition(parentType); + + // Create valid child conditions + ConditionType profileType = createProfilePropertyConditionType(); + Condition childCondition1 = new Condition(profileType); + childCondition1.setParameter("propertyName", "test1"); + childCondition1.setParameter("comparisonOperator", "equals"); + childCondition1.setParameter("propertyValue", "value1"); + + Condition childCondition2 = new Condition(profileType); + childCondition2.setParameter("propertyName", "test2"); + childCondition2.setParameter("comparisonOperator", "equals"); + childCondition2.setParameter("propertyValue", "value2"); + + // Test with valid list of conditions + parentCondition.setParameter("subConditions", Arrays.asList(childCondition1, childCondition2)); + List errors = conditionValidationService.validate(parentCondition); + assertNoErrors(errors); + + // Test with invalid condition in the list (missing required parameters) + Condition invalidChildCondition = new Condition(profileType); + parentCondition.setParameter("subConditions", Arrays.asList(childCondition1, invalidChildCondition)); + errors = conditionValidationService.validate(parentCondition); + // Should have at least 3 errors for missing required parameters in invalid child condition + assertTrue(errors.size() >= 3, "Should have at least 3 errors for missing required parameters in invalid child condition"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER && e.getParameterName() != null && e.getParameterName().equals("propertyName"))); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER && e.getParameterName() != null && e.getParameterName().equals("comparisonOperator"))); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER && e.getParameterName() != null && e.getParameterName().equals("propertyValue"))); + + // Test with non-condition object in the list + parentCondition.setParameter("subConditions", Arrays.asList(childCondition1, "not a condition")); + errors = conditionValidationService.validate(parentCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + + // Test with null in the list (should be allowed as per validator) + parentCondition.setParameter("subConditions", Arrays.asList(childCondition1, null)); + errors = conditionValidationService.validate(parentCondition); + assertNoErrors(errors); + + // Test with empty list (should fail as subConditions is required) + parentCondition.setParameter("subConditions", Collections.emptyList()); + errors = conditionValidationService.validate(parentCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.MISSING_REQUIRED_PARAMETER, errors.get(0).getType()); + } + + @Test + public void testMultivaluedParameterValidationForAllTypes() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + // Add multivalued parameters for all basic types + parameters.add(new Parameter("strings", "string", true)); + parameters.add(new Parameter("integers", "integer", true)); + parameters.add(new Parameter("longs", "long", true)); + parameters.add(new Parameter("floats", "float", true)); + parameters.add(new Parameter("doubles", "double", true)); + parameters.add(new Parameter("booleans", "boolean", true)); + parameters.add(new Parameter("dates", "date", true)); + parameters.add(new Parameter("operators", "comparisonOperator", true)); + parameters.add(new Parameter("conditions", "condition", true)); + + type.setParameters(parameters); + Condition condition = new Condition(type); + + // Test valid values for each type + Map> validValues = new HashMap<>(); + validValues.put("strings", Arrays.asList("test1", "test2")); + validValues.put("integers", Arrays.asList(1, 2, 3)); + validValues.put("longs", Arrays.asList(1L, 2L, 3L)); + validValues.put("floats", Arrays.asList(1.1f, 2.2f)); + validValues.put("doubles", Arrays.asList(1.1d, 2.2d)); + validValues.put("booleans", Arrays.asList(true, false)); + validValues.put("dates", Arrays.asList(new Date(), new Date())); + validValues.put("operators", Arrays.asList("equals", "notEquals")); + + // Create valid conditions for the conditions list + ConditionType profileType = createProfilePropertyConditionType(); + Condition subCondition1 = new Condition(profileType); + subCondition1.setParameter("propertyName", "test1"); + subCondition1.setParameter("comparisonOperator", "equals"); + subCondition1.setParameter("propertyValue", "value1"); + + Condition subCondition2 = new Condition(profileType); + subCondition2.setParameter("propertyName", "test2"); + subCondition2.setParameter("comparisonOperator", "equals"); + subCondition2.setParameter("propertyValue", "value2"); + + validValues.put("conditions", Arrays.asList(subCondition1, subCondition2)); + + // Test all valid values + validValues.forEach(condition::setParameter); + List errors = conditionValidationService.validate(condition); + assertNoErrors(errors); + + // Test invalid values for each type + Map> invalidValues = new HashMap<>(); + invalidValues.put("strings", Arrays.asList("test", 123)); + invalidValues.put("integers", Arrays.asList(1, "not a number")); + invalidValues.put("longs", Arrays.asList(1L, "not a long")); + invalidValues.put("floats", Arrays.asList(1.1f, "not a float")); + invalidValues.put("doubles", Arrays.asList(1.1d, "not a double")); + invalidValues.put("booleans", Arrays.asList(true, "not a boolean")); + invalidValues.put("dates", Arrays.asList(new Date(), "not a date")); + invalidValues.put("operators", Arrays.asList("equals", "invalid_operator")); + invalidValues.put("conditions", Arrays.asList(subCondition1, "not a condition")); + + // Test each invalid value type separately + invalidValues.forEach((paramName, invalidList) -> { + condition.setParameterValues(new LinkedHashMap<>()); // clear the previous parameters + condition.setParameter(paramName, invalidList); + List paramErrors = conditionValidationService.validate(condition); + assertEquals(1, paramErrors.size(), "Parameter " + paramName + " should have one error"); + assertEquals(ValidationErrorType.INVALID_VALUE, paramErrors.get(0).getType()); + }); + } + + @Test + public void testComplexExclusiveParameterValidation() { + ConditionType type = new ConditionType(new Metadata()); + List parameters = new ArrayList<>(); + + // Create parameters for first exclusive group (value group) + Parameter stringParam = new Parameter("stringValue", "string", false); + Parameter intParam = new Parameter("intValue", "integer", false); + ConditionValidation validation1 = new ConditionValidation(); + validation1.setExclusive(true); + validation1.setExclusiveGroup("value"); + stringParam.setValidation(validation1); + ConditionValidation validation2 = new ConditionValidation(); + validation2.setExclusive(true); + validation2.setExclusiveGroup("value"); + intParam.setValidation(validation2); + + // Create parameters for second exclusive group (operator group) + Parameter equalsParam = new Parameter("equals", "string", false); + Parameter rangeParam = new Parameter("range", "object", false); + ConditionValidation validation3 = new ConditionValidation(); + validation3.setExclusive(true); + validation3.setExclusiveGroup("operator"); + equalsParam.setValidation(validation3); + ConditionValidation validation4 = new ConditionValidation(); + validation4.setExclusive(true); + validation4.setExclusiveGroup("operator"); + rangeParam.setValidation(validation4); + + parameters.add(stringParam); + parameters.add(intParam); + parameters.add(equalsParam); + parameters.add(rangeParam); + type.setParameters(parameters); + + // Test valid combinations + Condition condition = new Condition(type); + condition.setParameter("stringValue", "test"); + condition.setParameter("equals", "value"); + List errors = conditionValidationService.validate(condition); + assertNoErrors(errors); + + // Test violation in first group + condition.setParameter("intValue", 42); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION, errors.get(0).getType()); + + // Test violation in second group + condition = new Condition(type); + condition.setParameter("stringValue", "test"); + condition.setParameter("equals", "value"); + condition.setParameter("range", new Object()); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION, errors.get(0).getType()); + + // Test violations in both groups + condition.setParameter("intValue", 42); + errors = conditionValidationService.validate(condition); + assertEquals(2, errors.size()); + assertTrue(errors.stream().allMatch(e -> e.getType() == ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION)); + } + + @Test + public void testDeepNestedConditionValidation() { + // Create a three-level deep condition structure with mixed validation rules + + // Level 3 (deepest) - Event property condition + ConditionType eventType = createEventPropertyConditionType(); + Condition eventCondition = new Condition(eventType); + eventCondition.setParameter("propertyName", "test"); + eventCondition.setParameter("comparisonOperator", "equals"); + eventCondition.setParameter("propertyValue", "value"); + + // Level 2 - Boolean condition with exclusive parameters + ConditionType booleanType = new ConditionType(new Metadata()); + List booleanParams = new ArrayList<>(); + + Parameter operatorParam = new Parameter("operator", "string", false); + Parameter subConditionsParam = new Parameter("subConditions", "Condition", true); + + ConditionValidation operatorValidation = new ConditionValidation(); + operatorValidation.setExclusive(true); + operatorValidation.setExclusiveGroup("operator"); + operatorValidation.setAllowedValues(new HashSet<>(Arrays.asList("and", "or"))); + operatorParam.setValidation(operatorValidation); + + ConditionValidation subConditionsValidation = new ConditionValidation(); + subConditionsValidation.setRequired(true); + subConditionsValidation.setAllowedConditionTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG))); + subConditionsParam.setValidation(subConditionsValidation); + + booleanParams.add(operatorParam); + booleanParams.add(subConditionsParam); + booleanType.setParameters(booleanParams); + booleanType.getMetadata().setSystemTags(new HashSet<>(Arrays.asList(EVENT_CONDITION_TAG))); + + Condition booleanCondition = new Condition(booleanType); + booleanCondition.setParameter("operator", "and"); + booleanCondition.setParameter("subConditions", Collections.singletonList(eventCondition)); + + // Level 1 (root) - Container condition with required and exclusive parameters + ConditionType containerType = new ConditionType(new Metadata()); + List containerParams = new ArrayList<>(); + + Parameter typeParam = new Parameter("type", "string", false); + Parameter conditionParam = new Parameter("condition", "Condition", false); + Parameter filterParam = new Parameter("filter", "string", false); + + ConditionValidation typeValidation = new ConditionValidation(); + typeValidation.setRequired(true); + typeValidation.setAllowedValues(new HashSet<>(Arrays.asList("include", "exclude"))); + typeParam.setValidation(typeValidation); + + ConditionValidation conditionValidation = new ConditionValidation(); + conditionValidation.setExclusive(true); + conditionValidation.setExclusiveGroup("content"); + conditionParam.setValidation(conditionValidation); + + ConditionValidation filterValidation = new ConditionValidation(); + filterValidation.setExclusive(true); + filterValidation.setExclusiveGroup("content"); + filterParam.setValidation(filterValidation); + + containerParams.add(typeParam); + containerParams.add(conditionParam); + containerParams.add(filterParam); + containerType.setParameters(containerParams); + + // Test valid deep nested structure + Condition containerCondition = new Condition(containerType); + containerCondition.setParameter("type", "include"); + containerCondition.setParameter("condition", booleanCondition); + List errors = conditionValidationService.validate(containerCondition); + assertNoErrors(errors); + + // Test missing required parameter at root level + containerCondition.setParameter("type", null); + errors = conditionValidationService.validate(containerCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.MISSING_REQUIRED_PARAMETER, errors.get(0).getType()); + + // Test exclusive parameter violation at root level + containerCondition.setParameter("type", "include"); + containerCondition.setParameter("filter", "some filter"); + errors = conditionValidationService.validate(containerCondition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.EXCLUSIVE_PARAMETER_VIOLATION, errors.get(0).getType()); + + // Test invalid value in middle level + containerCondition.setParameter("filter", null); + booleanCondition.setParameter("operator", "invalid"); + errors = conditionValidationService.validate(containerCondition); + // Should have at least one error for invalid operator + assertTrue(errors.size() >= 1, "Should have at least one error for invalid operator"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.INVALID_VALUE), + "Should have error for invalid operator value"); + // Reset operator for next test + booleanCondition.setParameter("operator", "and"); + + // Test missing required parameter in deepest level + eventCondition.setParameter("propertyName", null); + errors = conditionValidationService.validate(containerCondition); + // Should have at least 2 errors: one for invalid operator (if still set), one for missing propertyName + assertTrue(errors.size() >= 1, "Should have at least one error for missing propertyName"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER), + "Should have error for missing required parameter"); + } + + // --- Regression tests for review findings --- + + @Test + public void unbindValidator_nullTypeId_doesNotThrow() { + ValueTypeValidator badValidator = new ValueTypeValidator() { + @Override public String getValueTypeId() { return null; } + @Override public boolean validate(Object value) { return true; } + @Override public String getValueTypeDescription() { return "bad"; } + }; + assertDoesNotThrow(() -> conditionValidationService.unbindValidator(badValidator), + "unbindValidator should not NPE when getValueTypeId() returns null"); + } + + @Test + public void unbindValidator_builtInValidator_remainsRegistered() { + // Attempt to unbind the built-in "string" validator via a proxy — it must stay active + ValueTypeValidator proxy = new ValueTypeValidator() { + @Override public String getValueTypeId() { return "string"; } + @Override public boolean validate(Object value) { return true; } + @Override public String getValueTypeDescription() { return "proxy"; } + }; + conditionValidationService.unbindValidator(proxy); + + ConditionType type = new ConditionType(new Metadata()); + type.setParameters(Collections.singletonList(new Parameter("s", "string", false))); + Condition cond = new Condition(type); + cond.setParameter("s", 123); // wrong type — string validator must still catch this + List errors = conditionValidationService.validate(cond); + assertFalse(errors.isEmpty(), "Built-in string validator must remain active after an unbind attempt"); + } + + @Test + public void nullSubConditionType_inValidateAdditionalRules_reportsError() { + ConditionType parentType = new ConditionType(new Metadata()); + Parameter subParam = new Parameter("sub", "condition", false); + ConditionValidation v = new ConditionValidation(); + subParam.setValidation(v); + parentType.setParameters(Collections.singletonList(subParam)); + + // Condition whose conditionType was never resolved (null) + Condition subCond = new Condition(); + subCond.setConditionTypeId("ghost"); + + Condition parent = new Condition(parentType); + parent.setParameter("sub", subCond); + + List errors = conditionValidationService.validate(parent); + assertFalse(errors.isEmpty(), "Null subConditionType should produce a validation error"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.INVALID_CONDITION_TYPE), + "Error type should be INVALID_CONDITION_TYPE for unresolvable sub-condition"); + } + + @Test + public void listValuedParameter_containingContextualReference_skipsValidation() { + ConditionType type = new ConditionType(new Metadata()); + Parameter param = new Parameter("values", "string", true); + type.setParameters(Collections.singletonList(param)); + + Condition cond = new Condition(type); + // First element is a parameter reference — entire parameter must be skipped + cond.setParameter("values", Arrays.asList("parameter::someKey", "literal")); + + List errors = conditionValidationService.validate(cond); + assertTrue(errors.isEmpty(), + "Multivalued parameter containing a contextual reference should skip validation entirely"); + } + + @org.junit.jupiter.api.Nested + public class PartialValidationTests { + @Test + public void testValidate_SkipsParameterReferences() { + ConditionType conditionType = createConditionType("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("string"); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + param.setValidation(validation); + conditionType.setParameters(Collections.singletonList(param)); + + Condition condition = new Condition(); + condition.setConditionType(conditionType); + condition.setParameter("testParam", "parameter::someReference"); + + List errors = conditionValidationService.validate(condition); + + // A required parameter with a contextual reference emits an advisory (MISSING_RECOMMENDED_PARAMETER) + // so operators know the reference must resolve at evaluation time. No blocking errors allowed. + assertTrue( + errors.stream().allMatch(e -> e.getType() == ValidationErrorType.MISSING_RECOMMENDED_PARAMETER), + "Only advisory errors expected for required parameter references; got: " + errors); + } + + @Test + public void testValidate_SkipsScriptExpressions() { + ConditionType conditionType = createConditionType("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("string"); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + param.setValidation(validation); + conditionType.setParameters(Collections.singletonList(param)); + + Condition condition = new Condition(); + condition.setConditionType(conditionType); + condition.setParameter("testParam", "script::someScript"); + + List errors = conditionValidationService.validate(condition); + + // A required parameter with a script expression emits an advisory (MISSING_RECOMMENDED_PARAMETER) + // so operators know the script must resolve at evaluation time. No blocking errors allowed. + assertTrue( + errors.stream().allMatch(e -> e.getType() == ValidationErrorType.MISSING_RECOMMENDED_PARAMETER), + "Only advisory errors expected for required script expressions; got: " + errors); + } + + @Test + public void testValidate_ValidatesNonReferenceValues() { + ConditionType conditionType = createConditionType("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("string"); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + param.setValidation(validation); + conditionType.setParameters(Collections.singletonList(param)); + + Condition condition = new Condition(); + condition.setConditionType(conditionType); + condition.setParameter("testParam", "normalValue"); + + List errors = conditionValidationService.validate(condition); + + // Should validate normal values + assertNoErrors(errors); + } + + @Test + public void testValidate_ValidatesMissingRequiredForNonReferences() { + ConditionType conditionType = createConditionType("testCondition"); + Parameter param = new Parameter(); + param.setId("testParam"); + param.setType("string"); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + param.setValidation(validation); + conditionType.setParameters(Collections.singletonList(param)); + + Condition condition = new Condition(); + condition.setConditionType(conditionType); + // testParam not set + + List errors = conditionValidationService.validate(condition); + + // Should validate missing required parameter + assertSingleError(errors, "testParam"); + } + + @Test + public void testValidate_RecursivelyValidatesNestedConditions() { + ConditionType parentType = createConditionType("parentCondition"); + ConditionType childType = createConditionType("childCondition"); + Parameter childParam = new Parameter(); + childParam.setId("childParam"); + childParam.setType("string"); + ConditionValidation childValidation = new ConditionValidation(); + childValidation.setRequired(true); + childParam.setValidation(childValidation); + childType.setParameters(Collections.singletonList(childParam)); + + Parameter parentParam = new Parameter(); + parentParam.setId("childCondition"); + parentParam.setType("condition"); + parentType.setParameters(Collections.singletonList(parentParam)); + + Condition childCondition = new Condition(); + childCondition.setConditionType(childType); + childCondition.setParameter("childParam", "parameter::reference"); + + Condition parentCondition = new Condition(); + parentCondition.setConditionType(parentType); + parentCondition.setParameter("childCondition", childCondition); + + List errors = conditionValidationService.validate(parentCondition); + + // Should skip validation for nested condition with parameter reference + assertNoErrors(errors); + } + } + + @Test + public void validate_withUninitializedService_returnsEmptyListInsteadOfThrowing() { + ConditionValidationServiceImpl uninitializedService = new ConditionValidationServiceImpl(); + Condition condition = new Condition(); + condition.setConditionTypeId("someType"); + + List errors = uninitializedService.validate(condition); + + assertNotNull(errors, "Result must not be null"); + assertTrue(errors.isEmpty(), "Uninitialized service must return empty list rather than throwing IllegalStateException"); + } + + // TC2: multivalued + required + empty collection hits MISSING_REQUIRED_PARAMETER + @Test + public void validate_multivaluedRequiredEmptyCollection_reportsMissingRequired() { + ConditionType type = new ConditionType(new Metadata()); + Parameter param = new Parameter("items", "string", true); + ConditionValidation validation = new ConditionValidation(); + validation.setRequired(true); + param.setValidation(validation); + type.setParameters(Collections.singletonList(param)); + + Condition condition = new Condition(type); + condition.setParameter("items", Collections.emptyList()); + + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size(), "Empty required multivalued parameter must produce exactly one error"); + assertEquals(ValidationErrorType.MISSING_REQUIRED_PARAMETER, errors.get(0).getType(), + "Error type must be MISSING_REQUIRED_PARAMETER for empty required collection"); + assertEquals("items", errors.get(0).getParameterName(), "Error must name the offending parameter"); + } + + // TC7: when typeResolutionService is wired, a condition with null type gets resolved before validation + @Test + public void validate_withTypeResolutionService_autoResolvesNullConditionType() { + ConditionType resolved = new ConditionType(new Metadata()); + resolved.setItemId("realType"); + resolved.setParameters(Collections.emptyList()); + + org.apache.unomi.api.services.TypeResolutionService mockTrs = + org.mockito.Mockito.mock(org.apache.unomi.api.services.TypeResolutionService.class); + org.mockito.Mockito.doAnswer(invocation -> { + Condition c = invocation.getArgument(0); + c.setConditionType(resolved); + return null; + }).when(mockTrs).resolveConditionType( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + + ConditionValidationServiceImpl serviceWithTrs = + (ConditionValidationServiceImpl) TestHelper.createConditionValidationService(); + serviceWithTrs.setTypeResolutionService(mockTrs); + + Condition condition = new Condition(); + condition.setConditionTypeId("realType"); + + List errors = serviceWithTrs.validate(condition); + assertTrue(errors.isEmpty(), + "No errors expected when typeResolutionService resolves the type before validation"); + } + + // Untyped (legacy) nested-condition path: parameter type is NOT "condition" but its value IS a Condition. + // The bottom loop in validate() (lines 219-233) must recurse into it and report errors from the inner condition. + @Test + public void validate_untypedParameterHoldingCondition_recursesIntoNestedCondition() { + // Inner condition type that requires "propertyName" (string) + ConditionType innerType = new ConditionType(new Metadata()); + innerType.setItemId("innerType"); + Parameter requiredParam = new Parameter("propertyName", "string", false); + ConditionValidation requiredValidation = new ConditionValidation(); + requiredValidation.setRequired(true); + requiredParam.setValidation(requiredValidation); + innerType.setParameters(Collections.singletonList(requiredParam)); + + // Inner condition is missing its required "propertyName" + Condition innerCondition = new Condition(innerType); + // propertyName deliberately not set + + // Outer condition type whose parameter type is "object" (NOT "condition"), + // so it won't be included in alreadyValidatedConditionParams + ConditionType outerType = new ConditionType(new Metadata()); + outerType.setItemId("outerType"); + Parameter untypedParam = new Parameter("nestedCond", "object", false); + outerType.setParameters(Collections.singletonList(untypedParam)); + + Condition outerCondition = new Condition(outerType); + outerCondition.setParameter("nestedCond", innerCondition); + + List errors = conditionValidationService.validate(outerCondition); + + assertTrue(errors.size() >= 1, + "validate() must recurse into an untyped parameter that holds a Condition and report its errors"); + assertTrue(errors.stream().anyMatch(e -> e.getType() == ValidationErrorType.MISSING_REQUIRED_PARAMETER + && "propertyName".equals(e.getParameterName())), + "The recursed inner condition must report its missing required parameter 'propertyName'"); + } + + // TC8: two-arg ConditionValueTypeValidator.validate(Object, ConditionValidationService) behaves like one-arg + @Test + public void conditionValueTypeValidator_twoArgForm_behavesLikeOneArg() { + org.apache.unomi.services.impl.validation.validators.ConditionValueTypeValidator validator = + new org.apache.unomi.services.impl.validation.validators.ConditionValueTypeValidator(); + + ConditionType typeWithMeta = new ConditionType(new Metadata()); + typeWithMeta.setItemId("t"); + Condition validCondition = new Condition(typeWithMeta); + + assertTrue(validator.validate(validCondition, null), + "Two-arg validate must accept a condition with a type and metadata"); + assertFalse(validator.validate("not-a-condition", null), + "Two-arg validate must reject a non-Condition value"); + assertTrue(validator.validate(null, null), + "Two-arg validate must accept null (null is valid; required-check is separate)"); + } +} diff --git a/tools/shell-dev-commands/pom.xml b/tools/shell-dev-commands/pom.xml index 807677a638..1ef42fa015 100644 --- a/tools/shell-dev-commands/pom.xml +++ b/tools/shell-dev-commands/pom.xml @@ -92,8 +92,6 @@ org.apache.httpcomponents httpclient-osgi - ${httpclient-osgi.version} - bundle provided diff --git a/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/ListInvalidObjects.java b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/ListInvalidObjects.java new file mode 100644 index 0000000000..3328547d12 --- /dev/null +++ b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/ListInvalidObjects.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.shell.dev.commands; + +import org.apache.karaf.shell.api.action.Command; +import org.apache.karaf.shell.api.action.lifecycle.Reference; +import org.apache.karaf.shell.api.action.lifecycle.Service; +import org.apache.karaf.shell.support.table.ShellTable; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.InvalidObjectInfo; +import org.apache.unomi.api.services.TypeResolutionService; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Karaf shell command to list all invalid objects across all services. + * Invalid objects are those that have unresolved condition types, action types, or other validation issues. + */ +@Command(scope = "unomi", name = "list-invalid-objects", description = "Lists all invalid objects (rules, segments, goals, campaigns, scoring) that have unresolved condition types, action types, or other validation issues") +@Service +public class ListInvalidObjects extends BaseSimpleCommand { + + @Reference + DefinitionsService definitionsService; + + public Object execute() throws Exception { + if (definitionsService == null) { + println("DefinitionsService is not available."); + return null; + } + + TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService(); + if (typeResolutionService == null) { + println("TypeResolutionService is not available."); + return null; + } + + Map> allInvalidObjects = typeResolutionService.getAllInvalidObjects(); + int totalCount = typeResolutionService.getTotalInvalidObjectCount(); + + if (totalCount == 0) { + println("No invalid objects found."); + return null; + } + + println("Invalid Objects Summary:"); + println("Total invalid objects: " + totalCount); + println(""); + + // Create a table to display invalid objects with reasons + ShellTable table = new ShellTable(); + table.column("Object Type"); + table.column("Object ID"); + table.column("Reason"); + + // Sort object types for consistent output + List sortedTypes = new ArrayList<>(allInvalidObjects.keySet()); + Collections.sort(sortedTypes); + + for (String objectType : sortedTypes) { + Map invalidObjectsMap = allInvalidObjects.get(objectType); + List sortedIds = new ArrayList<>(invalidObjectsMap.keySet()); + Collections.sort(sortedIds); + + for (String objectId : sortedIds) { + InvalidObjectInfo info = invalidObjectsMap.get(objectId); + String reason = info.getReason(); + // Truncate long reasons for display + if (reason != null && reason.length() > 80) { + reason = reason.substring(0, 77) + "..."; + } + table.addRow().addContent(objectType, objectId, reason != null ? reason : "Unknown reason"); + } + } + + table.print(getConsole()); + println(""); + println("Object type counts:"); + for (String objectType : sortedTypes) { + println(" " + objectType + ": " + allInvalidObjects.get(objectType).size()); + } + + return null; + } +} +