This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 6ef2f2d85 fix(alert): persist cleared optional columns when a rule
update omits them (#4784)
6ef2f2d85 is described below
commit 6ef2f2d85d2984cebc0faf686c2aef20b2f959a8
Author: 烤化の初雪 <[email protected]>
AuthorDate: Thu Sep 24 18:13:39 2026 +0800
fix(alert): persist cleared optional columns when a rule update omits them
(#4784)
test(alert): pin that a fully-loaded rule update skips the clear pass
The clear pass only runs when a column would otherwise be silently
skipped. Lock the other half of that contract: an update carrying every
optional value issues updateById and nothing else, so the
fully-loaded callers (toggle, bulk toggle, import) are provably
unaffected.
fix(alert): persist a cleared optional column when an alert-rule update
omits it
The rule update replaces every editable field of the rule, but
MybatisPlusAlertRepository.replaceRule built the entity and relied on
updateById, whose NOT_NULL strategy omits null fields from the SET
clause. An update body that omits an optional value therefore passed
validation, the service echoed the submitted VO (field cleared), and
the stored rule silently kept its previous value - the scope filters,
severity, duration, channels, template and description could not be
cleared through the API. clearOmittedOptionalColumns now assigns those
columns explicitly, the same approach NameserverRegistryService uses
for its cleared registry columns (merged #4466). lastTriggered is not
an editable field (owned by markRuleTriggered) and keeps the
skip-on-null behaviour.
---
.../ops/alert/MybatisPlusAlertRepository.java | 69 ++++++++++++++++++-
.../ops/alert/MybatisPlusAlertRepositoryTest.java | 80 ++++++++++++++++++++++
2 files changed, 148 insertions(+), 1 deletion(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
index 4b904332b..6c1cd63ba 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepository.java
@@ -17,6 +17,7 @@
package org.apache.rocketmq.studio.ops.alert;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -142,7 +143,73 @@ public class MybatisPlusAlertRepository implements
AlertRepository {
if (rule.getId() == null || ruleMapper.selectById(rule.getId()) ==
null) {
return false;
}
- return ruleMapper.updateById(toRuleEntity(rule)) > 0;
+ RmqAlertRule entity = toRuleEntity(rule);
+ boolean updated = ruleMapper.updateById(entity) > 0;
+ if (updated) {
+ clearOmittedOptionalColumns(entity);
+ }
+ return updated;
+ }
+
+ /**
+ * The rule update replaces every editable field of the rule, but
MyBatis-Plus
+ * {@code updateById} omits null entity fields. An omitted optional value
would therefore
+ * silently keep its previous stored value even though the request
submitted no value for
+ * it (and the update response echoes the cleared value); assign those
columns explicitly
+ * so the stored rule matches what the request asked for. {@code
lastTriggered} is not an
+ * editable field (it is owned by {@link #markRuleTriggered}) and keeps
the skip-on-null
+ * behaviour.
+ */
+ private void clearOmittedOptionalColumns(RmqAlertRule entity) {
+ UpdateWrapper<RmqAlertRule> cleared = new UpdateWrapper<>();
+ boolean anyCleared = false;
+ if (entity.getThresholdUnit() == null) {
+ cleared.set("threshold_unit", null);
+ anyCleared = true;
+ }
+ if (entity.getDuration() == null) {
+ cleared.set("duration", null);
+ anyCleared = true;
+ }
+ if (entity.getChannels() == null) {
+ cleared.set("channels", null);
+ anyCleared = true;
+ }
+ if (entity.getDescription() == null) {
+ cleared.set("description", null);
+ anyCleared = true;
+ }
+ if (entity.getBrokerName() == null) {
+ cleared.set("broker_name", null);
+ anyCleared = true;
+ }
+ if (entity.getClusterName() == null) {
+ cleared.set("cluster_name", null);
+ anyCleared = true;
+ }
+ if (entity.getSeverity() == null) {
+ cleared.set("severity", null);
+ anyCleared = true;
+ }
+ if (entity.getInstanceId() == null) {
+ cleared.set("instance_id", null);
+ anyCleared = true;
+ }
+ if (entity.getConsumerGroup() == null) {
+ cleared.set("consumer_group", null);
+ anyCleared = true;
+ }
+ if (entity.getTopic() == null) {
+ cleared.set("topic", null);
+ anyCleared = true;
+ }
+ if (entity.getNotificationTemplate() == null) {
+ cleared.set("notification_template", null);
+ anyCleared = true;
+ }
+ if (anyCleared) {
+ ruleMapper.update(null, cleared.eq("id", entity.getId()));
+ }
}
@Override
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
index 852003124..f4c5ea2cd 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/MybatisPlusAlertRepositoryTest.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.ops.alert;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.rocketmq.studio.common.domain.PageResult;
@@ -43,6 +44,7 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -71,6 +73,84 @@ class MybatisPlusAlertRepositoryTest {
assertThat(repository.replaceRule(rule)).isFalse();
}
+ @Test
+ void replaceRuleShouldExplicitlyClearOmittedOptionalColumnsTest() {
+ // The update replaces every editable field, so a null optional field
on the submitted
+ // rule means "cleared". updateById omits null entity fields
(MyBatis-Plus NOT_NULL
+ // strategy), so the repository must assign those columns explicitly
or the stored
+ // values silently survive the update.
+ AlertRuleVO rule = AlertRuleVO.builder()
+ .id(3L)
+ .name("Lag")
+ .metric("consumer.lag.total")
+ .operator(">")
+ .threshold(100.0)
+ .domain(AlertDomain.BUSINESS)
+ .enabled(true)
+ .consecutiveSamples(1)
+ .reminderInterval("30m")
+ .build();
+
+ when(ruleMapper.selectById(3L)).thenReturn(new RmqAlertRule());
+ when(ruleMapper.updateById(any(RmqAlertRule.class))).thenReturn(1);
+
+ assertThat(repository.replaceRule(rule)).isTrue();
+
+ ArgumentCaptor<Wrapper<RmqAlertRule>> clearCaptor =
ArgumentCaptor.forClass(Wrapper.class);
+ verify(ruleMapper).update(isNull(), clearCaptor.capture());
+ UpdateWrapper<RmqAlertRule> cleared = (UpdateWrapper<RmqAlertRule>)
clearCaptor.getValue();
+ String sqlSet = cleared.getSqlSet();
+ // Every omitted optional column must be assigned NULL:
"column=#{ew.paramNameValuePairs.MPGENVALn}"
+ assertThat(sqlSet).isNotBlank();
+ for (String column : new String[] {
+ "threshold_unit", "duration", "channels", "description",
+ "broker_name", "cluster_name", "severity", "instance_id",
+ "consumer_group", "topic", "notification_template"}) {
+ assertThat(sqlSet).contains(column + "=");
+ }
+ // lastTriggered is not an editable field (owned by
markRuleTriggered), so it must
+ // keep the skip-on-null behaviour and stay out of the cleared
assignments.
+ assertThat(sqlSet).doesNotContain("last_triggered=");
+ assertThat(cleared.getParamNameValuePairs()).allSatisfy(
+ (key, value) -> assertThat(value).isNull());
+ }
+
+ @Test
+ void
replaceRuleShouldSkipTheClearPassWhenEveryOptionalFieldIsPresentTest() {
+ // A fully-loaded update (toggle, bulk toggle, import) must not issue
the extra clear
+ // statement at all: it would be redundant work per row and an empty
SET clause is
+ // exactly what MyBatis-Plus guards against.
+ AlertRuleVO rule = AlertRuleVO.builder()
+ .id(4L)
+ .name("Lag")
+ .metric("consumer.lag.total")
+ .operator(">")
+ .threshold(100.0)
+ .thresholdUnit("messages")
+ .duration("5m")
+ .channels(Arrays.asList("email"))
+ .description("checked")
+ .brokerName("broker-a")
+ .clusterName("cluster-a")
+ .severity("warning")
+ .instanceId("inst-1")
+ .consumerGroup("G1")
+ .topic("orders")
+ .notificationTemplate("body")
+ .domain(AlertDomain.BUSINESS)
+ .enabled(true)
+ .consecutiveSamples(1)
+ .reminderInterval("30m")
+ .build();
+
+ when(ruleMapper.selectById(4L)).thenReturn(new RmqAlertRule());
+ when(ruleMapper.updateById(any(RmqAlertRule.class))).thenReturn(1);
+
+ assertThat(repository.replaceRule(rule)).isTrue();
+
+ verify(ruleMapper, never()).update(isNull(), any(Wrapper.class));
+ }
+
@Test
void findRulePageShouldApplyFiltersOrderingAndDatabasePagination() {
RmqAlertRule entity = new RmqAlertRule();