diff --git a/java/src/main/java/org/eclipse/ditto/client/internal/OptionsToDittoHeaders.java b/java/src/main/java/org/eclipse/ditto/client/internal/OptionsToDittoHeaders.java index d6de5ae0..ce09f367 100644 --- a/java/src/main/java/org/eclipse/ditto/client/internal/OptionsToDittoHeaders.java +++ b/java/src/main/java/org/eclipse/ditto/client/internal/OptionsToDittoHeaders.java @@ -16,6 +16,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Map; import java.util.Optional; import java.util.SortedSet; import java.util.TreeSet; @@ -29,6 +30,9 @@ import org.eclipse.ditto.client.options.Option; import org.eclipse.ditto.client.options.OptionName; import org.eclipse.ditto.client.options.internal.OptionsEvaluator; +import org.eclipse.ditto.json.JsonObject; +import org.eclipse.ditto.json.JsonObjectBuilder; +import org.eclipse.ditto.json.JsonPointer; /** * This class provides the means to build {@link DittoHeaders} for a particular outgoing message. @@ -97,6 +101,7 @@ private DittoHeaders getDittoHeaders() { setEntityTagMatchers(); setCondition(); setLiveChannelCondition(); + setMergeThingPatchConditions(); return buildDittoHeaders(); } @@ -166,6 +171,18 @@ private void setLiveChannelCondition() { }); } + private void setMergeThingPatchConditions() { + globalOptionsEvaluator.getMergeThingPatchConditions() + .ifPresent(patchConditions -> { + validateIfOptionIsAllowed(OptionName.Global.MERGE_THING_PATCH_CONDITIONS); + final JsonObjectBuilder builder = JsonObject.newBuilder(); + for (final Map.Entry entry : patchConditions.entrySet()) { + builder.set(entry.getKey().toString(), entry.getValue()); + } + headersBuilder.putHeader("merge-thing-patch-conditions", builder.build().toString()); + }); + } + private DittoHeaders buildDittoHeaders() { return headersBuilder.build(); } diff --git a/java/src/main/java/org/eclipse/ditto/client/internal/OutgoingMessageFactory.java b/java/src/main/java/org/eclipse/ditto/client/internal/OutgoingMessageFactory.java index 5cdfddb5..9ae068ef 100644 --- a/java/src/main/java/org/eclipse/ditto/client/internal/OutgoingMessageFactory.java +++ b/java/src/main/java/org/eclipse/ditto/client/internal/OutgoingMessageFactory.java @@ -15,6 +15,7 @@ import static org.eclipse.ditto.base.model.common.ConditionChecker.checkNotNull; import static org.eclipse.ditto.client.options.OptionName.Global.CONDITION; import static org.eclipse.ditto.client.options.OptionName.Global.LIVE_CHANNEL_CONDITION; +import static org.eclipse.ditto.client.options.OptionName.Global.MERGE_THING_PATCH_CONDITIONS; import static org.eclipse.ditto.client.options.OptionName.Modify.EXISTS; import java.nio.charset.Charset; @@ -341,7 +342,7 @@ public MergeThing mergeAttribute(final ThingId thingId, return MergeThing.withAttribute(thingId, path, value, - buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public ModifyAttributes setAttributes(final ThingId thingId, @@ -356,7 +357,7 @@ public ModifyAttributes setAttributes(final ThingId thingId, public MergeThing mergeAttributes(final ThingId thingId, final JsonObject attributes, final Option[] options) { return MergeThing.withAttributes(thingId, ThingsModelFactory.newAttributes(attributes), - buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public DeleteAttribute deleteAttribute(final ThingId thingId, final JsonPointer path, final Option... options) { @@ -372,7 +373,7 @@ public ModifyFeature setFeature(final ThingId thingId, final Feature feature, fi } public MergeThing mergeFeature(final ThingId thingId, final Feature feature, final Option... options) { - return MergeThing.withFeature(thingId, feature, buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + return MergeThing.withFeature(thingId, feature, buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public ModifyFeatures setFeatures(final ThingId thingId, final Features features, final Option... options) { @@ -380,7 +381,7 @@ public ModifyFeatures setFeatures(final ThingId thingId, final Features features } public MergeThing mergeFeatures(final ThingId thingId, final Features features, final Option[] options) { - return MergeThing.withFeatures(thingId, features, buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + return MergeThing.withFeatures(thingId, features, buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public ModifyPolicyId setPolicyId(final ThingId thingId, final PolicyId policyId, final Option... options) { @@ -388,7 +389,7 @@ public ModifyPolicyId setPolicyId(final ThingId thingId, final PolicyId policyId } public MergeThing mergePolicyId(final ThingId thingId, final PolicyId policyId, final Option... options) { - return MergeThing.withPolicyId(thingId, policyId, buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + return MergeThing.withPolicyId(thingId, policyId, buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public RetrieveFeature retrieveFeature(final ThingId thingId, final String featureId, final Option... options) { @@ -455,7 +456,7 @@ public MergeThing mergeFeatureDefinition(final ThingId thingId, return MergeThing.withFeatureDefinition(thingId, featureId, featureDefinition, - buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } /** @@ -497,7 +498,7 @@ public MergeThing mergeFeatureProperty(final ThingId thingId, featureId, path, value, - buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public ModifyFeatureProperties setFeatureProperties(final ThingId thingId, @@ -519,7 +520,7 @@ public MergeThing mergeFeatureProperties(final ThingId thingId, return MergeThing.withFeatureProperties(thingId, featureId, ThingsModelFactory.newFeatureProperties(properties), - buildDittoHeaders(setOf(EXISTS, CONDITION), options)); + buildDittoHeaders(setOf(EXISTS, CONDITION, MERGE_THING_PATCH_CONDITIONS), options)); } public DeleteFeatureProperty deleteFeatureProperty(final ThingId thingId, diff --git a/java/src/main/java/org/eclipse/ditto/client/options/OptionName.java b/java/src/main/java/org/eclipse/ditto/client/options/OptionName.java index 279e31be..ad0ab70e 100755 --- a/java/src/main/java/org/eclipse/ditto/client/options/OptionName.java +++ b/java/src/main/java/org/eclipse/ditto/client/options/OptionName.java @@ -62,6 +62,14 @@ enum Global implements OptionName { */ LIVE_CHANNEL_CONDITION, + /** + * Name of the option for defining merge thing patch conditions, which map JSON pointer paths to RQL condition expressions. + * These conditions determine which parts of a merge payload should be applied based on the current state of the Thing. + * + * @since 3.8.0 + */ + MERGE_THING_PATCH_CONDITIONS, + } /** diff --git a/java/src/main/java/org/eclipse/ditto/client/options/Options.java b/java/src/main/java/org/eclipse/ditto/client/options/Options.java index a30f974c..8a17de9b 100755 --- a/java/src/main/java/org/eclipse/ditto/client/options/Options.java +++ b/java/src/main/java/org/eclipse/ditto/client/options/Options.java @@ -13,11 +13,13 @@ package org.eclipse.ditto.client.options; import java.util.Arrays; +import java.util.Map; import org.eclipse.ditto.base.model.common.ConditionChecker; import org.eclipse.ditto.base.model.headers.DittoHeaders; import org.eclipse.ditto.client.management.CommonManagement; import org.eclipse.ditto.json.JsonFieldSelector; +import org.eclipse.ditto.json.JsonPointer; import org.eclipse.ditto.policies.model.PolicyId; import org.eclipse.ditto.things.model.ThingId; @@ -90,6 +92,31 @@ public static Option liveChannelCondition(final CharSequence liveChannel liveChannelConditionExpression.toString()); } + /** + * Creates an option for specifying merge thing patch conditions. + *

+ * The returned option has the name {@link OptionName.Global#MERGE_THING_PATCH_CONDITIONS} and the given argument value. + *

+ *

+ * The patch conditions map JSON pointer paths to RQL condition expressions. These conditions determine which parts + * of a merge payload should be applied based on the current state of the Thing. + *

+ * + * @param patchConditions the map of JSON pointer paths to RQL condition expressions + * @return the new {@code Option}. + * @throws NullPointerException if {@code patchConditions} is {@code null}. + * @throws IllegalArgumentException if {@code patchConditions} is empty. + * @since 3.8.0 + */ + public static Option> mergeThingPatchConditions(final Map patchConditions) { + ConditionChecker.checkNotNull(patchConditions, "patchConditions"); + ConditionChecker.checkArgument(patchConditions, + argument -> !argument.isEmpty(), + () -> "The patchConditions map must not be empty."); + + return DefaultOption.newInstance(OptionName.Global.MERGE_THING_PATCH_CONDITIONS, patchConditions); + } + /** * The {@code Modify} class provides static factory methods for creating Options which are related to modifying * operations. diff --git a/java/src/main/java/org/eclipse/ditto/client/options/internal/MergeThingPatchConditionsOptionVisitor.java b/java/src/main/java/org/eclipse/ditto/client/options/internal/MergeThingPatchConditionsOptionVisitor.java new file mode 100644 index 00000000..ab99ef50 --- /dev/null +++ b/java/src/main/java/org/eclipse/ditto/client/options/internal/MergeThingPatchConditionsOptionVisitor.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.ditto.client.options.internal; + +import java.util.Map; + +import javax.annotation.concurrent.ThreadSafe; + +import org.eclipse.ditto.client.options.Option; +import org.eclipse.ditto.client.options.OptionName; +import org.eclipse.ditto.json.JsonPointer; + + +/** + * This visitor fetches and provides the value as {@code Map} for the option with name + * {@link OptionName.Global#MERGE_THING_PATCH_CONDITIONS} from the user provided options. + * + * @since 3.8.0 + */ +@ThreadSafe +final class MergeThingPatchConditionsOptionVisitor extends AbstractOptionVisitor> { + + /** + * Constructs a {@code MergeThingPatchConditionsOptionVisitor} object. + */ + MergeThingPatchConditionsOptionVisitor() { + super(OptionName.Global.MERGE_THING_PATCH_CONDITIONS); + } + + @Override + @SuppressWarnings("unchecked") + protected Map getValueFromOption(final Option option) { + return option.getValueAs(Map.class); + } + +} diff --git a/java/src/main/java/org/eclipse/ditto/client/options/internal/OptionsEvaluator.java b/java/src/main/java/org/eclipse/ditto/client/options/internal/OptionsEvaluator.java index 5c8e3998..07f6bb9f 100644 --- a/java/src/main/java/org/eclipse/ditto/client/options/internal/OptionsEvaluator.java +++ b/java/src/main/java/org/eclipse/ditto/client/options/internal/OptionsEvaluator.java @@ -12,6 +12,7 @@ */ package org.eclipse.ditto.client.options.internal; +import java.util.Map; import java.util.Optional; import javax.annotation.concurrent.Immutable; @@ -19,6 +20,7 @@ import org.eclipse.ditto.base.model.headers.DittoHeaders; import org.eclipse.ditto.client.options.Option; import org.eclipse.ditto.json.JsonFieldSelector; +import org.eclipse.ditto.json.JsonPointer; import org.eclipse.ditto.policies.model.PolicyId; import org.eclipse.ditto.things.model.ThingId; @@ -132,6 +134,17 @@ public Optional getLiveChannelCondition() { return getValue(new LiveChannelConditionOptionVisitor()); } + /** + * Returns the merge thing patch conditions as provided by the user. + * + * @return an Optional containing the map of JSON pointer paths to RQL condition expressions + * if provided by the user, an empty Optional else. + * @since 3.8.0 + */ + public Optional> getMergeThingPatchConditions() { + return getValue(new MergeThingPatchConditionsOptionVisitor()); + } + } /** diff --git a/java/src/test/java/org/eclipse/ditto/client/options/OptionsTest.java b/java/src/test/java/org/eclipse/ditto/client/options/OptionsTest.java index ed787bc7..f8c03dbe 100755 --- a/java/src/test/java/org/eclipse/ditto/client/options/OptionsTest.java +++ b/java/src/test/java/org/eclipse/ditto/client/options/OptionsTest.java @@ -12,8 +12,12 @@ */ package org.eclipse.ditto.client.options; +import java.util.HashMap; +import java.util.Map; + import org.assertj.core.api.Assertions; import org.assertj.core.api.JUnitSoftAssertions; +import org.eclipse.ditto.json.JsonPointer; import org.junit.Rule; import org.junit.Test; @@ -59,4 +63,34 @@ public void lifeChannelConditionWithValidExpressionReturnsExpected() { softly.assertThat(option.getValue()).as("option value").isEqualTo(liveChannelConditionExpression); } + @Test + public void mergeThingPatchConditionsWithNullMapThrowsException() { + Assertions.assertThatNullPointerException() + .isThrownBy(() -> Options.mergeThingPatchConditions(null)) + .withMessage("The patchConditions must not be null!") + .withNoCause(); + } + + @Test + public void mergeThingPatchConditionsWithEmptyMapThrowsException() { + final Map emptyMap = new HashMap<>(); + + Assertions.assertThatIllegalArgumentException() + .isThrownBy(() -> Options.mergeThingPatchConditions(emptyMap)) + .withMessage("The patchConditions map must not be empty.") + .withNoCause(); + } + + @Test + public void mergeThingPatchConditionsWithValidMapReturnsExpected() { + final Map patchConditions = new HashMap<>(); + patchConditions.put(JsonPointer.of("features/temperature/properties/value"), "gt(features/temperature/properties/value, 20)"); + patchConditions.put(JsonPointer.of("features/humidity/properties/value"), "lt(features/humidity/properties/value, 80)"); + + final Option> option = Options.mergeThingPatchConditions(patchConditions); + + softly.assertThat(option.getName()).as("option name").isEqualTo(OptionName.Global.MERGE_THING_PATCH_CONDITIONS); + softly.assertThat(option.getValue()).as("option value").isEqualTo(patchConditions); + } + }