This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 31fe93e4f UNOMI-920: Improve debugging with YAML-based toString() for
core API types (#768)
31fe93e4f is described below
commit 31fe93e4f0e79abe7d223d8d76dcd2ea437d3ab7
Author: Serge Huber <[email protected]>
AuthorDate: Sat May 30 12:01:50 2026 +0200
UNOMI-920: Improve debugging with YAML-based toString() for core API types
(#768)
---
.github/workflows/codeql-analysis-java.yml | 1 +
.github/workflows/codeql-analysis-javascript.yml | 1 +
.github/workflows/unomi-ci-build-tests.yml | 9 +-
api/pom.xml | 24 +
api/src/main/java/org/apache/unomi/api/Item.java | 65 +-
.../main/java/org/apache/unomi/api/Metadata.java | 45 +-
.../java/org/apache/unomi/api/MetadataItem.java | 56 +-
.../main/java/org/apache/unomi/api/Parameter.java | 67 +-
.../java/org/apache/unomi/api/actions/Action.java | 47 +-
.../org/apache/unomi/api/actions/ActionType.java | 41 +-
.../org/apache/unomi/api/conditions/Condition.java | 141 +++-
.../apache/unomi/api/conditions/ConditionType.java | 44 +-
.../main/java/org/apache/unomi/api/goals/Goal.java | 44 +-
.../main/java/org/apache/unomi/api/rules/Rule.java | 47 +-
.../org/apache/unomi/api/segments/Scoring.java | 39 +-
.../apache/unomi/api/segments/ScoringElement.java | 46 +-
.../org/apache/unomi/api/segments/Segment.java | 40 +-
.../java/org/apache/unomi/api/utils/YamlUtils.java | 330 ++++++++++
.../java/org/apache/unomi/api/ParameterTest.java | 81 +++
.../apache/unomi/api/conditions/ConditionTest.java | 170 +++++
.../org/apache/unomi/api/utils/YamlUtilsTest.java | 729 +++++++++++++++++++++
bom/pom.xml | 20 +
build.sh | 9 +-
itests/pom.xml | 22 +-
.../unomi/itests/PropertiesUpdateActionIT.java | 3 +-
pom.xml | 5 +
.../apache/unomi/rest/models/RESTParameter.java | 6 +-
.../services/impl/rules/RulesServiceImpl.java | 10 +-
28 files changed, 2085 insertions(+), 57 deletions(-)
diff --git a/.github/workflows/codeql-analysis-java.yml
b/.github/workflows/codeql-analysis-java.yml
index 0b8b7c427..c823fc472 100644
--- a/.github/workflows/codeql-analysis-java.yml
+++ b/.github/workflows/codeql-analysis-java.yml
@@ -17,6 +17,7 @@ on:
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
+ workflow_dispatch:
schedule:
- cron: '38 1 * * 0'
diff --git a/.github/workflows/codeql-analysis-javascript.yml
b/.github/workflows/codeql-analysis-javascript.yml
index 542b973b6..39efa7cbe 100644
--- a/.github/workflows/codeql-analysis-javascript.yml
+++ b/.github/workflows/codeql-analysis-javascript.yml
@@ -17,6 +17,7 @@ on:
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
+ workflow_dispatch:
schedule:
- cron: '38 1 * * 0'
diff --git a/.github/workflows/unomi-ci-build-tests.yml
b/.github/workflows/unomi-ci-build-tests.yml
index 2533a8b23..af90f2747 100644
--- a/.github/workflows/unomi-ci-build-tests.yml
+++ b/.github/workflows/unomi-ci-build-tests.yml
@@ -85,8 +85,15 @@ jobs:
itests/target/exam/**/data/log
itests/target/elasticsearch0/data
itests/target/elasticsearch0/logs
+ # Always publish so a later "re-run failed jobs" pass updates the check
to green.
+ # Previously `if: failure()` left a stale red "JUnit Test Report" when
ITs passed on re-run.
- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
- if: failure()
+ if: always()
+ continue-on-error: true
with:
report_paths: 'itests/target/failsafe-reports/TEST-*.xml'
+ check_name: 'JUnit Test Report (${{ matrix.search-engine }})'
+ update_check: true
+ fail_on_failure: false
+ require_tests: false
diff --git a/api/pom.xml b/api/pom.xml
index af8c96220..89d7d56ad 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -66,6 +66,7 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
+ <version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
@@ -73,6 +74,29 @@
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.yaml</groupId>
+ <artifactId>snakeyaml</artifactId>
+ </dependency>
+
+ <!-- Test Dependencies -->
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <!-- SLF4J Implementation for Testing -->
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-simple</artifactId>
+ <version>${slf4j.version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<reporting>
diff --git a/api/src/main/java/org/apache/unomi/api/Item.java
b/api/src/main/java/org/apache/unomi/api/Item.java
index de283ebe9..482802588 100644
--- a/api/src/main/java/org/apache/unomi/api/Item.java
+++ b/api/src/main/java/org/apache/unomi/api/Item.java
@@ -17,14 +17,18 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
-import java.util.HashMap;
-import java.util.Map;
+import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
/**
* A context server tracked entity. All tracked entities need to extend this
class so as to provide the minimal information the context server needs to be
able to track such
* entities and operate on them. Items are persisted according to their type
(structure) and identifier (identity). Of note, all Item subclasses
<strong>must</strong> define a
@@ -36,10 +40,13 @@ import java.util.concurrent.ConcurrentHashMap;
* though scopes could span across sites depending on the desired analysis
granularity). Scopes allow clients accessing the context server to filter data.
The context server
* defines a built-in scope ({@link Metadata#SYSTEM_SCOPE}) that clients can
use to share data across scopes.
*/
-public abstract class Item implements Serializable {
+public abstract class Item implements Serializable, YamlConvertible {
private static final Logger LOGGER =
LoggerFactory.getLogger(Item.class.getName());
- private static final long serialVersionUID = 7446061538573517071L;
+ /**
+ * Java serialization version; Unomi does not rely on Java serialization
of this type as a cross-version persistence contract.
+ */
+ private static final long serialVersionUID = 1217180125083162915L;
private static final Map<Class,String> itemTypeCache = new
ConcurrentHashMap<>();
@@ -150,4 +157,54 @@ public abstract class Item implements Serializable {
public void setSystemMetadata(String key, Object value) {
systemMetadata.put(key, value);
}
+
+ /**
+ * Converts this item to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this item
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("itemId", itemId)
+ .put("itemType", itemType)
+ .put("systemMetadata", "<max depth exceeded>")
+ .build();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ // Check if already visited - if so, we're being called from a child
class via super.toYaml()
+ // OR it's a real circular reference. We can't distinguish, but since
child classes
+ // (like Rule, ConditionType, etc.) all check for circular refs before
calling super,
+ // if we're already visited here, it's safe to assume it's a super
call, not a circular ref.
+ // If Item is directly serialized and encounters itself, the check
would happen at the
+ // top level before nested processing, so this should be safe.
+ boolean alreadyVisited = visitedSet.contains(this);
+ if (!alreadyVisited) {
+ // First time seeing this object - add it to track for circular
references
+ visitedSet.add(this);
+ }
+ try {
+ return YamlMapBuilder.create()
+ .put("itemId", itemId) // Always include, even if null, to
reflect actual state
+ .put("itemType", itemType) // Always include, even if null,
to reflect actual state
+ .putIfNotNull("scope", scope)
+ .putIfNotNull("version", version)
+ .putIfNotNull("systemMetadata", systemMetadata != null &&
!systemMetadata.isEmpty() ? toYamlValue(systemMetadata, visitedSet, maxDepth -
1) : null)
+ .build();
+ } finally {
+ // Only remove if we added it (i.e., if it wasn't already visited)
+ if (!alreadyVisited) {
+ visitedSet.remove(this);
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/Metadata.java
b/api/src/main/java/org/apache/unomi/api/Metadata.java
index 9a112e766..4f650589f 100644
--- a/api/src/main/java/org/apache/unomi/api/Metadata.java
+++ b/api/src/main/java/org/apache/unomi/api/Metadata.java
@@ -17,16 +17,23 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import java.io.Serializable;
import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.Set;
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+
/**
* A class providing information about context server entities.
*
* @see MetadataItem
*/
-public class Metadata implements Comparable<Metadata>, Serializable {
+public class Metadata implements Comparable<Metadata>, Serializable,
YamlConvertible {
private static final long serialVersionUID = 7446061538573517071L;
@@ -279,5 +286,41 @@ public class Metadata implements Comparable<Metadata>,
Serializable {
return result;
}
+ /**
+ * Converts this metadata to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this metadata
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .putIfNotNull("id", id)
+ .putIfNotNull("name", name)
+ .putIfNotNull("description", description)
+ .putIfNotNull("scope", scope)
+ .putIfNotEmpty("tags", tags)
+ .putIfNotEmpty("systemTags", systemTags)
+ .putIf("enabled", true, enabled)
+ .putIf("missingPlugins", true, missingPlugins)
+ .putIf("hidden", true, hidden)
+ .putIf("readOnly", true, readOnly)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/MetadataItem.java
b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
index 78a419863..68cf3b324 100644
--- a/api/src/main/java/org/apache/unomi/api/MetadataItem.java
+++ b/api/src/main/java/org/apache/unomi/api/MetadataItem.java
@@ -17,8 +17,15 @@
package org.apache.unomi.api;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A superclass for all {@link Item}s that bear {@link Metadata}.
@@ -31,7 +38,7 @@ public abstract class MetadataItem extends Item {
}
public MetadataItem(Metadata metadata) {
- super(metadata.getId());
+ super(metadata != null ? metadata.getId() : null);
this.metadata = metadata;
}
@@ -54,7 +61,52 @@ public abstract class MetadataItem extends Item {
@XmlTransient
public String getScope() {
- return metadata.getScope();
+ if (metadata != null) {
+ return metadata.getScope();
+ }
+ return scope;
+ }
+
+ /**
+ * Converts this metadata item to a Map structure for YAML output.
+ * Merges fields from Item parent class and adds metadata field.
+ * Subclasses should override this method, call super.toYaml(visited), and
add their specific fields.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this metadata item
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("metadata", "<max depth exceeded>")
+ .build();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ // Check if already visited - if so, we're being called from a child
class via super.toYaml()
+ // In that case, skip the circular reference check and just proceed
+ boolean alreadyVisited = visitedSet.contains(this);
+ if (!alreadyVisited) {
+ // Only check for circular references if this is the first time
we're seeing this object
+ visitedSet.add(this);
+ }
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("metadata", metadata != null ?
toYamlValue(metadata, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ // Only remove if we added it (i.e., if it wasn't already visited)
+ if (!alreadyVisited) {
+ visitedSet.remove(this);
+ }
+ }
}
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
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 4833c5a5f..7fc7c7453 100644
--- a/api/src/main/java/org/apache/unomi/api/Parameter.java
+++ b/api/src/main/java/org/apache/unomi/api/Parameter.java
@@ -17,20 +17,28 @@
package org.apache.unomi.api;
+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;
/**
* 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.
*/
-public class Parameter implements Serializable {
+public class Parameter implements Serializable, YamlConvertible {
- private static final long serialVersionUID = 7446061538573517071L;
+ /**
+ * Java serialization version; Unomi does not rely on Java serialization
of this type as a cross-version persistence contract.
+ */
+ private static final long serialVersionUID = 6019392686888941547L;
- String id;
- String type;
- boolean multivalued = false;
- String defaultValue = null;
+ private String id;
+ private String type;
+ private boolean multivalued;
+ private Object defaultValue;
public Parameter() {
}
@@ -45,14 +53,26 @@ public class Parameter implements Serializable {
return id;
}
+ public void setId(String id) {
+ this.id = id;
+ }
+
public String getType() {
return type;
}
+ public void setType(String type) {
+ this.type = type;
+ }
+
public boolean isMultivalued() {
return multivalued;
}
+ public void setMultivalued(boolean multivalued) {
+ this.multivalued = multivalued;
+ }
+
/**
* @param choiceListInitializerFilter a reference to a choicelist
* @deprecated As of version 1.1.0-incubating
@@ -62,11 +82,42 @@ public class Parameter implements Serializable {
// Avoid errors when deploying old definitions
}
- public String getDefaultValue() {
+ public Object getDefaultValue() {
return defaultValue;
}
- public void setDefaultValue(String defaultValue) {
+ public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
+
+ /**
+ * Converts this parameter 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 parameter
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("id", id)
+ .putIfNotNull("type", type)
+ .putIf("multivalued", true, multivalued)
+ .putIfNotNull("defaultValue", "<max depth exceeded>")
+ .build();
+ }
+ return YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("id", id)
+ .putIfNotNull("type", type)
+ .putIf("multivalued", true, multivalued)
+ .putIfNotNull("defaultValue", defaultValue)
+ .build();
+ }
+
+ @Override
+ public String toString() {
+ return YamlUtils.format(toYaml());
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/actions/Action.java
b/api/src/main/java/org/apache/unomi/api/actions/Action.java
index b3505bbe8..aeaba1bc6 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/Action.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/Action.java
@@ -18,18 +18,25 @@
package org.apache.unomi.api.actions;
import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* An action that can be executed as a consequence of a {@link Rule} being
triggered. An action is characterized by its associated {@link
* ActionType} and parameter values.
*/
-public class Action implements Serializable {
+public class Action implements Serializable, YamlConvertible {
private ActionType actionType;
private String actionTypeId;
private Map<String, Object> parameterValues = new HashMap<>();
@@ -117,4 +124,42 @@ public class Action implements Serializable {
parameterValues.put(name, value);
}
+ /**
+ * Converts this action to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this action
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("type", actionTypeId != null ? actionTypeId : "Action")
+ .put("parameterValues", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ YamlMapBuilder builder = YamlMapBuilder.create()
+ .put("type", actionTypeId != null ? actionTypeId : "Action");
+ if (parameterValues != null && !parameterValues.isEmpty()) {
+ builder.put("parameterValues", toYamlValue(parameterValues,
visitedSet, maxDepth - 1));
+ }
+ return builder.build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
index 5ff336d9f..5da949349 100644
--- a/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
+++ b/api/src/main/java/org/apache/unomi/api/actions/ActionType.java
@@ -20,14 +20,19 @@ package org.apache.unomi.api.actions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A type definition for {@link Action}s.
*/
-public class ActionType extends MetadataItem {
+public class ActionType extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "actionType";
private static final long serialVersionUID = -3522958600710010935L;
@@ -101,4 +106,34 @@ public class ActionType extends MetadataItem {
public int hashCode() {
return itemId.hashCode();
}
+
+ /**
+ * Converts this action type to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this action type
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("parameters", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("actionExecutor", actionExecutor)
+ .putIfNotEmpty("parameters", parameters != null ?
(Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
index 526ff463f..248d1c43a 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/Condition.java
@@ -17,16 +17,30 @@
package org.apache.unomi.api.conditions;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlTransient;
import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A set of elements that can be evaluated.
*/
-public class Condition implements Serializable {
+public class Condition implements Serializable, YamlConvertible {
private static final long serialVersionUID = 7584522402785053206L;
ConditionType conditionType;
@@ -65,7 +79,9 @@ public class Condition implements Serializable {
*/
public void setConditionType(ConditionType conditionType) {
this.conditionType = conditionType;
- this.conditionTypeId = conditionType.getItemId();
+ if (conditionType != null) {
+ this.conditionTypeId = conditionType.getItemId();
+ }
}
/**
@@ -103,7 +119,7 @@ public class Condition implements Serializable {
* @param parameterValues a Map containing the parameter name - value
pairs for this profile
*/
public void setParameterValues(Map<String, Object> parameterValues) {
- this.parameterValues = parameterValues;
+ this.parameterValues = parameterValues != null ? parameterValues : new
HashMap<>();
}
/**
@@ -113,7 +129,7 @@ public class Condition implements Serializable {
* @return {@code true} if this condition contains a parameter with the
specified name, {@code false} otherwise
*/
public boolean containsParameter(String name) {
- return parameterValues.containsKey(name);
+ return parameterValues != null && parameterValues.containsKey(name);
}
/**
@@ -123,7 +139,7 @@ public class Condition implements Serializable {
* @return the value of the specified parameter or {@code null} if no such
parameter exists
*/
public Object getParameter(String name) {
- return parameterValues.get(name);
+ return parameterValues != null ? parameterValues.get(name) : null;
}
/**
@@ -134,6 +150,9 @@ public class Condition implements Serializable {
* @param value the value of the parameter
*/
public void setParameter(String name, Object value) {
+ if (parameterValues == null) {
+ parameterValues = new HashMap<>();
+ }
parameterValues.put(name, value);
}
@@ -141,28 +160,112 @@ public class Condition implements Serializable {
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
-
Condition condition = (Condition) o;
-
- if (!conditionTypeId.equals(condition.conditionTypeId)) return false;
- return parameterValues.equals(condition.parameterValues);
-
+ return Objects.equals(conditionTypeId, condition.conditionTypeId)
+ && Objects.equals(parameterValues, condition.parameterValues);
}
@Override
public int hashCode() {
- int result = conditionTypeId.hashCode();
- result = 31 * result + parameterValues.hashCode();
- return result;
+ return Objects.hash(conditionTypeId, parameterValues);
+ }
+
+ /**
+ * Converts this condition to a Map structure for YAML output with depth
limiting.
+ * Implements YamlConvertible interface with circular reference detection
and depth limiting
+ * to prevent StackOverflowError from extremely deep nested structures.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError
from deep nesting)
+ * @return a Map representation of this condition
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("type", conditionTypeId != null ? conditionTypeId :
"Condition")
+ .put("parameterValues", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ YamlMapBuilder builder = YamlMapBuilder.create()
+ .put("type", conditionTypeId != null ? conditionTypeId :
"Condition");
+ if (parameterValues != null && !parameterValues.isEmpty()) {
+ builder.put("parameterValues", toYamlValue(parameterValues,
visitedSet, maxDepth - 1));
+ }
+ return builder.build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ /**
+ * Creates a deep copy of this condition, including all nested conditions
in parameter values.
+ * Recursively copies all nested conditions to avoid sharing references.
+ *
+ * @return a deep copy of this condition
+ * @throws IllegalStateException if the condition graph contains a cycle
through nested {@link Condition} values
+ */
+ public Condition deepCopy() {
+ return deepCopy(new IdentityHashMap<>());
+ }
+
+ private Condition deepCopy(IdentityHashMap<Condition, Boolean> copying) {
+ if (copying.put(this, Boolean.TRUE) != null) {
+ throw new IllegalStateException("Cyclic Condition graph: cannot
deepCopy()");
+ }
+ try {
+ Condition copied = new Condition();
+ if (this.conditionType != null) {
+ copied.setConditionType(this.conditionType);
+ } else if (this.conditionTypeId != null) {
+ copied.setConditionTypeId(this.conditionTypeId);
+ }
+
+ // Deep copy parameter values
+ Map<String, Object> copiedParams = new HashMap<>();
+ if (this.parameterValues != null) {
+ for (Map.Entry<String, Object> entry :
this.parameterValues.entrySet()) {
+ Object value = entry.getValue();
+ if (value instanceof Condition) {
+ copiedParams.put(entry.getKey(), ((Condition)
value).deepCopy(copying));
+ } else if (value instanceof Collection) {
+ Collection<?> collection = (Collection<?>) value;
+ Collection<Object> copiedCollection;
+ if (collection instanceof List) {
+ copiedCollection = new ArrayList<>();
+ } else {
+ copiedCollection = new LinkedHashSet<>();
+ }
+ for (Object item : collection) {
+ if (item instanceof Condition) {
+ copiedCollection.add(((Condition)
item).deepCopy(copying));
+ } else {
+ copiedCollection.add(item);
+ }
+ }
+ copiedParams.put(entry.getKey(), copiedCollection);
+ } else {
+ copiedParams.put(entry.getKey(), value);
+ }
+ }
+ }
+ copied.setParameterValues(copiedParams);
+
+ return copied;
+ } finally {
+ copying.remove(this);
+ }
}
@Override
public String toString() {
- final StringBuilder sb = new StringBuilder("Condition{");
- sb.append("conditionType=").append(conditionType);
- sb.append(", conditionTypeId='").append(conditionTypeId).append('\'');
- sb.append(", parameterValues=").append(parameterValues);
- sb.append('}');
- return sb.toString();
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
}
}
diff --git
a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
index c8a43225c..3d22c00a3 100644
--- a/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
+++ b/api/src/main/java/org/apache/unomi/api/conditions/ConditionType.java
@@ -20,10 +20,15 @@ package org.apache.unomi.api.conditions;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Parameter;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import javax.xml.bind.annotation.XmlElement;
-import java.util.ArrayList;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* ConditionTypes define new conditions that can be applied to items (for
example to decide whether a rule needs to be triggered or if a profile is
considered as taking part in a
@@ -31,7 +36,7 @@ import java.util.List;
* optimized by coding it. They may also be defined as combination of other
conditions. A simple condition could be: “User is male”, while a more generic
condition with
* parameters may test whether a given property has a specific value: “User
property x has value y”.
*/
-public class ConditionType extends MetadataItem {
+public class ConditionType extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "conditionType";
private static final long serialVersionUID = -6965481691241954969L;
@@ -142,4 +147,37 @@ public class ConditionType extends MetadataItem {
public int hashCode() {
return itemId.hashCode();
}
+
+ /**
+ * Converts this condition type to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this condition type
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("parentCondition", "<max depth exceeded>")
+ .put("parameters", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("conditionEvaluator", conditionEvaluator)
+ .putIfNotNull("queryBuilder", queryBuilder)
+ .putIfNotNull("parentCondition", parentCondition != null ?
toYamlValue(parentCondition, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("parameters", parameters != null ?
(Collection<?>) toYamlValue(parameters, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/goals/Goal.java
b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
index 1b2ff0e88..d2cac5db8 100644
--- a/api/src/main/java/org/apache/unomi/api/goals/Goal.java
+++ b/api/src/main/java/org/apache/unomi/api/goals/Goal.java
@@ -21,6 +21,15 @@ import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.campaigns.Campaign;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A tracked activity / action that can be accomplished by site (scope)
visitors. These are tracked in general because they relate to specific business
objectives or are
@@ -32,7 +41,7 @@ import org.apache.unomi.api.conditions.Condition;
* <li>audience filtering: any visitor is considered for scope-level goals
while campaign-level goals only consider visitors who match the campaign's
conditions
* </ul>
*/
-public class Goal extends MetadataItem {
+public class Goal extends MetadataItem implements YamlConvertible {
public static final String ITEM_TYPE = "goal";
private static final long serialVersionUID = 6131648013470949983L;
private Condition startEvent;
@@ -87,4 +96,37 @@ public class Goal extends MetadataItem {
public void setCampaignId(String campaignId) {
this.campaignId = campaignId;
}
+
+ /**
+ * Converts this goal to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this goal
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("startEvent", "<max depth exceeded>")
+ .put("targetEvent", "<max depth exceeded>")
+ .put("campaignId", campaignId)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("startEvent", startEvent != null ?
toYamlValue(startEvent, visitedSet, maxDepth - 1) : null)
+ .putIfNotNull("targetEvent", targetEvent != null ?
toYamlValue(targetEvent, visitedSet, maxDepth - 1) : null)
+ .putIfNotNull("campaignId", campaignId)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/rules/Rule.java
b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
index 0aedfb123..cd6f751e5 100644
--- a/api/src/main/java/org/apache/unomi/api/rules/Rule.java
+++ b/api/src/main/java/org/apache/unomi/api/rules/Rule.java
@@ -20,10 +20,16 @@ package org.apache.unomi.api.rules;
import org.apache.unomi.api.*;
import org.apache.unomi.api.actions.Action;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.List;
+import java.util.*;
import java.util.stream.Collectors;
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
+
/**
* A conditional set of actions to be executed in response to incoming events.
Triggering of rules is guarded by a condition: the rule is only triggered if
the associated
* condition ({@link #getCondition()}) is satisfied. Once a rule triggers, a
list of actions ({@link #getActions()} can be performed as consequences.
@@ -34,7 +40,7 @@ import java.util.stream.Collectors;
* We could also specify a priority for our rule in case it needs to be
executed before other ones when similar conditions match. This is accomplished
using the
* {@link #getPriority()} property.
*/
-public class Rule extends MetadataItem {
+public class Rule extends MetadataItem implements YamlConvertible {
/**
* The Rule ITEM_TYPE.
@@ -197,4 +203,41 @@ public class Rule extends MetadataItem {
public void setPriority(int priority) {
this.priority = priority;
}
+
+ /**
+ * Converts this rule to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this rule
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .put("actions", "<max depth exceeded>")
+ .put("priority", priority)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("condition", condition != null ?
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("actions", actions != null ? (Collection<?>)
toYamlValue(actions, visitedSet, maxDepth - 1) : null)
+ .putIfNotEmpty("linkedItems", linkedItems)
+ .putIf("raiseEventOnlyOnceForProfile", true,
raiseEventOnlyOnceForProfile)
+ .putIf("raiseEventOnlyOnceForSession", true,
raiseEventOnlyOnceForSession)
+ .putIf("raiseEventOnlyOnce", true, raiseEventOnlyOnce)
+ .putIf("priority", priority, priority != 0)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
index 6018fb376..b32aa8ab0 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Scoring.java
@@ -21,14 +21,20 @@ import org.apache.unomi.api.Item;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
-import java.util.List;
+import java.util.*;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A set of conditions associated with a value to assign to {@link Profile}s
when matching so that the associated users can be scored along that
* dimension. Each {@link ScoringElement} is evaluated and matching profiles'
scores are incremented with the associated value.
*/
-public class Scoring extends MetadataItem {
+public class Scoring extends MetadataItem implements YamlConvertible {
/**
* The Scoring ITEM_TYPE.
*
@@ -71,4 +77,33 @@ public class Scoring extends MetadataItem {
this.elements = elements;
}
+ /**
+ * Converts this scoring to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this scoring
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("elements", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotEmpty("elements", elements != null ? (Collection<?>)
toYamlValue(elements, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
}
diff --git
a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
index 1af5147fb..ab79d132a 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/ScoringElement.java
@@ -18,13 +18,21 @@
package org.apache.unomi.api.segments;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
import java.io.Serializable;
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A scoring dimension along profiles can be evaluated and associated value to
be assigned.
*/
-public class ScoringElement implements Serializable {
+public class ScoringElement implements Serializable, YamlConvertible {
private Condition condition;
private int value;
@@ -69,4 +77,40 @@ public class ScoringElement implements Serializable {
public void setValue(int value) {
this.value = value;
}
+
+ /**
+ * Converts this scoring element to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this scoring element
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .put("value", value)
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .putIfNotNull("condition", condition != null ?
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .put("value", value)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
+ @Override
+ public String toString() {
+ Map<String, Object> map = toYaml();
+ return YamlUtils.format(map);
+ }
}
diff --git a/api/src/main/java/org/apache/unomi/api/segments/Segment.java
b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
index 4e0d33830..126279c15 100644
--- a/api/src/main/java/org/apache/unomi/api/segments/Segment.java
+++ b/api/src/main/java/org/apache/unomi/api/segments/Segment.java
@@ -22,13 +22,22 @@ import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.MetadataItem;
import org.apache.unomi.api.Profile;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.apache.unomi.api.utils.YamlUtils.YamlConvertible;
+import org.apache.unomi.api.utils.YamlUtils.YamlMapBuilder;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.apache.unomi.api.utils.YamlUtils.circularRef;
+import static org.apache.unomi.api.utils.YamlUtils.toYamlValue;
/**
* A dynamically evaluated group of similar profiles in order to categorize
the associated users. To be considered part of a given segment, users must
satisfies
* the segment’s condition. If they match, users are automatically added to
the segment. Similarly, if at any given point during, they cease to satisfy the
segment’s condition,
* they are automatically removed from it.
*/
-public class Segment extends MetadataItem {
+public class Segment extends MetadataItem implements YamlConvertible {
/**
* The Segment ITEM_TYPE.
@@ -72,4 +81,33 @@ public class Segment extends MetadataItem {
this.condition = condition;
}
+ /**
+ * Converts this segment to a Map structure for YAML output.
+ * Implements YamlConvertible interface with circular reference detection.
+ *
+ * @param visited set of already visited objects to prevent infinite
recursion (may be null)
+ * @return a Map representation of this segment
+ */
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int maxDepth) {
+ if (maxDepth <= 0) {
+ return YamlMapBuilder.create()
+ .put("condition", "<max depth exceeded>")
+ .build();
+ }
+ if (visited != null && visited.contains(this)) {
+ return circularRef();
+ }
+ final Set<Object> visitedSet = visited != null ? visited :
YamlUtils.newIdentityVisitedSet();
+ visitedSet.add(this);
+ try {
+ return YamlMapBuilder.create()
+ .mergeObject(super.toYaml(visitedSet, maxDepth))
+ .putIfNotNull("condition", condition != null ?
toYamlValue(condition, visitedSet, maxDepth - 1) : null)
+ .build();
+ } finally {
+ visitedSet.remove(this);
+ }
+ }
+
}
diff --git a/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
new file mode 100644
index 000000000..0755fbe6d
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/YamlUtils.java
@@ -0,0 +1,330 @@
+/*
+ * 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.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.Yaml;
+
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * YAML utilities using SnakeYaml with fluent API wrapper.
+ * Provides utilities for building YAML structures and formatting them via
SnakeYaml.
+ */
+public class YamlUtils {
+ // ThreadLocal because SnakeYAML's Yaml is not thread-safe
+ private static final ThreadLocal<Yaml> YAML_INSTANCE =
ThreadLocal.withInitial(() -> {
+ DumperOptions options = new DumperOptions();
+ options.setIndent(2);
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setPrettyFlow(true);
+ options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
+ return new Yaml(options);
+ });
+
+ /**
+ * Interface for objects that can convert themselves to YAML Map
structures.
+ */
+ public interface YamlConvertible {
+ /**
+ * Converts this object to a Map structure for YAML output with depth
limiting.
+ * This method accepts an optional visited set to detect circular
references and a max depth
+ * to prevent StackOverflowError from extremely deep nested structures.
+ *
+ * @param visited optional set of visited objects to detect circular
references (may be null).
+ * When non-null, use identity semantics (e.g. {@link
YamlUtils#newIdentityVisitedSet()})
+ * so cycles are detected by object identity, not
{@code equals}.
+ * @param maxDepth maximum recursion depth (prevents
StackOverflowError from deep nesting)
+ * @return a Map representation of this object
+ */
+ Map<String, Object> toYaml(Set<Object> visited, int maxDepth);
+
+ /**
+ * Converts this object to a Map structure for YAML output.
+ * This method accepts an optional visited set to detect circular
references.
+ * Uses a default max depth of 20 to prevent StackOverflowError.
+ *
+ * @param visited optional set of visited objects to detect circular
references (may be null)
+ * @return a Map representation of this object
+ */
+ default Map<String, Object> toYaml(Set<Object> visited) {
+ return toYaml(visited, 20);
+ }
+
+ /**
+ * Converts this object to a Map structure for YAML output.
+ * This is a convenience method that calls toYaml(null, 20).
+ *
+ * @return a Map representation of this object
+ */
+ default Map<String, Object> toYaml() {
+ return toYaml(null, 20);
+ }
+ }
+
+ /**
+ * Fluent builder for creating YAML Map structures.
+ * Provides chaining methods to avoid repeating the map variable.
+ */
+ public static class YamlMapBuilder {
+ private final Map<String, Object> map;
+
+ private YamlMapBuilder() {
+ this.map = new LinkedHashMap<>();
+ }
+
+ /**
+ * Creates a new builder instance.
+ *
+ * @return a new YamlMapBuilder
+ */
+ public static YamlMapBuilder create() {
+ return new YamlMapBuilder();
+ }
+
+ /**
+ * Adds a field if the value is not null.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if not null)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotNull(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (value != null) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field if the condition is true.
+ *
+ * @param key the key (must not be null)
+ * @param value the value (only added if condition is true)
+ * @param condition the condition
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIf(String key, Object value, boolean
condition) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (condition) {
+ map.put(key, value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a field unconditionally.
+ *
+ * @param key the key (must not be null)
+ * @param value the value
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder put(String key, Object value) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ map.put(key, value);
+ return this;
+ }
+
+ /**
+ * Adds a field if the collection is not null and not empty.
+ *
+ * @param key the key (must not be null)
+ * @param collection the collection (only added if not null and not
empty)
+ * @return this builder for chaining
+ * @throws NullPointerException if key is null
+ */
+ public YamlMapBuilder putIfNotEmpty(String key,
java.util.Collection<?> collection) {
+ if (key == null) {
+ throw new NullPointerException("Key must not be null");
+ }
+ if (collection != null && !collection.isEmpty()) {
+ map.put(key, collection);
+ }
+ return this;
+ }
+
+ /**
+ * Merges all fields from a Map into this builder.
+ * This is useful for inheritance where subclasses want to include
parent class fields.
+ *
+ * Usage in subclasses:
+ * <pre>
+ * return YamlMapBuilder.create()
+ * .mergeObject(super.toYaml(visitedSet))
+ * .putIfNotNull("field", value)
+ * .build();
+ * </pre>
+ *
+ * @param objectMap the Map containing fields to merge (may be null,
in which case nothing is merged)
+ * @return this builder for chaining
+ */
+ public YamlMapBuilder mergeObject(Map<String, Object> objectMap) {
+ if (objectMap != null) {
+ objectMap.forEach(map::put);
+ }
+ return this;
+ }
+
+ /**
+ * Builds and returns a defensive copy of the map.
+ *
+ * @return a new LinkedHashMap containing the built entries
+ */
+ public Map<String, Object> build() {
+ return new LinkedHashMap<>(map);
+ }
+ }
+
+ /**
+ * Converts a Set to a sorted List for YAML output.
+ *
+ * @param set the set to convert
+ * @return a sorted list, or null if the set is null or empty
+ */
+ public static <T extends Comparable<T>> List<T> setToSortedList(Set<T>
set) {
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Converts a Set to a sorted List using a mapper function.
+ *
+ * @param set the set to convert
+ * @param mapper the mapper function (must not be null)
+ * @return a sorted list, or null if the set is null or empty
+ * @throws NullPointerException if mapper is null
+ */
+ public static <T, R extends Comparable<R>> List<R> setToSortedList(Set<T>
set, Function<T, R> mapper) {
+ if (mapper == null) {
+ throw new NullPointerException("Mapper function must not be null");
+ }
+ if (set == null || set.isEmpty()) {
+ return null;
+ }
+ return set.stream().map(mapper).sorted().collect(Collectors.toList());
+ }
+
+ /**
+ * Creates an empty {@link Set} suitable for {@link
YamlConvertible#toYaml(Set, int)} visited tracking.
+ * The set uses reference identity ({@link IdentityHashMap}), not {@link
Object#equals(Object) equals},
+ * so distinct object graphs are not mistaken for cycles when types
override equality.
+ *
+ * @return a new modifiable identity-based set
+ */
+ public static Set<Object> newIdentityVisitedSet() {
+ return Collections.newSetFromMap(new IdentityHashMap<>());
+ }
+
+ /**
+ * Converts a value to YAML-compatible format, handling nested structures.
+ * For objects that implement YamlConvertible, circular reference
detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection
(may be null)
+ * @return the converted value
+ */
+ public static Object toYamlValue(Object value, Set<Object> visited) {
+ return toYamlValue(value, visited, Integer.MAX_VALUE);
+ }
+
+ /**
+ * Converts a value to YAML-compatible format with depth limiting to
prevent StackOverflowError.
+ * For objects that implement YamlConvertible, circular reference
detection is
+ * handled by passing the visited set to their toYaml() implementation.
+ *
+ * @param value the value to convert
+ * @param visited set of visited objects for circular reference detection
(may be null)
+ * @param maxDepth maximum recursion depth (prevents StackOverflowError
from deep nesting)
+ * @return the converted value, or a placeholder if max depth exceeded
+ */
+ public static Object toYamlValue(Object value, Set<Object> visited, int
maxDepth) {
+ if (maxDepth <= 0) {
+ return "<max depth exceeded>";
+ }
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof YamlConvertible) {
+ // toYaml already decrements depth; pass maxDepth through
unchanged for the map-sorting pass
+ Map<String, Object> result = ((YamlConvertible)
value).toYaml(visited, maxDepth - 1);
+ return toYamlValue(result, visited, maxDepth);
+ }
+ if (value instanceof List) {
+ return ((List<?>) value).stream()
+ .map(item -> toYamlValue(item, visited, maxDepth - 1))
+ .collect(Collectors.toList());
+ }
+ if (value instanceof Map) {
+ Map<?, ?> inputMap = (Map<?, ?>) value;
+ Map<String, Object> result = new LinkedHashMap<>();
+
+ if (!inputMap.isEmpty()) {
+ // Sort entries alphabetically by key string representation
+ inputMap.entrySet().stream()
+ .sorted((e1, e2) ->
String.valueOf(e1.getKey()).compareTo(String.valueOf(e2.getKey())))
+ .forEach(entry ->
+ result.put(String.valueOf(entry.getKey()),
toYamlValue(entry.getValue(), visited, maxDepth - 1)));
+ }
+ return result;
+ }
+ return value;
+ }
+
+
+ /**
+ * Formats a value as YAML using SnakeYaml.
+ * This is a convenience method that delegates to SnakeYaml.
+ *
+ * @param value the value to format
+ * @return YAML string representation
+ */
+ public static String format(Object value) {
+ return YAML_INSTANCE.get().dump(value);
+ }
+
+ /**
+ * Creates a circular reference marker map.
+ *
+ * @return a map indicating a circular reference
+ */
+ public static Map<String, Object> circularRef() {
+ return YamlMapBuilder.create()
+ .put("$ref", "circular")
+ .build();
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/ParameterTest.java
b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
new file mode 100644
index 000000000..2ed12d0c6
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/ParameterTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import org.junit.Test;
+
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Parameter} YAML and accessors.
+ */
+public class ParameterTest {
+
+ @Test
+ public void testToYamlMaxDepthZeroIncludesExpectedKeys() {
+ Parameter p = new Parameter();
+ p.setId("pid");
+ p.setType("string");
+ p.setMultivalued(true);
+ p.setDefaultValue("def");
+ Map<String, Object> y = p.toYaml(null, 0);
+ assertEquals("pid", y.get("id"));
+ assertEquals("string", y.get("type"));
+ assertTrue(y.containsKey("multivalued"));
+ assertEquals("<max depth exceeded>", y.get("defaultValue"));
+ }
+
+ @Test
+ public void
testToYamlMaxDepthZeroAddsDefaultValueTruncationMarkerEvenWhenUnset() {
+ Parameter p = new Parameter();
+ p.setMultivalued(false);
+ Map<String, Object> y = p.toYaml(null, 0);
+ assertFalse(y.containsKey("id"));
+ assertFalse(y.containsKey("type"));
+ assertFalse(y.containsKey("multivalued"));
+ assertEquals("<max depth exceeded>", y.get("defaultValue"));
+ }
+
+ @Test
+ public void testToYamlNormalPath() {
+ Parameter p = new Parameter("id1", "number", false);
+ p.setDefaultValue(42);
+ Map<String, Object> y = p.toYaml(null, 10);
+ assertEquals("id1", y.get("id"));
+ assertEquals("number", y.get("type"));
+ assertFalse(y.containsKey("multivalued"));
+ assertEquals(42, y.get("defaultValue"));
+ }
+
+ @Test
+ public void testToYamlMultivaluedTrueAddsFlag() {
+ Parameter p = new Parameter("i", "t", true);
+ Map<String, Object> y = p.toYaml(null, 10);
+ assertEquals(Boolean.TRUE, y.get("multivalued"));
+ }
+
+ @Test
+ public void testToStringIsNonEmptyYaml() {
+ Parameter p = new Parameter("x", "boolean", false);
+ String s = p.toString();
+ assertNotNull(s);
+ assertTrue(s.length() > 0);
+ assertTrue(s.contains("x"));
+ }
+}
diff --git
a/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.java
new file mode 100644
index 000000000..0fff914cf
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/conditions/ConditionTest.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.api.conditions;
+
+import org.apache.unomi.api.Metadata;
+import org.apache.unomi.api.utils.YamlUtils;
+import org.junit.Test;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link Condition} behavior (parameters, YAML, deep copy).
+ */
+public class ConditionTest {
+
+ @Test
+ public void testSetParameterValuesNullReplacesWithEmptyMap() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ c.getParameterValues().put("k", "v");
+ c.setParameterValues(null);
+ assertNotNull(c.getParameterValues());
+ assertTrue(c.getParameterValues().isEmpty());
+ assertFalse(c.containsParameter("k"));
+ assertNull(c.getParameter("k"));
+ }
+
+ @Test
+ public void testSetParameterAfterClearingParameterValues() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ c.setParameterValues(null);
+ c.setParameter("x", 1);
+ assertEquals(Integer.valueOf(1), c.getParameter("x"));
+ assertTrue(c.containsParameter("x"));
+ }
+
+ @Test
+ public void testToYamlMaxDepthZeroUsesPlaceholder() {
+ Condition c = new Condition();
+ c.setConditionTypeId("myType");
+ c.getParameterValues().put("p", "v");
+ Map<String, Object> y = c.toYaml(null, 0);
+ assertEquals("myType", y.get("type"));
+ assertEquals("<max depth exceeded>", y.get("parameterValues"));
+ }
+
+ @Test
+ public void testToYamlMaxDepthZeroDefaultTypeWhenIdMissing() {
+ Condition c = new Condition();
+ Map<String, Object> y = c.toYaml(null, 0);
+ assertEquals("Condition", y.get("type"));
+ }
+
+ @Test
+ public void testToYamlWhenAlreadyVisitedReturnsCircularMarker() {
+ Condition c = new Condition();
+ c.setConditionTypeId("t");
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ visited.add(c);
+ Map<String, Object> y = c.toYaml(visited, 5);
+ assertEquals("circular", y.get("$ref"));
+ }
+
+ @Test
+ public void testToYamlOmitsParameterValuesWhenEmpty() {
+ Condition c = new Condition();
+ c.setConditionTypeId("onlyType");
+ Map<String, Object> y = c.toYaml(null, 10);
+ assertFalse(y.containsKey("parameterValues"));
+ }
+
+ @Test
+ public void testToStringUsesYamlFormat() {
+ Condition c = new Condition();
+ c.setConditionTypeId("ctype");
+ String s = c.toString();
+ assertNotNull(s);
+ assertTrue(s.contains("ctype"));
+ }
+
+ @Test
+ public void testDeepCopyPreservesConditionTypeIdOnly() {
+ Condition c = new Condition();
+ c.setConditionTypeId("idOnly");
+ Condition copy = c.deepCopy();
+ assertNotSame(c, copy);
+ assertEquals("idOnly", copy.getConditionTypeId());
+ assertNull(copy.getConditionType());
+ }
+
+ @Test
+ public void testDeepCopyPreservesConditionTypeReference() {
+ ConditionType ct = new ConditionType(new Metadata("meta-ct"));
+ ct.setItemId("evaluatorType");
+ Condition c = new Condition(ct);
+ Condition copy = c.deepCopy();
+ assertSame(ct, copy.getConditionType());
+ assertEquals("evaluatorType", copy.getConditionTypeId());
+ }
+
+ @Test
+ public void testDeepCopyNestedConditionInSetPreservesSetType() {
+ Condition inner = new Condition();
+ inner.setConditionTypeId("inner");
+ Condition outer = new Condition();
+ outer.setConditionTypeId("outer");
+ Set<Condition> nested = new LinkedHashSet<>();
+ nested.add(inner);
+ outer.getParameterValues().put("conds", nested);
+
+ Condition copy = outer.deepCopy();
+ Object copiedVal = copy.getParameterValues().get("conds");
+ assertTrue(copiedVal instanceof LinkedHashSet);
+ @SuppressWarnings("unchecked")
+ Collection<Condition> col = (Collection<Condition>) copiedVal;
+ assertEquals(1, col.size());
+ Condition copyInner = col.iterator().next();
+ assertNotSame(inner, copyInner);
+ assertEquals("inner", copyInner.getConditionTypeId());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testDeepCopyRejectsSelfReferenceInParameterMap() {
+ Condition c = new Condition();
+ c.setConditionTypeId("self");
+ c.getParameterValues().put("me", c);
+ c.deepCopy();
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testDeepCopyRejectsSelfInSingletonCollection() {
+ Condition c = new Condition();
+ c.setConditionTypeId("self");
+ c.getParameterValues().put("list", Collections.singletonList(c));
+ c.deepCopy();
+ }
+
+ @Test
+ public void testEqualsAndHashCode() {
+ Condition a = new Condition();
+ a.setConditionTypeId("t");
+ a.getParameterValues().put("k", 1);
+ Condition b = new Condition();
+ b.setConditionTypeId("t");
+ b.getParameterValues().put("k", 1);
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
new file mode 100644
index 000000000..af4150512
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/YamlUtilsTest.java
@@ -0,0 +1,729 @@
+/*
+ * 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.Metadata;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.rules.Rule;
+import org.junit.Test;
+
+import java.util.*;
+
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for YamlUtils fluent API.
+ * Tests focus on our fluent API, not SnakeYaml's implementation.
+ */
+public class YamlUtilsTest {
+
+ @Test
+ public void testYamlMapBuilderCreate() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ assertNotNull("Builder should be created", builder);
+ }
+
+ @Test
+ public void testYamlMapBuilderPut() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("key1", "value1")
+ .put("key2", 42)
+ .build();
+ assertEquals("First value should be set", "value1", map.get("key1"));
+ assertEquals("Second value should be set", 42, map.get("key2"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotNull() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotNull("key1", "value1")
+ .putIfNotNull("key2", null)
+ .putIfNotNull("key3", "value3")
+ .build();
+ assertEquals("Non-null value should be set", "value1",
map.get("key1"));
+ assertFalse("Null value should not be set", map.containsKey("key2"));
+ assertEquals("Another non-null value should be set", "value3",
map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIf() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIf("key1", "value1", true)
+ .putIf("key2", "value2", false)
+ .putIf("key3", "value3", true)
+ .build();
+ assertEquals("Value with true condition should be set", "value1",
map.get("key1"));
+ assertFalse("Value with false condition should not be set",
map.containsKey("key2"));
+ assertEquals("Another value with true condition should be set",
"value3", map.get("key3"));
+ }
+
+ @Test
+ public void testYamlMapBuilderPutIfNotEmpty() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .putIfNotEmpty("key1", Arrays.asList("a", "b"))
+ .putIfNotEmpty("key2", Collections.emptyList())
+ .putIfNotEmpty("key3", null)
+ .putIfNotEmpty("key4", Arrays.asList("c"))
+ .build();
+ assertTrue("Non-empty collection should be set",
map.containsKey("key1"));
+ assertFalse("Empty collection should not be set",
map.containsKey("key2"));
+ assertFalse("Null collection should not be set",
map.containsKey("key3"));
+ assertTrue("Another non-empty collection should be set",
map.containsKey("key4"));
+ }
+
+ @Test
+ public void testYamlMapBuilderChaining() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("a", 1)
+ .putIfNotNull("b", "value")
+ .putIf("c", 3, true)
+ .putIfNotEmpty("d", Arrays.asList(1, 2))
+ .build();
+ assertEquals("All valid entries should be added", 4, map.size());
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyThrowsException() {
+ try {
+ YamlUtils.YamlMapBuilder.create().put(null, "value");
+ fail("Null key should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotNull() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotNull(null, "value");
+ fail("Null key in putIfNotNull should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIf() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIf(null, "value", true);
+ fail("Null key in putIf should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderNullKeyInPutIfNotEmpty() {
+ try {
+ YamlUtils.YamlMapBuilder.create().putIfNotEmpty(null,
Arrays.asList(1));
+ fail("Null key in putIfNotEmpty should throw
NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testYamlMapBuilderBuildReturnsNewMap() {
+ YamlUtils.YamlMapBuilder builder = YamlUtils.YamlMapBuilder.create();
+ builder.put("key", "value");
+ Map<String, Object> map1 = builder.build();
+ Map<String, Object> map2 = builder.build();
+ assertNotSame("Each build() should return a new map", map1, map2);
+ assertEquals("Both maps should have same content", map1, map2);
+ }
+
+ @Test
+ public void testSetToSortedList() {
+ Set<String> set = new LinkedHashSet<>(Arrays.asList("zebra", "apple",
"banana"));
+ List<String> result = YamlUtils.setToSortedList(set);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list",
Arrays.asList("apple", "banana", "zebra"), result);
+ }
+
+ @Test
+ public void testSetToSortedListNull() {
+ List<String> result = YamlUtils.setToSortedList((Set<String>) null);
+ assertNull("Null set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListEmpty() {
+ List<String> result =
YamlUtils.setToSortedList(Collections.<String>emptySet());
+ assertNull("Empty set should return null", result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapper() {
+ Set<Integer> set = new LinkedHashSet<>(Arrays.asList(3, 1, 2));
+ List<String> result = YamlUtils.setToSortedList(set, String::valueOf);
+ assertNotNull("Result should not be null", result);
+ assertEquals("Set should be converted to sorted list using mapper",
Arrays.asList("1", "2", "3"), result);
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNull() {
+ try {
+ YamlUtils.<Integer,
String>setToSortedList(Collections.singleton(1), null);
+ fail("Null mapper should throw NullPointerException");
+ } catch (NullPointerException e) {
+ // Expected
+ }
+ }
+
+ @Test
+ public void testSetToSortedListWithMapperNullSet() {
+ List<String> result = YamlUtils.setToSortedList(null, String::valueOf);
+ assertNull("Null set should return null even with mapper", result);
+ }
+
+ @Test
+ public void testToYamlValueWithYamlConvertible() {
+ YamlUtils.YamlConvertible convertible = (visited, maxDepth) -> {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("test", "value");
+ return map;
+ };
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(convertible, visited);
+ assertTrue("YamlConvertible should be converted to Map", result
instanceof Map);
+ Map<?, ?> map = (Map<?, ?>) result;
+ assertEquals("Converted map should contain test value", "value",
map.get("test"));
+ }
+
+ @Test
+ public void testToYamlValueWithList() {
+ List<Object> list = Arrays.asList("a", "b", "c");
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(list, visited);
+ assertTrue("List should remain a List", result instanceof List);
+ assertEquals("List should be unchanged", list, result);
+ }
+
+ @Test
+ public void testToYamlValueWithMap() {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("key", "value");
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(map, visited);
+ assertTrue("Map should remain a Map", result instanceof Map);
+ assertEquals("Map should contain key-value", "value", ((Map<?, ?>)
result).get("key"));
+ }
+
+ @Test
+ public void testToYamlValueWithNull() {
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(null, visited);
+ assertNull("Null should return null", result);
+ }
+
+ @Test
+ public void testToYamlValueWithPrimitive() {
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ Object result = YamlUtils.toYamlValue(42, visited);
+ assertEquals("Primitive should remain unchanged", 42, result);
+ }
+
+ @Test
+ public void testCircularRef() {
+ Map<String, Object> result = YamlUtils.circularRef();
+ assertNotNull("circularRef should return a map", result);
+ assertEquals("Should contain $ref: circular", "circular",
result.get("$ref"));
+ assertEquals("Should have only one entry", 1, result.size());
+ }
+
+ @Test
+ public void testFormatBasic() {
+ // Just verify format() works - we don't test SnakeYaml's output format
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("key", "value");
+ String result = YamlUtils.format(map);
+ assertNotNull("Format should return a string", result);
+ assertTrue("Format should contain key", result.contains("key"));
+ assertTrue("Format should contain value", result.contains("value"));
+ }
+
+ // ========== Circular Reference Detection Tests ==========
+
+ @Test
+ public void testRuleInheritanceChainNoCircularRef() {
+ // Test that Rule -> MetadataItem -> Item inheritance chain doesn't
produce false circular refs
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertFalse("Should not contain circular reference marker",
result.containsKey("$ref"));
+ assertTrue("Should contain condition",
result.containsKey("condition"));
+ assertTrue("Should contain itemId from Item parent",
result.containsKey("itemId"));
+ assertTrue("Should contain metadata from MetadataItem parent",
result.containsKey("metadata"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInCondition() {
+ // Test that a real circular reference (Rule referenced in condition's
parameterValues) is detected
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Create a circular reference: condition's parameterValues contains
the rule itself
+ condition.getParameterValues().put("referencedRule", rule);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain condition",
result.containsKey("condition"));
+
+ // Check that the circular reference is detected in the condition's
parameterValues
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ assertNotNull("Condition should be serialized", conditionMap);
+ Map<String, Object> paramValues = (Map<String, Object>)
conditionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map<String, Object> circularRef = (Map<String, Object>)
paramValues.get("referencedRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular",
circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testRuleWithCircularReferenceInActions() {
+ // Test circular reference in actions list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Action action = new Action();
+ action.setActionTypeId("testAction");
+ // Create circular reference: action's parameterValues contains the
rule
+ action.getParameterValues().put("triggeringRule", rule);
+ rule.setActions(Collections.singletonList(action));
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ List<?> actions = (List<?>) result.get("actions");
+ assertNotNull("Actions list should exist", actions);
+ assertEquals("Should have one action", 1, actions.size());
+
+ Map<String, Object> actionMap = (Map<String, Object>) actions.get(0);
+ Map<String, Object> paramValues = (Map<String, Object>)
actionMap.get("parameterValues");
+ assertNotNull("Parameter values should exist", paramValues);
+ Map<String, Object> circularRef = (Map<String, Object>)
paramValues.get("triggeringRule");
+ assertNotNull("Circular reference should be detected", circularRef);
+ assertEquals("Should contain circular reference marker", "circular",
circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNestedCircularReference() {
+ // Test nested circular reference: Rule -> Condition -> nested
Condition -> Rule
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition outerCondition = new Condition();
+ outerCondition.setConditionTypeId("outerCondition");
+
+ Condition nestedCondition = new Condition();
+ nestedCondition.setConditionTypeId("nestedCondition");
+ // Nested condition references the rule
+ nestedCondition.getParameterValues().put("ruleRef", rule);
+
+ // Outer condition contains nested condition
+ outerCondition.getParameterValues().put("nested", nestedCondition);
+ rule.setCondition(outerCondition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ assertNotNull("Rule should serialize to YAML", result);
+
+ // Navigate through the nested structure
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>)
conditionMap.get("parameterValues");
+ Map<String, Object> nestedConditionMap = (Map<String, Object>)
paramValues.get("nested");
+ Map<String, Object> nestedParamValues = (Map<String, Object>)
nestedConditionMap.get("parameterValues");
+ Map<String, Object> circularRef = (Map<String, Object>)
nestedParamValues.get("ruleRef");
+
+ assertNotNull("Circular reference should be detected in nested
structure", circularRef);
+ assertEquals("Should contain circular reference marker", "circular",
circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testMultipleCircularReferences() {
+ // Test multiple circular references to the same object
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Multiple references to the same rule
+ condition.getParameterValues().put("rule1", rule);
+ condition.getParameterValues().put("rule2", rule);
+ condition.getParameterValues().put("rule3", rule);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>)
conditionMap.get("parameterValues");
+
+ // All three references should show circular ref
+ for (String key : Arrays.asList("rule1", "rule2", "rule3")) {
+ Map<String, Object> circularRef = (Map<String, Object>)
paramValues.get(key);
+ assertNotNull("Circular reference should be detected for " + key,
circularRef);
+ assertEquals("Should contain circular reference marker for " +
key, "circular", circularRef.get("$ref"));
+ }
+ }
+
+
+ @Test
+ public void testCircularReferenceInList() {
+ // Test circular reference in a list
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // List containing the rule itself
+ condition.getParameterValues().put("ruleList", Arrays.asList(rule,
"other", rule));
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>)
conditionMap.get("parameterValues");
+ List<?> ruleList = (List<?>) paramValues.get("ruleList");
+
+ assertNotNull("Rule list should exist", ruleList);
+ assertEquals("List should have 3 elements", 3, ruleList.size());
+
+ // First element should be circular ref
+ Map<String, Object> circularRef1 = (Map<String, Object>)
ruleList.get(0);
+ assertEquals("First element should be circular ref", "circular",
circularRef1.get("$ref"));
+
+ // Second element should be string
+ assertEquals("Second element should be string", "other",
ruleList.get(1));
+
+ // Third element should also be circular ref
+ Map<String, Object> circularRef2 = (Map<String, Object>)
ruleList.get(2);
+ assertEquals("Third element should be circular ref", "circular",
circularRef2.get("$ref"));
+ }
+
+ @Test
+ public void testCircularReferenceInNestedMap() {
+ // Test circular reference in nested map structure
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("testCondition");
+ // Nested map containing the rule
+ Map<String, Object> level2 = new HashMap<>();
+ level2.put("rule", rule);
+ Map<String, Object> level1 = new HashMap<>();
+ level1.put("level2", level2);
+ Map<String, Object> nestedMap = new HashMap<>();
+ nestedMap.put("level1", level1);
+ condition.getParameterValues().put("nested", nestedMap);
+ rule.setCondition(condition);
+
+ Map<String, Object> result = rule.toYaml(null);
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ Map<String, Object> paramValues = (Map<String, Object>)
conditionMap.get("parameterValues");
+ Map<String, Object> nested = (Map<String, Object>)
paramValues.get("nested");
+ Map<String, Object> nestedLevel1 = (Map<String, Object>)
nested.get("level1");
+ Map<String, Object> nestedLevel2 = (Map<String, Object>)
nestedLevel1.get("level2");
+ Map<String, Object> circularRef = (Map<String, Object>)
nestedLevel2.get("rule");
+
+ assertNotNull("Circular reference should be detected in nested map",
circularRef);
+ assertEquals("Should contain circular reference marker", "circular",
circularRef.get("$ref"));
+ }
+
+ @Test
+ public void testNoFalseCircularRefInInheritance() {
+ // Test that inheritance chain (Rule -> MetadataItem -> Item) doesn't
create false circular refs
+ // This is the main bug we're fixing
+ Rule rule = new Rule();
+ rule.setItemId("test-rule");
+ Metadata metadata = new Metadata("test-rule");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Condition condition = new Condition();
+ condition.setConditionTypeId("unavailableConditionType");
+ condition.getParameterValues().put("comparisonOperator", "equals");
+ condition.getParameterValues().put("propertyName", "testProperty");
+ condition.getParameterValues().put("propertyValue", "testValue");
+ rule.setCondition(condition);
+
+ Action action = new Action();
+ action.setActionTypeId("test");
+ rule.setActions(Collections.singletonList(action));
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // Should NOT contain $ref: circular at the top level
+ assertNotNull("Rule should serialize", result);
+ assertFalse("Should not have false circular reference at top level",
+ result.containsKey("$ref") &&
"circular".equals(result.get("$ref")));
+
+ // Should contain all expected fields from inheritance chain
+ assertTrue("Should contain itemId from Item",
result.containsKey("itemId"));
+ assertTrue("Should contain itemType from Item",
result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule",
result.get("itemType"));
+ assertTrue("Should contain metadata from MetadataItem",
result.containsKey("metadata"));
+ assertTrue("Should contain condition",
result.containsKey("condition"));
+ assertTrue("Should contain actions", result.containsKey("actions"));
+
+ // Verify condition structure
+ Map<String, Object> conditionMap = (Map<String, Object>)
result.get("condition");
+ assertNotNull("Condition should be present", conditionMap);
+ assertEquals("Condition should have correct type",
"unavailableConditionType", conditionMap.get("type"));
+
+ // Verify actions structure
+ List<?> actions = (List<?>) result.get("actions");
+ assertNotNull("Actions should be present", actions);
+ assertEquals("Should have one action", 1, actions.size());
+ }
+
+ @Test
+ public void testItemTypeIsAlwaysIncluded() {
+ // Test that itemType is always included in YAML output, even if null
+ // This reflects the actual state of the object
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // itemType should always be present in output (set in Item
constructor for Rule)
+ assertTrue("itemType should be included",
result.containsKey("itemType"));
+ assertEquals("itemType should be 'rule'", "rule",
result.get("itemType"));
+
+ // itemId should also always be included
+ assertTrue("itemId should be included", result.containsKey("itemId"));
+ }
+
+ @Test
+ public void testItemIdAndItemTypeIncludedEvenWhenNull() {
+ // Test that itemId and itemType are always included, even when null
+ // This ensures YAML output reflects the actual state of the object
+ Rule rule = new Rule();
+ // Explicitly set itemId and itemType to null to test null handling
+ rule.setItemId(null);
+ rule.setItemType(null);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // Both should be included even if null
+ assertTrue("itemId should be included even when null",
result.containsKey("itemId"));
+ assertNull("itemId should be null", result.get("itemId"));
+
+ assertTrue("itemType should be included even when null",
result.containsKey("itemType"));
+ assertNull("itemType should be null", result.get("itemType"));
+ }
+
+ @Test
+ public void testItemIdFromMetadata() {
+ // Test that itemId is set from metadata and included in YAML
+ Rule rule = new Rule();
+ Metadata metadata = new Metadata("test-rule-id");
+ metadata.setScope("systemscope");
+ rule.setMetadata(metadata);
+
+ Map<String, Object> result = rule.toYaml(null);
+
+ // itemId should be set from metadata.getId()
+ assertTrue("itemId should be included when set from metadata",
result.containsKey("itemId"));
+ assertEquals("itemId should match metadata id", "test-rule-id",
result.get("itemId"));
+ }
+
+ @Test
+ public void testVisitedSetIsSharedCorrectly() {
+ // Test that visited set is properly shared across nested calls
+ Rule rule1 = new Rule();
+ rule1.setItemId("rule1");
+ rule1.setMetadata(new Metadata("rule1"));
+
+ Rule rule2 = new Rule();
+ rule2.setItemId("rule2");
+ rule2.setMetadata(new Metadata("rule2"));
+
+ // rule1 references rule2, rule2 references rule1 (mutual circular
reference)
+ Condition condition1 = new Condition();
+ condition1.setConditionTypeId("test");
+ condition1.getParameterValues().put("otherRule", rule2);
+ rule1.setCondition(condition1);
+
+ Condition condition2 = new Condition();
+ condition2.setConditionTypeId("test");
+ condition2.getParameterValues().put("otherRule", rule1);
+ rule2.setCondition(condition2);
+
+ // Serialize rule1 - should detect circular ref when it encounters
rule2 which references rule1
+ Map<String, Object> result1 = rule1.toYaml(null);
+ assertNotNull("Rule1 should serialize", result1);
+
+ Map<String, Object> conditionMap1 = (Map<String, Object>)
result1.get("condition");
+ Map<String, Object> paramValues1 = (Map<String, Object>)
conditionMap1.get("parameterValues");
+ Map<String, Object> rule2Ref = (Map<String, Object>)
paramValues1.get("otherRule");
+
+ // rule2 should be serialized, but when it tries to reference rule1,
it should detect circular ref
+ assertNotNull("Rule2 reference should exist", rule2Ref);
+ // rule2 itself should be fully serialized (not circular), but its
condition's otherRule should be circular
+ Map<String, Object> conditionMap2 = (Map<String, Object>)
rule2Ref.get("condition");
+ assertNotNull("Rule2's condition should exist", conditionMap2);
+ Map<String, Object> paramValues2 = (Map<String, Object>)
conditionMap2.get("parameterValues");
+ Map<String, Object> rule1CircularRef = (Map<String, Object>)
paramValues2.get("otherRule");
+ assertNotNull("Circular reference to rule1 should be detected",
rule1CircularRef);
+ assertEquals("Should contain circular reference marker", "circular",
rule1CircularRef.get("$ref"));
+ }
+
+ @Test
+ public void testConditionDeepCopyCopiesNestedConditions() {
+ Condition inner = new Condition();
+ inner.setConditionTypeId("inner");
+ Condition outer = new Condition();
+ outer.setConditionTypeId("outer");
+ outer.getParameterValues().put("c", inner);
+
+ Condition copy = outer.deepCopy();
+ assertNotSame(outer, copy);
+ Condition copyInner = (Condition) copy.getParameterValues().get("c");
+ assertNotSame(inner, copyInner);
+ assertEquals("inner", copyInner.getConditionTypeId());
+ }
+
+ @Test(expected = IllegalStateException.class)
+ public void testConditionDeepCopyRejectsCycle() {
+ Condition a = new Condition();
+ a.setConditionTypeId("a");
+ Condition b = new Condition();
+ b.setConditionTypeId("b");
+ a.getParameterValues().put("child", b);
+ b.getParameterValues().put("child", a);
+ a.deepCopy();
+ }
+
+ @Test
+ public void testNewIdentityVisitedSetUsesReferenceIdentity() {
+ Set<Object> visited = YamlUtils.newIdentityVisitedSet();
+ String a = new String("x");
+ String b = new String("x");
+ assertTrue(visited.add(a));
+ assertTrue(visited.add(b));
+ assertEquals("equal strings are distinct objects for identity set", 2,
visited.size());
+ }
+
+ @Test
+ public void testYamlMapBuilderMergeObjectNullIsNoOp() {
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("k", "v")
+ .mergeObject(null)
+ .build();
+ assertEquals(1, map.size());
+ assertEquals("v", map.get("k"));
+ }
+
+ @Test
+ public void testYamlMapBuilderMergeObjectCopiesEntries() {
+ Map<String, Object> extra = new LinkedHashMap<>();
+ extra.put("a", 1);
+ extra.put("b", 2);
+ Map<String, Object> map = YamlUtils.YamlMapBuilder.create()
+ .put("z", 0)
+ .mergeObject(extra)
+ .build();
+ assertEquals(3, map.size());
+ assertEquals(Integer.valueOf(0), map.get("z"));
+ assertEquals(Integer.valueOf(1), map.get("a"));
+ assertEquals(Integer.valueOf(2), map.get("b"));
+ }
+
+ @Test
+ public void testToYamlValueMaxDepthZeroReturnsPlaceholder() {
+ assertEquals("<max depth exceeded>", YamlUtils.toYamlValue("anything",
null, 0));
+ }
+
+ @Test
+ public void testToYamlValueEmptyMapWithDepth() {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> out = (Map<String, Object>)
YamlUtils.toYamlValue(Collections.emptyMap(), null, 5);
+ assertNotNull(out);
+ assertTrue(out.isEmpty());
+ }
+
+ @Test
+ public void testToYamlValueSortsMapKeysLexicographically() {
+ Map<String, Object> in = new LinkedHashMap<>();
+ in.put("z", 1);
+ in.put("a", 2);
+ @SuppressWarnings("unchecked")
+ Map<String, Object> out = (Map<String, Object>)
YamlUtils.toYamlValue(in, null, 10);
+ assertEquals(Arrays.asList("a", "z"), new ArrayList<>(out.keySet()));
+ }
+
+ @Test
+ public void testToYamlValueNonStringMapKeysBecomeStrings() {
+ Map<Integer, String> in = new HashMap<>();
+ in.put(10, "ten");
+ in.put(2, "two");
+ @SuppressWarnings("unchecked")
+ Map<String, Object> out = (Map<String, Object>)
YamlUtils.toYamlValue(in, null, 10);
+ assertTrue(out.keySet().stream().allMatch(k -> k instanceof String));
+ assertEquals("two", out.get("2"));
+ assertEquals("ten", out.get("10"));
+ }
+
+ @Test
+ public void testToYamlValueTwoArgDelegatesToUnboundedDepth() {
+ Condition c = new Condition();
+ c.setConditionTypeId("c");
+ c.getParameterValues().put("n", 1);
+ Object result = YamlUtils.toYamlValue(c,
YamlUtils.newIdentityVisitedSet());
+ assertTrue(result instanceof Map);
+ }
+
+ @Test
+ public void testYamlConvertibleDefaultToYamlWithVisitedOnly() {
+ YamlUtils.YamlConvertible convertible = new
YamlUtils.YamlConvertible() {
+ @Override
+ public Map<String, Object> toYaml(Set<Object> visited, int
maxDepth) {
+ Map<String, Object> m = new LinkedHashMap<>();
+ m.put("depth", maxDepth);
+ return m;
+ }
+ };
+ Map<String, Object> map = convertible.toYaml(null);
+ assertEquals(Integer.valueOf(20), map.get("depth"));
+ }
+}
diff --git a/bom/pom.xml b/bom/pom.xml
index 69cb5a051..17b0f3752 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -503,6 +503,26 @@
<artifactId>junit</artifactId>
<version>${junit.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit-jupiter.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-core</artifactId>
+ <version>${mockito.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-junit-jupiter</artifactId>
+ <version>${mockito.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>
diff --git a/build.sh b/build.sh
index 94891527d..f5239703a 100755
--- a/build.sh
+++ b/build.sh
@@ -25,7 +25,9 @@ trap 'handle_error $? $LINENO "${BASH_COMMAND:0:200}"' ERR
handle_error() {
local exit_code=$1
local line_no=$2
- local failed_cmd=$3
+ local bash_lineno=$3
+ local last_command=$4
+ local func_trace=$5
cat << "EOF"
_____ ____ ____ ___ ____
@@ -36,9 +38,12 @@ handle_error() {
EOF
echo "Error occurred in:"
- echo " Command: $failed_cmd"
+ echo " Command: $last_command"
echo " Line: $line_no"
echo " Exit code: $exit_code"
+ if [ ! -z "$func_trace" ]; then
+ echo " Function trace: $func_trace"
+ fi
exit $exit_code
}
diff --git a/itests/pom.xml b/itests/pom.xml
index 239ef3015..34f993c4c 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -32,6 +32,8 @@
<unomi.search.engine>elasticsearch</unomi.search.engine>
<use.opensearch>false</use.opensearch>
<docker.container.name>itests-opensearch</docker.container.name>
+ <!-- Skip caching for this module only -->
+ <maven.build.cache.skip>true</maven.build.cache.skip>
<!-- Set to true to keep the search engine container running after
tests (useful for post-failure inspection) -->
<it.keepContainer>false</it.keepContainer>
</properties>
@@ -45,6 +47,14 @@
<type>pom</type>
<scope>import</scope>
</dependency>
+ <!-- Override awaitility version for integration tests to use
older version compatible with hamcrest 1.3 -->
+ <!-- Awaitility 4.0.0+ requires hamcrest 2.1+, but we need
hamcrest 1.3 for OSGi bundle compatibility -->
+ <!-- Using awaitility 3.1.6 which works with hamcrest 1.3 -->
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>3.1.6</version>
+ </dependency>
</dependencies>
</dependencyManagement>
@@ -55,6 +65,16 @@
<artifactId>common</artifactId>
<version>${karaf.version}</version>
<scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </exclusion>
+ </exclusions>
</dependency>
<!-- Define the Apache Karaf version to download and use for the test
-->
<!-- We use a released version here to avoid SNAPSHOT resolution -->
@@ -107,7 +127,7 @@
<groupId>org.apache.servicemix.bundles</groupId>
<artifactId>org.apache.servicemix.bundles.hamcrest</artifactId>
<version>1.3_1</version>
- <scope>runtime</scope>
+ <scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
diff --git
a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
index 66f1de7fc..51368c503 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -94,7 +94,8 @@ public class PropertiesUpdateActionIT extends BaseIT {
int changes = eventService.send(updateProperties);
Assert.assertTrue("eventService.send() reported no changes — action
may not have fired", changes > 0);
- // Current profile on the event is updated in memory; do not poll
persistence here.
+ // UpdatePropertiesAction updates event.getProfile() in-memory for the
current-profile path;
+ // persistence is not guaranteed synchronous here so we assert the
in-memory state directly.
Assert.assertEquals("UPDATED FIRST NAME CURRENT PROFILE",
profile.getProperty("firstName"));
}
diff --git a/pom.xml b/pom.xml
index f7e85f403..e368fba06 100644
--- a/pom.xml
+++ b/pom.xml
@@ -80,6 +80,8 @@
<snakeyaml.version>2.3</snakeyaml.version>
<opencsv.version>3.10</opencsv.version>
<log4j.version>2.19.0</log4j.version>
+ <!-- Used by unomi-api slf4j dependencies; single place to align SLF4J
for the build -->
+ <slf4j.version>1.7.36</slf4j.version>
<lucene.version>9.12.2</lucene.version>
<failsafe.version>2.4.0</failsafe.version>
<joda-time.version>2.12.7</joda-time.version>
@@ -102,6 +104,9 @@
<httpclient-osgi.version>4.5.14</httpclient-osgi.version>
<httpcore-osgi.version>4.4.16</httpcore-osgi.version>
<junit.version>4.13.2</junit.version>
+ <junit-jupiter.version>5.8.2</junit-jupiter.version>
+ <mockito.version>4.5.1</mockito.version>
+ <awaitility.version>4.2.0</awaitility.version>
<kafka-client.version>2.2.1</kafka-client.version>
<st4.version>4.3.4</st4.version>
<commons-email.version>1.6.0</commons-email.version>
diff --git a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
index bc7ab9acf..d6257c6f0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/models/RESTParameter.java
@@ -26,7 +26,7 @@ public class RESTParameter {
private String id;
private String type;
private boolean multivalued = false;
- private String defaultValue = null;
+ private Object defaultValue = null;
public String getId() {
return id;
@@ -52,11 +52,11 @@ public class RESTParameter {
this.multivalued = multivalued;
}
- public String getDefaultValue() {
+ public Object getDefaultValue() {
return defaultValue;
}
- public void setDefaultValue(String defaultValue) {
+ public void setDefaultValue(Object defaultValue) {
this.defaultValue = defaultValue;
}
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 fe3fe8fb0..8b6e7063c 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
@@ -450,7 +450,15 @@ public class RulesServiceImpl implements RulesService,
EventListenerService, Syn
trackedCondition.getConditionType().getParameters().forEach(parameter -> {
try {
if (TRACKED_PARAMETER.equals(parameter.getId())) {
-
Arrays.stream(StringUtils.split(parameter.getDefaultValue(),
",")).forEach(trackedParameter -> {
+ // Parameter#getDefaultValue is Object; null
must not call toString() (NPE) or be passed to split.
+ Object defaultValue =
parameter.getDefaultValue();
+ if (defaultValue == null) {
+ LOGGER.debug(
+ "Skipping tracked parameter
mapping: parameter id={} has null defaultValue for condition type {}",
+ parameter.getId(),
trackedCondition.getConditionType().getItemId());
+ return;
+ }
+
Arrays.stream(StringUtils.split(defaultValue.toString(),
",")).forEach(trackedParameter -> {
String[] param =
StringUtils.split(StringUtils.trim(trackedParameter), ":");
trackedParameters.put(StringUtils.trim(param[1]),
trackedCondition.getParameter(StringUtils.trim(param[0])));
});