missingActionTypeIds,
+ String contextName) {
+ this.lastSeenTimestamp.set(System.currentTimeMillis());
+ this.encounterCount.incrementAndGet();
+
+ if (missingConditionTypeIds != null) {
+ this.missingConditionTypeIds.addAll(missingConditionTypeIds);
+ }
+
+ if (missingActionTypeIds != null) {
+ this.missingActionTypeIds.addAll(missingActionTypeIds);
+ }
+
+ if (contextName != null) {
+ this.contextNames.add(contextName);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ InvalidObjectInfo that = (InvalidObjectInfo) o;
+ return Objects.equals(objectType, that.objectType) && Objects.equals(objectId, that.objectId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(objectType, objectId);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("InvalidObjectInfo{");
+ sb.append("objectType='").append(objectType).append('\'');
+ sb.append(", objectId='").append(objectId).append('\'');
+ sb.append(", reason='").append(reason).append('\'');
+ sb.append(", firstSeen=").append(firstSeenTimestamp);
+ sb.append(", lastSeen=").append(lastSeenTimestamp.get());
+ sb.append(", encounters=").append(encounterCount.get());
+ if (!missingConditionTypeIds.isEmpty()) {
+ sb.append(", missingConditionTypes=").append(missingConditionTypeIds);
+ }
+ if (!missingActionTypeIds.isEmpty()) {
+ sb.append(", missingActionTypes=").append(missingActionTypeIds);
+ }
+ if (!contextNames.isEmpty()) {
+ sb.append(", contexts=").append(contextNames);
+ }
+ sb.append('}');
+ return sb.toString();
+ }
+}
+
diff --git a/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java b/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java
new file mode 100644
index 0000000000..1ca1f7113f
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/services/TypeResolutionService.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.api.services;
+
+import org.apache.unomi.api.MetadataItem;
+import org.apache.unomi.api.PropertyType;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.rules.Rule;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Service for resolving condition types, action types, and value types, with automatic
+ * tracking of invalid objects that have unresolved types.
+ *
+ * This service centralizes type resolution logic and automatically tracks objects
+ * that fail to resolve, providing detailed error information.
+ */
+public interface TypeResolutionService {
+
+ /**
+ * Resolve a condition type and all its nested conditions.
+ * This is a low-level method that only performs resolution - it does NOT track invalid objects
+ * or handle missingPlugins. Use {@link #resolveCondition(String, MetadataItem, Condition, String)}
+ * for validation contexts where tracking is needed.
+ *
+ * @param rootCondition the condition to resolve
+ * @param contextObjectName name/ID of the object containing this condition (for error messages)
+ * @return true if resolution succeeded, false otherwise
+ */
+ boolean resolveConditionType(Condition rootCondition, String contextObjectName);
+
+ /**
+ * Resolve all action types in a rule.
+ * This is a low-level method that only performs resolution - it does NOT track invalid objects
+ * or handle missingPlugins.
+ *
+ * @param rule the rule containing actions to resolve
+ * @param ignoreErrors if true, don't log warnings for missing actions
+ * @return true if all actions resolved successfully, false otherwise
+ */
+ boolean resolveActionTypes(Rule rule, boolean ignoreErrors);
+
+ /**
+ * Resolve a single action type.
+ * This is a low-level method that only performs resolution - it does NOT track invalid objects
+ * or handle missingPlugins.
+ *
+ * @param action the action to resolve
+ * @return true if resolution succeeded, false otherwise
+ */
+ boolean resolveActionType(Action action);
+
+ /**
+ * Resolve a value type for a property type.
+ *
+ * @param propertyType the property type to resolve
+ */
+ void resolveValueType(PropertyType propertyType);
+
+ /**
+ * Resolve condition types for a MetadataItem with automatic tracking and missingPlugins handling.
+ *
+ * This method resolves condition types (needed for validation) but skips parameter value resolution.
+ * Parameter resolution happens on-demand in query builders and evaluators.
+ *
+ *
This method performs three operations:
+ *
+ * - Resolves the condition type (always)
+ * - Tracks invalid objects in the tracking service
+ * - Sets/clears the missingPlugins flag on the item's metadata
+ *
+ *
+ * This is the recommended method for save operations (e.g., when saving rules, segments, goals).
+ *
+ * @param objectType the type of object (e.g., "rules", "segments", "goals", "campaigns", "scoring")
+ * @param item the MetadataItem object (e.g., Rule, Segment, Goal, Campaign, Scoring)
+ * @param condition the condition to resolve (may be null)
+ * @param contextName context name for error messages
+ * @return true if condition type resolved successfully (or was null), false otherwise
+ */
+ boolean resolveCondition(String objectType, MetadataItem item, Condition condition, String contextName);
+
+ /**
+ * Resolve action types for a rule with automatic tracking and missingPlugins handling.
+ *
+ *
This method resolves action types (needed for validation) but skips parameter value resolution.
+ * Parameter resolution happens on-demand in query builders and evaluators.
+ *
+ *
This method performs three operations:
+ *
+ * - Resolves the action types
+ * - Tracks invalid objects in the tracking service
+ * - Sets/clears the missingPlugins flag on the rule's metadata
+ *
+ *
+ * Note: For complete rule validation (condition + actions), use {@link #resolveRule(String, Rule)} instead.
+ *
+ * @param objectType the type of object (e.g., "rules")
+ * @param rule the rule containing actions to resolve
+ * @return true if all action types resolved successfully, false otherwise
+ */
+ boolean resolveActions(String objectType, Rule rule);
+
+ /**
+ * Resolve both condition and actions for a rule with automatic tracking and missingPlugins handling.
+ *
+ *
This method resolves condition and action types (needed for validation) but skips parameter value resolution.
+ * Parameter resolution happens on-demand in query builders and evaluators.
+ *
+ *
This is a convenience method that handles both condition and action resolution together,
+ * ensuring missingPlugins is set correctly based on both resolutions.
+ *
+ * @param objectType the type of object (e.g., "rules")
+ * @param rule the rule to resolve
+ * @return true if both condition and action types resolved successfully, false otherwise
+ */
+ boolean resolveRule(String objectType, Rule rule);
+
+ // Invalid object tracking methods
+
+ /**
+ * Mark an object as invalid with a reason.
+ *
+ * @param objectType the type of object (e.g., "rules", "segments", "goals", "campaigns", "scoring")
+ * @param objectId the ID of the object
+ * @param reason the reason why the object is invalid (e.g., "Unresolved condition type: xyz", "Unresolved action type: abc")
+ */
+ void markInvalid(String objectType, String objectId, String reason);
+
+ /**
+ * Mark an object as valid (remove it from invalid tracking).
+ *
+ * @param objectType the type of object
+ * @param objectId the ID of the object
+ */
+ void markValid(String objectType, String objectId);
+
+ /**
+ * Check if an object is invalid.
+ *
+ * @param objectType the type of object
+ * @param objectId the ID of the object
+ * @return true if the object is invalid, false otherwise
+ */
+ boolean isInvalid(String objectType, String objectId);
+
+ /**
+ * Get the invalidation reason for an object.
+ *
+ * @param objectType the type of object
+ * @param objectId the ID of the object
+ * @return the reason why the object is invalid, or null if the object is valid
+ */
+ String getInvalidationReason(String objectType, String objectId);
+
+ /**
+ * Get all invalid objects grouped by object type, with their reasons.
+ *
+ * @return a map where keys are object type names (e.g., "rules", "segments", "goals", "campaigns", "scoring")
+ * and values are maps of object ID to InvalidObjectInfo
+ */
+ Map> getAllInvalidObjects();
+
+ /**
+ * Get invalid objects for a specific object type, with their reasons.
+ *
+ * @param objectType the object type (e.g., "rules", "segments", "goals", "campaigns", "scoring")
+ * @return map of object ID to InvalidObjectInfo for the specified type, or empty map if type is unknown
+ */
+ Map getInvalidObjects(String objectType);
+
+ /**
+ * Get all invalid object IDs grouped by object type (for backward compatibility).
+ *
+ * @return a map where keys are object type names and values are sets of invalid object IDs
+ */
+ Map> getAllInvalidObjectIds();
+
+ /**
+ * Get invalid object IDs for a specific object type (for backward compatibility).
+ *
+ * @param objectType the object type
+ * @return set of invalid object IDs for the specified type, or empty set if type is unknown
+ */
+ Set getInvalidObjectIds(String objectType);
+
+ /**
+ * Get the total count of all invalid objects across all types.
+ *
+ * @return total count of invalid objects
+ */
+ int getTotalInvalidObjectCount();
+}
+
diff --git a/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java b/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java
new file mode 100644
index 0000000000..ec6a3e2558
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/services/ValueTypeValidator.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.services;
+
+/**
+ * A service interface for validating values against specific types.
+ */
+public interface ValueTypeValidator {
+
+ /**
+ * @return The type identifier this validator handles
+ */
+ String getValueTypeId();
+
+ /**
+ * Validates if a value matches the expected type
+ * @param value The value to validate
+ * @return true if the value is valid for this type, false otherwise
+ */
+ boolean validate(Object value);
+
+ /**
+ * @return A human readable description of what this type expects, used in error messages
+ */
+ String getValueTypeDescription();
+}
diff --git a/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java b/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java
index 12043fc3d5..0ab6f08d6b 100644
--- a/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java
+++ b/api/src/main/java/org/apache/unomi/api/utils/ParserHelper.java
@@ -28,11 +28,13 @@
import org.apache.unomi.api.conditions.ConditionType;
import org.apache.unomi.api.rules.Rule;
import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.TypeResolutionService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
/**
* Helper class to resolve condition, action and values types when loading definitions from JSON files
@@ -41,13 +43,17 @@ public class ParserHelper {
private static final Logger LOGGER = LoggerFactory.getLogger(ParserHelper.class);
- private static final Set unresolvedActionTypes = new HashSet<>();
- private static final Set unresolvedConditionTypes = new HashSet<>();
+ private static final Set unresolvedActionTypes = ConcurrentHashMap.newKeySet();
+ private static final Set unresolvedConditionTypes = ConcurrentHashMap.newKeySet();
+ // Track rules that have already been warned about null/empty actions to avoid log spam
+ private static final Set warnedRulesWithNullActions = ConcurrentHashMap.newKeySet();
private static final String VALUE_NAME_SEPARATOR = "::";
private static final String PLACEHOLDER_PREFIX = "${";
private static final String PLACEHOLDER_SUFFIX = "}";
+ private static final int MAX_RECURSION_DEPTH = 1000;
+
public interface ConditionVisitor {
void visit(Condition condition);
void postVisit(Condition condition);
@@ -68,34 +74,88 @@ public interface ValueExtractor {
}
public static boolean resolveConditionType(final DefinitionsService definitionsService, Condition rootCondition, String contextObjectName) {
+ return resolveConditionType(definitionsService, rootCondition, contextObjectName,
+ new HashSet<>(), false, 0);
+ }
+
+ private static boolean resolveConditionType(final DefinitionsService definitionsService, Condition rootCondition,
+ String contextObjectName, Set parentChainPath, boolean isGoingUp, int depth) {
if (rootCondition == null) {
LOGGER.warn("Couldn't resolve null condition for {}", contextObjectName);
return false;
}
- final List result = new ArrayList();
- visitConditions(rootCondition, new ConditionVisitor() {
- @Override
- public void visit(Condition condition) {
- if (condition.getConditionType() == null) {
- ConditionType conditionType = definitionsService.getConditionType(condition.getConditionTypeId());
- if (conditionType != null) {
- unresolvedConditionTypes.remove(condition.getConditionTypeId());
- condition.setConditionType(conditionType);
- } else {
- result.add(condition.getConditionTypeId());
- if (!unresolvedConditionTypes.contains(condition.getConditionTypeId())) {
- unresolvedConditionTypes.add(condition.getConditionTypeId());
- LOGGER.warn("Couldn't resolve condition type: {} for {}", condition.getConditionTypeId(), contextObjectName);
+
+ if (depth > MAX_RECURSION_DEPTH) {
+ LOGGER.error("Maximum recursion depth ({}) exceeded when resolving condition type {} in {}",
+ MAX_RECURSION_DEPTH, rootCondition.getConditionTypeId(), contextObjectName);
+ return false;
+ }
+
+ if (isGoingUp) {
+ if (!parentChainPath.add(rootCondition.getConditionTypeId())) {
+ LOGGER.warn("Detected circular reference for condition type {} in {}", rootCondition.getConditionTypeId(), contextObjectName);
+ return false;
+ }
+ }
+
+ try {
+ // Resolve current condition type if needed
+ if (rootCondition.getConditionType() == null) {
+ String conditionTypeId = rootCondition.getConditionTypeId();
+ if (conditionTypeId == null) {
+ LOGGER.warn("Condition has no type ID for {}", contextObjectName);
+ return false;
+ }
+ ConditionType conditionType = definitionsService.getConditionType(conditionTypeId);
+ if (conditionType == null) {
+ if (!unresolvedConditionTypes.contains(conditionTypeId)) {
+ unresolvedConditionTypes.add(conditionTypeId);
+ LOGGER.warn("Couldn't resolve condition type: {} for {}", conditionTypeId, contextObjectName);
+ }
+ return false;
+ }
+ unresolvedConditionTypes.remove(rootCondition.getConditionTypeId());
+ rootCondition.setConditionType(conditionType);
+
+ if (conditionType.getParentCondition() != null) {
+ Set pathForParent = new HashSet<>(parentChainPath);
+ if (!isGoingUp) {
+ pathForParent.add(rootCondition.getConditionTypeId());
+ }
+ if (!resolveConditionType(definitionsService, conditionType.getParentCondition(), contextObjectName,
+ pathForParent, true, depth + 1)) {
+ rootCondition.setConditionType(null);
+ LOGGER.warn("Failed to resolve parent condition for type: {} in {}",
+ rootCondition.getConditionTypeId(), contextObjectName);
+ return false;
+ }
+ }
+ }
+
+ for (Object value : rootCondition.getParameterValues().values()) {
+ if (value instanceof Condition) {
+ if (!resolveConditionType(definitionsService, (Condition) value, contextObjectName,
+ parentChainPath, false, depth + 1)) {
+ return false;
+ }
+ } else if (value instanceof Collection) {
+ for (Object item : (Collection>) value) {
+ if (item instanceof Condition) {
+ if (!resolveConditionType(definitionsService, (Condition) item, contextObjectName,
+ parentChainPath, false, depth + 1)) {
+ return false;
+ }
}
}
}
}
- @Override
- public void postVisit(Condition condition) {
+ return true;
+ } finally {
+ if (isGoingUp) {
+ parentChainPath.remove(rootCondition.getConditionTypeId());
}
- });
- return result.isEmpty();
+ }
}
public static List getConditionTypeIds(Condition rootCondition) {
@@ -136,20 +196,33 @@ public static void visitConditions(Condition rootCondition, ConditionVisitor vis
public static boolean resolveActionTypes(DefinitionsService definitionsService, Rule rule, boolean ignoreErrors) {
boolean result = true;
+ String ruleId = rule.getItemId();
if (rule.getActions() == null) {
if (!ignoreErrors) {
- LOGGER.warn("Rule {}:{} has null actions", rule.getItemId(), rule.getMetadata().getName());
+ // Only warn once per rule to avoid log spam
+ if (warnedRulesWithNullActions.add(ruleId)) {
+ LOGGER.warn("Rule {}:{} has null actions", ruleId, rule.getMetadata().getName());
+ }
}
return false;
}
if (rule.getActions().isEmpty()) {
if (!ignoreErrors) {
- LOGGER.warn("Rule {}:{} has empty actions", rule.getItemId(), rule.getMetadata().getName());
+ // Only warn once per rule to avoid log spam
+ if (warnedRulesWithNullActions.add(ruleId)) {
+ LOGGER.warn("Rule {}:{} has empty actions", ruleId, rule.getMetadata().getName());
+ }
}
return false;
}
+ TypeResolutionService typeResolutionService = definitionsService != null ? definitionsService.getTypeResolutionService() : null;
for (Action action : rule.getActions()) {
- result &= ParserHelper.resolveActionType(definitionsService, action);
+ if (typeResolutionService != null) {
+ result &= typeResolutionService.resolveActionType(action);
+ } else {
+ // Fallback to direct resolution if TypeResolutionService is not available
+ result &= ParserHelper.resolveActionType(definitionsService, action);
+ }
}
return result;
}
@@ -175,6 +248,15 @@ public static boolean resolveActionType(DefinitionsService definitionsService, A
}
public static void resolveValueType(DefinitionsService definitionsService, PropertyType propertyType) {
+ if (definitionsService == null) {
+ return;
+ }
+ TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService();
+ if (typeResolutionService != null) {
+ typeResolutionService.resolveValueType(propertyType);
+ return;
+ }
+ // Fallback to direct resolution if TypeResolutionService is not available
if (propertyType.getValueType() == null) {
ValueType valueType = definitionsService.getValueType(propertyType.getValueTypeId());
if (valueType != null) {
@@ -184,22 +266,55 @@ public static void resolveValueType(DefinitionsService definitionsService, Prope
}
+ /**
+ * @deprecated Use {@link #resolveConditionEventTypes(Condition, DefinitionsService)} instead.
+ */
+ @Deprecated
public static Set resolveConditionEventTypes(Condition rootCondition) {
+ return resolveConditionEventTypes(rootCondition, null);
+ }
+
+ public static Set resolveConditionEventTypes(Condition rootCondition, DefinitionsService definitionsService) {
if (rootCondition == null) {
return new HashSet<>();
}
- EventTypeConditionVisitor eventTypeConditionVisitor = new EventTypeConditionVisitor();
+ EventTypeConditionVisitor eventTypeConditionVisitor = new EventTypeConditionVisitor(definitionsService);
visitConditions(rootCondition, eventTypeConditionVisitor);
return eventTypeConditionVisitor.getEventTypeIds();
}
public static class EventTypeConditionVisitor implements ConditionVisitor {
+ private final DefinitionsService definitionsService;
private Set eventTypeIds = new HashSet<>();
private Stack conditionTypeStack = new Stack<>();
+ public EventTypeConditionVisitor(DefinitionsService definitionsService) {
+ this.definitionsService = definitionsService;
+ }
+
public void visit(Condition condition) {
conditionTypeStack.push(condition.getConditionTypeId());
+
+ // Ensure condition type is resolved before checking parent conditions
+ if (definitionsService != null && condition.getConditionType() == null) {
+ TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService();
+ if (typeResolutionService != null) {
+ typeResolutionService.resolveConditionType(condition, "eventTypeResolution");
+ } else {
+ // Fallback to direct resolution if TypeResolutionService is not available
+ String conditionTypeId = condition.getConditionTypeId();
+ if (conditionTypeId != null) {
+ ConditionType conditionType = definitionsService.getConditionType(conditionTypeId);
+ if (conditionType != null) {
+ condition.setConditionType(conditionType);
+ } else {
+ LOGGER.warn("Condition type {} could not be resolved!", conditionTypeId);
+ }
+ }
+ }
+ }
+
if ("eventTypeCondition".equals(condition.getConditionTypeId())) {
String eventTypeId = (String) condition.getParameter("eventTypeId");
if (eventTypeId == null) {
@@ -214,7 +329,18 @@ public void visit(Condition condition) {
}
}
} else if (condition.getConditionType() != null && condition.getConditionType().getParentCondition() != null) {
- visitConditions(condition.getConditionType().getParentCondition(), this);
+ // Resolve parent condition type if needed before traversing
+ Condition parentCondition = condition.getConditionType().getParentCondition();
+ if (definitionsService != null && parentCondition.getConditionType() == null) {
+ TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService();
+ if (typeResolutionService != null) {
+ typeResolutionService.resolveConditionType(parentCondition, "eventTypeResolution");
+ } else {
+ // Fallback to direct resolution if TypeResolutionService is not available
+ resolveConditionType(definitionsService, parentCondition, "eventTypeResolution");
+ }
+ }
+ visitConditions(parentCondition, this);
}
}
@@ -272,7 +398,12 @@ public static Object extractValue(String s, Event event, Map values, Map getParentChain(
+ ConditionType conditionType,
+ DefinitionsService definitionsService,
+ String contextName,
+ int maxDepth) {
+
+ if (conditionType == null || definitionsService == null) {
+ return new ArrayList<>();
+ }
+
+ List chain = new ArrayList<>();
+ Set visited = new HashSet<>();
+
+ ConditionType current = conditionType;
+ int depth = 0;
+
+ while (current != null && current.getParentCondition() != null && depth < maxDepth) {
+ Condition parentCondition = current.getParentCondition();
+
+ // Resolve parent condition type if needed
+ if (parentCondition.getConditionType() == null) {
+ TypeResolutionService typeResolutionService = definitionsService.getTypeResolutionService();
+ if (typeResolutionService != null) {
+ typeResolutionService.resolveConditionType(parentCondition, contextName);
+ } else {
+ // Fallback to direct resolution if TypeResolutionService is not available
+ resolveConditionType(definitionsService, parentCondition, contextName);
+ }
+ }
+
+ ConditionType parentType = parentCondition.getConditionType();
+ if (parentType == null) {
+ LOGGER.warn("Parent condition type could not be resolved for {} in {}",
+ current.getItemId(), contextName);
+ break;
+ }
+
+ String parentId = parentType.getItemId();
+
+ // Check for circular reference
+ if (visited.contains(parentId)) {
+ LOGGER.warn("Circular reference detected in parent chain for {} in {}: {}",
+ conditionType.getItemId(), contextName, visited);
+ return null;
+ }
+
+ visited.add(parentId);
+ chain.add(parentCondition);
+
+ current = parentType;
+ depth++;
+ }
+
+ if (depth >= maxDepth) {
+ LOGGER.warn("Maximum depth ({}) exceeded when traversing parent chain for {} in {}",
+ maxDepth, conditionType.getItemId(), contextName);
+ return null;
+ }
+
+ return chain;
+ }
+
+ /**
+ * Deep copies a parameter value, handling Condition objects and collections containing Conditions.
+ * This is a helper method to avoid code duplication when merging parameters.
+ *
+ * @param value the parameter value to deep copy
+ * @return a deep copy of the value, or the original value if it's not a Condition or collection
+ */
+ private static Object deepCopyParameterValue(Object value) {
+ if (value instanceof Condition) {
+ return ((Condition) value).deepCopy();
+ } else if (value instanceof Collection) {
+ Collection> collection = (Collection>) value;
+ Collection