From b0c3ce2ae101e8e08ae66a42d784d0ec2190d5c1 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 13 Jun 2026 19:26:55 +0200 Subject: [PATCH 01/10] UNOMI-883: Add condition validation and type resolution (PR C) Introduce ConditionValidationService and TypeResolutionService with ParserHelper and evaluator/query-builder wiring so parent conditions resolve correctly in Karaf. Includes standardized condition JSON, null-safe evaluators, and integration-test fixes. --- api/pom.xml | 2 - .../java/org/apache/unomi/api/Parameter.java | 13 + .../api/conditions/ConditionValidation.java | 147 ++ .../BadSegmentConditionException.java | 12 + .../services/ConditionValidationService.java | 167 +++ .../api/services/DefinitionsService.java | 45 +- .../unomi/api/services/InvalidObjectInfo.java | 166 +++ .../api/services/TypeResolutionService.java | 212 +++ .../api/services/ValueTypeValidator.java | 40 + .../apache/unomi/api/utils/ParserHelper.java | 409 +++++- .../unomi/api/utils/ParserHelperTest.java | 1160 +++++++++++++++ .../ConditionESQueryBuilderDispatcher.java | 119 +- .../OSGI-INF/blueprint/blueprint.xml | 10 + .../ConditionOSQueryBuilderDispatcher.java | 115 +- .../OSGI-INF/blueprint/blueprint.xml | 10 + .../conditions/ConditionContextHelper.java | 393 ++++- .../persistence/spi/conditions/DateUtils.java | 53 +- .../ConditionEvaluatorDispatcherImpl.java | 129 +- .../ConditionContextHelperTest.java | 525 +++++++ .../conditions/BooleanConditionEvaluator.java | 7 +- .../conditions/IdsConditionEvaluator.java | 6 +- .../conditions/NotConditionEvaluator.java | 3 + .../PropertyConditionEvaluator.java | 257 +++- .../META-INF/cxs/conditions/IdsCondition.json | 2 +- .../cxs/conditions/booleanCondition.json | 17 +- .../conditions/eventPropertyCondition.json | 12 +- .../cxs/conditions/matchAllCondition.json | 2 +- .../cxs/conditions/nestedCondition.json | 16 +- .../META-INF/cxs/conditions/notCondition.json | 2 +- .../cxs/conditions/pastEventCondition.json | 41 +- .../conditions/profilePropertyCondition.json | 78 +- .../conditions/sessionPropertyCondition.json | 10 + services/pom.xml | 1 + .../services/impl/AbstractServiceImpl.java | 4 +- .../impl/TypeResolutionServiceImpl.java | 519 +++++++ .../definitions/DefinitionsServiceImpl.java | 122 +- .../services/impl/goals/GoalsServiceImpl.java | 18 +- .../impl/profiles/ProfileServiceImpl.java | 3 +- .../impl/queries/QueryServiceImpl.java | 2 +- .../services/impl/rules/RulesServiceImpl.java | 123 +- .../impl/segments/SegmentServiceImpl.java | 50 +- .../ConditionValidationServiceImpl.java | 473 ++++++ .../AbstractNumericValueTypeValidator.java | 44 + .../validators/BooleanValueTypeValidator.java | 36 + .../ComparisonOperatorValueTypeValidator.java | 59 + .../ConditionValueTypeValidator.java | 78 + .../validators/DateValueTypeValidator.java | 50 + .../validators/DoubleValueTypeValidator.java | 23 + .../validators/FloatValueTypeValidator.java | 23 + .../validators/IntegerValueTypeValidator.java | 23 + .../validators/LongValueTypeValidator.java | 23 + .../validators/NumericValueTypeValidator.java | 44 + .../validators/StringValueTypeValidator.java | 36 + .../OSGI-INF/blueprint/blueprint.xml | 47 + .../org/apache/unomi/services/TestHelper.java | 32 + .../impl/TestConditionEvaluators.java | 30 + .../impl/TypeResolutionServiceImplTest.java | 969 +++++++++++++ .../TestSetEventOccurrenceCountAction.java | 3 + .../ConditionValidationServiceImplTest.java | 1291 +++++++++++++++++ tools/shell-dev-commands/pom.xml | 2 - .../dev/commands/ListInvalidObjects.java | 103 ++ 61 files changed, 8100 insertions(+), 311 deletions(-) create mode 100644 api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java create mode 100644 api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java create mode 100644 api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java create mode 100644 api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java create mode 100644 api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java create mode 100644 api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java create mode 100644 persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImpl.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/AbstractNumericValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/BooleanValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/ComparisonOperatorValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/ConditionValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/DateValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/DoubleValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/FloatValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/IntegerValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/LongValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java create mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/StringValueTypeValidator.java create mode 100644 services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java create mode 100644 services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java create mode 100644 tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/ListInvalidObjects.java 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..6bcd9aa8a0 --- /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 Class customType; // For OBJECT type, specifies the exact class expected + + 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..a0c4fbb167 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java @@ -0,0 +1,167 @@ +/* + * 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 conditionTypeName; + 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 conditionTypeName, Map context, + ValidationError parentError) { + this.parameterName = parameterName; + this.message = message; + this.type = type; + this.conditionId = conditionId; + this.conditionTypeName = conditionTypeName; + 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 getConditionTypeName() { + return conditionTypeName; + } + + 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 (conditionTypeName != null) { + sb.append("In condition type '").append(conditionTypeName).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..5bbd732179 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -0,0 +1,166 @@ +/* + * 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.*; + +/** + * 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 volatile long lastSeenTimestamp; + private volatile int encounterCount; + private final List missingConditionTypeIds; + private final List 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 = this.firstSeenTimestamp; + this.encounterCount = 1; + this.missingConditionTypeIds = missingConditionTypeIds != null + ? new ArrayList<>(missingConditionTypeIds) : new ArrayList<>(); + this.missingActionTypeIds = missingActionTypeIds != null + ? new ArrayList<>(missingActionTypeIds) : new ArrayList<>(); + this.contextNames = new HashSet<>(); + 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; + } + + public int getEncounterCount() { + return encounterCount; + } + + public List getMissingConditionTypeIds() { + return Collections.unmodifiableList(missingConditionTypeIds); + } + + public List getMissingActionTypeIds() { + return Collections.unmodifiableList(missingActionTypeIds); + } + + public Set getContextNames() { + return Collections.unmodifiableSet(contextNames); + } + + /** + * Update the info when the object is encountered again. + * This method is called internally by TypeResolutionService to track repeated encounters. + * + * @param missingConditionTypeIds additional missing condition type IDs + * @param missingActionTypeIds additional missing action type IDs + * @param contextName context where it was encountered + */ + public void updateEncounter(List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { + this.lastSeenTimestamp = System.currentTimeMillis(); + this.encounterCount++; + + if (missingConditionTypeIds != null) { + for (String typeId : missingConditionTypeIds) { + if (!this.missingConditionTypeIds.contains(typeId)) { + this.missingConditionTypeIds.add(typeId); + } + } + } + + if (missingActionTypeIds != null) { + for (String typeId : missingActionTypeIds) { + if (!this.missingActionTypeIds.contains(typeId)) { + this.missingActionTypeIds.add(typeId); + } + } + } + + 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); + sb.append(", encounters=").append(encounterCount); + 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..2e1ca50961 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,6 +28,7 @@ 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; @@ -43,11 +44,15 @@ public class ParserHelper { private static final Set unresolvedActionTypes = new HashSet<>(); private static final Set unresolvedConditionTypes = new HashSet<>(); + // Track rules that have already been warned about null/empty actions to avoid log spam + private static final Set warnedRulesWithNullActions = Collections.synchronizedSet(new HashSet<>()); 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 +73,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 +195,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 +247,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 +265,47 @@ public static void resolveValueType(DefinitionsService definitionsService, Prope } - public static Set resolveConditionEventTypes(Condition rootCondition) { + 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 +320,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 +389,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; + if (collection instanceof List) { + copiedCollection = new ArrayList<>(); + } else { + 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) + TypeResolutionService typeResolutionService = definitionsService != null ? definitionsService.getTypeResolutionService() : null; + 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; + } + + // Get parent chain + List parentChain = getParentChain(type, definitionsService, + contextName, maxDepth); + if (parentChain == null || 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..c7f1887829 --- /dev/null +++ b/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java @@ -0,0 +1,1160 @@ +/* + * 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()); + } +} 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..e8927df879 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,29 +105,60 @@ 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-all + if (condition.getConditionType() == null) { + LOGGER.debug("Condition type is null for condition typeID={}, returning match-all query", condition.getConditionTypeId()); + return Query.of(q -> q.matchAll(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) { + effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); } + // Check if effective condition has a type - if not, return match-all query + if (effectiveCondition.getConditionType() == null) { + LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchAll(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-all query", effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchAll(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); } @@ -125,29 +176,61 @@ 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"); + } } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return count(condition.getConditionType().getParentCondition(), context); + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); } + // 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 @@ + + + + + + + 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,29 +104,56 @@ 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-all + if (condition.getConditionType() == null) { + LOGGER.debug("Condition type is null for condition typeID={}, returning match-all query", condition.getConditionTypeId()); + return Query.of(q -> q.matchAll(t -> t)); + } + } + + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); } - 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-all query + if (effectiveCondition.getConditionType() == null) { + LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchAll(t -> t)); } + String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { - throw new UnsupportedOperationException("No query builder defined for : " + condition.getConditionTypeId()); + LOGGER.warn("No query builder defined for condition type: {}, returning match-all query", effectiveCondition.getConditionTypeId()); + return Query.of(q -> q.matchAll(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); } @@ -126,29 +173,61 @@ 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"); + } } - String queryBuilderKey = condition.getConditionType().getQueryBuilder(); - if (queryBuilderKey == null && condition.getConditionType().getParentCondition() != null) { - context.putAll(condition.getParameterValues()); - return count(condition.getConditionType().getParentCondition(), context); + // Resolve effective condition from parent chain if needed + Condition effectiveCondition = condition; + if (definitionsService != null) { + effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "query builder"); } + // 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<>(); @@ -83,58 +111,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; } + + // 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); + } + } + @SuppressWarnings("unchecked") - Map values = (Map) parseParameter(context, condition.getParameterValues(), scriptExecutor); + Map values = (Map) parseParameterWithValidation( + context, condition.getParameterValues(), scriptExecutor, + parameterDefs, condition.getConditionTypeId()); + if (values == null) { return null; } 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 null 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); + // If further resolution returns null due to cycle or max depth, propagate it + // But if it's just a missing parameter, return null (not a cycle) + 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) { + 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, return null for entire map + if (parameter == RESOLUTION_ERROR) { return null; } + + // 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, skip this element + if (parameter == RESOLUTION_ERROR) { + continue; } + // 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 +465,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 +475,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..3fa743198c 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,82 @@ 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.debug("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) { + effectiveCondition = ParserHelper.resolveEffectiveCondition( + condition, definitionsService, context, "condition evaluator"); + } 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) { + if (effectiveCondition.getConditionType().getParentCondition() != null) { + context.putAll(effectiveCondition.getParameterValues()); + return eval(effectiveCondition.getConditionType().getParentCondition(), item, context); + } + 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("Error during condition evaluation", 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..2c914057ea --- /dev/null +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelperTest.java @@ -0,0 +1,525 @@ +/* + * 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")); + } + + // ========== 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/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..1ed4603ab6 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 @@ -36,13 +36,16 @@ public boolean eval(Condition condition, Item item, Map context, boolean isAnd = "and".equalsIgnoreCase((String) condition.getParameter("operator")); @SuppressWarnings("unchecked") List conditions = (List) condition.getParameter("subConditions"); + + if (conditions == null || conditions.isEmpty()) { + return true; + } + 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..68c0e670b0 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,11 @@ 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()); + if (ids == null || ids.isEmpty()) { + return false; + } + + boolean contained = ids.contains(item.getItemId()); return match == 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/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..a62e4138fa --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -0,0 +1,519 @@ +/* + * 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. + * + * This service centralizes all resolution logic that was previously in ParserHelper. + * It can work in degraded mode when DefinitionsService is not available (returns false for resolution attempts). + */ +public class TypeResolutionServiceImpl implements TypeResolutionService { + + private static final Logger LOGGER = LoggerFactory.getLogger(TypeResolutionServiceImpl.class.getName()); + + private static final int MAX_RECURSION_DEPTH = 1000; + + private DefinitionsService definitionsService; + + // Map of object type -> Map of object ID -> InvalidObjectInfo + private final Map> invalidObjects = new ConcurrentHashMap<>(); + + // Track unresolved types to avoid log spam (similar to ParserHelper's static sets) + private final Set unresolvedActionTypes = Collections.synchronizedSet(new HashSet<>()); + private final Set unresolvedConditionTypes = Collections.synchronizedSet(new HashSet<>()); + // Track rules that have already been warned about null/empty actions to avoid log spam + private final Set warnedRulesWithNullActions = Collections.synchronizedSet(new HashSet<>()); + + /** + * 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) { + 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 { + // 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 (!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; + } + } + } + + // Recursively resolve nested conditions in parameter values + for (Object value : rootCondition.getParameterValues().values()) { + if (value instanceof Condition) { + if (!resolveConditionTypeInternal((Condition) value, contextObjectName, + parentChainPath, false, depth + 1)) { + return false; + } + } else if (value instanceof Collection) { + for (Object item : (Collection) value) { + if (item instanceof Condition) { + if (!resolveConditionTypeInternal((Condition) item, contextObjectName, + parentChainPath, false, depth + 1)) { + return false; + } + } + } + } + } + + return true; + } finally { + if (isGoingUp) { + parentChainPath.remove(rootCondition.getConditionTypeId()); + } + } + } + + @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) { + 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.contains(action.getActionTypeId())) { + LOGGER.warn("Couldn't resolve action type : {}", action.getActionTypeId()); + unresolvedActionTypes.add(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); + } + } + } + + @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) { + // Extract the specific unresolved condition type ID for better error message + String unresolvedTypeId = condition.getConditionTypeId(); + 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()); + } + } + } + String reason = unresolvedActionIds.isEmpty() + ? "Unresolved action type" + : "Unresolved action type(s): " + String.join(", ", unresolvedActionIds); + if (objectId != null) { + markInvalid(objectType, objectId, reason, null, unresolvedActionIds, "rule " + objectId); + } + // Set missingPlugins flag when types can't be resolved + if (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) { + String unresolvedTypeId = rule.getCondition().getConditionTypeId(); + if (unresolvedTypeId != null) { + missingConditionTypeIds.add(unresolvedTypeId); + } + reasons.add("Unresolved condition type" + (unresolvedTypeId != null ? ": " + unresolvedTypeId : "")); + } + } + + boolean actionsResolved = resolveActionTypes(rule, false); + if (!actionsResolved) { + // Collect all unresolved action type IDs + if (rule.getActions() != null) { + for (Action action : rule.getActions()) { + if (action.getActionType() == null && action.getActionTypeId() != null) { + missingActionTypeIds.add(action.getActionTypeId()); + } + } + } + reasons.add("Unresolved action type(s): " + (missingActionTypeIds.isEmpty() ? "unknown" : String.join(", ", missingActionTypeIds))); + } + + // Set/clear missingPlugins based on both condition and actions resolution + boolean allResolved = conditionResolved && actionsResolved; + if (rule.getMetadata() != null) { + rule.getMetadata().setMissingPlugins(!allResolved); + } + + // 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 existingInfo = typeMap.get(objectId); + 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 - create info and log + InvalidObjectInfo newInfo = new InvalidObjectInfo( + objectType, + objectId, + reason != null ? reason : "Unknown reason", + missingConditionTypeIds, + missingActionTypeIds, + contextName + ); + typeMap.put(objectId, newInfo); + + // Log only on first encounter with detailed information + StringBuilder logMessage = new StringBuilder("Marked ").append(objectType).append(" ").append(objectId) + .append(" as invalid: ").append(reason != null ? reason : "Unknown reason"); + + if (missingConditionTypeIds != null && !missingConditionTypeIds.isEmpty()) { + logMessage.append(" (missing condition types: ").append(String.join(", ", missingConditionTypeIds)).append(")"); + } + if (missingActionTypeIds != null && !missingActionTypeIds.isEmpty()) { + logMessage.append(" (missing action types: ").append(String.join(", ", missingActionTypeIds)).append(")"); + } + if (contextName != null) { + logMessage.append(" [context: ").append(contextName).append("]"); + } + + LOGGER.warn(logMessage.toString()); + } + } + + @Override + public void markValid(String objectType, String objectId) { + if (objectType == null || objectId == null) { + return; + } + + Map typeMap = invalidObjects.get(objectType); + if (typeMap != null) { + InvalidObjectInfo removed = typeMap.remove(objectId); + if (removed != null) { + LOGGER.debug("Marked {} {} as valid (removed from invalid tracking)", objectType, objectId); + } + // Clean up empty type maps + if (typeMap.isEmpty()) { + invalidObjects.remove(objectType); + } + } + } + + @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/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..539ba93092 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,9 @@ public Set getGoalMetadatas() { } public Set getGoalMetadatas(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } Set descriptions = new LinkedHashSet<>(); List goals = persistenceService.query(query.getCondition(), query.getSortby(), Goal.class, query.getOffset(), query.getLimit()).getList(); @@ -262,7 +264,9 @@ public Set getCampaignMetadatas() { } public Set getCampaignMetadatas(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } 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 +275,9 @@ public Set getCampaignMetadatas(Query query) { } public PartialList getCampaignDetails(Query query) { - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } 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 +388,7 @@ public GoalReport getGoalReport(String goalId, AggregateQuery query) { } if (query != null && query.getCondition() != null) { - ParserHelper.resolveConditionType(definitionsService, query.getCondition(), "goal " + goalId + " report"); + definitionsService.getConditionValidationService().validate(query.getCondition()); list.add(query.getCondition()); } @@ -464,7 +470,9 @@ public PartialList getEvents(Query query) { if(query.isForceRefresh()){ persistenceService.refreshIndex(CampaignEvent.class); } - definitionsService.resolveConditionType(query.getCondition()); + if (query.getCondition() != null) { + definitionsService.getConditionValidationService().validate(query.getCondition()); + } 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..439333e005 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,8 @@ 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) { + definitionsService.getConditionValidationService().validate(query.getCondition()); 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..69a0f987e7 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,51 @@ 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()); + } + } + + 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..526c602752 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImpl.java @@ -0,0 +1,473 @@ +/* + * 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; + 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 typeId = validator.getValueTypeId().toLowerCase(); + // Only remove if it's not a built-in validator + if (builtInValidators.stream().noneMatch(v -> v.getValueTypeId().equalsIgnoreCase(typeId))) { + validators.remove(typeId); + LOGGER.debug("Removed custom validator for type: {}", validator.getValueTypeId()); + } + } + + /** + * 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) { + throw new IllegalStateException("ConditionValidationService not properly initialized"); + } + 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<>(); + for (Parameter param : type.getParameters()) { + 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 : type.getParameters()) { + String paramName = param.getId(); + Object value = condition.getParameter(paramName); + String location = "condition[" + condition.getConditionTypeId() + "]." + paramName; + + // Skip validation entirely for parameters with references/scripts + if (ConditionContextHelper.hasContextualParameter(value)) { + 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 (with same partial logic) + for (Object value : condition.getParameterValues().values()) { + 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 && !ConditionContextHelper.hasContextualParameter(value)) { + // Check allowed values + if (validation.getAllowedValues() != null && !validation.getAllowedValues().isEmpty()) { + if (!validation.getAllowedValues().contains(value.toString())) { + 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)); + } + } + + // 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") + "]"; + + // 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 type validation + // Other constraints (required, allowedValues, etc.) are still validated + // Type will be validated when the reference is resolved + 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 { + context.put("availableValidators", new ArrayList<>(validators.keySet())); + errors.add(new ValidationError(parameterDescription, + "No validator found for type: " + paramType, + ValidationErrorType.INVALID_VALUE, + condition.getConditionTypeId(), + type.getItemId(), + context, + null)); + } + + 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..4d16ebf71d --- /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.HashSet; +import java.util.Set; + +public class ComparisonOperatorValueTypeValidator implements ValueTypeValidator { + private static final Set VALID_OPERATORS = new HashSet<>(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/NumericValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java new file mode 100644 index 0000000000..427957e451 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.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 class NumericValueTypeValidator implements ValueTypeValidator { + private final String type; + private final Class expectedClass; + + public NumericValueTypeValidator(String type, Class expectedClass) { + this.type = type; + this.expectedClass = expectedClass; + } + + @Override + public String getValueTypeId() { + return type; + } + + @Override + public boolean validate(Object value) { + return expectedClass.isInstance(value); + } + + @Override + public String getValueTypeDescription() { + return "Value must be a " + type; + } +} 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..e92a77ef7a --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -0,0 +1,969 @@ +/* + * 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.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +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() { + // This test would require PropertyType and ValueType classes + // For now, we'll just verify the method exists and doesn't throw + assertDoesNotThrow(() -> typeResolutionService.resolveValueType(null), "resolveValueType should handle null gracefully"); + } + + @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"); + } + + // 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"); + } + + @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"); + } + + @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"); + } + } +} + 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..1ca647e32f --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java @@ -0,0 +1,1291 @@ +/* + * 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 location information"); + } + + @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 + conditionValidationService.unbindValidator(testValidator); + errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + assertEquals("No validator found for type: test", errors.get(0).getMessage()); + } + + @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() { + 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"); + + List errors = conditionValidationService.validate(condition); + assertEquals(1, errors.size()); + assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); + assertEquals("No validator found for type: unknown_type", errors.get(0).getMessage()); + } + + @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"); + } + + 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); + + // Should skip validation for parameter references + assertNoErrors(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); + + // Should skip validation for script expressions + assertNoErrors(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); + } + } +} 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; + } +} + From 71689bf76d12641e59c8151f235b9cca474376cf Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 13 Jun 2026 19:30:34 +0200 Subject: [PATCH 02/10] Ignore local .claude directory in git. --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From bccb264fe26fcbfd7f0140317991ccc0fb6d0e63 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Sat, 13 Jun 2026 20:21:38 +0200 Subject: [PATCH 03/10] UNOMI-883: Fix issues raised in PR #773 review - BooleanConditionEvaluator: empty OR returns false, not true - IdsConditionEvaluator: null-safe unboxing for match parameter - ConditionValidationServiceImpl: null guard in unbindValidator - ComparisonOperatorValueTypeValidator: LinkedHashSet for stable description order - TypeResolutionServiceImplTest: relax timestamp assertions to >= to avoid CI flakiness --- .../baseplugin/conditions/BooleanConditionEvaluator.java | 2 +- .../plugins/baseplugin/conditions/IdsConditionEvaluator.java | 3 ++- .../impl/validation/ConditionValidationServiceImpl.java | 2 +- .../validators/ComparisonOperatorValueTypeValidator.java | 4 ++-- .../unomi/services/impl/TypeResolutionServiceImplTest.java | 4 ++-- 5 files changed, 8 insertions(+), 7 deletions(-) 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 1ed4603ab6..93d9d207c6 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 @@ -38,7 +38,7 @@ public boolean eval(Condition condition, Item item, Map context, List conditions = (List) condition.getParameter("subConditions"); if (conditions == null || conditions.isEmpty()) { - return true; + return isAnd; } for (Condition sub : conditions) { 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 68c0e670b0..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 @@ -35,6 +35,7 @@ public boolean eval(Condition condition, Item item, Map context, } boolean contained = ids.contains(item.getItemId()); - return match == contained; + boolean matchValue = match == null || match; + return matchValue == contained; } } 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 index 526c602752..7f2ee043fe 100644 --- 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 @@ -58,7 +58,7 @@ public void unbindValidator(ValueTypeValidator validator) { } String typeId = validator.getValueTypeId().toLowerCase(); // Only remove if it's not a built-in validator - if (builtInValidators.stream().noneMatch(v -> v.getValueTypeId().equalsIgnoreCase(typeId))) { + if (builtInValidators == null || builtInValidators.stream().noneMatch(v -> v.getValueTypeId().equalsIgnoreCase(typeId))) { validators.remove(typeId); LOGGER.debug("Removed custom validator for type: {}", validator.getValueTypeId()); } 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 index 4d16ebf71d..14311e379e 100644 --- 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 @@ -19,11 +19,11 @@ import org.apache.unomi.api.services.ValueTypeValidator; import java.util.Arrays; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Set; public class ComparisonOperatorValueTypeValidator implements ValueTypeValidator { - private static final Set VALID_OPERATORS = new HashSet<>(Arrays.asList( + private static final Set VALID_OPERATORS = new LinkedHashSet<>(Arrays.asList( // Equality operators "equals", "notEquals", // Comparison operators 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 index e92a77ef7a..912a2d2fe1 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -622,7 +622,7 @@ public void testInvalidObjectInfo_UpdateEncounter_AccumulatesInformation() { 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"); + 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"); @@ -739,7 +739,7 @@ public void testInvalidObjectInfo_TimestampFields() { 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() >= firstSeen, "Last seen timestamp should be updated"); assertTrue(info2.getLastSeenTimestamp() >= lastSeen, "Last seen should be greater than or equal to previous last seen"); } From 257f9edb02fc9e669463115cbb3b15f8b9b32871 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 15 Jun 2026 10:16:22 +0200 Subject: [PATCH 04/10] UNOMI-883: Add condition evaluator unit tests and clean up remaining review findings - New BooleanConditionEvaluatorTest (11 tests, Mockito same() for identity matching) - New IdsConditionEvaluatorTest (9 tests) - ParserHelper regression guard: context must use putAll so parameter:: refs resolve - Add mockito-core test dependency to baseplugin - Improve TypeResolutionServiceImplTest with real assertions - Add @Nested to PartialValidationTests in ConditionValidationServiceImplTest - Fix TOCTOU in TypeResolutionServiceImpl.markValid() via ConcurrentHashMap.compute() - Gate segment validation on isEnabled() in SegmentServiceImpl - Null guard for subConditionType in ConditionValidationServiceImpl - Delete dead NumericValueTypeValidator (no references, duplicate of abstract base) --- .../apache/unomi/api/utils/ParserHelper.java | 7 +- .../unomi/api/utils/ParserHelperTest.java | 45 ++++- plugins/baseplugin/pom.xml | 5 + .../BooleanConditionEvaluatorTest.java | 170 ++++++++++++++++++ .../conditions/IdsConditionEvaluatorTest.java | 111 ++++++++++++ .../impl/TypeResolutionServiceImpl.java | 22 ++- .../impl/segments/SegmentServiceImpl.java | 38 ++-- .../ConditionValidationServiceImpl.java | 19 +- .../validators/NumericValueTypeValidator.java | 44 ----- .../impl/TypeResolutionServiceImplTest.java | 67 ++++++- .../ConditionValidationServiceImplTest.java | 1 + 11 files changed, 438 insertions(+), 91 deletions(-) create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluatorTest.java create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/IdsConditionEvaluatorTest.java delete mode 100644 services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java 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 2e1ca50961..2110bae799 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 @@ -510,12 +510,7 @@ private static Object deepCopyParameterValue(Object value) { return ((Condition) value).deepCopy(); } else if (value instanceof Collection) { Collection collection = (Collection) value; - Collection copiedCollection; - if (collection instanceof List) { - copiedCollection = new ArrayList<>(); - } else { - copiedCollection = new ArrayList<>(); - } + Collection copiedCollection = new ArrayList<>(); for (Object item : collection) { if (item instanceof Condition) { copiedCollection.add(((Condition) item).deepCopy()); 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 index c7f1887829..f5e3440d09 100644 --- a/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java +++ b/api/src/test/java/org/apache/unomi/api/utils/ParserHelperTest.java @@ -1148,13 +1148,52 @@ 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", + 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/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/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..2e85f384f8 --- /dev/null +++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/conditions/BooleanConditionEvaluatorTest.java @@ -0,0 +1,170 @@ +/* + * 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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +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)); + } + + @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)); + } + + @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)); + } + + // --- 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/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java index a62e4138fa..c83eb7dd18 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -39,7 +39,7 @@ * with automatic tracking of invalid objects that have unresolved types. * * This service centralizes all resolution logic that was previously in ParserHelper. - * It can work in degraded mode when DefinitionsService is not available (returns false for resolution attempts). + * Requires a non-null DefinitionsService; the constructor enforces this. */ public class TypeResolutionServiceImpl implements TypeResolutionService { @@ -443,17 +443,15 @@ public void markValid(String objectType, String objectId) { if (objectType == null || objectId == null) { return; } - - Map typeMap = invalidObjects.get(objectType); - if (typeMap != null) { - InvalidObjectInfo removed = typeMap.remove(objectId); - if (removed != null) { - LOGGER.debug("Marked {} {} as valid (removed from invalid tracking)", objectType, objectId); - } - // Clean up empty type maps - if (typeMap.isEmpty()) { - invalidObjects.remove(objectType); - } + 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); } } 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 69a0f987e7..49b50ecda6 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 @@ -393,32 +393,32 @@ public void setSegmentDefinition(Segment segment) { if (typeResolutionService != null) { typeResolutionService.resolveCondition("segments", segment, segment.getCondition(), "segment " + segment.getItemId()); } - } - List validationErrors = definitionsService.getConditionValidationService().validate(segment.getCondition()); + List validationErrors = definitionsService.getConditionValidationService().validate(segment.getCondition()); - List errors = validationErrors.stream() - .filter(error -> error.getType() != ValidationErrorType.MISSING_RECOMMENDED_PARAMETER) - .collect(Collectors.toList()); + 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()); + 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()); + 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()); } - LOGGER.warn(warningMessage.toString()); - } - if (!errors.isEmpty()) { - StringBuilder errorMessage = new StringBuilder("Invalid segment condition:"); - for (ValidationError error : errors) { - errorMessage.append("\n- ").append(error.getDetailedMessage()); + 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()); } - throw new BadSegmentConditionException(errorMessage.toString()); } } 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 index 7f2ee043fe..86de7d4c05 100644 --- 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 @@ -246,7 +246,7 @@ private List validateAdditionalRules(String paramName, Object v null)); } - if (value != null && !ConditionContextHelper.hasContextualParameter(value)) { + if (value != null) { // Check allowed values if (validation.getAllowedValues() != null && !validation.getAllowedValues().isEmpty()) { if (!validation.getAllowedValues().contains(value.toString())) { @@ -269,6 +269,18 @@ private List validateAdditionalRules(String paramName, Object v 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(); @@ -392,9 +404,8 @@ private List validateSingleParameterValue(String paramName, Obj // 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 type validation - // Other constraints (required, allowedValues, etc.) are still validated - // Type will be validated when the reference is resolved + // 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); diff --git a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java b/services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java deleted file mode 100644 index 427957e451..0000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/validation/validators/NumericValueTypeValidator.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 NumericValueTypeValidator implements ValueTypeValidator { - private final String type; - private final Class expectedClass; - - public NumericValueTypeValidator(String type, Class expectedClass) { - this.type = type; - this.expectedClass = expectedClass; - } - - @Override - public String getValueTypeId() { - return type; - } - - @Override - public boolean validate(Object value) { - return expectedClass.isInstance(value); - } - - @Override - public String getValueTypeDescription() { - return "Value must be a " + type; - } -} 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 index 912a2d2fe1..1e5449793d 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -511,12 +511,73 @@ public void testResolveConditionType_WithUnresolvedParent() { } @Test - public void testResolveValueType() { - // This test would require PropertyType and ValueType classes - // For now, we'll just verify the method exists and doesn't throw + 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"); 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 index 1ca647e32f..75881bbbd0 100644 --- 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 @@ -1172,6 +1172,7 @@ public void testDeepNestedConditionValidation() { "Should have error for missing required parameter"); } + @org.junit.jupiter.api.Nested public class PartialValidationTests { @Test public void testValidate_SkipsParameterReferences() { From 1be3003bbff053f1045af16eb9267bb282cfb64d Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 15 Jun 2026 14:38:01 +0200 Subject: [PATCH 05/10] UNOMI-883: Address PR #773 review findings (thread safety, API clarity, error handling) - InvalidObjectInfo: replace volatile int/long with AtomicInteger/AtomicLong and synchronize updateEncounter() to prevent data races under concurrent evaluation - ConditionContextHelper: throw ExceptionInInitializerError on missing fold-mapping file instead of silently degrading; fix French error message - TypeResolutionServiceImpl: WARN log in resolveValueType on null lookup; WARN in resolveActionType null guard; throw IAE in setDefinitionsService(null); remove two restatement inline comments - ConditionValidationServiceImpl: unknown validator type now WARN+skip (backward compat) instead of INVALID_VALUE error; null-guard getValueTypeId() in unbindValidator to prevent NPE in OSGi lifecycle - ConditionValidation: make customType transient for OSGi cross-bundle serialization - ConditionValidationService: rename conditionTypeName -> conditionTypeId (field stores machine ID); keep deprecated getConditionTypeName() alias - ParserHelper: WARN log when getParentChain returns null; add deprecated single-arg resolveConditionEventTypes overload for API compatibility - Tests: concurrent updateEncounter safety, setDefinitionsService IAE, resolveValueType null, AND/OR short-circuit verify(never()), null subConditionType, list contextual param skip, unbindValidator null typeId, built-in validator survives unbind --- .../api/conditions/ConditionValidation.java | 2 +- .../services/ConditionValidationService.java | 18 +++-- .../unomi/api/services/InvalidObjectInfo.java | 40 +++++----- .../apache/unomi/api/utils/ParserHelper.java | 16 +++- .../conditions/ConditionContextHelper.java | 2 +- .../BooleanConditionEvaluatorTest.java | 4 + .../impl/TypeResolutionServiceImpl.java | 10 ++- .../ConditionValidationServiceImpl.java | 19 ++--- .../impl/TypeResolutionServiceImplTest.java | 45 +++++++++++ .../ConditionValidationServiceImplTest.java | 80 +++++++++++++++++-- 10 files changed, 186 insertions(+), 50 deletions(-) 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 index 6bcd9aa8a0..37c7acf37a 100644 --- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java +++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionValidation.java @@ -50,7 +50,7 @@ public enum Type { 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 Class customType; // For OBJECT type, specifies the exact class expected + private transient Class customType; public ConditionValidation() { } 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 index a0c4fbb167..b9d73ea925 100644 --- a/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java +++ b/api/src/main/java/org/apache/unomi/api/services/ConditionValidationService.java @@ -44,7 +44,7 @@ class ValidationError { private final String message; private final ValidationErrorType type; private final String conditionId; - private final String conditionTypeName; + private final String conditionTypeId; private final Map context; private final ValidationError parentError; @@ -53,13 +53,13 @@ public ValidationError(String parameterName, String message, ValidationErrorType } public ValidationError(String parameterName, String message, ValidationErrorType type, - String conditionId, String conditionTypeName, Map context, + String conditionId, String conditionTypeId, Map context, ValidationError parentError) { this.parameterName = parameterName; this.message = message; this.type = type; this.conditionId = conditionId; - this.conditionTypeName = conditionTypeName; + this.conditionTypeId = conditionTypeId; this.context = context != null ? new HashMap<>(context) : new HashMap<>(); this.parentError = parentError; } @@ -80,8 +80,14 @@ public String getConditionId() { return conditionId; } + public String getConditionTypeId() { + return conditionTypeId; + } + + /** @deprecated Use {@link #getConditionTypeId()} instead. */ + @Deprecated public String getConditionTypeName() { - return conditionTypeName; + return conditionTypeId; } public Map getContext() { @@ -100,8 +106,8 @@ public String getDetailedMessage() { StringBuilder sb = new StringBuilder(); // Build location context - if (conditionTypeName != null) { - sb.append("In condition type '").append(conditionTypeName).append("'"); + if (conditionTypeId != null) { + sb.append("In condition type '").append(conditionTypeId).append("'"); if (conditionId != null) { sb.append(" (ID: ").append(conditionId).append(")"); } 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 index 5bbd732179..da5235495f 100644 --- a/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -18,6 +18,8 @@ package org.apache.unomi.api.services; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; /** * Information about an invalid object, including detailed reasons for invalidation. @@ -27,8 +29,8 @@ public class InvalidObjectInfo { private final String objectId; private final String reason; private final long firstSeenTimestamp; - private volatile long lastSeenTimestamp; - private volatile int encounterCount; + private final AtomicLong lastSeenTimestamp; + private final AtomicInteger encounterCount; private final List missingConditionTypeIds; private final List missingActionTypeIds; private final Set contextNames; @@ -45,8 +47,8 @@ public InvalidObjectInfo(String objectType, String objectId, String reason, this.objectId = objectId; this.reason = reason; this.firstSeenTimestamp = System.currentTimeMillis(); - this.lastSeenTimestamp = this.firstSeenTimestamp; - this.encounterCount = 1; + this.lastSeenTimestamp = new AtomicLong(this.firstSeenTimestamp); + this.encounterCount = new AtomicInteger(1); this.missingConditionTypeIds = missingConditionTypeIds != null ? new ArrayList<>(missingConditionTypeIds) : new ArrayList<>(); this.missingActionTypeIds = missingActionTypeIds != null @@ -74,11 +76,11 @@ public long getFirstSeenTimestamp() { } public long getLastSeenTimestamp() { - return lastSeenTimestamp; + return lastSeenTimestamp.get(); } public int getEncounterCount() { - return encounterCount; + return encounterCount.get(); } public List getMissingConditionTypeIds() { @@ -94,18 +96,18 @@ public Set getContextNames() { } /** - * Update the info when the object is encountered again. - * This method is called internally by TypeResolutionService to track repeated encounters. - * - * @param missingConditionTypeIds additional missing condition type IDs - * @param missingActionTypeIds additional missing action type IDs - * @param contextName context where it was encountered + * Updates tracking info when the object is encountered again during type resolution. + * This method is thread-safe and may be called concurrently from multiple resolution threads. + * + * @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 = System.currentTimeMillis(); - this.encounterCount++; + public synchronized void updateEncounter(List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { + this.lastSeenTimestamp.set(System.currentTimeMillis()); + this.encounterCount.incrementAndGet(); if (missingConditionTypeIds != null) { for (String typeId : missingConditionTypeIds) { @@ -148,8 +150,8 @@ public String toString() { sb.append(", objectId='").append(objectId).append('\''); sb.append(", reason='").append(reason).append('\''); sb.append(", firstSeen=").append(firstSeenTimestamp); - sb.append(", lastSeen=").append(lastSeenTimestamp); - sb.append(", encounters=").append(encounterCount); + sb.append(", lastSeen=").append(lastSeenTimestamp.get()); + sb.append(", encounters=").append(encounterCount.get()); if (!missingConditionTypeIds.isEmpty()) { sb.append(", missingConditionTypes=").append(missingConditionTypeIds); } 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 2110bae799..2dcd3c453b 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 @@ -265,6 +265,14 @@ 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<>(); @@ -597,10 +605,14 @@ public static Condition resolveEffectiveCondition( return condition; } - // Get parent chain List parentChain = getParentChain(type, definitionsService, contextName, maxDepth); - if (parentChain == null || parentChain.isEmpty()) { + if (parentChain == null) { + LOGGER.warn("Failed to build parent chain for condition type '{}' in '{}', returning unresolved condition", + type.getItemId(), contextName); + return condition; + } + if (parentChain.isEmpty()) { return condition; } diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java index aac2cf3576..39cb316a41 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java @@ -71,7 +71,7 @@ public class ConditionContextHelper { try { loadMappingFile(); } catch (IOException e) { - LOGGER.error("Erreur lors du chargement du fichier de mapping", e); + throw new ExceptionInInitializerError(e); } } 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 index 2e85f384f8..888c2012a3 100644 --- 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 @@ -33,6 +33,8 @@ import static org.junit.Assert.assertTrue; 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) @@ -93,6 +95,7 @@ public void and_firstFalse_returnsFalse() { 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 @@ -121,6 +124,7 @@ public void or_firstTrue_returnsTrue() { 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 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 index c83eb7dd18..0cf4567de4 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -79,6 +79,9 @@ public TypeResolutionServiceImpl(DefinitionsService definitionsService) { * @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; } @@ -116,7 +119,6 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } try { - // Resolve current condition type if needed if (rootCondition.getConditionType() == null) { String conditionTypeId = rootCondition.getConditionTypeId(); if (conditionTypeId == null) { @@ -149,7 +151,6 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } } - // Recursively resolve nested conditions in parameter values for (Object value : rootCondition.getParameterValues().values()) { if (value instanceof Condition) { if (!resolveConditionTypeInternal((Condition) value, contextObjectName, @@ -211,6 +212,8 @@ public boolean resolveActionTypes(Rule rule, boolean ignoreErrors) { @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) { @@ -238,6 +241,9 @@ public void resolveValueType(PropertyType propertyType) { 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()); } } } 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 index 86de7d4c05..099706f074 100644 --- 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 @@ -56,11 +56,14 @@ public void unbindValidator(ValueTypeValidator validator) { if (validator == null) { return; } - String typeId = validator.getValueTypeId().toLowerCase(); - // Only remove if it's not a built-in validator + String rawTypeId = validator.getValueTypeId(); + if (rawTypeId == null) { + return; + } + String typeId = rawTypeId.toLowerCase(); if (builtInValidators == null || builtInValidators.stream().noneMatch(v -> v.getValueTypeId().equalsIgnoreCase(typeId))) { validators.remove(typeId); - LOGGER.debug("Removed custom validator for type: {}", validator.getValueTypeId()); + LOGGER.debug("Removed custom validator for type: {}", rawTypeId); } } @@ -469,14 +472,8 @@ private List validateSingleParameterValue(String paramName, Obj null)); } } else { - context.put("availableValidators", new ArrayList<>(validators.keySet())); - errors.add(new ValidationError(parameterDescription, - "No validator found for type: " + paramType, - ValidationErrorType.INVALID_VALUE, - condition.getConditionTypeId(), - type.getItemId(), - context, - null)); + LOGGER.warn("No ValueTypeValidator for type '{}' (param '{}') — plugin MUST register one, skipping type check", + paramType, paramName); } return errors; 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 index 1e5449793d..a0f0b9ce72 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -613,6 +613,51 @@ public void testResolveCondition_UpdatesInvalidTracking() { 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"); + } + // Tests for enhanced InvalidObjectInfo functionality @Test 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 index 75881bbbd0..7663a870cc 100644 --- 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 @@ -702,12 +702,10 @@ public String getValueTypeDescription() { assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); assertEquals("Value must be a test string", errors.get(0).getMessage()); - // Test unbinding + // Test unbinding — after removal, unknown type is now permissive (WARN+skip) conditionValidationService.unbindValidator(testValidator); errors = conditionValidationService.validate(condition); - assertEquals(1, errors.size()); - assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); - assertEquals("No validator found for type: test", errors.get(0).getMessage()); + assertTrue(errors.isEmpty(), "Unknown type after unbind should produce no errors (WARN+skip)"); } @Test @@ -761,7 +759,7 @@ public void testAllBuiltInValidators() { } @Test - public void testUnknownTypeValidation() { + public void testUnknownTypeValidation_isPermissive() { ConditionType type = new ConditionType(new Metadata()); List parameters = new ArrayList<>(); @@ -772,10 +770,9 @@ public void testUnknownTypeValidation() { 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); - assertEquals(1, errors.size()); - assertEquals(ValidationErrorType.INVALID_VALUE, errors.get(0).getType()); - assertEquals("No validator found for type: unknown_type", errors.get(0).getMessage()); + assertTrue(errors.isEmpty(), "Unknown parameter types should produce no errors — WARN+skip for backward compatibility"); } @Test @@ -1172,6 +1169,73 @@ public void testDeepNestedConditionValidation() { "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 From f69ab493212722f9bc4c5c55a5d76a35c61a66d8 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 15 Jun 2026 19:24:39 +0200 Subject: [PATCH 06/10] UNOMI-883: Fix second-pass review findings (behavioral regressions, concurrency, error propagation) A second review of PR #773 identified several issues introduced or exposed by the new condition validation infrastructure: Behavioral regressions: - SegmentServiceImpl: restore persistenceService.isValidCondition() guard that was removed when validation was moved to ConditionValidationService. Without it, conditions that pass type-metadata validation but produce invalid ES/OS queries are now silently accepted and fail at query time. - ProfileServiceImpl.doSearch(): restore the gate that skips persistence queries when the condition type could not be resolved. The old code returned no results; the new code was passing unresolved conditions to the persistence layer and causing query errors. Concurrency: - TypeResolutionServiceImpl.markInvalid(): replace get/put sequence with putIfAbsent to eliminate the TOCTOU window where two concurrent callers for the same object ID could both see null and overwrite each other, losing encounter data and emitting duplicate WARN logs. Semantics: - TypeResolutionServiceImpl.resolveActions(): null or empty actions on a rule are a structural error, not a missing-plugin error. Only set missingPlugins=true when action type IDs are present but unresolvable, so rules with structural problems are not retried as if their plugins are simply not yet loaded. Error propagation: - ConditionValidationServiceImpl.validate(): replace IllegalStateException with WARN + empty list when builtInValidators is null, so read-path callers (GoalsService, ProfileService, QueryService) do not throw during the startup window before Blueprint wiring completes. - ConditionValidationServiceImpl.unbindValidator(): null-guard each built-in validator's getValueTypeId() in the stream to prevent NPE when any built-in validator returns null from that method. - ConditionContextHelper: Map and List branches now consistently return RESOLUTION_ERROR on failure (Map was returning null, masking the error). getContextualCondition() intercepts RESOLUTION_ERROR before the unchecked Map cast to convert it to null for callers. PastEventCondition ES/OS query builders now guard against a null result from getContextualCondition() and throw IllegalArgumentException rather than silently adding null to the sub-conditions list. - ConditionEvaluatorDispatcherImpl: elevate unresolved condition type log from DEBUG to WARN so operators see it in default logging configuration. - GoalsServiceImpl: log WARN when validate() returns issues rather than silently discarding the result. Tests: add concurrent read-side test for InvalidObjectInfo, assertSame for markInvalid idempotent-insert, and validate that an uninitialized ConditionValidationService returns empty list instead of throwing. --- .../unomi/api/services/InvalidObjectInfo.java | 5 ++- .../PastEventConditionESQueryBuilder.java | 6 ++- .../PastEventConditionOSQueryBuilder.java | 6 ++- .../conditions/ConditionContextHelper.java | 20 +++++----- .../ConditionEvaluatorDispatcherImpl.java | 2 +- .../impl/TypeResolutionServiceImpl.java | 35 ++++++++--------- .../services/impl/goals/GoalsServiceImpl.java | 20 ++++++++-- .../impl/profiles/ProfileServiceImpl.java | 4 ++ .../impl/segments/SegmentServiceImpl.java | 4 ++ .../ConditionValidationServiceImpl.java | 5 ++- .../impl/TypeResolutionServiceImplTest.java | 38 +++++++++++++++++++ .../ConditionValidationServiceImplTest.java | 12 ++++++ 12 files changed, 118 insertions(+), 39 deletions(-) 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 index da5235495f..3dbc1f8781 100644 --- a/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -97,7 +97,10 @@ public Set getContextNames() { /** * Updates tracking info when the object is encountered again during type resolution. - * This method is thread-safe and may be called concurrently from multiple resolution threads. + * This method is thread-safe with respect to concurrent calls to {@code updateEncounter} itself. + * Note: reads via {@code getMissingConditionTypeIds()}, {@code getMissingActionTypeIds()}, and + * {@code getContextNames()} are not synchronized and may observe inconsistent state if called + * concurrently with an in-progress {@code updateEncounter} call. * * @param missingConditionTypeIds additional missing condition type IDs found in this encounter * @param missingActionTypeIds additional missing action type IDs found in this encounter 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-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/advanced/PastEventConditionOSQueryBuilder.java b/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/advanced/PastEventConditionOSQueryBuilder.java index 78ff167a23..884520305c 100644 --- a/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/advanced/PastEventConditionOSQueryBuilder.java +++ b/persistence-opensearch/conditions/src/main/java/org/apache/unomi/persistence/opensearch/querybuilders/advanced/PastEventConditionOSQueryBuilder.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-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java index 39cb316a41..6c5737a12b 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java @@ -161,14 +161,15 @@ public static Condition getContextualCondition( } } - @SuppressWarnings("unchecked") - Map values = (Map) parseParameterWithValidation( + Object rawValues = parseParameterWithValidation( context, condition.getParameterValues(), scriptExecutor, parameterDefs, condition.getConditionTypeId()); - if (values == null) { + if (rawValues == null || rawValues == RESOLUTION_ERROR) { return null; } + @SuppressWarnings("unchecked") + Map values = (Map) rawValues; Condition n = new Condition(condition.getConditionType()); n.setParameterValues(values); @@ -203,7 +204,7 @@ private static Object parseParameterWithValidation( * @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 null if cycle detected or max depth exceeded + * @return resolved value, or {@code RESOLUTION_ERROR} sentinel if cycle detected or max depth exceeded */ @SuppressWarnings("unchecked") private static Object parseParameterWithValidationRecursive( @@ -265,8 +266,7 @@ private static Object parseParameterWithValidationRecursive( if (resolvedValue != null && isParameterReference(resolvedValue)) { Object furtherResolved = parseParameterWithValidationRecursive( context, resolvedValue, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth + 1); - // If further resolution returns null due to cycle or max depth, propagate it - // But if it's just a missing parameter, return null (not a cycle) + // Propagate RESOLUTION_ERROR if cycle/max-depth was detected, or resolved value otherwise return furtherResolved; } @@ -286,9 +286,9 @@ private static Object parseParameterWithValidationRecursive( Object parameter = parseParameterWithValidationRecursive( context, paramValue, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth); - // If resolution returned an error marker, return null for entire map + // If resolution returned an error marker, propagate failure if (parameter == RESOLUTION_ERROR) { - return null; + return RESOLUTION_ERROR; } // Validate type if parameter definition is available and value is not null @@ -306,9 +306,9 @@ private static Object parseParameterWithValidationRecursive( for (Object o : ((List) value)) { Object parameter = parseParameterWithValidationRecursive( context, o, scriptExecutor, parameterDefs, conditionTypeId, resolutionChain, depth); - // If resolution returned an error marker, skip this element + // If resolution returned an error marker, propagate failure (consistent with Map behavior) if (parameter == RESOLUTION_ERROR) { - continue; + return RESOLUTION_ERROR; } // Add the value, even if null (missing parameter is valid in lists) values.add(parameter); 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 3fa743198c..65fb3c39fa 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 @@ -119,7 +119,7 @@ public boolean eval(Condition condition, Item item, Map context) } // If still null after attempting resolution (or definitionsService was null), return false gracefully if (condition.getConditionType() == null) { - LOGGER.debug("Condition type is null for condition typeID={}, returning false gracefully", condition.getConditionTypeId()); + LOGGER.warn("Condition type is null for condition typeID={}, returning false gracefully", condition.getConditionTypeId()); return false; } } 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 index 0cf4567de4..67240cda25 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -38,7 +38,6 @@ * Implementation of TypeResolutionService that resolves condition types, action types, and value types, * with automatic tracking of invalid objects that have unresolved types. * - * This service centralizes all resolution logic that was previously in ParserHelper. * Requires a non-null DefinitionsService; the constructor enforces this. */ public class TypeResolutionServiceImpl implements TypeResolutionService { @@ -296,7 +295,7 @@ public boolean resolveActions(String objectType, Rule rule) { boolean resolved = resolveActionTypes(rule, false); String objectId = rule.getItemId(); - + if (!resolved) { // Collect all unresolved action type IDs List unresolvedActionIds = new ArrayList<>(); @@ -307,14 +306,15 @@ public boolean resolveActions(String objectType, Rule rule) { } } } - String reason = unresolvedActionIds.isEmpty() - ? "Unresolved action type" + 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); } - // Set missingPlugins flag when types can't be resolved - if (rule.getMetadata() != null) { + // Only set missingPlugins when action plugin types are genuinely missing, not for structural errors + if (!structuralError && rule.getMetadata() != null) { rule.getMetadata().setMissingPlugins(true); } } else { @@ -410,23 +410,20 @@ private void markInvalid(String objectType, String objectId, String reason, Map typeMap = invalidObjects.computeIfAbsent(objectType, k -> new ConcurrentHashMap<>()); - InvalidObjectInfo existingInfo = typeMap.get(objectId); + 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 - create info and log - InvalidObjectInfo newInfo = new InvalidObjectInfo( - objectType, - objectId, - reason != null ? reason : "Unknown reason", - missingConditionTypeIds, - missingActionTypeIds, - contextName - ); - typeMap.put(objectId, newInfo); - - // Log only on first encounter with detailed information + // First time encountering this invalid object - log StringBuilder logMessage = new StringBuilder("Marked ").append(objectType).append(" ").append(objectId) .append(" as invalid: ").append(reason != null ? reason : "Unknown reason"); 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 539ba93092..8a301b7d4e 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 @@ -152,7 +152,10 @@ public Set getGoalMetadatas() { public Set getGoalMetadatas(Query query) { if (query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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<>(); @@ -265,7 +268,10 @@ public Set getCampaignMetadatas() { public Set getCampaignMetadatas(Query query) { if (query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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()) { @@ -276,7 +282,10 @@ public Set getCampaignMetadatas(Query query) { public PartialList getCampaignDetails(Query query) { if (query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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<>(); @@ -388,7 +397,10 @@ public GoalReport getGoalReport(String goalId, AggregateQuery query) { } if (query != null && query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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()); } 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 439333e005..bb77e712f7 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 @@ -431,6 +431,10 @@ private PartialList doSearch(Query query, Class clazz) { } if (query.getCondition() != 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/segments/SegmentServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/segments/SegmentServiceImpl.java index 49b50ecda6..8d01b1c62b 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 @@ -394,6 +394,10 @@ public void setSegmentDefinition(Segment segment) { typeResolutionService.resolveCondition("segments", segment, segment.getCondition(), "segment " + segment.getItemId()); } + if (!persistenceService.isValidCondition(segment.getCondition(), new Profile(VALIDATION_PROFILE_ID))) { + throw new BadSegmentConditionException(); + } + List validationErrors = definitionsService.getConditionValidationService().validate(segment.getCondition()); List errors = validationErrors.stream() 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 index 099706f074..e572f32b5b 100644 --- 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 @@ -61,7 +61,7 @@ public void unbindValidator(ValueTypeValidator validator) { return; } String typeId = rawTypeId.toLowerCase(); - if (builtInValidators == null || builtInValidators.stream().noneMatch(v -> v.getValueTypeId().equalsIgnoreCase(typeId))) { + 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); } @@ -107,7 +107,8 @@ private Map buildValidationContext(String paramName, Object valu @Override public List validate(Condition condition) { if (builtInValidators == null) { - throw new IllegalStateException("ConditionValidationService not properly initialized"); + LOGGER.warn("ConditionValidationService not properly initialized (no built-in validators), skipping validation"); + return Collections.emptyList(); } List errors = new ArrayList<>(); 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 index a0f0b9ce72..d69011bee5 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -658,6 +658,43 @@ public void updateEncounter_concurrentAccess_isThreadSafe() throws InterruptedEx "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 @@ -913,6 +950,7 @@ public void testMarkInvalid_OnlyLogsFirstEncounter() { 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 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 index 7663a870cc..0644a1c595 100644 --- 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 @@ -1353,4 +1353,16 @@ public void testValidate_RecursivelyValidatesNestedConditions() { 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"); + } } From 6d2e64fe1ffab01bef2a15260bb054b3a3b2a12a Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 15 Jun 2026 20:53:34 +0200 Subject: [PATCH 07/10] UNOMI-883: Fix third-pass review findings (double validation, parent rollback, null guards, tests) - Fix double-validation of nested condition parameters (H2): skip bottom-loop re-validation for parameters already handled by the typed-parameter loop - Fix parent conditionType not rolled back on child resolution failure (M1) - Guard validate() calls behind null check on conditionValidationService (M3) in SegmentServiceImpl, GoalsServiceImpl, and ProfileServiceImpl - Resolve condition types via ParserHelper before calling validate() (H1) to avoid relying on the optional TypeResolutionService OSGi wiring - Guard ConditionContextHelper.loadMappingFile() against null InputStream (M4) - Guard setBuiltInValidators() and getParameters() against null inputs (M2, M5) - Warn and skip allowedValues check for non-String parameter values (L1) - Add TC1-TC8 covering all changed paths across four test classes --- .../conditions/ConditionContextHelper.java | 7 +- .../ConditionContextHelperTest.java | 16 ++ .../ConditionEvaluatorDispatcherImplTest.java | 159 ++++++++++++++++++ .../impl/TypeResolutionServiceImpl.java | 10 +- .../services/impl/goals/GoalsServiceImpl.java | 44 +++-- .../impl/profiles/ProfileServiceImpl.java | 5 +- .../impl/segments/SegmentServiceImpl.java | 38 +++-- .../ConditionValidationServiceImpl.java | 52 ++++-- .../impl/TypeResolutionServiceImplTest.java | 44 +++++ .../ConditionValidationServiceImplTest.java | 67 ++++++++ 10 files changed, 392 insertions(+), 50 deletions(-) create mode 100644 persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImplTest.java diff --git a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java index 6c5737a12b..d8c974af70 100644 --- a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java +++ b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/conditions/ConditionContextHelper.java @@ -76,8 +76,11 @@ public class ConditionContextHelper { } 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) { 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 index 2c914057ea..b9577595c4 100644 --- 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 @@ -512,6 +512,22 @@ public void testMultipleParametersWithMixedReferences() { 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) { 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..a2f3a3595a --- /dev/null +++ b/persistence-spi/src/test/java/org/apache/unomi/persistence/spi/conditions/evaluator/impl/ConditionEvaluatorDispatcherImplTest.java @@ -0,0 +1,159 @@ +/* + * 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); + } + + // 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); + } +} 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 index 67240cda25..cf82a93f11 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -118,7 +118,9 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } try { - if (rootCondition.getConditionType() == null) { + // 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); @@ -154,6 +156,9 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con if (value instanceof Condition) { if (!resolveConditionTypeInternal((Condition) value, contextObjectName, parentChainPath, false, depth + 1)) { + if (conditionTypeWasNull) { + rootCondition.setConditionType(null); + } return false; } } else if (value instanceof Collection) { @@ -161,6 +166,9 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con if (item instanceof Condition) { if (!resolveConditionTypeInternal((Condition) item, contextObjectName, parentChainPath, false, depth + 1)) { + if (conditionTypeWasNull) { + rootCondition.setConditionType(null); + } return false; } } 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 8a301b7d4e..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 @@ -152,9 +152,12 @@ public Set getGoalMetadatas() { public Set getGoalMetadatas(Query query) { if (query.getCondition() != null) { - List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); - if (!validationErrors.isEmpty()) { - LOGGER.warn("Goal query condition has {} validation issue(s)", validationErrors.size()); + 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<>(); @@ -268,9 +271,12 @@ public Set getCampaignMetadatas() { public Set getCampaignMetadatas(Query query) { if (query.getCondition() != null) { - List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); - if (!validationErrors.isEmpty()) { - LOGGER.warn("Campaign query condition has {} validation issue(s)", validationErrors.size()); + 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(); @@ -282,9 +288,12 @@ public Set getCampaignMetadatas(Query query) { public PartialList getCampaignDetails(Query query) { if (query.getCondition() != null) { - List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); - if (!validationErrors.isEmpty()) { - LOGGER.warn("Campaign details query condition has {} validation issue(s)", validationErrors.size()); + 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()); @@ -397,9 +406,12 @@ public GoalReport getGoalReport(String goalId, AggregateQuery query) { } if (query != null && query.getCondition() != null) { - List validationErrors = definitionsService.getConditionValidationService().validate(query.getCondition()); - if (!validationErrors.isEmpty()) { - LOGGER.warn("Events query condition has {} validation issue(s)", validationErrors.size()); + 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()); } @@ -483,7 +495,13 @@ public PartialList getEvents(Query query) { persistenceService.refreshIndex(CampaignEvent.class); } if (query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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 bb77e712f7..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 @@ -430,7 +430,10 @@ private PartialList doSearch(Query query, Class clazz) { return persistenceService.continueScrollQuery(clazz, query.getScrollIdentifier(), query.getScrollTimeValidity()); } if (query.getCondition() != null) { - definitionsService.getConditionValidationService().validate(query.getCondition()); + 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<>(); 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 8d01b1c62b..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 @@ -398,30 +398,32 @@ public void setSegmentDefinition(Segment segment) { throw new BadSegmentConditionException(); } - List validationErrors = definitionsService.getConditionValidationService().validate(segment.getCondition()); + 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 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()); + 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()); + 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()); } - LOGGER.warn(warningMessage.toString()); - } - if (!errors.isEmpty()) { - StringBuilder errorMessage = new StringBuilder("Invalid segment condition:"); - for (ValidationError error : errors) { - errorMessage.append("\n- ").append(error.getDetailedMessage()); + 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()); } - throw new BadSegmentConditionException(errorMessage.toString()); } } } 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 index e572f32b5b..d653e07f17 100644 --- 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 @@ -41,6 +41,9 @@ public class ConditionValidationServiceImpl implements ConditionValidationServic public void setBuiltInValidators(List builtInValidators) { this.builtInValidators = builtInValidators; + if (builtInValidators == null) { + return; + } for (ValueTypeValidator validator : builtInValidators) { validators.put(validator.getValueTypeId().toLowerCase(), validator); } @@ -142,7 +145,8 @@ public List validate(Condition condition) { // Group parameters by exclusive group (only for parameters with validation) Map> exclusiveGroups = new HashMap<>(); - for (Parameter param : type.getParameters()) { + List typeParameters = type.getParameters() != null ? type.getParameters() : Collections.emptyList(); + for (Parameter param : typeParameters) { if (param.getValidation() != null && param.getValidation().isExclusive() && param.getValidation().getExclusiveGroup() != null) { @@ -152,7 +156,7 @@ public List validate(Condition condition) { } // Check each parameter, skipping those with references/scripts (partial validation) - for (Parameter param : type.getParameters()) { + for (Parameter param : typeParameters) { String paramName = param.getId(); Object value = condition.getParameter(paramName); String location = "condition[" + condition.getConditionTypeId() + "]." + paramName; @@ -203,8 +207,20 @@ public List validate(Condition condition) { } } - // Recursively validate nested conditions (with same partial logic) - for (Object value : condition.getParameterValues().values()) { + // 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) { @@ -251,18 +267,24 @@ private List validateAdditionalRules(String paramName, Object v } if (value != null) { - // Check allowed values + // 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 (!validation.getAllowedValues().contains(value.toString())) { - 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)); + 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()); } } 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 index d69011bee5..2e19384b04 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -985,6 +985,50 @@ public void testResolveCondition_WithMultipleEncounters() { 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"); + } + @Nested class ResolutionTests { @Test 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 index 0644a1c595..94ac79937b 100644 --- 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 @@ -1365,4 +1365,71 @@ public void validate_withUninitializedService_returnsEmptyListInsteadOfThrowing( 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"); + } + + // 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)"); + } } From 2b92ae7c5de28960fa79be22a800e3cc352e08e6 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 16 Jun 2026 08:37:18 +0200 Subject: [PATCH 08/10] UNOMI-883: Fix fourth-pass review findings (concurrency, OS parity, null safety, tests) - InvalidObjectInfo: replace ArrayList/HashSet with CopyOnWriteArrayList/Set so reads are safe during concurrent updateEncounter calls (no more synchronized needed) - TypeResolutionServiceImpl: use ConcurrentHashMap.newKeySet() for atomic log-spam suppression; mark definitionsService volatile; use SLF4J parameterized logging in markInvalid; roll back all newly-resolved sibling conditions on child failure - ConditionOSQueryBuilderDispatcher.buildFilter: add missing parent-condition fallback (was present in ES counterpart but absent in OS, causing divergent behaviour) - ParserHelper.resolveEffectiveCondition: return null on cycle/max-depth failure instead of silently returning the unresolved original; all callers (ES/OS buildFilter+count, evaluator dispatcher) updated with explicit null guards - Both query-builder dispatchers: include conditionTypeId+queryBuilderKey in the "no matching query builder" WARN log - ConditionEvaluatorDispatcherImpl: outer catch now logs conditionTypeId and itemId - BooleanConditionEvaluator: guard subConditions cast with instanceof check; throw IllegalArgumentException on type mismatch instead of hiding it with @SuppressWarnings - Tests: add successful-eval dispatch test; add resolveRule(null), empty-actions, null-conditionTypeId, getAllInvalidObjectIds coverage; add untyped nested-condition validation test; fix weak || assertion to && in ConditionValidationServiceImplTest --- .../unomi/api/services/InvalidObjectInfo.java | 35 ++++----- .../apache/unomi/api/utils/ParserHelper.java | 4 +- .../ConditionESQueryBuilderDispatcher.java | 26 +++++-- .../ConditionOSQueryBuilderDispatcher.java | 34 ++++++--- .../ConditionEvaluatorDispatcherImpl.java | 11 ++- .../ConditionEvaluatorDispatcherImplTest.java | 21 ++++++ .../conditions/BooleanConditionEvaluator.java | 7 +- .../impl/TypeResolutionServiceImpl.java | 73 ++++++++++--------- .../impl/TypeResolutionServiceImplTest.java | 46 ++++++++++++ .../ConditionValidationServiceImplTest.java | 40 +++++++++- 10 files changed, 223 insertions(+), 74 deletions(-) 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 index 3dbc1f8781..e04dd5d8a8 100644 --- a/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -18,6 +18,8 @@ package org.apache.unomi.api.services; import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -31,9 +33,9 @@ public class InvalidObjectInfo { private final long firstSeenTimestamp; private final AtomicLong lastSeenTimestamp; private final AtomicInteger encounterCount; - private final List missingConditionTypeIds; + private final List missingConditionTypeIds; // CopyOnWriteArrayList — safe for concurrent reads during updateEncounter private final List missingActionTypeIds; - private final Set contextNames; + private final Set contextNames; // CopyOnWriteArraySet public InvalidObjectInfo(String objectType, String objectId, String reason) { this(objectType, objectId, reason, null, null, null); @@ -49,11 +51,11 @@ public InvalidObjectInfo(String objectType, String objectId, String reason, this.firstSeenTimestamp = System.currentTimeMillis(); this.lastSeenTimestamp = new AtomicLong(this.firstSeenTimestamp); this.encounterCount = new AtomicInteger(1); - this.missingConditionTypeIds = missingConditionTypeIds != null - ? new ArrayList<>(missingConditionTypeIds) : new ArrayList<>(); - this.missingActionTypeIds = missingActionTypeIds != null - ? new ArrayList<>(missingActionTypeIds) : new ArrayList<>(); - this.contextNames = new HashSet<>(); + this.missingConditionTypeIds = missingConditionTypeIds != null + ? new CopyOnWriteArrayList<>(missingConditionTypeIds) : new CopyOnWriteArrayList<>(); + this.missingActionTypeIds = missingActionTypeIds != null + ? new CopyOnWriteArrayList<>(missingActionTypeIds) : new CopyOnWriteArrayList<>(); + this.contextNames = new CopyOnWriteArraySet<>(); if (contextName != null) { this.contextNames.add(contextName); } @@ -97,21 +99,20 @@ public Set getContextNames() { /** * Updates tracking info when the object is encountered again during type resolution. - * This method is thread-safe with respect to concurrent calls to {@code updateEncounter} itself. - * Note: reads via {@code getMissingConditionTypeIds()}, {@code getMissingActionTypeIds()}, and - * {@code getContextNames()} are not synchronized and may observe inconsistent state if called - * concurrently with an in-progress {@code updateEncounter} call. + * 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 synchronized void updateEncounter(List missingConditionTypeIds, - List missingActionTypeIds, - String contextName) { + public void updateEncounter(List missingConditionTypeIds, + List missingActionTypeIds, + String contextName) { this.lastSeenTimestamp.set(System.currentTimeMillis()); this.encounterCount.incrementAndGet(); - + if (missingConditionTypeIds != null) { for (String typeId : missingConditionTypeIds) { if (!this.missingConditionTypeIds.contains(typeId)) { @@ -119,7 +120,7 @@ public synchronized void updateEncounter(List missingConditionTypeIds, } } } - + if (missingActionTypeIds != null) { for (String typeId : missingActionTypeIds) { if (!this.missingActionTypeIds.contains(typeId)) { @@ -127,7 +128,7 @@ public synchronized void updateEncounter(List missingConditionTypeIds, } } } - + if (contextName != null) { this.contextNames.add(contextName); } 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 2dcd3c453b..b9d02f1ac0 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 @@ -608,9 +608,9 @@ public static Condition resolveEffectiveCondition( List parentChain = getParentChain(type, definitionsService, contextName, maxDepth); if (parentChain == null) { - LOGGER.warn("Failed to build parent chain for condition type '{}' in '{}', returning unresolved condition", + LOGGER.warn("Failed to build parent chain for condition type '{}' in '{}' (cycle or max depth exceeded), cannot resolve effective condition", type.getItemId(), contextName); - return condition; + return null; } if (parentChain.isEmpty()) { return 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 e8927df879..a73ee234eb 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 @@ -129,13 +129,19 @@ public Query buildFilter(Condition condition, Map context) { // Resolve effective condition from parent chain if needed Condition effectiveCondition = condition; if (definitionsService != null) { - effectiveCondition = ParserHelper.resolveEffectiveCondition( + 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-all query", + condition.getConditionTypeId()); + return Query.of(q -> q.matchAll(m -> m)); + } + effectiveCondition = resolved; } // Check if effective condition has a type - if not, return match-all query if (effectiveCondition.getConditionType() == null) { - LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", effectiveCondition.getConditionTypeId()); return Query.of(q -> q.matchAll(m -> m)); } @@ -163,9 +169,9 @@ public Query buildFilter(Condition condition, Map context) { return queryBuilder.buildQuery(contextualCondition, context, this); } } 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)); @@ -201,13 +207,19 @@ public long count(Condition condition, Map context) { // Resolve effective condition from parent chain if needed Condition effectiveCondition = condition; if (definitionsService != null) { - effectiveCondition = ParserHelper.resolveEffectiveCondition( + 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", + 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()); } 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 7483cb7193..bb84ffb2be 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 @@ -128,19 +128,29 @@ public Query buildFilter(Condition condition, Map context) { // Resolve effective condition from parent chain if needed Condition effectiveCondition = condition; if (definitionsService != null) { - effectiveCondition = ParserHelper.resolveEffectiveCondition( + 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-all query", + condition.getConditionTypeId()); + return Query.of(q -> q.matchAll(t -> t)); + } + effectiveCondition = resolved; } // Check if effective condition has a type - if not, return match-all query if (effectiveCondition.getConditionType() == null) { - LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", effectiveCondition.getConditionTypeId()); return Query.of(q -> q.matchAll(t -> t)); } String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); if (queryBuilderKey == null) { + 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-all query", effectiveCondition.getConditionTypeId()); return Query.of(q -> q.matchAll(t -> t)); } @@ -158,14 +168,12 @@ public Query buildFilter(Condition condition, Map context) { return queryBuilder.buildQuery(contextualCondition, context, this); } } 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.matchAll(t -> t)); } public long count(Condition condition) { @@ -198,13 +206,19 @@ public long count(Condition condition, Map context) { // Resolve effective condition from parent chain if needed Condition effectiveCondition = condition; if (definitionsService != null) { - effectiveCondition = ParserHelper.resolveEffectiveCondition( + 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", + 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()); } 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 65fb3c39fa..299f46c019 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 @@ -127,8 +127,14 @@ public boolean eval(Condition condition, Item item, Map context) // Resolve effective condition from parent chain if needed Condition effectiveCondition = condition; if (definitionsService != null) { - effectiveCondition = ParserHelper.resolveEffectiveCondition( + 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()); @@ -178,7 +184,8 @@ public Boolean execute(Object... args) throws Exception { return false; } } catch (Exception e) { - LOGGER.error("Error during condition evaluation", e); + LOGGER.error("Infrastructure error during condition evaluation for typeID={} on item={}", + condition.getConditionTypeId(), item != null ? item.getItemId() : "null", e); return false; } } 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 index a2f3a3595a..1d37617508 100644 --- 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 @@ -140,6 +140,27 @@ public void eval_missingEvaluatorKey_returnsFalse() { 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() { 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 93d9d207c6..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,8 +34,13 @@ 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; 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 index cf82a93f11..3ee461592e 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -46,16 +46,15 @@ public class TypeResolutionServiceImpl implements TypeResolutionService { private static final int MAX_RECURSION_DEPTH = 1000; - private DefinitionsService definitionsService; - + 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 (similar to ParserHelper's static sets) - private final Set unresolvedActionTypes = Collections.synchronizedSet(new HashSet<>()); - private final Set unresolvedConditionTypes = Collections.synchronizedSet(new HashSet<>()); - // Track rules that have already been warned about null/empty actions to avoid log spam - private final Set warnedRulesWithNullActions = Collections.synchronizedSet(new HashSet<>()); + + // 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. @@ -152,25 +151,31 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } } + // 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) { - if (!resolveConditionTypeInternal((Condition) value, contextObjectName, - parentChainPath, false, depth + 1)) { - if (conditionTypeWasNull) { - rootCondition.setConditionType(null); - } + 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) { - if (!resolveConditionTypeInternal((Condition) item, contextObjectName, - parentChainPath, false, depth + 1)) { - if (conditionTypeWasNull) { - rootCondition.setConditionType(null); - } + 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); + } } } } @@ -184,6 +189,15 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } } + 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) { @@ -431,21 +445,14 @@ private void markInvalid(String objectType, String objectId, String reason, // 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 - StringBuilder logMessage = new StringBuilder("Marked ").append(objectType).append(" ").append(objectId) - .append(" as invalid: ").append(reason != null ? reason : "Unknown reason"); - - if (missingConditionTypeIds != null && !missingConditionTypeIds.isEmpty()) { - logMessage.append(" (missing condition types: ").append(String.join(", ", missingConditionTypeIds)).append(")"); - } - if (missingActionTypeIds != null && !missingActionTypeIds.isEmpty()) { - logMessage.append(" (missing action types: ").append(String.join(", ", missingActionTypeIds)).append(")"); - } - if (contextName != null) { - logMessage.append(" [context: ").append(contextName).append("]"); - } - - LOGGER.warn(logMessage.toString()); + // 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); } } 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 index 2e19384b04..84ae7c5a71 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -1029,6 +1029,52 @@ public void resolveConditionType_preResolvedRootWithUnresolvedChild_returnsFalse 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 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 index 94ac79937b..c4c387dcf8 100644 --- 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 @@ -656,8 +656,8 @@ public void testContextInformation() { 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 location information"); + error.getContext().containsKey("location") && error.getContext().containsKey("parameterType"), + "Context should contain both location and parameterType keys"); } @Test @@ -1415,6 +1415,42 @@ public void validate_withTypeResolutionService_autoResolvesNullConditionType() { "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() { From a8eda807d8e4a0c4f6e60f91ada40a4dc5d6b19e Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 16 Jun 2026 14:21:30 +0200 Subject: [PATCH 09/10] UNOMI-883: Fix fifth-pass review findings (test coverage, CI flakiness) - Thread safety: replace CopyOnWriteArrayList with CopyOnWriteArraySet in InvalidObjectInfo to eliminate TOCTOU on concurrent addAll() - TypeResolutionServiceImpl: fix TOCTOU on unresolved type logging, walk sub-condition tree to report leaf type ID (not root) in error messages, separate structural vs. missing-plugin action errors for missingPlugins flag - ConditionValidationServiceImpl: emit MISSING_RECOMMENDED_PARAMETER advisory when a required parameter uses a contextual reference; fix duplicate local variable declaration (compilation error) - ES/OS dispatchers: replace matchAll fallbacks with matchNone in buildFilter() and log null contextualCondition at WARN level - ConditionEvaluatorDispatcherImpl: remove dead secondary parent-chain walk that could cause unbounded recursion - Tests: add cycle, max-depth, sibling-rollback, concurrent-dedup, null-actions, and leaf-ID tests to TypeResolutionServiceImplTest; add parent-chain-cycle test to ConditionEvaluatorDispatcherImplTest; add IAE test to BooleanConditionEvaluatorTest; update ConditionValidationServiceImplTest to reflect new advisory behaviour - LogCheckerTest: fix flaky timing assertion by adding JVM warmup and raising threshold from 1ms to 50ms --- .../unomi/api/services/InvalidObjectInfo.java | 27 +-- .../apache/unomi/api/utils/ParserHelper.java | 12 +- .../unomi/itests/tools/LogCheckerTest.java | 12 +- .../ConditionESQueryBuilderDispatcher.java | 24 +-- .../ConditionOSQueryBuilderDispatcher.java | 24 +-- .../ConditionEvaluatorDispatcherImpl.java | 6 +- .../ConditionEvaluatorDispatcherImplTest.java | 31 +++ .../BooleanConditionEvaluatorTest.java | 19 ++ .../impl/TypeResolutionServiceImpl.java | 59 ++++-- .../ConditionValidationServiceImpl.java | 15 +- .../impl/TypeResolutionServiceImplTest.java | 177 ++++++++++++++++++ .../ConditionValidationServiceImplTest.java | 14 +- 12 files changed, 345 insertions(+), 75 deletions(-) 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 index e04dd5d8a8..7307fb4bd3 100644 --- a/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java +++ b/api/src/main/java/org/apache/unomi/api/services/InvalidObjectInfo.java @@ -18,7 +18,6 @@ package org.apache.unomi.api.services; import java.util.*; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; @@ -33,9 +32,9 @@ public class InvalidObjectInfo { private final long firstSeenTimestamp; private final AtomicLong lastSeenTimestamp; private final AtomicInteger encounterCount; - private final List missingConditionTypeIds; // CopyOnWriteArrayList — safe for concurrent reads during updateEncounter - private final List missingActionTypeIds; - private final Set contextNames; // CopyOnWriteArraySet + 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); @@ -52,9 +51,9 @@ public InvalidObjectInfo(String objectType, String objectId, String reason, this.lastSeenTimestamp = new AtomicLong(this.firstSeenTimestamp); this.encounterCount = new AtomicInteger(1); this.missingConditionTypeIds = missingConditionTypeIds != null - ? new CopyOnWriteArrayList<>(missingConditionTypeIds) : new CopyOnWriteArrayList<>(); + ? new CopyOnWriteArraySet<>(missingConditionTypeIds) : new CopyOnWriteArraySet<>(); this.missingActionTypeIds = missingActionTypeIds != null - ? new CopyOnWriteArrayList<>(missingActionTypeIds) : new CopyOnWriteArrayList<>(); + ? new CopyOnWriteArraySet<>(missingActionTypeIds) : new CopyOnWriteArraySet<>(); this.contextNames = new CopyOnWriteArraySet<>(); if (contextName != null) { this.contextNames.add(contextName); @@ -86,11 +85,11 @@ public int getEncounterCount() { } public List getMissingConditionTypeIds() { - return Collections.unmodifiableList(missingConditionTypeIds); + return Collections.unmodifiableList(new ArrayList<>(missingConditionTypeIds)); } public List getMissingActionTypeIds() { - return Collections.unmodifiableList(missingActionTypeIds); + return Collections.unmodifiableList(new ArrayList<>(missingActionTypeIds)); } public Set getContextNames() { @@ -114,19 +113,11 @@ public void updateEncounter(List missingConditionTypeIds, this.encounterCount.incrementAndGet(); if (missingConditionTypeIds != null) { - for (String typeId : missingConditionTypeIds) { - if (!this.missingConditionTypeIds.contains(typeId)) { - this.missingConditionTypeIds.add(typeId); - } - } + this.missingConditionTypeIds.addAll(missingConditionTypeIds); } if (missingActionTypeIds != null) { - for (String typeId : missingActionTypeIds) { - if (!this.missingActionTypeIds.contains(typeId)) { - this.missingActionTypeIds.add(typeId); - } - } + this.missingActionTypeIds.addAll(missingActionTypeIds); } if (contextName != null) { 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 b9d02f1ac0..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 @@ -34,6 +34,7 @@ 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 @@ -42,10 +43,10 @@ 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 = Collections.synchronizedSet(new HashSet<>()); + private static final Set warnedRulesWithNullActions = ConcurrentHashMap.newKeySet(); private static final String VALUE_NAME_SEPARATOR = "::"; private static final String PLACEHOLDER_PREFIX = "${"; @@ -400,7 +401,7 @@ public static Object extractValue(String s, Event event, Map context) { } 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-all + // If still null after attempting resolution (or definitionsService was null), return match-none if (condition.getConditionType() == null) { - LOGGER.debug("Condition type is null for condition typeID={}, returning match-all query", condition.getConditionTypeId()); - return Query.of(q -> q.matchAll(m -> m)); + LOGGER.warn("Condition type is null for condition typeID={}, returning match-none query", condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(m -> m)); } } @@ -132,18 +132,18 @@ public Query buildFilter(Condition condition, Map context) { 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-all query", + LOGGER.warn("Could not resolve effective condition for typeID={} (cycle or max depth), returning match-none query", condition.getConditionTypeId()); - return Query.of(q -> q.matchAll(m -> m)); + return Query.of(q -> q.matchNone(m -> m)); } effectiveCondition = resolved; } - // Check if effective condition has a type - if not, return match-all query + // Check if effective condition has a type - if not, return match-none query if (effectiveCondition.getConditionType() == null) { - LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + LOGGER.warn("Effective condition type is null for condition typeID={}, returning match-none query", effectiveCondition.getConditionTypeId()); - return Query.of(q -> q.matchAll(m -> m)); + return Query.of(q -> q.matchNone(m -> m)); } String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); @@ -152,8 +152,8 @@ public Query buildFilter(Condition condition, Map context) { context.putAll(effectiveCondition.getParameterValues()); return buildFilter(effectiveCondition.getConditionType().getParentCondition(), context); } - LOGGER.warn("No query builder defined for condition type: {}, returning match-all query", effectiveCondition.getConditionTypeId()); - return Query.of(q -> q.matchAll(m -> m)); + 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) @@ -168,13 +168,15 @@ public Query buildFilter(Condition condition, Map context) { if (contextualCondition != null) { return queryBuilder.buildQuery(contextualCondition, context, this); } + LOGGER.warn("getContextualCondition returned null for conditionTypeId={}, returning match-none query", + effectiveCondition.getConditionTypeId()); } else { 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) { 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 bb84ffb2be..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 @@ -118,10 +118,10 @@ public Query buildFilter(Condition condition, Map context) { } 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-all + // If still null after attempting resolution (or definitionsService was null), return match-none if (condition.getConditionType() == null) { - LOGGER.debug("Condition type is null for condition typeID={}, returning match-all query", condition.getConditionTypeId()); - return Query.of(q -> q.matchAll(t -> t)); + LOGGER.warn("Condition type is null for condition typeID={}, returning match-none query", condition.getConditionTypeId()); + return Query.of(q -> q.matchNone(t -> t)); } } @@ -131,18 +131,18 @@ public Query buildFilter(Condition condition, Map context) { 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-all query", + LOGGER.warn("Could not resolve effective condition for typeID={} (cycle or max depth), returning match-none query", condition.getConditionTypeId()); - return Query.of(q -> q.matchAll(t -> t)); + return Query.of(q -> q.matchNone(t -> t)); } effectiveCondition = resolved; } - // Check if effective condition has a type - if not, return match-all query + // Check if effective condition has a type - if not, return match-none query if (effectiveCondition.getConditionType() == null) { - LOGGER.debug("Effective condition type is null for condition typeID={}, returning match-all query", + LOGGER.warn("Effective condition type is null for condition typeID={}, returning match-none query", effectiveCondition.getConditionTypeId()); - return Query.of(q -> q.matchAll(t -> t)); + return Query.of(q -> q.matchNone(t -> t)); } String queryBuilderKey = effectiveCondition.getConditionType().getQueryBuilder(); @@ -151,8 +151,8 @@ public Query buildFilter(Condition condition, Map context) { context.putAll(effectiveCondition.getParameterValues()); return buildFilter(effectiveCondition.getConditionType().getParentCondition(), context); } - LOGGER.warn("No query builder defined for condition type: {}, returning match-all query", effectiveCondition.getConditionTypeId()); - return Query.of(q -> q.matchAll(t -> t)); + 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) @@ -167,13 +167,15 @@ public Query buildFilter(Condition condition, Map context) { if (contextualCondition != null) { return queryBuilder.buildQuery(contextualCondition, context, this); } + LOGGER.warn("getContextualCondition returned null for conditionTypeId={}, returning match-none query", + effectiveCondition.getConditionTypeId()); } else { 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) { 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 299f46c019..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 @@ -150,10 +150,8 @@ public boolean eval(Condition condition, Item item, Map context) String conditionEvaluatorKey = effectiveCondition.getConditionType().getConditionEvaluator(); if (conditionEvaluatorKey == null) { - if (effectiveCondition.getConditionType().getParentCondition() != null) { - context.putAll(effectiveCondition.getParameterValues()); - return eval(effectiveCondition.getConditionType().getParentCondition(), item, context); - } + // 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; } 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 index 1d37617508..3994becae8 100644 --- 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 @@ -177,4 +177,35 @@ public void addRemoveEvaluator_registrationRoundTrip() { 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/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 index 888c2012a3..67a027be6b 100644 --- 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 @@ -31,6 +31,7 @@ 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; @@ -163,6 +164,24 @@ public void nullOperator_emptySubConditions_returnsFalse() { 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) { 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 index 3ee461592e..329756a75b 100644 --- a/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java +++ b/services/src/main/java/org/apache/unomi/services/impl/TypeResolutionServiceImpl.java @@ -127,8 +127,7 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } ConditionType conditionType = definitionsService.getConditionType(conditionTypeId); if (conditionType == null) { - if (!unresolvedConditionTypes.contains(conditionTypeId)) { - unresolvedConditionTypes.add(conditionTypeId); + if (unresolvedConditionTypes.add(conditionTypeId)) { LOGGER.warn("Couldn't resolve condition type: {} for {}", conditionTypeId, contextObjectName); } return false; @@ -189,6 +188,26 @@ private boolean resolveConditionTypeInternal(Condition rootCondition, String con } } + /** 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); @@ -243,9 +262,8 @@ public boolean resolveActionType(Action action) { unresolvedActionTypes.remove(action.getActionTypeId()); action.setActionType(actionType); } else { - if (!unresolvedActionTypes.contains(action.getActionTypeId())) { + if (unresolvedActionTypes.add(action.getActionTypeId())) { LOGGER.warn("Couldn't resolve action type : {}", action.getActionTypeId()); - unresolvedActionTypes.add(action.getActionTypeId()); } return false; } @@ -281,13 +299,13 @@ public boolean resolveCondition(String objectType, MetadataItem item, Condition boolean resolved = resolveConditionType(condition, contextName); String objectId = item != null ? item.getItemId() : null; - + if (!resolved) { - // Extract the specific unresolved condition type ID for better error message - String unresolvedTypeId = condition.getConditionTypeId(); - List missingConditionTypeIds = unresolvedTypeId != null + // 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 + String reason = unresolvedTypeId != null ? "Unresolved condition type: " + unresolvedTypeId : "Unresolved condition type"; if (objectId != null) { @@ -363,31 +381,36 @@ public boolean resolveRule(String objectType, Rule rule) { if (rule.getCondition() != null) { conditionResolved = resolveConditionType(rule.getCondition(), contextName); if (!conditionResolved) { - String unresolvedTypeId = rule.getCondition().getConditionTypeId(); + // 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 actionsResolved = resolveActionTypes(rule, false); + + boolean structuralActionsError = rule.getActions() == null || rule.getActions().isEmpty(); + boolean actionsResolved = structuralActionsError ? false : resolveActionTypes(rule, false); if (!actionsResolved) { - // Collect all unresolved action type IDs - if (rule.getActions() != null) { + if (!structuralActionsError && rule.getActions() != null) { for (Action action : rule.getActions()) { if (action.getActionType() == null && action.getActionTypeId() != null) { missingActionTypeIds.add(action.getActionTypeId()); } } } - reasons.add("Unresolved action type(s): " + (missingActionTypeIds.isEmpty() ? "unknown" : String.join(", ", missingActionTypeIds))); + 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); } - - // Set/clear missingPlugins based on both condition and actions resolution + 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(!allResolved); + rule.getMetadata().setMissingPlugins(hasMissingPlugins); } // Track invalid objects 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 index d653e07f17..810e9386e6 100644 --- 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 @@ -161,8 +161,21 @@ public List validate(Condition condition) { Object value = condition.getParameter(paramName); String location = "condition[" + condition.getConditionTypeId() + "]." + paramName; - // Skip validation entirely for parameters with references/scripts + // 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; } 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 index 84ae7c5a71..86e3a00517 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/TypeResolutionServiceImplTest.java @@ -36,10 +36,15 @@ 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) @@ -1199,5 +1204,177 @@ public void testResolveRule_WithUnresolvedCondition() { 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/validation/ConditionValidationServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/validation/ConditionValidationServiceImplTest.java index c4c387dcf8..bad1b1c2a4 100644 --- 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 @@ -1255,8 +1255,11 @@ public void testValidate_SkipsParameterReferences() { List errors = conditionValidationService.validate(condition); - // Should skip validation for parameter references - assertNoErrors(errors); + // 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 @@ -1276,8 +1279,11 @@ public void testValidate_SkipsScriptExpressions() { List errors = conditionValidationService.validate(condition); - // Should skip validation for script expressions - assertNoErrors(errors); + // 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 From 6ba17e3ad2d0b0e2eecae747d35f0ac044ea56ae Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 16 Jun 2026 20:54:11 +0200 Subject: [PATCH 10/10] UNOMI-883: Fix ClusterServiceImplTest flaky test (CI race condition) - cancelScheduledTasks(), cleanupStaleNodes(), updateSystemStats() promoted from private to package-private so tests can call them directly - testCleanupStaleNodes: call cancelScheduledTasks() before creating nodes to prevent the background cleanup task (initialDelay=0) from racing with the precondition assertions and deleting the stale node first - testGetClusterNodesReturnsAllNodes: call updateSystemStats() directly - Remove java.lang.reflect.Method import (no more reflection in the test) --- .../impl/cluster/ClusterServiceImpl.java | 6 +-- .../impl/cluster/ClusterServiceImplTest.java | 52 +++++++------------ 2 files changed, 22 insertions(+), 36 deletions(-) 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/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");