Browse Source

Fix rule chain notes: extract DAO, reduce column size, add tests

- Extract RuleChainDetailsDao from RuleChainDao to reuse JpaAbstractDao
  save logic (proper @Version increment, flush, detach)
- Reduce notes column size from 10MB to 1MB in schema and migration
- Catch "value too long" DB error with user-friendly message
- Pass tenantId consistently through the details DAO API
- Exclude content/markdownCss from RuleChainNote toString
- Add tests: save/load notes, update notes, preserve notes on
  RuleChain save, remove notes, exceeded size limit
pull/15121/head
Viacheslav Klimov 6 months ago
parent
commit
a3c0f0b689
Failed to extract signature
  1. 2
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 196
      application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java
  3. 3
      common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java
  4. 7
      dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java
  5. 5
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java
  6. 29
      dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java
  7. 15
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java
  8. 65
      dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java
  9. 2
      dao/src/main/resources/sql/schema-entities.sql

2
application/src/main/data/upgrade/basic/schema_update.sql

@ -16,6 +16,6 @@
-- RULE CHAIN NOTES MIGRATION START
ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(10000000);
ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000);
-- RULE CHAIN NOTES MIGRATION END

196
application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java

@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.rule.RuleChainNote;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.security.Authority;
@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest {
assertThat(ruleChain.getVersion()).isEqualTo(5);
}
@Test
public void testSaveAndLoadRuleChainMetaDataWithNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain with notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
RuleChainNote note1 = new RuleChainNote();
note1.setId("note-1");
note1.setX(100);
note1.setY(200);
note1.setWidth(300);
note1.setHeight(150);
note1.setContent("# Test Note\nSome markdown content");
note1.setBackgroundColor("#FFF9C4");
note1.setBorderColor("#E6C800");
note1.setBorderWidth(2);
note1.setApplyDefaultMarkdownStyle(true);
notes.add(note1);
RuleChainNote note2 = new RuleChainNote();
note2.setId("note-2");
note2.setX(500);
note2.setY(300);
note2.setWidth(200);
note2.setHeight(100);
note2.setContent("Simple note");
note2.setBackgroundColor("#C8E6C9");
notes.add(note2);
ruleChainMetaData.setNotes(notes);
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertNotNull(savedMetaData);
Assert.assertNotNull(savedMetaData.getNotes());
Assert.assertEquals(2, savedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote loadedNote1 = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote1);
Assert.assertEquals(100, loadedNote1.getX());
Assert.assertEquals(200, loadedNote1.getY());
Assert.assertEquals(300, loadedNote1.getWidth());
Assert.assertEquals(150, loadedNote1.getHeight());
Assert.assertEquals("# Test Note\nSome markdown content", loadedNote1.getContent());
Assert.assertEquals("#FFF9C4", loadedNote1.getBackgroundColor());
Assert.assertEquals("#E6C800", loadedNote1.getBorderColor());
Assert.assertEquals(Integer.valueOf(2), loadedNote1.getBorderWidth());
Assert.assertEquals(Boolean.TRUE, loadedNote1.getApplyDefaultMarkdownStyle());
RuleChainNote loadedNote2 = loadedMetaData.getNotes().stream()
.filter(n -> "note-2".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(loadedNote2);
Assert.assertEquals(500, loadedNote2.getX());
Assert.assertEquals(300, loadedNote2.getY());
Assert.assertEquals("Simple note", loadedNote2.getContent());
Assert.assertEquals("#C8E6C9", loadedNote2.getBackgroundColor());
}
@Test
public void testUpdateRuleChainNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain update notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Original content");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
note.setContent("Updated content");
note.setX(50);
RuleChainNote newNote = new RuleChainNote();
newNote.setId("note-2");
newNote.setX(200);
newNote.setY(300);
newNote.setWidth(150);
newNote.setHeight(75);
newNote.setContent("New note");
savedMetaData.setNotes(List.of(note, newNote));
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
Assert.assertEquals(2, updatedMetaData.getNotes().size());
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertEquals(2, loadedMetaData.getNotes().size());
RuleChainNote updatedNote = loadedMetaData.getNotes().stream()
.filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null);
Assert.assertNotNull(updatedNote);
Assert.assertEquals("Updated content", updatedNote.getContent());
Assert.assertEquals(50, updatedNote.getX());
}
@Test
public void testSaveRuleChainDoesNotOverwriteNotes() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain preserve notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Persistent note");
ruleChainMetaData.setNotes(List.of(note));
doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
// Save the RuleChain itself (not metadata) — e.g. rename
ruleChain = doGet("/api/ruleChain/" + ruleChain.getId().getId(), RuleChain.class);
ruleChain.setName("Renamed RuleChain");
ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class);
Assert.assertEquals("Renamed RuleChain", ruleChain.getName());
// Notes must still be present
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertNotNull(loadedMetaData.getNotes());
Assert.assertEquals(1, loadedMetaData.getNotes().size());
Assert.assertEquals("Persistent note", loadedMetaData.getNotes().get(0).getContent());
}
@Test
public void testRemoveNotesByUpdatingMetadata() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain remove notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
RuleChainNote note = new RuleChainNote();
note.setId("note-1");
note.setX(10);
note.setY(20);
note.setWidth(100);
note.setHeight(50);
note.setContent("Will be removed");
ruleChainMetaData.setNotes(List.of(note));
RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class);
Assert.assertEquals(1, savedMetaData.getNotes().size());
// Save metadata without notes — should clear them
savedMetaData.setNotes(null);
RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class);
RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class);
Assert.assertTrue(loadedMetaData.getNotes() == null || loadedMetaData.getNotes().isEmpty());
}
@Test
public void testSaveRuleChainNotesExceedsSizeLimit() throws Exception {
RuleChain ruleChain = createRuleChain("RuleChain oversized notes");
RuleChainMetaData ruleChainMetaData = new RuleChainMetaData();
ruleChainMetaData.setRuleChainId(ruleChain.getId());
ruleChainMetaData.setNodes(new ArrayList<>());
List<RuleChainNote> notes = new ArrayList<>();
for (int i = 0; i < 20; i++) {
RuleChainNote note = new RuleChainNote();
note.setId("note-" + i);
note.setX(i * 10);
note.setY(i * 10);
note.setWidth(300);
note.setHeight(150);
note.setContent(StringUtils.randomAlphabetic(60000));
notes.add(note);
}
ruleChainMetaData.setNotes(notes);
String error = getErrorMessage(doPost("/api/ruleChain/metadata", ruleChainMetaData)
.andExpect(status().isBadRequest()));
assertThat(error).contains("Rule chain notes data is too large");
}
private RuleChain createRuleChain(String name) {
RuleChain ruleChain = new RuleChain();
ruleChain.setName(name);

3
common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java

@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.ToString;
/**
* Represents a sticky note on the rule chain canvas.
@ -43,6 +44,7 @@ public class RuleChainNote {
@Schema(description = "Height of the note, in pixels")
private int height;
@ToString.Exclude
@Schema(description = "Markdown or HTML content of the note")
private String content;
@ -58,6 +60,7 @@ public class RuleChainNote {
@Schema(description = "Whether to apply the default markdown stylesheet to the note content")
private Boolean applyDefaultMarkdownStyle;
@ToString.Exclude
@Schema(description = "Custom CSS styles applied to the note content")
private String markdownCss;

7
dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java

@ -105,6 +105,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Autowired
private RuleChainDao ruleChainDao;
@Autowired
private RuleChainDetailsDao ruleChainDetailsDao;
@Autowired
private RuleNodeDao ruleNodeDao;
@ -319,7 +322,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
}
RuleChainDetails ruleChainDetails = new RuleChainDetails(ruleChain);
ruleChainDetails.setNotes(ruleChainMetaData.getNotes());
ruleChainDetails = ruleChainDao.saveDetails(ruleChainDetails);
ruleChainDetails = ruleChainDetailsDao.save(tenantId, ruleChainDetails);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChainDetails)
.entityId(ruleChainDetails.getId()).broadcastEvent(publishSaveEvent).build());
return RuleChainUpdateResult.successful(updatedRuleNodes);
@ -344,7 +347,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC
@Override
public RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId) {
Validator.validateId(ruleChainId, "Incorrect rule chain id.");
RuleChainDetails ruleChainDetails = ruleChainDao.findDetailsById(ruleChainId.getId());
RuleChainDetails ruleChainDetails = ruleChainDetailsDao.findById(tenantId, ruleChainId.getId());
if (ruleChainDetails == null) {
return null;
}

5
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDao.java

@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.ExportableEntityDao;
@ -86,8 +85,4 @@ public interface RuleChainDao extends Dao<RuleChain>, TenantEntityDao<RuleChain>
List<RuleChain> findRuleChainsByTenantIdAndIds(UUID tenantId, List<UUID> ruleChainIds);
RuleChainDetails findDetailsById(UUID id);
RuleChainDetails saveDetails(RuleChainDetails ruleChainDetails);
}

29
dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed 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.thingsboard.server.dao.rule;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import java.util.UUID;
public interface RuleChainDetailsDao {
RuleChainDetails findById(TenantId tenantId, UUID id);
RuleChainDetails save(TenantId tenantId, RuleChainDetails ruleChainDetails);
}

15
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDao.java

@ -29,10 +29,8 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.DaoUtil;
import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity;
import org.thingsboard.server.dao.model.sql.RuleChainEntity;
import org.thingsboard.server.dao.rule.RuleChainDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
@ -51,9 +49,6 @@ public class JpaRuleChainDao extends JpaAbstractDao<RuleChainEntity, RuleChain>
@Autowired
private RuleChainRepository ruleChainRepository;
@Autowired
private RuleChainDetailsRepository ruleChainDetailsRepository;
@Override
protected Class<RuleChainEntity> getEntityClass() {
return RuleChainEntity.class;
@ -168,16 +163,6 @@ public class JpaRuleChainDao extends JpaAbstractDao<RuleChainEntity, RuleChain>
return ruleChainRepository.findNextBatch(id, Limit.of(batchSize));
}
@Override
public RuleChainDetails findDetailsById(UUID id) {
return DaoUtil.getData(ruleChainDetailsRepository.findById(id));
}
@Override
public RuleChainDetails saveDetails(RuleChainDetails ruleChainDetails) {
return DaoUtil.getData(ruleChainDetailsRepository.save(new RuleChainDetailsEntity(ruleChainDetails)));
}
@Override
public EntityType getEntityType() {
return EntityType.RULE_CHAIN;

65
dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java

@ -0,0 +1,65 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed 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.thingsboard.server.dao.sql.rule;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.rule.RuleChainDetails;
import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity;
import org.thingsboard.server.dao.rule.RuleChainDetailsDao;
import org.thingsboard.server.dao.sql.JpaAbstractDao;
import org.thingsboard.server.dao.util.SqlDao;
import org.thingsboard.server.exception.DataValidationException;
import java.util.UUID;
@Slf4j
@Component
@SqlDao
@RequiredArgsConstructor
public class JpaRuleChainDetailsDao extends JpaAbstractDao<RuleChainDetailsEntity, RuleChainDetails> implements RuleChainDetailsDao {
private final RuleChainDetailsRepository ruleChainDetailsRepository;
@Override
public RuleChainDetails save(TenantId tenantId, RuleChainDetails ruleChainDetails) {
try {
return super.save(tenantId, ruleChainDetails);
} catch (Exception e) {
String rootMsg = ExceptionUtils.getRootCauseMessage(e);
if (StringUtils.contains(rootMsg, "value too long")) {
throw new DataValidationException("Rule chain notes data is too large. Please reduce the number or size of notes.");
}
throw e;
}
}
@Override
protected Class<RuleChainDetailsEntity> getEntityClass() {
return RuleChainDetailsEntity.class;
}
@Override
protected JpaRepository<RuleChainDetailsEntity, UUID> getRepository() {
return ruleChainDetailsRepository;
}
}

2
dao/src/main/resources/sql/schema-entities.sql

@ -162,7 +162,7 @@ CREATE TABLE IF NOT EXISTS rule_chain (
created_time bigint NOT NULL,
additional_info varchar,
configuration varchar(10000000),
notes varchar(10000000),
notes varchar(1000000),
name varchar(255),
type varchar(255),
first_rule_node_id uuid,

Loading…
Cancel
Save