From d203e0252db78dac2a0e47786ba8c02f00a0da93 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 16:04:25 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(crud):=20=E4=BF=9D=E8=AF=81=E5=88=86?= =?UTF-8?q?=E7=BB=84=E6=A0=91=E4=B8=8E=E5=AE=9E=E4=BD=93=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E4=BA=8B=E5=8A=A1=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/crud/service/ReactiveCrudService.java | 9 +- .../ReactiveTreeSortEntityService.java | 172 +++++----- .../crud/service/TreeSortServiceHelper.java | 9 +- .../web/crud/entity/TestTreeSortEntity.java | 6 +- ...opedReactiveTreeSortEntityServiceTest.java | 316 ++++++++++++++++++ .../webflux/DimensionTypeResponse.java | 4 +- .../DefaultDimensionUserServiceTest.java | 5 +- .../webflux/DimensionTypeResponseTest.java | 65 ++++ 8 files changed, 497 insertions(+), 89 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java create mode 100644 hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java index db5217870..8147b6a66 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java @@ -10,8 +10,10 @@ import org.hswebframework.web.api.crud.entity.TransactionManagers; import org.hswebframework.web.crud.query.QueryHelper; import org.hswebframework.web.crud.query.PagerQueryPolicy; +import org.hswebframework.web.crud.utils.TransactionUtils; import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.context.ContextView; @@ -71,7 +73,9 @@ default ReactiveQuery createQuery() { * @return 动态更新接口 */ default ReactiveUpdate createUpdate() { - return getRepository().createUpdate(); + // DSL 构造方法返回的不是 Publisher;在 execute 时统一包住 SQL 和实体事件。 + return getRepository().createUpdate().onExecute((update, operation) -> + TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition())); } /** @@ -88,7 +92,8 @@ default ReactiveUpdate createUpdate() { * @return 动态更新接口 */ default ReactiveDelete createDelete() { - return getRepository().createDelete(); + return getRepository().createDelete().onExecute((delete, operation) -> + TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition())); } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 31e385540..718c0d8d8 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -1,29 +1,25 @@ package org.hswebframework.web.crud.service; import org.apache.commons.collections4.CollectionUtils; -import org.hswebframework.ezorm.core.MethodReferenceColumn; -import org.hswebframework.ezorm.core.StaticMethodReferenceColumn; import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; import org.hswebframework.ezorm.rdb.operator.dml.Terms; -import org.hswebframework.utils.RandomUtil; import org.hswebframework.web.api.crud.entity.*; -import org.hswebframework.web.exception.ValidationException; import org.hswebframework.web.id.IDGenerator; -import org.hswebframework.web.validator.CreateGroup; +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.crud.utils.TransactionUtils; import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import reactor.math.MathFlux; import java.util.*; -import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; -import java.util.stream.Collectors; /** * 树形结构的通用增删改查服务 @@ -95,16 +91,12 @@ default Flux queryIncludeChildren(Collection idList) { */ @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Flux queryIncludeChildren(Flux entities) { - Set duplicateCheck = new HashSet<>(); return entities - .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) - ? Mono.just(e) - : createQuery() - .where() - //使用path快速查询 - .like$("path", e.getPath()) - .fetch(), - Integer.MAX_VALUE) + .concatMap(e -> !StringUtils.hasText(e.getPath()) + ? Mono.just(e) + : applyTreeScope(e, createQuery()) + .like$("path", e.getPath()) + .fetch(), Integer.MAX_VALUE) .distinct(TreeSupportEntity::getId); } @@ -128,18 +120,14 @@ default Flux queryIncludeParent(Collection idList) { */ @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Flux queryIncludeParent(Flux entities) { - Set duplicateCheck = new HashSet<>(); - return entities - .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) + .concatMap(e -> !StringUtils.hasText(e.getPath()) ? Mono.just(e) - : createQuery() - .where() - //where ? like path and path !='' and path not null - .accept(Terms.Like.reversal("path", e.getPath(), false, true)) - .notEmpty("path") - .notNull("path") - .fetch(), Integer.MAX_VALUE) + : applyTreeScope(e, createQuery()) + .accept(Terms.Like.reversal("path", e.getPath(), false, true)) + .notEmpty("path") + .notNull("path") + .fetch(), Integer.MAX_VALUE) .distinct(TreeSupportEntity::getId); } @@ -151,26 +139,38 @@ default Flux queryIncludeParent(Flux entities) { */ @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Flux queryIncludeChildren(QueryParamEntity queryParam) { - Set duplicateCheck = new HashSet<>(); + QueryParamEntity seeds = queryParam.clone(); + seeds.setIncludes(new HashSet<>()); + seeds.setExcludes(new HashSet<>()); + // 分组与去重必须使用完整内部节点,调用方的投影只应用于最终输出。 + Flux fullNodes = queryIncludeChildren(query(seeds)); + if (queryParam.getIncludes().isEmpty() && queryParam.getExcludes().isEmpty()) { + return fullNodes; + } + return fullNodes.map(TreeSupportEntity::getId).buffer(getBufferSize()) + .concatMap(ids -> createQuery().in("id", ids).as(q -> { + if (CollectionUtils.isNotEmpty(queryParam.getIncludes())) { + q.select(queryParam.getIncludes().toArray(new String[0])); + } + if (CollectionUtils.isNotEmpty(queryParam.getExcludes())) { + q.selectExcludes(queryParam.getExcludes().toArray(new String[0])); + } + return q; + }).fetch()); + } - return query(queryParam) - .concatMap(e -> !StringUtils.hasText(e.getPath()) || !duplicateCheck.add(e.getPath()) - ? Mono.just(e) - : createQuery() - .as(q -> { - if (CollectionUtils.isNotEmpty(queryParam.getIncludes())) { - q.select(queryParam.getIncludes().toArray(new String[0])); - } - if (CollectionUtils.isNotEmpty(queryParam.getExcludes())) { - q.selectExcludes(queryParam.getExcludes().toArray(new String[0])); - } - return q; - }) - .where() - .like$("path", e.getPath()) - .fetch() - , Integer.MAX_VALUE) - .distinct(TreeSupportEntity::getId); + /** + * 为同一数据表中的独立树追加分组条件,祖先、子树查询及子树删除统一使用此扩展点。 + * 默认不追加条件,保持已有单树服务的行为。分组实现应只追加条件,不修改实体或执行查询; + * 传入实体必须包含分组字段,不能仅依靠随机 path 判断归属。 + * + * @param node 当前树节点 + * @param query 已创建的查询 + * @return 追加同组约束后的查询 + * @since 5.0.2 + */ + default ReactiveQuery applyTreeScope(E node, ReactiveQuery query) { + return query; } @Override @@ -205,12 +205,25 @@ default int getBufferSize() { @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) default Mono save(Publisher entityPublisher) { - return new ReactiveTreeSortServiceHelper<>(this) - .prepare(Flux.from(entityPublisher)) -// .doOnNext(e -> e.tryValidate(CreateGroup.class)) - .buffer(getBufferSize()) - .concatMap(this.getRepository()::save) - .reduce(SaveResult::merge); + Mono operation = Mono.defer(() -> { + // 每次订阅独立准备树快照;helper 不在服务单例或重复订阅之间共享。 + ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); + return helper.prepare(Flux.from(entityPublisher)) + .buffer(getBufferSize()) + .concatMap(batch -> Flux.fromIterable(batch) + .filter(helper::isMovingToRoot) + // ORM save 忽略普通 null;仅既有节点显式移根时,在原 save 前清空父级。 + // 同时写入已准备的 path/level,modify 与随后的 save 事件共享原事务。 + .concatMap(node -> getRepository().createUpdate() + .set("path", node.getPath()) + .set("level", node.getLevel()) + .setNull("parentId") + .where("id", node.getId()) + .execute()) + .then(getRepository().save(batch))) + .reduce(SaveResult::merge); + }); + return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); } @@ -234,13 +247,21 @@ default Mono save(E data) { @Override @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Mono updateById(K id, Mono entityPublisher) { - return this - .findById(id) - .map(e -> this - .save(entityPublisher.doOnNext(data -> data.setId(id))) - .map(SaveResult::getTotal)) - .defaultIfEmpty(Mono.just(0)) - .flatMap(Function.identity()); + return entityPublisher.flatMap(data -> findById(id).flatMap(existing -> { + // 部分修改忽略未提供的 null;显式移根保存完整状态,或在有树生命周期维护的服务中使用 DSL setNull。 + Map updates = FastBeanCopier.copy(data, new LinkedHashMap<>()); + updates.values().removeIf(Objects::isNull); + updates.remove("id"); + FastBeanCopier.copy(updates, existing); + existing.setId(id); + return save(existing).map(SaveResult::getTotal); + })).defaultIfEmpty(0); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, E entity) { + return updateById(id, Mono.just(entity)); } @Override @@ -252,12 +273,9 @@ default Mono deleteById(K id) { @Override @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Mono deleteById(Publisher idPublisher) { - return this - .findById(Flux.from(idPublisher)) - .concatMap(e -> StringUtils.hasText(e.getPath()) - ? getRepository().createDelete().where().like$(e::getPath).execute() - : getRepository().deleteById(e.getId()), Integer.MAX_VALUE) - .as(MathFlux::sumInt); + return Flux.from(idPublisher).collectList().flatMap(ids -> ids.isEmpty() + ? Mono.just(0) + : createDelete().in("id", ids).execute()); } IDGenerator getIDGenerator(); @@ -283,21 +301,15 @@ default boolean isRootNode(E entity) { } @Override - @SuppressWarnings("all") default ReactiveDelete createDelete() { - return ReactiveCrudService.super - .createDelete() - .onExecute((delete, executor) -> this - .queryIncludeChildren(delete.toQueryParam(QueryParamEntity::new) - .includes("id", "path", "parentId")) - .map(TreeSupportEntity::getId) - .buffer(200) - .concatMap(list -> getRepository() - .createDelete() - .where() - .in("id", list) - .execute(), Integer.MAX_VALUE) - //.concatWith(executor) - .reduce(0, Math::addExact)); + return ReactiveCrudService.super.createDelete().onExecute((delete, executor) -> + TransactionUtils.tryRunInTransaction( + // 展开时读取完整实体,让分组扩展点可用;整棵子树在同一个删除事件中校验。 + queryIncludeChildren(delete.toQueryParam(QueryParamEntity::new)) + .map(TreeSupportEntity::getId) + .collectList() + .flatMap(ids -> ids.isEmpty() ? Mono.just(0) : getRepository() + .createDelete().in("id", ids).execute()), + new DefaultTransactionDefinition())); } } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 4050cd442..8aa3a939b 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -112,6 +112,13 @@ private Mono init(Flux source) { .then(); } + /** 当前完整保存明确移根,且原持久化节点具有父级。 */ + protected boolean isMovingToRoot(E node) { + E previous = oldData.get(node.getId()); + return node.getParentId() == null && previous != null + && !ObjectUtils.isEmpty(previous.getParentId()); + } + private void initChildren() { childrenMapping.clear(); @@ -193,7 +200,7 @@ private void refactorPath() { if (old != null) { PK newParentId = data.getParentId(); //父节点发生变化,更新所有子节点path - if (newParentId != null && !newParentId.equals(parentId)) { + if (!Objects.equals(newParentId, parentId)) { Consumer childConsumer = child -> { //更新了父节点,但是同时也传入的对应的子节点 E readyToUpdate = thisTime.get(child.getId()); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java index 02eb2d3b0..0c27787fd 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/entity/TestTreeSortEntity.java @@ -4,7 +4,7 @@ import lombok.Setter; import org.hswebframework.ezorm.rdb.mapping.annotation.DefaultValue; import org.hswebframework.web.api.crud.entity.GenericTreeSortSupportEntity; -import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.crud.annotation.EnableEntityEvent; import org.hswebframework.web.validator.CreateGroup; import javax.persistence.Column; @@ -15,12 +15,16 @@ @Getter @Setter @Table(name = "test_tree_sort") +@EnableEntityEvent public class TestTreeSortEntity extends GenericTreeSortSupportEntity { @Column private String name; + @Column + private String groupId; + @Column(nullable = false) @NotBlank(groups = CreateGroup.class) @DefaultValue("test") diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java new file mode 100644 index 000000000..957621b64 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -0,0 +1,316 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityDeletedEvent; +import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.crud.events.EntitySavedEvent; +import org.hswebframework.web.crud.utils.TransactionUtils; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** 公共树分组、旧单树部分修改及真实实体事件事务的数据库回归。 */ +@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class, + ScopedReactiveTreeSortEntityServiceTest.Configuration.class}) +@RunWith(SpringJUnit4ClassRunner.class) +public class ScopedReactiveTreeSortEntityServiceTest { + + @Autowired + private ScopedTreeService scoped; + + @Autowired + private TestTreeSortEntityService unscoped; + + @Autowired + private FailureListener listener; + + @Test + public void samePathsInDifferentGroupsStayIsolatedForParentsChildrenAndDeletion() { + String groupA = id(); + String groupB = id(); + TestTreeSortEntity rootA = node(groupA, null, "same"); + TestTreeSortEntity rootB = node(groupB, null, "same"); + TestTreeSortEntity childA = node(groupA, rootA.getId(), "same-chld"); + TestTreeSortEntity childB = node(groupB, rootB.getId(), "same-chld"); + // 直接保存刻意同path的历史数据,验证隔离不能依赖随机生成器的低碰撞概率。 + scoped.getRepository().save(List.of(rootA, rootB, childA, childB)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + scoped.queryIncludeChildren(List.of(rootA.getId(), rootB.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> assertEquals( + Set.of(rootA.getId(), rootB.getId(), childA.getId(), childB.getId()), ids(nodes))) + .verifyComplete(); + scoped.queryIncludeParent(List.of(childA.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(rootA.getId(), childA.getId()), ids(nodes))).verifyComplete(); + scoped.deleteById(rootA.getId()).as(StepVerifier::create).expectNext(2).verifyComplete(); + scoped.findById(List.of(rootB.getId(), childB.getId())).count() + .as(StepVerifier::create).expectNext(2L).verifyComplete(); + scoped.createDelete().where("id", rootB.getId()).execute() + .as(StepVerifier::create).expectNext(2).verifyComplete(); + } + + @Test + public void projectionsDoNotRemoveInternalTreeScopeOrChangeRequestedFields() { + String groupA = id(); + String groupB = id(); + TestTreeSortEntity rootA = node(groupA, null, "same"); + TestTreeSortEntity rootB = node(groupB, null, "same"); + TestTreeSortEntity rootNull = node(null, null, "same"); + TestTreeSortEntity childA = node(groupA, rootA.getId(), "same-chld"); + TestTreeSortEntity childB = node(groupB, rootB.getId(), "same-chld"); + TestTreeSortEntity childNull = node(null, rootNull.getId(), "same-chld"); + scoped.getRepository().save(List.of(rootA, rootB, rootNull, childA, childB, childNull)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + QueryParamEntity included = QueryParamEntity.of("id", rootA.getId()).includes("id", "name"); + scoped.queryIncludeChildren(included).collectList().as(StepVerifier::create).assertNext(nodes -> { + assertEquals(Set.of(rootA.getId(), childA.getId()), ids(nodes)); + assertTrue(nodes.stream().allMatch(node -> node.getGroupId() == null && node.getPath() == null)); + }).verifyComplete(); + assertEquals(Set.of("id", "name"), included.getIncludes()); + QueryParamEntity excluded = QueryParamEntity.of("id", rootA.getId()).excludes("groupId"); + scoped.queryIncludeChildren(excluded).collectList().as(StepVerifier::create).assertNext(nodes -> { + assertEquals(Set.of(rootA.getId(), childA.getId()), ids(nodes)); + assertTrue(nodes.stream().allMatch(node -> node.getGroupId() == null)); + }).verifyComplete(); + assertEquals(Set.of("groupId"), excluded.getExcludes()); + QueryParamEntity namesOnly = QueryParamEntity.of("id", rootA.getId()).includes("name"); + scoped.queryIncludeChildren(namesOnly) + .map(TestTreeSortEntity::getName).collectList().as(StepVerifier::create) + .assertNext(names -> assertEquals(Set.of(rootA.getName(), childA.getName()), Set.copyOf(names))) + .verifyComplete(); + // 空分组也是真实独立树;清理使用精确ID,不让普通无scope服务跨组按path删除。 + scoped.getRepository().createDelete().in("id", List.of(rootA.getId(), rootB.getId(), rootNull.getId(), + childA.getId(), childB.getId(), childNull.getId())).execute() + .as(StepVerifier::create).expectNext(6).verifyComplete(); + } + + @Test + public void defaultSingleTreeNamePatchesPreserveParentAndOrdinaryDelete() { + TestTreeSortEntity root = node(null, null, null); + TestTreeSortEntity child = node(null, root.getId(), null); + root.setChildren(List.of(child)); + unscoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + TestTreeSortEntity firstPatch = new TestTreeSortEntity(); + firstPatch.setName("renamed first"); + unscoped.updateById(child.getId(), firstPatch) + .as(StepVerifier::create).expectNext(1).verifyComplete(); + TestTreeSortEntity secondPatch = new TestTreeSortEntity(); + secondPatch.setName("renamed second"); + unscoped.updateById(child.getId(), Mono.just(secondPatch)).then(unscoped.findById(child.getId())) + .as(StepVerifier::create).assertNext(after -> { + assertEquals("renamed second", after.getName()); + assertEquals(root.getId(), after.getParentId()); + assertTrue(after.getPath().startsWith(root.getPath() + "-")); + }).verifyComplete(); + unscoped.deleteById(root.getId()).as(StepVerifier::create).expectNext(2).verifyComplete(); + } + + @Test + public void fullEntitySaveMovesWholeSubtreeToRoot() { + TestTreeSortEntity root = node(null, null, null); + TestTreeSortEntity branch = node(null, root.getId(), null); + branch.setName("verify-root-" + id()); + int observedRoots = listener.observedRoots.get(); + TestTreeSortEntity leaf = node(null, branch.getId(), null); + root.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + unscoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + unscoped.findById(branch.getId()).flatMap(existing -> { + existing.setParentId(null); + return unscoped.save(existing); + }).then(unscoped.queryIncludeChildren(List.of(root.getId())).count()) + .as(StepVerifier::create).expectNext(1L).verifyComplete(); + unscoped.queryIncludeChildren(List.of(branch.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(branch.getId(), leaf.getId()), ids(nodes))).verifyComplete(); + Mono.zip(unscoped.findById(branch.getId()), unscoped.findById(leaf.getId())) + .as(StepVerifier::create).assertNext(nodes -> { + assertNull(nodes.getT1().getParentId()); + assertEquals(Integer.valueOf(1), nodes.getT1().getLevel()); + assertEquals(branch.getId(), nodes.getT2().getParentId()); + assertEquals(Integer.valueOf(2), nodes.getT2().getLevel()); + }).verifyComplete(); + assertEquals(observedRoots + 1, listener.observedRoots.get()); + unscoped.createDelete().in("id", List.of(root.getId(), branch.getId())).execute() + .as(StepVerifier::create).expectNext(3).verifyComplete(); + } + + @Test + public void explicitRootClearAndTreeSaveRollbackWithEventsAndCallerTransaction() { + for (String failure : List.of("modify", "save", "caller")) { + TestTreeSortEntity root = node(null, null, null); + TestTreeSortEntity branch = node(null, root.getId(), null); + branch.setName("reject-root-" + failure + "-" + id()); + TestTreeSortEntity leaf = node(null, branch.getId(), null); + root.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + unscoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + String branchPath = branch.getPath(); + String leafPath = leaf.getPath(); + Mono move = unscoped.findById(branch.getId()).flatMap(existing -> { + existing.setParentId(null); + return unscoped.save(existing); + }); + if ("caller".equals(failure)) { + move = TransactionUtils.tryRunInTransaction( + move.then(Mono.error(new IllegalStateException("reject source transaction"))), + new DefaultTransactionDefinition()); + } + move.as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + Mono.zip(unscoped.findById(branch.getId()), unscoped.findById(leaf.getId())) + .as(StepVerifier::create).assertNext(nodes -> { + assertEquals(root.getId(), nodes.getT1().getParentId()); + assertEquals(branchPath, nodes.getT1().getPath()); + assertEquals(Integer.valueOf(2), nodes.getT1().getLevel()); + assertEquals(branch.getId(), nodes.getT2().getParentId()); + assertEquals(leafPath, nodes.getT2().getPath()); + assertEquals(Integer.valueOf(3), nodes.getT2().getLevel()); + }).verifyComplete(); + unscoped.deleteById(root.getId()).as(StepVerifier::create).expectNext(3).verifyComplete(); + } + } + + @Test + public void dslSqlAndRealEntityEventsRollbackTogether() { + TestTreeSortEntity root = node(id(), null, null); + TestTreeSortEntity child = node(root.getGroupId(), root.getId(), null); + root.setChildren(List.of(child)); + scoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + String before = root.getName(); + scoped.createUpdate().set("name", "reject-dsl-" + id()).where("id", root.getId()).execute() + .as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + scoped.findById(root.getId()).as(StepVerifier::create) + .assertNext(existing -> assertEquals(before, existing.getName())).verifyComplete(); + scoped.createUpdate().set("name", "reject-delete-" + id()).where("id", root.getId()).execute() + .as(StepVerifier::create).expectNext(1).verifyComplete(); + scoped.createDelete().where("id", root.getId()).execute() + .as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + scoped.findById(List.of(root.getId(), child.getId())).count() + .as(StepVerifier::create).expectNext(2L).verifyComplete(); + // 使用原生产事件链恢复正常状态后清理,不直接调用listener。 + scoped.createUpdate().set("name", before).where("id", root.getId()).execute() + .then(scoped.deleteById(root.getId())).as(StepVerifier::create).expectNext(2).verifyComplete(); + } + + private static Set ids(List nodes) { + return nodes.stream().map(TestTreeSortEntity::getId).collect(Collectors.toSet()); + } + + private static String id() { + return UUID.randomUUID().toString(); + } + + private static TestTreeSortEntity node(String group, String parent, String path) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setId(id()); + node.setName(node.getId()); + node.setGroupId(group); + node.setParentId(parent); + node.setPath(path); + node.setLevel(parent == null ? 1 : 2); + return node; + } + + static class ScopedTreeService extends GenericReactiveCrudService + implements ReactiveTreeSortEntityService { + ScopedTreeService(ReactiveRepository repository) { + super(repository); + } + + @Override + public IDGenerator getIDGenerator() { + return IDGenerator.MD5; + } + + @Override + public void setChildren(TestTreeSortEntity entity, List children) { + entity.setChildren(children); + } + + @Override + public ReactiveQuery applyTreeScope(TestTreeSortEntity node, + ReactiveQuery query) { + return query.and("groupId", node.getGroupId()); + } + } + + static class FailureListener { + private final ReactiveRepository repository; + private final AtomicInteger observedRoots = new AtomicInteger(); + + FailureListener(ReactiveRepository repository) { + this.repository = repository; + } + + @EventListener + public void afterModify(EntityModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class + && event.getAfter().stream().anyMatch(node -> node.getName().startsWith("reject-dsl-") + || (node.getParentId() == null && node.getName().startsWith("reject-root-modify-")))) { + event.async(Mono.error(new IllegalStateException("reject modified tree"))); + } + } + + @EventListener + public void afterSave(EntitySavedEvent event) { + if (event.getEntityType() != TestTreeSortEntity.class) { + return; + } + for (TestTreeSortEntity node : event.getEntity()) { + if (node.getParentId() == null && node.getName().startsWith("reject-root-save-")) { + event.async(Mono.error(new IllegalStateException("reject saved root"))); + } + if (node.getParentId() == null && node.getName().startsWith("verify-root-")) { + event.async(repository.findById(node.getId()).doOnNext(stored -> { + assertNull(stored.getParentId()); + assertEquals(node.getPath(), stored.getPath()); + assertEquals(Integer.valueOf(1), stored.getLevel()); + observedRoots.incrementAndGet(); + }).then()); + } + } + } + + @EventListener + public void afterDelete(EntityDeletedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class + && event.getEntity().stream().anyMatch(node -> node.getName().startsWith("reject-delete-"))) { + event.async(Mono.error(new IllegalStateException("reject deleted tree"))); + } + } + } + + @TestConfiguration + static class Configuration { + @Bean + ScopedTreeService scopedTreeService(ReactiveRepository repository) { + return new ScopedTreeService(repository); + } + + @Bean + FailureListener treeFailureListener(ReactiveRepository repository) { + return new FailureListener(repository); + } + } +} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java index ae40f1423..88199b2bc 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java @@ -6,6 +6,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; +import org.hswebframework.web.bean.FastBeanCopier; @Getter @Setter @@ -20,6 +22,6 @@ public class DimensionTypeResponse { private String name; public static DimensionTypeResponse of(DimensionType type) { - return of(type.getId(), type.getName()); + return FastBeanCopier.copy(type, EntityFactoryHolder.newInstance(DimensionTypeResponse.class, DimensionTypeResponse::new)); } } diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java index 98013d9fd..4009026e3 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java @@ -17,10 +17,7 @@ import static org.junit.Assert.*; @RunWith(SpringRunner.class) -@SpringBootTest(classes = { - ReactiveTestApplication.class, - DefaultReactiveUserService.class -}) +@SpringBootTest(classes = ReactiveTestApplication.class) public class DefaultDimensionUserServiceTest { @Autowired diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java new file mode 100644 index 000000000..4c592ce76 --- /dev/null +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java @@ -0,0 +1,65 @@ +package org.hswebframework.web.system.authorization.defaults.webflux; + +import lombok.Getter; +import lombok.Setter; +import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; +import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.simple.SimpleDimensionType; +import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.Assert.*; + +public class DimensionTypeResponseTest { + private Object previous; + + @Before + public void isolateFactory() { + previous = ReflectionTestUtils.getField(EntityFactoryHolder.class, "FACTORY"); + ReflectionTestUtils.setField(EntityFactoryHolder.class, "FACTORY", null); + } + + @After + public void restoreFactory() { + ReflectionTestUtils.setField(EntityFactoryHolder.class, "FACTORY", previous); + } + + @Test + public void keepsStaticResponseAndTwoArgumentFactory() { + DimensionTypeResponse response = DimensionTypeResponse.of(SimpleDimensionType.of("org", "Organization")); + assertEquals(DimensionTypeResponse.class, response.getClass()); + assertEquals("org", response.getId()); + assertEquals("Organization", response.getName()); + assertEquals("Role", DimensionTypeResponse.of("role", "Role").getName()); + } + + @Test + public void copiesExtendedMetadataThroughRegisteredEntityFactory() { + MapperEntityFactory factory = new MapperEntityFactory(); + factory.addMapping(DimensionTypeResponse.class, ExtendedResponse::new); + ReflectionTestUtils.setField(EntityFactoryHolder.class, "FACTORY", factory); + ExtendedType type = new ExtendedType(); + type.setId("business"); type.setName("Business"); type.setDescription("Business dimension"); + DimensionTypeResponse mapped = DimensionTypeResponse.of(type); + assertTrue(mapped instanceof ExtendedResponse); + ExtendedResponse response = (ExtendedResponse) mapped; + assertEquals("business", response.getId()); + assertEquals("Business", response.getName()); + assertEquals("Business dimension", response.getDescription()); + } + + @Getter @Setter + public static class ExtendedType implements DimensionType { + private String id; + private String name; + private String description; + } + + @Getter @Setter + public static class ExtendedResponse extends DimensionTypeResponse { + private String description; + } +} From 8fbd0cf5594bd604ea8c1d8c3429ea4c57f356b1 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 16:26:36 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix(crud):=20=E4=B8=B2=E8=A1=8C=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=A0=91=E5=88=86=E7=89=87=E5=8F=8A=E7=88=B6=E5=AD=90?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 17 +- .../crud/service/TreeSortServiceHelper.java | 107 ++++++---- ...opedReactiveTreeSortEntityServiceTest.java | 195 ++++++++++++++++++ 3 files changed, 267 insertions(+), 52 deletions(-) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 718c0d8d8..a4280ceef 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -188,13 +188,16 @@ default Mono insert(E data) { @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) default Mono insertBatch(Publisher> entityPublisher) { - return this - .getRepository() - .insertBatch(new ReactiveTreeSortServiceHelper<>(this) - .prepare(Flux.from(entityPublisher) - .flatMapIterable(Function.identity())) - // .doOnNext(e -> e.tryValidate(CreateGroup.class)) - .buffer(getBufferSize())); + Mono operation = Mono.defer(() -> { + // 每次订阅独立准备;前一批 SQL 和异步实体事件完成后,才处理下一批。 + ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); + return helper.prepare(Flux.from(entityPublisher).flatMapIterable(Function.identity())) + .buffer(getBufferSize()) + .concatMap(batch -> getRepository().insertBatch(Mono.just(batch))) + .reduce(Math::addExact) + .defaultIfEmpty(0); + }); + return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); } default int getBufferSize() { diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 8aa3a939b..49d3bb6dd 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -69,7 +69,10 @@ public Flux prepare(Flux source) { .then(Mono.fromRunnable(this::checkCyclicDependency)) .then(Mono.fromRunnable(this::refactorPath)) .thenMany(Flux.defer(() -> Flux.fromIterable(readyToSave.values()))) - .doOnNext(this::refactor); + .doOnNext(this::refactor) + // sort 稳定保留同层输入顺序;分批前保证父节点位于子节点之前。 + .sort(Comparator.comparing(TreeSupportEntity::getLevel, + Comparator.nullsFirst(Comparator.naturalOrder()))); } private Mono init(Flux source) { @@ -192,60 +195,74 @@ private void refactorPath() { .getOrDefault(id, Collections.emptyMap()) .values(); - for (E data : thisTime.values()) { - E old = data.getId() == null ? null : oldData.get(data.getId()); - PK parentId = old != null ? old.getParentId() : data.getParentId(); - E oldParent = parentId == null ? null : allData.get(parentId); - //编辑节点 - if (old != null) { - PK newParentId = data.getParentId(); - //父节点发生变化,更新所有子节点path - if (!Objects.equals(newParentId, parentId)) { - Consumer childConsumer = child -> { - //更新了父节点,但是同时也传入的对应的子节点 - E readyToUpdate = thisTime.get(child.getId()); - if (null != readyToUpdate) { - readyToUpdate.setPath(child.getPath()); - } - }; - - //变更到了顶级节点 - if (isRootNode(data)) { - data.setPath(RandomUtil.randomChar(4)); - this.refactorChildPath(old.getId(), data.getPath(), childConsumer); - //重新保存所有子节点 - putChildToReadyToSave(childGetter, old); + // flat 输入可能先给子节点;沿用已校验的父子映射,先准备父路径再处理后代。 + Queue queue = new ArrayDeque<>(); + for (E node : allData.values()) { + if (isRootNode(node) || !allData.containsKey(node.getParentId())) { + queue.add(node); + } + } + for (E node = queue.poll(); node != null; node = queue.poll()) { + E data = thisTime.get(node.getId()); + if (data != null) { + refactorPath(data, childGetter); + } + queue.addAll(childGetter.apply(node.getId())); + } + } - } else { - E newParent = allData.get(newParentId); - if (null != newParent) { - data.setPath(newParent.getPath() + "-" + RandomUtil.randomChar(4)); - this.refactorChildPath(data.getId(), data.getPath(), childConsumer); - //重新保存所有子节点 - putChildToReadyToSave(childGetter, data); - } + private void refactorPath(E data, Function> childGetter) { + E old = data.getId() == null ? null : oldData.get(data.getId()); + PK parentId = old != null ? old.getParentId() : data.getParentId(); + E oldParent = parentId == null ? null : allData.get(parentId); + //编辑节点 + if (old != null) { + PK newParentId = data.getParentId(); + //父节点发生变化,更新所有子节点path + if (!Objects.equals(newParentId, parentId)) { + Consumer childConsumer = child -> { + //更新了父节点,但是同时也传入的对应的子节点 + E readyToUpdate = thisTime.get(child.getId()); + if (null != readyToUpdate) { + readyToUpdate.setPath(child.getPath()); } + }; + + //变更到了顶级节点 + if (isRootNode(data)) { + data.setPath(RandomUtil.randomChar(4)); + this.refactorChildPath(old.getId(), data.getPath(), childConsumer); + //重新保存所有子节点 + putChildToReadyToSave(childGetter, old); + } else { - if (oldParent != null) { - if (old.getPath().startsWith(oldParent.getPath())) { - data.setPath(old.getPath()); - } else { - data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); - } - } else { - data.setPath(old.getPath()); + E newParent = allData.get(newParentId); + if (null != newParent) { + data.setPath(newParent.getPath() + "-" + RandomUtil.randomChar(4)); + this.refactorChildPath(data.getId(), data.getPath(), childConsumer); + //重新保存所有子节点 + putChildToReadyToSave(childGetter, data); } } - } - - //新增节点 - else if (parentId != null) { + } else { if (oldParent != null) { - data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + if (old.getPath().startsWith(oldParent.getPath())) { + data.setPath(old.getPath()); + } else { + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + } + } else { + data.setPath(old.getPath()); } } } + //新增节点 + else if (parentId != null) { + if (oldParent != null) { + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + } + } } private void putChildToReadyToSave(Function> childGetter, E data) { diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index 957621b64..d525d271c 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -5,6 +5,9 @@ import org.hswebframework.web.crud.TestApplication; import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityBeforeCreateEvent; +import org.hswebframework.web.crud.events.EntityBeforeSaveEvent; +import org.hswebframework.web.crud.events.EntityCreatedEvent; import org.hswebframework.web.crud.events.EntityDeletedEvent; import org.hswebframework.web.crud.events.EntityModifyEvent; import org.hswebframework.web.crud.events.EntitySavedEvent; @@ -13,16 +16,20 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.event.EventListener; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @@ -37,6 +44,7 @@ public class ScopedReactiveTreeSortEntityServiceTest { @Autowired + @Qualifier("scopedTreeService") private ScopedTreeService scoped; @Autowired @@ -45,6 +53,9 @@ public class ScopedReactiveTreeSortEntityServiceTest { @Autowired private FailureListener listener; + @Autowired + private BatchedTreeService batched; + @Test public void samePathsInDifferentGroupsStayIsolatedForParentsChildrenAndDeletion() { String groupA = id(); @@ -213,6 +224,111 @@ public void dslSqlAndRealEntityEventsRollbackTogether() { .then(scoped.deleteById(root.getId())).as(StepVerifier::create).expectNext(2).verifyComplete(); } + @Test + public void preparedFlatTreesUseParentDependenciesAndKeepSameLevelInputOrder() { + String group = id(); + TestTreeSortEntity rootA = node(group, null, null); + TestTreeSortEntity rootB = node(group, null, null); + TestTreeSortEntity branchA = node(group, rootA.getId(), null); + TestTreeSortEntity branchB = node(group, rootB.getId(), null); + TestTreeSortEntity leafA = node(group, branchA.getId(), null); + TestTreeSortEntity leafB = node(group, branchB.getId(), null); + new ReactiveTreeSortServiceHelper<>(batched) + .prepare(Flux.fromIterable(List.of(leafB, branchA, rootB, leafA, branchB, rootA))) + .collectList().as(StepVerifier::create).assertNext(prepared -> { + assertEquals(List.of(rootB.getId(), rootA.getId(), branchA.getId(), branchB.getId(), + leafB.getId(), leafA.getId()), prepared.stream().map(TestTreeSortEntity::getId) + .collect(Collectors.toList())); + assertTreeStructure(prepared); + }).verifyComplete(); + batched.insertBatch(Flux.empty()).as(StepVerifier::create).expectNext(0).verifyComplete(); + } + + @Test + public void smallBatchesCompleteParentEventsBeforeChildrenAndMoveToExistingRoot() { + for (String operation : List.of("insert", "save")) { + String group = id(); + TestTreeSortEntity rootA = batchNode(group, null); + TestTreeSortEntity rootB = batchNode(group, null); + TestTreeSortEntity branchA = batchNode(group, rootA.getId()); + TestTreeSortEntity branchB = batchNode(group, rootB.getId()); + TestTreeSortEntity leafA = batchNode(group, branchA.getId()); + TestTreeSortEntity leafB = batchNode(group, branchB.getId()); + List flat = List.of(leafB, branchA, rootB, leafA, branchB, rootA); + Mono write = "insert".equals(operation) + ? batched.insertBatch(Mono.just(flat)) : batched.save(Flux.fromIterable(flat)); + write.then(batched.findById(flat.stream().map(TestTreeSortEntity::getId) + .collect(Collectors.toList())).collectList()).as(StepVerifier::create) + .assertNext(stored -> { + assertEquals(6, stored.size()); + assertTreeStructure(stored); + assertTrue(stored.stream().allMatch(node -> "tree-event-complete".equals(node.getDefaultTest()))); + }).verifyComplete(); + // 目标根未包含在本次保存中;原图叶节点也必须随分支一同移到新树。 + batched.findById(branchA.getId()).flatMap(branch -> { + branch.setParentId(rootB.getId()); + return batched.save(branch); + }).then(batched.findById(flat.stream().map(TestTreeSortEntity::getId) + .collect(Collectors.toList())).collectList()).as(StepVerifier::create) + .assertNext(ScopedReactiveTreeSortEntityServiceTest::assertTreeStructure).verifyComplete(); + batched.queryIncludeChildren(List.of(rootA.getId())).count() + .as(StepVerifier::create).expectNext(1L).verifyComplete(); + batched.queryIncludeChildren(List.of(rootB.getId())).count() + .as(StepVerifier::create).expectNext(5L).verifyComplete(); + batched.getRepository().createDelete().where("groupId", group).execute() + .as(StepVerifier::create).expectNext(6).verifyComplete(); + } + } + + @Test + public void laterBatchEventsAndCallerFailureRollbackEveryBatchAndOriginalWrite() { + for (String operation : List.of("insert", "save")) { + for (String failure : List.of("event", "caller")) { + String group = id(); + TestTreeSortEntity original = node(group, null, null); + TestTreeSortEntity root = batchNode(group, null); + TestTreeSortEntity branch = batchNode(group, root.getId()); + TestTreeSortEntity leaf = batchNode(group, branch.getId()); + if ("event".equals(failure)) { + leaf.setName("reject-batch-event-" + id()); + } + List flat = List.of(leaf, branch, root); + Mono write = "insert".equals(operation) + ? batched.insertBatch(Mono.just(flat)) : batched.save(Flux.fromIterable(flat)); + Mono transaction = batched.getRepository().save(original).then(write); + if ("caller".equals(failure)) { + transaction = transaction.then(Mono.error(new IllegalStateException("reject original transaction"))); + } + TransactionUtils.tryRunInTransaction(transaction, new DefaultTransactionDefinition()) + .as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + batched.createQuery().where("groupId", group).count() + .as(StepVerifier::create).expectNext(0).verifyComplete(); + } + } + } + + private static void assertTreeStructure(Collection nodes) { + Map tree = nodes.stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + for (TestTreeSortEntity node : nodes) { + if (node.getParentId() == null) { + assertEquals(Integer.valueOf(1), node.getLevel()); + } else { + TestTreeSortEntity parent = tree.get(node.getParentId()); + assertNotNull(parent); + assertEquals(parent.getGroupId(), node.getGroupId()); + assertTrue(node.getPath().startsWith(parent.getPath() + "-")); + assertEquals(Integer.valueOf(parent.getLevel() + 1), node.getLevel()); + } + } + } + + private static TestTreeSortEntity batchNode(String group, String parent) { + TestTreeSortEntity node = node(group, parent, null); + node.setName("verify-batch-" + node.getId()); + return node; + } + private static Set ids(List nodes) { return nodes.stream().map(TestTreeSortEntity::getId).collect(Collectors.toSet()); } @@ -255,6 +371,17 @@ public ReactiveQuery applyTreeScope(TestTreeSortEntity node, } } + static class BatchedTreeService extends ScopedTreeService { + BatchedTreeService(ReactiveRepository repository) { + super(repository); + } + + @Override + public int getBufferSize() { + return 2; + } + } + static class FailureListener { private final ReactiveRepository repository; private final AtomicInteger observedRoots = new AtomicInteger(); @@ -263,6 +390,68 @@ static class FailureListener { this.repository = repository; } + @EventListener + public void beforeCreate(EntityBeforeCreateEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.async(validateBatch(event.getEntity())); + } + } + + @EventListener + public void beforeSave(EntityBeforeSaveEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.async(validateBatch(event.getEntity())); + } + } + + private Mono validateBatch(List input) { + return Mono.defer(() -> { + Map batch = input.stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + return Flux.fromIterable(input).filter(this::isBatchNode) + .filter(node -> node.getParentId() != null).concatMap(node -> { + TestTreeSortEntity parent = batch.get(node.getParentId()); + Mono parentState = parent == null + ? repository.findById(node.getParentId()).switchIfEmpty(Mono.error( + new IllegalStateException("parent batch has not been written"))) + : Mono.just(parent); + return parentState.doOnNext(stored -> { + assertEquals(stored.getGroupId(), node.getGroupId()); + assertTrue(node.getPath().startsWith(stored.getPath() + "-")); + assertEquals(Integer.valueOf(stored.getLevel() + 1), node.getLevel()); + if (parent == null) { + // 此字段由前批真实异步 afterCreate/afterSave SQL 更新。 + assertEquals("tree-event-complete", stored.getDefaultTest()); + } + }).then(); + }).then(); + }); + } + + private boolean isBatchNode(TestTreeSortEntity node) { + return node.getName().startsWith("verify-batch-") || node.getName().startsWith("reject-batch-event-"); + } + + private Mono completeBatch(List input) { + List nodes = input.stream().filter(this::isBatchNode).collect(Collectors.toList()); + if (nodes.stream().anyMatch(node -> node.getName().startsWith("reject-batch-event-"))) { + return Mono.error(new IllegalStateException("reject later batch event")); + } + if (nodes.isEmpty()) { + return Mono.empty(); + } + return repository.createUpdate().set("defaultTest", "tree-event-complete") + .in("id", nodes.stream().map(TestTreeSortEntity::getId).collect(Collectors.toList())) + .execute().then(); + } + + @EventListener + public void afterCreate(EntityCreatedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.async(completeBatch(event.getEntity())); + } + } + @EventListener public void afterModify(EntityModifyEvent event) { if (event.getEntityType() == TestTreeSortEntity.class @@ -277,6 +466,7 @@ public void afterSave(EntitySavedEvent event) { if (event.getEntityType() != TestTreeSortEntity.class) { return; } + event.async(completeBatch(event.getEntity())); for (TestTreeSortEntity node : event.getEntity()) { if (node.getParentId() == null && node.getName().startsWith("reject-root-save-")) { event.async(Mono.error(new IllegalStateException("reject saved root"))); @@ -308,6 +498,11 @@ ScopedTreeService scopedTreeService(ReactiveRepository repository) { + return new BatchedTreeService(repository); + } + @Bean FailureListener treeFailureListener(ReactiveRepository repository) { return new FailureListener(repository); From d8f4e3939ba0962311a8c9126c62d6181ce851b1 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 16:35:59 +0800 Subject: [PATCH 03/17] =?UTF-8?q?fix(=E8=AE=A4=E8=AF=81):=20=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E5=8F=AA=E8=AF=BB=E7=BB=B4=E5=BA=A6=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E5=BF=85=E9=80=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../defaults/webflux/DimensionTypeResponse.java | 7 ++++++- .../defaults/webflux/DimensionTypeResponseTest.java | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java index 88199b2bc..c0915eeb3 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponse.java @@ -22,6 +22,11 @@ public class DimensionTypeResponse { private String name; public static DimensionTypeResponse of(DimensionType type) { - return FastBeanCopier.copy(type, EntityFactoryHolder.newInstance(DimensionTypeResponse.class, DimensionTypeResponse::new)); + DimensionTypeResponse response = FastBeanCopier.copy(type, + EntityFactoryHolder.newInstance(DimensionTypeResponse.class, DimensionTypeResponse::new)); + // 类型接口允许枚举和只读实现;必选字段不依赖属性复制器的可写 Bean 属性。 + response.setId(type.getId()); + response.setName(type.getName()); + return response; } } diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java index 4c592ce76..3d93a5c9d 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/webflux/DimensionTypeResponseTest.java @@ -4,6 +4,7 @@ import lombok.Setter; import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; import org.hswebframework.web.authorization.DimensionType; +import org.hswebframework.web.authorization.DefaultDimensionType; import org.hswebframework.web.authorization.simple.SimpleDimensionType; import org.hswebframework.web.crud.entity.factory.MapperEntityFactory; import org.junit.After; @@ -34,6 +35,9 @@ public void keepsStaticResponseAndTwoArgumentFactory() { assertEquals("org", response.getId()); assertEquals("Organization", response.getName()); assertEquals("Role", DimensionTypeResponse.of("role", "Role").getName()); + DimensionTypeResponse role = DimensionTypeResponse.of(DefaultDimensionType.role); + assertEquals("role", role.getId()); + assertEquals(DefaultDimensionType.role.getName(), role.getName()); } @Test From 3a9953c01e867b0f287e371b3e1f91c203bb4c7d Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 16:58:55 +0800 Subject: [PATCH 04/17] =?UTF-8?q?test(=E8=AE=A4=E8=AF=81):=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E6=B5=8B=E8=AF=95=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E8=87=AA=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../defaults/service/DefaultDimensionUserServiceTest.java | 6 +++++- .../DefaultReactiveAuthenticationManagerTest.java | 7 +++++-- .../service/reactive/DefaultReactiveUserServiceTest.java | 7 +++++-- .../service/reactive/WebFluxPermissionControllerTest.java | 8 +++++++- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java index 4009026e3..eb8f1b3a7 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserServiceTest.java @@ -17,7 +17,11 @@ import static org.junit.Assert.*; @RunWith(SpringRunner.class) -@SpringBootTest(classes = ReactiveTestApplication.class) +// 每个测试类使用独立数据库和认证状态,避免 Spring 上下文复用缓存及 H2 默认库跨类共享。 +@SpringBootTest(classes = ReactiveTestApplication.class, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=DefaultDimensionUserServiceTest" +}) public class DefaultDimensionUserServiceTest { @Autowired diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveAuthenticationManagerTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveAuthenticationManagerTest.java index 302276e9f..c56b1fe64 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveAuthenticationManagerTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveAuthenticationManagerTest.java @@ -10,7 +10,6 @@ import org.hswebframework.web.system.authorization.api.entity.PermissionEntity; import org.hswebframework.web.system.authorization.api.entity.UserEntity; import org.hswebframework.web.system.authorization.api.service.reactive.ReactiveUserService; -import org.hswebframework.web.system.authorization.defaults.service.DefaultReactiveUserService; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -24,7 +23,11 @@ import java.util.Collections; @RunWith(SpringRunner.class) -@SpringBootTest(classes = {ReactiveTestApplication.class, DefaultReactiveUserService.class}) +// 每个测试类使用独立数据库和认证状态,避免 Spring 上下文复用缓存及 H2 默认库跨类共享。 +@SpringBootTest(classes = ReactiveTestApplication.class, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=DefaultReactiveAuthenticationManagerTest" +}) public class DefaultReactiveAuthenticationManagerTest { @Autowired diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveUserServiceTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveUserServiceTest.java index eeb66b885..ad93b6d53 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveUserServiceTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/DefaultReactiveUserServiceTest.java @@ -3,7 +3,6 @@ import org.hswebframework.web.exception.ValidationException; import org.hswebframework.web.system.authorization.api.entity.UserEntity; import org.hswebframework.web.system.authorization.api.service.reactive.ReactiveUserService; -import org.hswebframework.web.system.authorization.defaults.service.DefaultReactiveUserService; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -18,7 +17,11 @@ import java.util.function.Supplier; @RunWith(SpringRunner.class) -@SpringBootTest(classes = {ReactiveTestApplication.class, DefaultReactiveUserService.class}) +// 每个测试类使用独立数据库和认证状态,避免 Spring 上下文复用缓存及 H2 默认库跨类共享。 +@SpringBootTest(classes = ReactiveTestApplication.class, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=DefaultReactiveUserServiceTest" +}) public class DefaultReactiveUserServiceTest { @Autowired diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/WebFluxPermissionControllerTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/WebFluxPermissionControllerTest.java index 45c091ae9..cca58507b 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/WebFluxPermissionControllerTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/reactive/WebFluxPermissionControllerTest.java @@ -1,6 +1,7 @@ package org.hswebframework.web.system.authorization.defaults.service.reactive; import org.hswebframework.web.crud.configuration.EasyormConfiguration; +import org.hswebframework.web.crud.configuration.EntityFactoryConfiguration; import org.hswebframework.web.crud.configuration.JdbcSqlExecutorConfiguration; import org.hswebframework.web.crud.configuration.R2dbcSqlExecutorConfiguration; import org.hswebframework.web.system.authorization.defaults.configuration.AuthorizationServiceAutoConfiguration; @@ -20,11 +21,16 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; @RunWith(SpringRunner.class) -@WebFluxTest(WebFluxPermissionController.class) +// 每个测试类使用独立数据库和认证状态,避免 Spring 上下文复用缓存及 H2 默认库跨类共享。 +@WebFluxTest(controllers = WebFluxPermissionController.class, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=WebFluxPermissionControllerTest" +}) @ImportAutoConfiguration(value = { AuthorizationWebAutoConfiguration.class, AuthorizationServiceAutoConfiguration.class, EasyormConfiguration.class, + EntityFactoryConfiguration.class, R2dbcSqlExecutorConfiguration.class, R2dbcAutoConfiguration.class, R2dbcTransactionManagerAutoConfiguration.class },exclude = { From d64ac0fcf66a275fc148275321c337a173fe90ad Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 17:44:02 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(crud):=20=E4=BF=AE=E5=A4=8D=E5=90=8C?= =?UTF-8?q?=E6=89=B9=E7=A7=BB=E5=8A=A8=E8=B7=AF=E5=BE=84=E4=B8=8E=E5=AD=90?= =?UTF-8?q?=E6=A0=91=E5=88=A0=E9=99=A4=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 12 +- .../crud/service/TreeSortServiceHelper.java | 18 +- .../web/crud/PlainCrudTransactionTest.java | 189 +++++++++ ...opedReactiveTreeSortEntityServiceTest.java | 360 ++++++++++++++++++ .../SyncTreeSortServiceHelperTest.java | 94 +++++ 5 files changed, 661 insertions(+), 12 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index a4280ceef..ab2d68915 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -307,12 +307,16 @@ default boolean isRootNode(E entity) { default ReactiveDelete createDelete() { return ReactiveCrudService.super.createDelete().onExecute((delete, executor) -> TransactionUtils.tryRunInTransaction( - // 展开时读取完整实体,让分组扩展点可用;整棵子树在同一个删除事件中校验。 + // 完整实体用于分组和层级排序;叶先有界分片,每批SQL与事件完成后才删除父级。 + // 全部批次仍参与同一个原事务,后批事件或调用方失败必须整体回滚。 queryIncludeChildren(delete.toQueryParam(QueryParamEntity::new)) + .sort(Comparator.comparing((E node) -> node.getLevel(), + Comparator.nullsFirst(Comparator.naturalOrder())).reversed()) .map(TreeSupportEntity::getId) - .collectList() - .flatMap(ids -> ids.isEmpty() ? Mono.just(0) : getRepository() - .createDelete().in("id", ids).execute()), + .buffer(getBufferSize()) + .concatMap(ids -> getRepository().createDelete().in("id", ids).execute()) + .reduce(Math::addExact) + .defaultIfEmpty(0), new DefaultTransactionDefinition())); } } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 49d3bb6dd..28ac69cb7 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -217,17 +217,16 @@ private void refactorPath(E data, Function> childGetter) { E oldParent = parentId == null ? null : allData.get(parentId); //编辑节点 if (old != null) { + Consumer childConsumer = child -> { + //更新了父节点,但是同时也传入的对应的子节点 + E readyToUpdate = thisTime.get(child.getId()); + if (null != readyToUpdate) { + readyToUpdate.setPath(child.getPath()); + } + }; PK newParentId = data.getParentId(); //父节点发生变化,更新所有子节点path if (!Objects.equals(newParentId, parentId)) { - Consumer childConsumer = child -> { - //更新了父节点,但是同时也传入的对应的子节点 - E readyToUpdate = thisTime.get(child.getId()); - if (null != readyToUpdate) { - readyToUpdate.setPath(child.getPath()); - } - }; - //变更到了顶级节点 if (isRootNode(data)) { data.setPath(RandomUtil.randomChar(4)); @@ -249,7 +248,10 @@ private void refactorPath(E data, Function> childGetter) { if (old.getPath().startsWith(oldParent.getPath())) { data.setPath(old.getPath()); } else { + //父ID未变但祖先已移动;自身重建path时,遗漏输入的后代也必须保持一致。 data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + this.refactorChildPath(data.getId(), data.getPath(), childConsumer); + putChildToReadyToSave(childGetter, data); } } else { data.setPath(old.getPath()); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java new file mode 100644 index 000000000..2a7aad023 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java @@ -0,0 +1,189 @@ +package org.hswebframework.web.crud; + +import org.hswebframework.web.crud.entity.CustomTestEntity; +import org.hswebframework.web.crud.entity.TestEntity; +import org.hswebframework.web.crud.events.EntityDeletedEvent; +import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.crud.service.CustomTestCustom; +import org.hswebframework.web.crud.service.TestEntityService; +import org.hswebframework.web.id.IDGenerator; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.reactive.TransactionSynchronization; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; + +/** + * 使用现有普通 CRUD 服务验证 DSL 的 SQL 与异步实体事件属于同一事务。 + * 事件控制器只装配在本测试上下文,不修改共享应用、实体或生产事务配置。 + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = { + TestApplication.class, TestEntityService.class, CustomTestCustom.class, + PlainCrudTransactionTest.EventConfiguration.class +}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=PlainCrudTransactionTest" +}) +public class PlainCrudTransactionTest { + + private static final Duration VERIFY_TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private TestEntityService service; + + @Autowired + private ControlledEntityEvents events; + + @After + public void resetEvents() { + events.failModifyId = null; + events.deleteCancellation = null; + } + + @Test + public void updateRollsBackWhenModifyEventFails() { + TestEntity entity = insert("before-update"); + events.failModifyId = entity.getId(); + + service.createUpdate() + .set(TestEntity::getName, "after-update") + .where(TestEntity::getId, entity.getId()) + .execute() + .as(StepVerifier::create) + .expectErrorMatches(error -> error == events.modifyFailure) + .verify(VERIFY_TIMEOUT); + + service.findById(entity.getId()) + .map(TestEntity::getName) + .as(StepVerifier::create) + .expectNext("before-update") + .expectComplete() + .verify(VERIFY_TIMEOUT); + } + + @Test + public void deleteRollsBackWhenCancelledAfterSqlAndEventStart() { + TestEntity entity = insert("before-delete"); + DeleteCancellation cancellation = new DeleteCancellation(entity.getId()); + events.deleteCancellation = cancellation; + + // 删除事件在当前事务中确认行已消失后发信号,再由下游取消挂起的执行流。 + service.createDelete() + .where(TestEntity::getId, entity.getId()) + .execute() + .takeUntilOther(cancellation.started.asMono()) + .as(StepVerifier::create) + .expectComplete() + .verify(VERIFY_TIMEOUT); + + // 取消的清理是异步的;等待真实回滚完成信号后再验证数据库,不使用延时或轮询。 + cancellation.completion.asMono() + .as(StepVerifier::create) + .expectNext(TransactionSynchronization.STATUS_ROLLED_BACK) + .expectComplete() + .verify(VERIFY_TIMEOUT); + + service.findById(entity.getId()) + .map(TestEntity::getName) + .as(StepVerifier::create) + .expectNext("before-delete") + .expectComplete() + .verify(VERIFY_TIMEOUT); + } + + private TestEntity insert(String name) { + CustomTestEntity entity = new CustomTestEntity(); + entity.setId(IDGenerator.RANDOM.generate()); + entity.setName(name); + entity.setAge(1); + service.insert(entity) + .as(StepVerifier::create) + .expectNext(1) + .expectComplete() + .verify(VERIFY_TIMEOUT); + return entity; + } + + @TestConfiguration + static class EventConfiguration { + @Bean + ControlledEntityEvents controlledEntityEvents(TestEntityService service) { + return new ControlledEntityEvents(service); + } + } + + /** 每个用例只控制自己插入的行;其他测试及实体事件使用原有行为。 */ + static class ControlledEntityEvents { + private final TestEntityService service; + private final IllegalStateException modifyFailure = new IllegalStateException("modify event failed"); + private volatile String failModifyId; + private volatile DeleteCancellation deleteCancellation; + + ControlledEntityEvents(TestEntityService service) { + this.service = service; + } + + @EventListener + public void modified(EntityModifyEvent event) { + if (event.getAfter().stream().anyMatch(entity -> entity.getId().equals(failModifyId))) { + event.async(Mono.error(modifyFailure)); + } + } + + @EventListener + public void deleted(EntityDeletedEvent event) { + DeleteCancellation cancellation = deleteCancellation; + if (cancellation != null && event.getEntity().stream() + .anyMatch(entity -> entity.getId().equals(cancellation.id))) { + event.async(awaitCancellation(cancellation)); + } + } + + private Mono awaitCancellation(DeleteCancellation cancellation) { + return TransactionSynchronizationManager.forCurrentTransaction() + .flatMap(manager -> { + if (!manager.isActualTransactionActive()) { + return Mono.error(new AssertionError("delete event requires the SQL transaction")); + } + manager.registerSynchronization(new TransactionSynchronization() { + @Override + public Mono afterCompletion(int status) { + return Mono.fromRunnable(() -> cancellation.completion.emitValue( + status, Sinks.EmitFailureHandler.FAIL_FAST)); + } + }); + return service.getRepository().findById(cancellation.id).hasElement(); + }) + .flatMap(exists -> { + if (exists) { + return Mono.error(new AssertionError("delete event started before the SQL deleted the row")); + } + return Mono.fromRunnable(() -> cancellation.started.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST)) + .then(Mono.never()); + }); + } + } + + /** 信号分离事件启动和事务回滚完成,避免在 SQL 执行前取消或提前读取数据库。 */ + static class DeleteCancellation { + private final String id; + private final Sinks.Empty started = Sinks.empty(); + private final Sinks.One completion = Sinks.one(); + + DeleteCancellation(String id) { + this.id = id; + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index d525d271c..cbd77e231 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -6,6 +6,7 @@ import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.entity.TestTreeSortEntity; import org.hswebframework.web.crud.events.EntityBeforeCreateEvent; +import org.hswebframework.web.crud.events.EntityBeforeDeleteEvent; import org.hswebframework.web.crud.events.EntityBeforeSaveEvent; import org.hswebframework.web.crud.events.EntityCreatedEvent; import org.hswebframework.web.crud.events.EntityDeletedEvent; @@ -13,6 +14,7 @@ import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.crud.utils.TransactionUtils; import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.exception.ValidationException; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -32,7 +34,10 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -307,6 +312,340 @@ public void laterBatchEventsAndCallerFailureRollbackEveryBatchAndOriginalWrite() } } + @Test + public void savingParentAndChildTogetherKeepsOmittedGrandchildInMovedSubtree() { + for (boolean childFirst : List.of(false, true)) { + for (boolean moveToRoot : List.of(false, true)) { + String group = id(); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity target = node(group, null, null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity child = node(group, branch.getId(), null); + TestTreeSortEntity grandchild = node(group, child.getId(), null); + branch.setSortIndex(17L); + child.setSortIndex(23L); + grandchild.setSortIndex(31L); + scoped.save(List.of(root, target, branch, child, grandchild)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + Mono.zip(scoped.findById(branch.getId()), scoped.findById(child.getId())) + .flatMap(pair -> { + TestTreeSortEntity moved = pair.getT1(); + TestTreeSortEntity suppliedChild = pair.getT2(); + moved.setParentId(moveToRoot ? null : target.getId()); + // 两节点来自数据库,children为空;孙节点必须由持久图加载并随路径修复。 + assertNull(moved.getChildren()); + assertNull(suppliedChild.getChildren()); + return scoped.save(childFirst ? List.of(suppliedChild, moved) + : List.of(moved, suppliedChild)); + }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertEquals(5, tree.size()); + assertTreeStructure(tree.values()); + TestTreeSortEntity moved = tree.get(branch.getId()); + assertEquals(moveToRoot ? null : target.getId(), moved.getParentId()); + assertEquals(Integer.valueOf(moveToRoot ? 1 : 2), moved.getLevel()); + assertEquals(root.getPath(), tree.get(root.getId()).getPath()); + assertEquals(target.getPath(), tree.get(target.getId()).getPath()); + assertEquals(Long.valueOf(17), moved.getSortIndex()); + assertEquals(Long.valueOf(23), tree.get(child.getId()).getSortIndex()); + assertEquals(Long.valueOf(31), tree.get(grandchild.getId()).getSortIndex()); + }).verifyComplete(); + scoped.queryIncludeChildren(List.of(child.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(child.getId(), grandchild.getId()), ids(nodes))).verifyComplete(); + scoped.deleteById(child.getId()).as(StepVerifier::create).expectNext(2).verifyComplete(); + stored(scoped, group).as(StepVerifier::create).assertNext(tree -> { + assertEquals(Set.of(root.getId(), target.getId(), branch.getId()), tree.keySet()); + assertTreeStructure(tree.values()); + }).verifyComplete(); + scoped.getRepository().createDelete().where("groupId", group).execute() + .as(StepVerifier::create).expectNext(3).verifyComplete(); + } + } + } + + @Test + public void suppliedParentAndChildCanMoveToDifferentExistingParents() { + for (boolean childFirst : List.of(false, true)) { + String group = id(); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity parentTarget = node(group, null, null); + TestTreeSortEntity childTarget = node(group, parentTarget.getId(), null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity child = node(group, branch.getId(), null); + TestTreeSortEntity grandchild = node(group, child.getId(), null); + scoped.save(List.of(root, parentTarget, childTarget, branch, child, grandchild)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + Mono.zip(scoped.findById(branch.getId()), scoped.findById(child.getId())) + .flatMap(pair -> { + pair.getT1().setParentId(parentTarget.getId()); + pair.getT2().setParentId(childTarget.getId()); + return scoped.save(childFirst ? List.of(pair.getT2(), pair.getT1()) + : List.of(pair.getT1(), pair.getT2())); + }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertEquals(6, tree.size()); + assertTreeStructure(tree.values()); + assertEquals(parentTarget.getId(), tree.get(branch.getId()).getParentId()); + assertEquals(childTarget.getId(), tree.get(child.getId()).getParentId()); + assertEquals(child.getId(), tree.get(grandchild.getId()).getParentId()); + assertEquals(root.getPath(), tree.get(root.getId()).getPath()); + assertEquals(parentTarget.getPath(), tree.get(parentTarget.getId()).getPath()); + assertEquals(childTarget.getPath(), tree.get(childTarget.getId()).getPath()); + }).verifyComplete(); + scoped.queryIncludeChildren(List.of(branch.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(branch.getId()), ids(nodes))).verifyComplete(); + scoped.queryIncludeChildren(List.of(childTarget.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(childTarget.getId(), child.getId(), grandchild.getId()), ids(nodes))) + .verifyComplete(); + scoped.getRepository().createDelete().where("groupId", group).execute() + .as(StepVerifier::create).expectNext(6).verifyComplete(); + } + } + + @Test + public void externalDeepParentSupportsExistingMovesAndNewFlatBranches() { + String group = id(); + TestTreeSortEntity targetRoot = node(group, null, null); + TestTreeSortEntity targetBranch = node(group, targetRoot.getId(), null); + TestTreeSortEntity target = node(group, targetBranch.getId(), null); + TestTreeSortEntity oldRoot = node(group, null, null); + TestTreeSortEntity branch = node(group, oldRoot.getId(), null); + TestTreeSortEntity leaf = node(group, branch.getId(), null); + scoped.save(List.of(targetRoot, targetBranch, target, oldRoot, branch, leaf)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + scoped.findById(branch.getId()).flatMap(existing -> { + existing.setParentId(target.getId()); + return scoped.save(existing); + }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertTreeStructure(tree.values()); + assertEquals(Integer.valueOf(4), tree.get(branch.getId()).getLevel()); + assertEquals(Integer.valueOf(5), tree.get(leaf.getId()).getLevel()); + }).verifyComplete(); + for (String operation : List.of("insert", "save")) { + TestTreeSortEntity added = node(group, target.getId(), null); + TestTreeSortEntity addedLeaf = node(group, added.getId(), null); + // 只提供新分支且子先父后;已有level3目标及其祖先均不在输入中。 + Mono write = "insert".equals(operation) ? scoped.insert(Flux.just(addedLeaf, added)) + : scoped.save(Flux.just(addedLeaf, added)); + write.then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertTreeStructure(tree.values()); + assertEquals(Integer.valueOf(4), tree.get(added.getId()).getLevel()); + assertEquals(Integer.valueOf(5), tree.get(addedLeaf.getId()).getLevel()); + assertEquals(targetRoot.getPath(), tree.get(targetRoot.getId()).getPath()); + assertEquals(targetBranch.getPath(), tree.get(targetBranch.getId()).getPath()); + assertEquals(target.getPath(), tree.get(target.getId()).getPath()); + assertEquals(oldRoot.getPath(), tree.get(oldRoot.getId()).getPath()); + }).verifyComplete(); + } + scoped.deleteById(targetRoot.getId()).as(StepVerifier::create).expectNext(9).verifyComplete(); + scoped.deleteById(oldRoot.getId()).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + + @Test + public void repeatedSaveSubscriptionReadsFreshTreeAndEmptyWritesKeepExistingSemantics() { + String group = id(); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity target = node(group, null, null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity leaf = node(group, branch.getId(), null); + scoped.save(List.of(root, target, branch, leaf)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + AtomicInteger subscriptions = new AtomicInteger(); + Mono repeated = scoped.save(Flux.defer(() -> { + int subscription = subscriptions.incrementAndGet(); + return scoped.findById(branch.getId()).doOnNext(existing -> { + existing.setParentId(target.getId()); + existing.setName("subscription-" + subscription); + }); + })); + for (int subscription = 1; subscription <= 2; subscription++) { + int expectedSubscription = subscription; + repeated.then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertTreeStructure(tree.values()); + assertEquals(target.getId(), tree.get(branch.getId()).getParentId()); + assertEquals("subscription-" + expectedSubscription, tree.get(branch.getId()).getName()); + assertEquals(Integer.valueOf(3), tree.get(leaf.getId()).getLevel()); + }).verifyComplete(); + if (subscription == 1) { + // 在两次订阅间改变真实持久图,复用Publisher不得沿用第一次helper快照。 + scoped.findById(branch.getId()).flatMap(existing -> { + existing.setParentId(null); + return scoped.save(existing); + }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertTreeStructure(tree.values()); + assertNull(tree.get(branch.getId()).getParentId()); + assertEquals(Integer.valueOf(2), tree.get(leaf.getId()).getLevel()); + }).verifyComplete(); + } + } + assertEquals(2, subscriptions.get()); + scoped.save(Flux.empty()).as(StepVerifier::create).verifyComplete(); + scoped.insertBatch(Flux.empty()).as(StepVerifier::create).expectNext(0).verifyComplete(); + scoped.getRepository().createDelete().where("groupId", group).execute() + .as(StepVerifier::create).expectNext(4).verifyComplete(); + } + + @Test + public void rejectedCyclesAndMissingParentsLeavePersistedTreeAndCallerWritesUnchanged() { + for (String scenario : List.of("insert-self", "insert-batch", "save-self", "save-ancestor", "save-missing")) { + String group = id(); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity leaf = node(group, branch.getId(), null); + scoped.save(List.of(root, branch, leaf)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + AtomicReference> before = new AtomicReference<>(); + stored(scoped, group).as(StepVerifier::create).assertNext(before::set).verifyComplete(); + TestTreeSortEntity unrelated = node(id(), null, null); + Mono invalid; + if ("save-ancestor".equals(scenario) || "save-missing".equals(scenario)) { + invalid = scoped.findById(root.getId()).flatMap(existing -> { + existing.setParentId("save-ancestor".equals(scenario) ? leaf.getId() : id()); + return scoped.save(existing); + }); + } else { + TestTreeSortEntity first = node(group, null, null); + TestTreeSortEntity second = node(group, first.getId(), null); + first.setParentId(scenario.endsWith("self") ? first.getId() : second.getId()); + List input = scenario.endsWith("self") ? List.of(first) : List.of(second, first); + invalid = scenario.startsWith("insert") ? scoped.insert(Flux.fromIterable(input)) + : scoped.save(Flux.fromIterable(input)); + } + Mono caller = scoped.getRepository().save(unrelated) + .then(scoped.getRepository().createUpdate().set("name", "must-roll-back") + .where("id", root.getId()).execute()).then(invalid); + TransactionUtils.tryRunInTransaction(caller, new DefaultTransactionDefinition()) + .as(StepVerifier::create).expectErrorMatches(error -> error instanceof ValidationException + && error.getMessage().contains(scenario.endsWith("missing") + ? "tree_entity_parent_id_not_exist" : "tree_entity_cyclic_dependency")).verify(); + stored(scoped, group).as(StepVerifier::create).assertNext(after -> + assertSnapshot(before.get(), after)).verifyComplete(); + scoped.findById(unrelated.getId()).as(StepVerifier::create).verifyComplete(); + scoped.deleteById(root.getId()).as(StepVerifier::create).expectNext(3).verifyComplete(); + } + } + + @Test + public void subtreeDeleteUsesBoundedLeafFirstBatchesAndRollsBackLaterFailures() { + for (String outcome : List.of("success", "event", "caller")) { + String group = id(); + TestTreeSortEntity rootA = deleteBatchNode(group, null); + TestTreeSortEntity branchA = deleteBatchNode(group, rootA.getId()); + TestTreeSortEntity leafA = deleteBatchNode(group, branchA.getId()); + TestTreeSortEntity rootB = deleteBatchNode(group, null); + TestTreeSortEntity leafB = deleteBatchNode(group, rootB.getId()); + if ("event".equals(outcome)) { + rootB.setName("reject-delete-batch-later-" + rootB.getId()); + } + batched.save(List.of(rootA, branchA, leafA, rootB, leafB)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + AtomicReference> before = new AtomicReference<>(); + stored(batched, group).as(StepVerifier::create).assertNext(before::set).verifyComplete(); + TestTreeSortEntity unrelated = node(id(), null, null); + Mono deletion = batched.createDelete() + .in("id", List.of(rootA.getId(), branchA.getId(), rootB.getId(), id())).execute(); + Mono caller = batched.getRepository().save(unrelated).then(deletion); + if ("caller".equals(outcome)) { + caller = caller.flatMap(ignored -> Mono.error(new IllegalStateException("reject delete caller"))); + } + Mono transaction = TransactionUtils.tryRunInTransaction(caller, new DefaultTransactionDefinition()); + if ("success".equals(outcome)) { + transaction.as(StepVerifier::create).expectNext(5).verifyComplete(); + stored(batched, group).as(StepVerifier::create).assertNext(tree -> assertTrue(tree.isEmpty())) + .verifyComplete(); + batched.getRepository().deleteById(unrelated.getId()) + .as(StepVerifier::create).expectNext(1).verifyComplete(); + } else { + transaction.as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + stored(batched, group).as(StepVerifier::create).assertNext(after -> + assertSnapshot(before.get(), after)).verifyComplete(); + batched.findById(unrelated.getId()).as(StepVerifier::create).verifyComplete(); + batched.getRepository().createUpdate().set("name", "cleanup").where("groupId", group).execute() + .then(batched.getRepository().createDelete().where("groupId", group).execute()) + .as(StepVerifier::create).expectNext(5).verifyComplete(); + } + List batches = listener.deleteBatches.get(group); + assertNotNull(batches); + assertTrue("must reach later delete batches", batches.size() >= 2); + assertTrue(batches.stream().allMatch(size -> size <= batched.getBufferSize())); + if ("success".equals(outcome)) { + assertEquals(3, batches.size()); + } + } + batched.createDelete().where("id", id()).execute() + .as(StepVerifier::create).expectNext(0).verifyComplete(); + batched.deleteById(Flux.empty()).as(StepVerifier::create).expectNext(0).verifyComplete(); + } + + @Test + public void bothUpdateByIdParentPatchOverloadsMoveCompleteSubtreeToDeepParent() { + for (String overload : List.of("entity", "mono")) { + String group = id(); + TestTreeSortEntity targetRoot = node(group, null, null); + TestTreeSortEntity targetBranch = node(group, targetRoot.getId(), null); + TestTreeSortEntity target = node(group, targetBranch.getId(), null); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity leaf = node(group, branch.getId(), null); + branch.setSortIndex(17L); + leaf.setSortIndex(23L); + scoped.save(List.of(targetRoot, targetBranch, target, root, branch, leaf)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setParentId(target.getId()); + Mono move = "entity".equals(overload) ? scoped.updateById(branch.getId(), patch) + : scoped.updateById(branch.getId(), Mono.just(patch)); + move.as(StepVerifier::create).expectNext(2).verifyComplete(); + stored(scoped, group).as(StepVerifier::create).assertNext(tree -> { + assertEquals(6, tree.size()); + assertTreeStructure(tree.values()); + assertEquals(target.getId(), tree.get(branch.getId()).getParentId()); + assertEquals(Integer.valueOf(4), tree.get(branch.getId()).getLevel()); + assertEquals(Integer.valueOf(5), tree.get(leaf.getId()).getLevel()); + assertEquals(branch.getName(), tree.get(branch.getId()).getName()); + assertEquals(Long.valueOf(17), tree.get(branch.getId()).getSortIndex()); + assertEquals(Long.valueOf(23), tree.get(leaf.getId()).getSortIndex()); + assertEquals(root.getPath(), tree.get(root.getId()).getPath()); + assertEquals(targetRoot.getPath(), tree.get(targetRoot.getId()).getPath()); + assertEquals(targetBranch.getPath(), tree.get(targetBranch.getId()).getPath()); + assertEquals(target.getPath(), tree.get(target.getId()).getPath()); + }).verifyComplete(); + scoped.queryIncludeChildren(List.of(root.getId())).count() + .as(StepVerifier::create).expectNext(1L).verifyComplete(); + scoped.queryIncludeChildren(List.of(branch.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(branch.getId(), leaf.getId()), ids(nodes))).verifyComplete(); + scoped.deleteById(targetRoot.getId()).as(StepVerifier::create).expectNext(5).verifyComplete(); + scoped.deleteById(root.getId()).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + + private static Mono> stored(ScopedTreeService service, String group) { + return service.createQuery().where("groupId", group).fetch().collectMap(TestTreeSortEntity::getId); + } + + private static void assertSnapshot(Map before, + Map after) { + assertEquals(before.keySet(), after.keySet()); + for (TestTreeSortEntity expected : before.values()) { + TestTreeSortEntity actual = after.get(expected.getId()); + assertEquals(expected.getParentId(), actual.getParentId()); + assertEquals(expected.getPath(), actual.getPath()); + assertEquals(expected.getLevel(), actual.getLevel()); + assertEquals(expected.getSortIndex(), actual.getSortIndex()); + assertEquals(expected.getName(), actual.getName()); + assertEquals(expected.getGroupId(), actual.getGroupId()); + } + assertTreeStructure(after.values()); + } + + private static TestTreeSortEntity deleteBatchNode(String group, String parent) { + TestTreeSortEntity node = node(group, parent, null); + node.setName("verify-delete-batch-" + node.getId()); + return node; + } + private static void assertTreeStructure(Collection nodes) { Map tree = nodes.stream() .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); @@ -385,6 +724,7 @@ public int getBufferSize() { static class FailureListener { private final ReactiveRepository repository; private final AtomicInteger observedRoots = new AtomicInteger(); + private final Map> deleteBatches = new ConcurrentHashMap<>(); FailureListener(ReactiveRepository repository) { this.repository = repository; @@ -482,6 +822,26 @@ public void afterSave(EntitySavedEvent event) { } } + @EventListener + public void beforeDelete(EntityBeforeDeleteEvent event) { + if (event.getEntityType() != TestTreeSortEntity.class + || event.getEntity().stream().noneMatch(node -> node.getName().startsWith("verify-delete-batch-") + || node.getName().startsWith("reject-delete-batch-later-"))) { + return; + } + event.async(Mono.defer(() -> { + List batch = event.getEntity(); + String group = batch.get(0).getGroupId(); + deleteBatches.computeIfAbsent(group, ignored -> new CopyOnWriteArrayList<>()).add(batch.size()); + assertTrue("delete batch exceeds configured buffer", batch.size() <= 2); + List batchIds = batch.stream().map(TestTreeSortEntity::getId).collect(Collectors.toList()); + // 在真实beforeDelete阶段查询,父节点的子级只能已被前批删除或属于当前批。 + return repository.createQuery().where("groupId", group).in("parentId", batchIds) + .notIn("id", batchIds).count().doOnNext(outsideChildren -> + assertEquals("parent deleted before an outside child", Integer.valueOf(0), outsideChildren)).then(); + })); + } + @EventListener public void afterDelete(EntityDeletedEvent event) { if (event.getEntityType() == TestTreeSortEntity.class diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java new file mode 100644 index 000000000..f340af30f --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java @@ -0,0 +1,94 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** 同步入口共享path算法的回归;持久图使用替身,不作为同步JDBC CRUD集成证据。 */ +public class SyncTreeSortServiceHelperTest { + + @Test + public void sameBatchParentAndChildMoveKeepsOmittedGrandchildPathConsistent() { + for (boolean childFirst : List.of(false, true)) { + for (boolean moveToRoot : List.of(false, true)) { + TestTreeSortEntity root = node("root", null, "root"); + TestTreeSortEntity target = node("target", null, "next"); + TestTreeSortEntity branch = node("branch", root.getId(), "root-bran"); + TestTreeSortEntity child = node("child", branch.getId(), "root-bran-chld"); + TestTreeSortEntity grandchild = node("grandchild", child.getId(), "root-bran-chld-leaf"); + grandchild.setSortIndex(31L); + Map persisted = List.of(root, target, branch, child, grandchild) + .stream().collect(Collectors.toMap(TestTreeSortEntity::getId, value -> value)); + TreeSortEntityService service = mock(TreeSortEntityService.class); + when(service.getIDGenerator()).thenReturn(IDGenerator.MD5); + when(service.isRootNode(any())).thenCallRealMethod(); + doAnswer(invocation -> { + TestTreeSortEntity parent = invocation.getArgument(0); + parent.setChildren(invocation.getArgument(1)); + return null; + }).when(service).setChildren(any(), anyList()); + when(service.findById(anyCollection())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return ids.stream().filter(persisted::containsKey).map(persisted::get) + .map(SyncTreeSortServiceHelperTest::copy).collect(Collectors.toList()); + }); + when(service.queryIncludeChildren(anyCollection())).thenAnswer(invocation -> { + Collection ids = invocation.getArgument(0); + return persisted.values().stream().filter(candidate -> ids.stream() + .map(persisted::get).filter(seed -> seed != null) + .anyMatch(seed -> candidate.getId().equals(seed.getId()) + || candidate.getPath().startsWith(seed.getPath() + "-"))) + .map(SyncTreeSortServiceHelperTest::copy).collect(Collectors.toList()); + }); + TestTreeSortEntity moved = copy(branch); + TestTreeSortEntity suppliedChild = copy(child); + moved.setParentId(moveToRoot ? null : target.getId()); + List prepared = new SyncTreeSortServiceHelper<>(service) + .prepare(childFirst ? List.of(suppliedChild, moved) : List.of(moved, suppliedChild)); + Map actual = prepared.stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, value -> value)); + assertEquals(3, actual.size()); + TestTreeSortEntity actualBranch = actual.get(branch.getId()); + TestTreeSortEntity actualChild = actual.get(child.getId()); + TestTreeSortEntity actualGrandchild = actual.get(grandchild.getId()); + assertEquals(moveToRoot ? null : target.getId(), actualBranch.getParentId()); + assertEquals(Integer.valueOf(moveToRoot ? 1 : 2), actualBranch.getLevel()); + assertTrue(actualChild.getPath().startsWith(actualBranch.getPath() + "-")); + assertTrue(actualGrandchild.getPath().startsWith(actualChild.getPath() + "-")); + assertEquals(Integer.valueOf(actualChild.getLevel() + 1), actualGrandchild.getLevel()); + assertEquals(Long.valueOf(31), actualGrandchild.getSortIndex()); + // 旧快照必须保留;只用它判定持久父级是否变化。 + assertEquals("root-bran-chld-leaf", persisted.get(grandchild.getId()).getPath()); + } + } + } + + private static TestTreeSortEntity copy(TestTreeSortEntity source) { + TestTreeSortEntity copy = node(source.getId(), source.getParentId(), source.getPath()); + copy.setSortIndex(source.getSortIndex()); + return copy; + } + + private static TestTreeSortEntity node(String id, String parentId, String path) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setId(id); + node.setName(id); + node.setParentId(parentId); + node.setPath(path); + node.setLevel(path.split("-").length); + return node; + } +} From 1646ade2fcfaa9805a59c0297ecff97e3e6aadc5 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sat, 10 Oct 2026 21:00:22 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix(crud):=20=E4=BF=9D=E7=95=99=E7=A8=80?= =?UTF-8?q?=E7=96=8F=E6=A0=91=E6=9B=B4=E6=96=B0=E5=AD=97=E6=AE=B5=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8C=E6=AD=A5=E7=A7=BB=E6=A0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 28 ++- .../crud/service/TreeSortEntityService.java | 18 +- .../ConcurrentTreePatchRegressionTest.java | 200 ++++++++++++++++ ...opedReactiveTreeSortEntityServiceTest.java | 41 ++++ .../service/SyncTreeCrudRegressionTest.java | 217 ++++++++++++++++++ 5 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentTreePatchRegressionTest.java create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index ab2d68915..6eb226530 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -247,18 +247,38 @@ default Mono save(E data) { return save(Flux.just(data)); } + /** + * 部分更新忽略 null,完整节点仅用于树准备;业务列仍按调用方提供的字段更新。 + * 更新使用 ORM Modify 事件及其完整 before/after 状态;显式移根使用完整 save 或维护树生命周期的 DSL。 + */ @Override - @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) default Mono updateById(K id, Mono entityPublisher) { - return entityPublisher.flatMap(data -> findById(id).flatMap(existing -> { - // 部分修改忽略未提供的 null;显式移根保存完整状态,或在有树生命周期维护的服务中使用 DSL setNull。 + Mono operation = entityPublisher.flatMap(data -> findById(id).flatMap(existing -> { Map updates = FastBeanCopier.copy(data, new LinkedHashMap<>()); updates.values().removeIf(Objects::isNull); updates.remove("id"); FastBeanCopier.copy(updates, existing); existing.setId(id); - return save(existing).map(SaveResult::getTotal); + ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); + return helper.prepare(Flux.just(existing)) + .concatMap(node -> { + if (Objects.equals(id, node.getId())) { + // 不把完整快照回写到数据库,避免覆盖其他事务修改的独立业务字段。 + data.setPath(node.getPath()); + data.setLevel(node.getLevel()); + return getRepository().updateById(id, data); + } + // 未提供的后代仅同步树路径,不重写其业务状态或排序。 + return getRepository().createUpdate() + .set("path", node.getPath()) + .set("level", node.getLevel()) + .where("id", node.getId()) + .execute(); + }) + .reduce(Math::addExact); })).defaultIfEmpty(0); + return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); } @Override diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java index 1977b69c0..3b8f8d0c1 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java @@ -89,10 +89,24 @@ default int insert(Collection entityPublisher) { @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) default SaveResult save(List entities) { - return new SyncTreeSortServiceHelper<>(this) + SyncTreeSortServiceHelper helper = new SyncTreeSortServiceHelper<>(this); + return helper .prepare(Flux.fromIterable(entities)) .buffer(getBufferSize()) - .map(this.getRepository()::save) + .map(batch -> { + for (E node : batch) { + if (helper.isMovingToRoot(node)) { + // ORM save 忽略普通 null;显式移根与随后保存的路径、实体事件共享原事务。 + getRepository().createUpdate() + .set("path", node.getPath()) + .set("level", node.getLevel()) + .setNull("parentId") + .where("id", node.getId()) + .execute(); + } + } + return getRepository().save(batch); + }) .reduce(SaveResult::merge) .blockOptional() .orElse(SaveResult.of(0,0)); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentTreePatchRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentTreePatchRegressionTest.java new file mode 100644 index 000000000..24e7db734 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentTreePatchRegressionTest.java @@ -0,0 +1,200 @@ +package org.hswebframework.web.crud.service; + +import io.r2dbc.spi.ConnectionFactory; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; + +import static org.junit.Assert.*; + +/** 两个真实事务读取同一旧行后,独立业务字段的稀疏修改不能互相覆盖。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, ConcurrentTreePatchRegressionTest.Configuration.class}, + properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=ConcurrentTreePatchRegressionTest", + "spring.r2dbc.pool.enabled=true", + "spring.r2dbc.pool.initial-size=2", + "spring.r2dbc.pool.max-size=4" + }) +public class ConcurrentTreePatchRegressionTest { + + private static final String WRITER = ConcurrentTreePatchRegressionTest.class.getName(); + private static final Duration VERIFY_TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private ControlledTreeService service; + + @Autowired + private ConnectionFactory connectionFactory; + + @Autowired + @Qualifier("connectionFactoryTransactionManager") + private ReactiveTransactionManager transactionManager; + + @After + public void resetGate() { + service.setReadGate(null); + } + + @Test + public void monoPatchesInSeparateTransactionsKeepBothBusinessFields() { + verifyIndependentPatches(false); + } + + @Test + public void entityPatchesInSeparateTransactionsKeepBothBusinessFields() { + verifyIndependentPatches(true); + } + + private void verifyIndependentPatches(boolean entityOverload) { + TestTreeSortEntity root = new TestTreeSortEntity(); + root.setId(IDGenerator.RANDOM.generate()); + root.setName("name-before"); + root.setDefaultTest("business-before"); + root.setSortIndex(23L); + service.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + ReadGate gate = new ReadGate(root.getId(), connectionFactory); + service.setReadGate(gate); + + TestTreeSortEntity namePatch = new TestTreeSortEntity(); + namePatch.setName("name-after"); + TestTreeSortEntity businessPatch = new TestTreeSortEntity(); + businessPatch.setDefaultTest("business-after"); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + TransactionalOperator transactions = TransactionalOperator.create(transactionManager, definition); + + Mono firstUpdate = entityOverload ? service.updateById(root.getId(), namePatch) + : service.updateById(root.getId(), Mono.just(namePatch)); + Mono secondUpdate = entityOverload ? service.updateById(root.getId(), businessPatch) + : service.updateById(root.getId(), Mono.just(businessPatch)); + // 两次操作均在自己的连接和事务中读取旧值。第一事务完整提交后才放行第二次保存。 + Mono first = transactions.transactional(firstUpdate) + .contextWrite(context -> context.put(WRITER, "first")) + .doOnNext(total -> assertEquals(Integer.valueOf(1), total)) + .then(Mono.fromRunnable(() -> gate.secondRelease.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST))); + Mono second = transactions.transactional(secondUpdate) + .contextWrite(context -> context.put(WRITER, "second")) + .doOnNext(total -> assertEquals(Integer.valueOf(1), total)).then(); + Mono releaseFirst = Mono.zip(gate.firstRead.asMono(), gate.secondRead.asMono()) + .doOnNext(pair -> { + assertEquals("name-before", pair.getT1().name); + assertEquals("business-before", pair.getT1().business); + assertEquals("name-before", pair.getT2().name); + assertEquals("business-before", pair.getT2().business); + assertNotSame("the writers must hold different transaction connections", + pair.getT1().transactionResource, pair.getT2().transactionResource); + }) + .then(Mono.fromRunnable(() -> gate.firstRelease.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST))); + + Mono.when(first, second, releaseFirst).as(StepVerifier::create).expectComplete().verify(VERIFY_TIMEOUT); + service.findById(root.getId()).as(StepVerifier::create).assertNext(after -> { + assertEquals("name-after", after.getName()); + assertEquals("business-after", after.getDefaultTest()); + assertNull(after.getParentId()); + assertEquals(root.getPath(), after.getPath()); + assertEquals(root.getLevel(), after.getLevel()); + assertEquals(root.getSortIndex(), after.getSortIndex()); + }).expectComplete().verify(VERIFY_TIMEOUT); + } + + static class ReadSnapshot { + final String name; + final String business; + final Object transactionResource; + + ReadSnapshot(TestTreeSortEntity entity, Object transactionResource) { + this.name = entity.getName(); + this.business = entity.getDefaultTest(); + this.transactionResource = transactionResource; + } + } + + /** 只暂停真实仓储首次读取的下游,不替换数据库结果或树保存实现。 */ + static class ReadGate { + final String id; + final ConnectionFactory connectionFactory; + final Sinks.One firstRead = Sinks.one(); + final Sinks.One secondRead = Sinks.one(); + final Sinks.Empty firstRelease = Sinks.empty(); + final Sinks.Empty secondRelease = Sinks.empty(); + + ReadGate(String id, ConnectionFactory connectionFactory) { + this.id = id; + this.connectionFactory = connectionFactory; + } + + Mono afterRead(String writer, TestTreeSortEntity entity) { + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(manager -> { + assertTrue("the old row must be read inside the writer transaction", manager.isActualTransactionActive()); + Object resource = manager.getResource(connectionFactory); + assertNotNull("the writer transaction must bind its real connection", resource); + ReadSnapshot snapshot = new ReadSnapshot(entity, resource); + if ("first".equals(writer)) { + firstRead.emitValue(snapshot, Sinks.EmitFailureHandler.FAIL_FAST); + return firstRelease.asMono().thenReturn(entity); + } + secondRead.emitValue(snapshot, Sinks.EmitFailureHandler.FAIL_FAST); + return secondRelease.asMono().thenReturn(entity); + }); + } + } + + static class ControlledTreeService extends GenericReactiveTreeSupportCrudService { + private volatile ReadGate gate; + + // 通过代理调用方法设置真实服务目标,不能直接写事务代理对象上的字段。 + public void setReadGate(ReadGate gate) { + this.gate = gate; + } + + @Override + public Mono findById(String id) { + return super.findById(id).flatMap(entity -> Mono.deferContextual(context -> { + ReadGate current = gate; + return current != null && current.id.equals(id) && context.hasKey(WRITER) + ? current.afterRead(context.get(WRITER), entity) : Mono.just(entity); + })); + } + + @Override + public IDGenerator getIDGenerator() { + return IDGenerator.MD5; + } + + @Override + public void setChildren(TestTreeSortEntity entity, List children) { + entity.setChildren(children); + } + } + + @TestConfiguration + static class Configuration { + @Bean + ControlledTreeService controlledTreeService() { + return new ControlledTreeService(); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index cbd77e231..e09e7fc1c 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -621,6 +621,47 @@ public void bothUpdateByIdParentPatchOverloadsMoveCompleteSubtreeToDeepParent() } } + @Test + public void bothUpdateByIdMovesRollbackAfterDescendantModifyEventOrCallerFailure() { + for (String overload : List.of("entity", "mono")) { + for (String failure : List.of("modify", "caller")) { + String group = id(); + TestTreeSortEntity target = node(group, null, null); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity branch = node(group, root.getId(), null); + TestTreeSortEntity leaf = node(group, branch.getId(), null); + if ("modify".equals(failure)) { + // 初始 save 正常完成;移动目标写入后,遗漏后代的真实 Modify 后事件拒绝事务。 + leaf.setName("reject-dsl-" + id()); + } + scoped.save(List.of(target, root, branch, leaf)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + AtomicReference> before = new AtomicReference<>(); + stored(scoped, group).as(StepVerifier::create).assertNext(before::set).verifyComplete(); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setParentId(target.getId()); + patch.setName("patched-" + id()); + Mono move = "entity".equals(overload) ? scoped.updateById(branch.getId(), patch) + : scoped.updateById(branch.getId(), Mono.just(patch)); + Mono operation = move; + if ("caller".equals(failure)) { + operation = TransactionUtils.tryRunInTransaction( + move.doOnNext(total -> assertEquals(Integer.valueOf(2), total)) + .then(Mono.error(new IllegalStateException("reject patch caller"))), + new DefaultTransactionDefinition()); + } + operation.as(StepVerifier::create).expectErrorMatches(error -> + error instanceof IllegalStateException && error.getMessage().equals( + "modify".equals(failure) ? "reject modified tree" : "reject patch caller")) + .verify(); + stored(scoped, group).as(StepVerifier::create).assertNext(after -> + assertSnapshot(before.get(), after)).verifyComplete(); + scoped.getRepository().createDelete().where("groupId", group).execute() + .as(StepVerifier::create).expectNext(4).verifyComplete(); + } + } + } + private static Mono> stored(ScopedTreeService service, String group) { return service.createQuery().where("groupId", group).fetch().collectMap(TestTreeSortEntity::getId); } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java new file mode 100644 index 000000000..73056e380 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java @@ -0,0 +1,217 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.executor.SyncSqlExecutor; +import org.hswebframework.web.crud.annotation.EnableEasyormRepository; +import org.hswebframework.web.crud.configuration.EasyormConfiguration; +import org.hswebframework.web.crud.configuration.EntityFactoryConfiguration; +import org.hswebframework.web.crud.configuration.JdbcSqlExecutorConfiguration; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntitySavedEvent; +import org.hswebframework.web.crud.sql.DefaultJdbcExecutor; +import org.hswebframework.web.id.IDGenerator; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.TransactionTemplate; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.junit.Assert.*; + +/** 同步树入口使用真实 JDBC 仓储,验证移根的父 ID、路径和原事务保持一致。 */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = SyncTreeCrudRegressionTest.JdbcConfiguration.class) +@TestPropertySource(properties = {"easyorm.dialect=h2", "easyorm.default-schema=PUBLIC"}) +public class SyncTreeCrudRegressionTest { + + @Autowired + private JdbcTreeService service; + + @Autowired + private SyncSqlExecutor executor; + + @Autowired + private SavedEvents events; + + @Autowired + @Qualifier("transactionManager") + private PlatformTransactionManager transactionManager; + + @After + public void resetFailure() { + events.rejectedId = null; + } + + @Test + public void updateByIdNamePatchKeepsParentAndPersistedSubtree() { + assertTrue("the synchronous repository must execute JDBC SQL", executor instanceof DefaultJdbcExecutor); + List tree = insertTree(); + TestTreeSortEntity branch = stored(tree.get(1).getId()); + TestTreeSortEntity leaf = stored(tree.get(2).getId()); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("renamed branch"); + patch.setDefaultTest(branch.getDefaultTest()); + + // 此入口继承 save(E) 的普通仓储保存,不把它误当成 save(List) 的树准备路径。 + assertEquals(1, service.updateById(branch.getId(), patch)); + + TestTreeSortEntity after = stored(branch.getId()); + assertEquals("renamed branch", after.getName()); + assertSameTreeState(branch, after); + assertSameTreeState(leaf, stored(leaf.getId())); + assertEquals(3, service.queryIncludeChildren(List.of(tree.get(0).getId())).size()); + } + + @Test + public void listSaveMovesBranchAndOmittedLeafToRootInDatabase() { + List tree = insertTree(); + TestTreeSortEntity root = stored(tree.get(0).getId()); + TestTreeSortEntity branch = stored(tree.get(1).getId()); + TestTreeSortEntity leaf = stored(tree.get(2).getId()); + assertNull(branch.getChildren()); + branch.setParentId(null); + + assertEquals(2, service.save(List.of(branch)).getTotal()); + + TestTreeSortEntity moved = stored(branch.getId()); + TestTreeSortEntity movedLeaf = stored(leaf.getId()); + assertNull("a root path must be committed with a cleared parent ID", moved.getParentId()); + assertEquals(Integer.valueOf(1), moved.getLevel()); + assertFalse(moved.getPath().contains("-")); + assertEquals(moved.getId(), movedLeaf.getParentId()); + assertTrue(movedLeaf.getPath().startsWith(moved.getPath() + "-")); + assertEquals(Integer.valueOf(2), movedLeaf.getLevel()); + assertEquals(branch.getName(), moved.getName()); + assertEquals(leaf.getName(), movedLeaf.getName()); + assertEquals(branch.getSortIndex(), moved.getSortIndex()); + assertEquals(leaf.getSortIndex(), movedLeaf.getSortIndex()); + assertSameTreeState(root, stored(root.getId())); + assertEquals(1, service.queryIncludeChildren(List.of(root.getId())).size()); + assertEquals(2, service.queryIncludeChildren(List.of(branch.getId())).size()); + } + + @Test + public void listRootMoveRollsBackWithSavedEventOrCallerFailure() { + for (String failure : List.of("event", "caller")) { + List tree = insertTree(); + TestTreeSortEntity root = stored(tree.get(0).getId()); + TestTreeSortEntity branch = stored(tree.get(1).getId()); + TestTreeSortEntity leaf = stored(tree.get(2).getId()); + TestTreeSortEntity moved = stored(branch.getId()); + moved.setParentId(null); + if ("event".equals(failure)) { + events.rejectedId = branch.getId(); + } + + IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> { + if ("event".equals(failure)) { + service.save(List.of(moved)); + } else { + new TransactionTemplate(transactionManager).execute(status -> { + service.save(List.of(moved)); + throw new IllegalStateException("reject caller transaction"); + }); + } + }); + assertTrue(rejected.getMessage().contains("reject")); + events.rejectedId = null; + + assertSameTreeState(root, stored(root.getId())); + assertSameTreeState(branch, stored(branch.getId())); + assertSameTreeState(leaf, stored(leaf.getId())); + assertEquals(3, service.queryIncludeChildren(List.of(root.getId())).size()); + } + } + + private List insertTree() { + TestTreeSortEntity root = node(null, "root"); + TestTreeSortEntity branch = node(root.getId(), "branch"); + TestTreeSortEntity leaf = node(branch.getId(), "leaf"); + List tree = List.of(root, branch, leaf); + assertEquals(3, service.insert(tree)); + return tree; + } + + private TestTreeSortEntity stored(String id) { + return service.findById(id).orElseThrow(AssertionError::new); + } + + private static TestTreeSortEntity node(String parentId, String name) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setId(IDGenerator.RANDOM.generate()); + node.setParentId(parentId); + node.setName(name); + // 同步事件在 ORM 应用列默认值前校验 CreateGroup,夹具必须提供合法必填值。 + node.setDefaultTest("business-before"); + node.setSortIndex(17L); + return node; + } + + private static void assertSameTreeState(TestTreeSortEntity expected, TestTreeSortEntity actual) { + assertEquals(expected.getParentId(), actual.getParentId()); + assertEquals(expected.getPath(), actual.getPath()); + assertEquals(expected.getLevel(), actual.getLevel()); + assertEquals(expected.getSortIndex(), actual.getSortIndex()); + } + + static class JdbcTreeService extends GenericTreeSupportCrudService { + @Override + public IDGenerator getIDGenerator() { + return IDGenerator.MD5; + } + + @Override + public void setChildren(TestTreeSortEntity entity, List children) { + entity.setChildren(children); + } + } + + static class SavedEvents { + private volatile String rejectedId; + + @EventListener + public void saved(EntitySavedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class + && event.getEntity().stream().anyMatch(entity -> entity.getId().equals(rejectedId))) { + event.async(Mono.error(new IllegalStateException("reject saved tree event"))); + } + } + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement(proxyTargetClass = true) + @EnableEasyormRepository(value = "org.hswebframework.web.crud.entity.TestTreeSortEntity", + reactive = false, nonReactive = true) + @ImportAutoConfiguration({EasyormConfiguration.class, EntityFactoryConfiguration.class, JdbcSqlExecutorConfiguration.class}) + static class JdbcConfiguration { + @Bean(destroyMethod = "shutdown") + EmbeddedDatabase dataSource() { + return new EmbeddedDatabaseBuilder().generateUniqueName(true).setType(EmbeddedDatabaseType.H2).build(); + } + + @Bean + JdbcTreeService jdbcTreeService() { + return new JdbcTreeService(); + } + + @Bean + SavedEvents savedEvents() { + return new SavedEvents(); + } + } +} From 9a8ffd83c1a177960dae7738caa42a0a5edd44bc Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sun, 11 Oct 2026 16:02:10 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix(=E6=A1=86=E6=9E=B6):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=A8=80=E7=96=8F=E6=A0=91=E6=9B=B4=E6=96=B0=E4=B8=8E?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=E6=88=90=E5=91=98=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 28 +- .../ReactiveTreeSortServiceHelper.java | 4 + .../crud/service/TreeSortServiceHelper.java | 107 ++++- .../ConcurrentParentPatchRegressionTest.java | 158 ++++++++ .../ExplicitTreeUpdateRegressionTest.java | 328 +++++++++++++++ .../api/entity/DimensionUserEntity.java | 14 +- .../api/entity/DimensionUserIdentityTest.java | 54 +++ .../service/DefaultDimensionUserService.java | 104 +++++ .../messages_en.properties | 3 +- .../messages_zh.properties | 3 +- .../DimensionUserIdentityRegressionTest.java | 375 ++++++++++++++++++ 11 files changed, 1151 insertions(+), 27 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java create mode 100644 hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java create mode 100644 hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 6eb226530..7ae24f0ec 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -7,7 +7,6 @@ import org.hswebframework.ezorm.rdb.operator.dml.Terms; import org.hswebframework.web.api.crud.entity.*; import org.hswebframework.web.id.IDGenerator; -import org.hswebframework.web.bean.FastBeanCopier; import org.hswebframework.web.crud.utils.TransactionUtils; import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; @@ -248,28 +247,27 @@ default Mono save(E data) { } /** - * 部分更新忽略 null,完整节点仅用于树准备;业务列仍按调用方提供的字段更新。 - * 更新使用 ORM Modify 事件及其完整 before/after 状态;显式移根使用完整 save 或维护树生命周期的 DSL。 + * 部分更新忽略 null;显式 children 参与修改和新增,遗漏后代仅维护树字段。 + * 已有节点使用 ORM Modify 完整 before/after 事件,新节点经过服务 save 保留业务扩展。 + * 显式移根使用完整 save 或维护树生命周期的 DSL。 */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) default Mono updateById(K id, Mono entityPublisher) { Mono operation = entityPublisher.flatMap(data -> findById(id).flatMap(existing -> { - Map updates = FastBeanCopier.copy(data, new LinkedHashMap<>()); - updates.values().removeIf(Objects::isNull); - updates.remove("id"); - FastBeanCopier.copy(updates, existing); - existing.setId(id); + data.setId(id); ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); - return helper.prepare(Flux.just(existing)) + return helper.prepareUpdate(Flux.just(data)) .concatMap(node -> { - if (Objects.equals(id, node.getId())) { - // 不把完整快照回写到数据库,避免覆盖其他事务修改的独立业务字段。 - data.setPath(node.getPath()); - data.setLevel(node.getLevel()); - return getRepository().updateById(id, data); + E patch = helper.getUpdateData(node); + if (patch != null) { + if (helper.isNewNode(node)) { + // 使用服务入口,让业务 ID、创建校验及实体事件参与原事务。 + return save(patch).map(SaveResult::getTotal); + } + // 不把完整快照回写,避免覆盖其他事务修改的独立业务字段。 + return getRepository().updateById(node.getId(), patch); } - // 未提供的后代仅同步树路径,不重写其业务状态或排序。 return getRepository().createUpdate() .set("path", node.getPath()) .set("level", node.getLevel()) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortServiceHelper.java index 02faae4aa..36b0b0f11 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortServiceHelper.java @@ -14,6 +14,10 @@ public ReactiveTreeSortServiceHelper(ReactiveTreeSortEntityService servi this.service = service; } + Flux prepareUpdate(Flux source) { + return prepare(source, true); + } + @Override protected IDGenerator getIdGenerator() { return service.getIDGenerator(); diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 28ac69cb7..a8cb34652 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -3,10 +3,13 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; import org.hswebframework.utils.RandomUtil; +import org.hswebframework.web.api.crud.entity.EntityFactoryHolder; import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.bean.FastBeanCopier; import org.hswebframework.web.exception.ValidationException; import org.hswebframework.web.id.IDGenerator; +import org.springframework.beans.BeanUtils; import org.springframework.util.ObjectUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -30,6 +33,10 @@ public abstract class TreeSortServiceHelper, protected final Map> childrenMapping = new LinkedHashMap<>(); + private final Map updateData = new LinkedHashMap<>(); + + private final List updateRoots = new ArrayList<>(); + protected abstract IDGenerator getIdGenerator(); protected abstract void applyChildren(E parent, List children); @@ -42,8 +49,28 @@ public abstract class TreeSortServiceHelper, public Flux prepare(Flux source) { + return prepare(source, false); + } + + protected Flux prepare(Flux source, boolean partialUpdate) { Flux cache = source - .flatMapIterable(e -> TreeSupportEntity.expandTree2List(e, getIdGenerator())) + .flatMapIterable(root -> { + Map patches = partialUpdate ? copyUpdateInput(root) : Collections.emptyMap(); + List expanded = TreeSupportEntity.expandTree2List(root, getIdGenerator()); + if (partialUpdate) { + updateRoots.add(root); + for (E node : expanded) { + E patch = patches.get(node); + patch.setId(node.getId()); + if (node != root) { + // children 的嵌套关系本身就是显式父级意图。 + patch.setParentId(node.getParentId()); + } + updateData.put(node.getId(), patch); + } + } + return expanded; + }) .collectList() .flatMapIterable(list -> { @@ -64,7 +91,7 @@ public Flux prepare(Flux source) { }) .cache(); - return init(cache) + return init(cache, partialUpdate) .then(Mono.defer(this::checkParentId)) .then(Mono.fromRunnable(this::checkCyclicDependency)) .then(Mono.fromRunnable(this::refactorPath)) @@ -75,7 +102,29 @@ public Flux prepare(Flux source) { Comparator.nullsFirst(Comparator.naturalOrder()))); } - private Mono init(Flux source) { + @SuppressWarnings("unchecked") + private Map copyUpdateInput(E root) { + Map copies = new IdentityHashMap<>(); + Queue queue = new ArrayDeque<>(); + queue.add(root); + for (E node = queue.poll(); node != null; node = queue.poll()) { + if (copies.containsKey(node)) { + continue; + } + Class nodeType = (Class) node.getClass(); + E copy = EntityFactoryHolder.newInstance( + nodeType, + () -> BeanUtils.instantiateClass(nodeType)); + FastBeanCopier.copy(node, copy); + copies.put(node, copy); + if (CollectionUtils.isNotEmpty(node.getChildren())) { + queue.addAll(node.getChildren()); + } + } + return copies; + } + + private Mono init(Flux source, boolean partialUpdate) { oldData = new LinkedHashMap<>(); thisTime = new LinkedHashMap<>(); allData = new LinkedHashMap<>(); @@ -105,6 +154,9 @@ private Mono init(Flux source) { } } + if (partialUpdate) { + completeUpdateInput(); + } readyToSave.putAll(thisTime); allData.putAll(includeChildren); @@ -115,6 +167,48 @@ private Mono init(Flux source) { .then(); } + private void completeUpdateInput() { + for (E node : thisTime.values()) { + E previous = oldData.get(node.getId()); + E patch = updateData.get(node.getId()); + if (previous != null) { + // 仅本次持久图补齐遗漏字段;首次存在性读取不能成为用户的移动意图。 + List children = node.getChildren(); + Map updates = FastBeanCopier.copy(patch, new LinkedHashMap<>()); + updates.values().removeIf(Objects::isNull); + updates.remove("children"); + FastBeanCopier.copy(previous, node); + FastBeanCopier.copy(updates, node); + applyChildren(node, children); + } else { + // 初次展开早于持久父级补全;新节点的默认排序需按补全后的父级计算。 + node.setSortIndex(patch.getSortIndex()); + } + } + for (E root : updateRoots) { + TreeSupportEntity.expandTree2List(root, getIdGenerator()); + } + } + + /** 显式输入保留稀疏业务列;遗漏后代没有对应输入。 */ + protected E getUpdateData(E node) { + E patch = updateData.get(node.getId()); + if (patch != null) { + patch.setPath(node.getPath()); + patch.setLevel(node.getLevel()); + if (isNewNode(node)) { + patch.setSortIndex(node.getSortIndex()); + } + // helper 已展开全部显式节点,逐节点保存不能再次递归写入 children。 + applyChildren(patch, Collections.emptyList()); + } + return patch; + } + + protected boolean isNewNode(E node) { + return !oldData.containsKey(node.getId()); + } + /** 当前完整保存明确移根,且原持久化节点具有父级。 */ protected boolean isMovingToRoot(E node) { E previous = oldData.get(node.getId()); @@ -262,7 +356,12 @@ private void refactorPath(E data, Function> childGetter) { //新增节点 else if (parentId != null) { if (oldParent != null) { - data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + String parentPath = oldParent.getPath() + "-"; + // 新节点经过服务 save 扩展再次准备时,保留已与当前父级一致的直接子路径。 + if (data.getPath() == null || !data.getPath().startsWith(parentPath) + || data.getPath().indexOf('-', parentPath.length()) >= 0) { + data.setPath(parentPath + RandomUtil.randomChar(4)); + } } } } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java new file mode 100644 index 000000000..78fcf4c5e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java @@ -0,0 +1,158 @@ +package org.hswebframework.web.crud.service; + +import io.r2dbc.spi.ConnectionFactory; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** 稀疏业务修改不能用旧快照覆盖另一真实事务已提交的树移动。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, ConcurrentParentPatchRegressionTest.Configuration.class}, + properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=R9ConcurrentParentPatchRegressionTest", + "spring.r2dbc.pool.enabled=true", + "spring.r2dbc.pool.initial-size=2", + "spring.r2dbc.pool.max-size=4" + }) +public class ConcurrentParentPatchRegressionTest { + private static final String PATCH = ConcurrentParentPatchRegressionTest.class.getName(); + @Autowired @Qualifier("controlledTree") private ControlledTreeService service; + @Autowired private ConnectionFactory connectionFactory; + @Autowired @Qualifier("connectionFactoryTransactionManager") private ReactiveTransactionManager manager; + + @Test + public void monoNamePatchPreservesTheOtherTransactionsCommittedParentAndPaths() { + verifyNamePatchAfterParentMove(false); + } + + @Test + public void entityNamePatchPreservesTheOtherTransactionsCommittedParentAndPaths() { + verifyNamePatchAfterParentMove(true); + } + + private void verifyNamePatchAfterParentMove(boolean entityOverload) { + TestTreeSortEntity parentA = node("parent-a"); + TestTreeSortEntity parentB = node("parent-b"); + TestTreeSortEntity branch = node("branch-before"); + TestTreeSortEntity leaf = node("leaf-before"); + parentA.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + service.save(List.of(parentA, parentB)).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + Gate gate = new Gate(branch.getId(), parentA.getId(), connectionFactory); + service.installGate(gate); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + TransactionalOperator transactions = TransactionalOperator.create(manager, definition); + + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("branch-after"); + Mono nameUpdate = transactions.transactional(entityOverload ? service.updateById(branch.getId(), patch) + : service.updateById(branch.getId(), Mono.just(patch))) + .contextWrite(context -> context.put(PATCH, true)).then(); + AtomicReference> moved = new AtomicReference<>(); + Mono treeMove = gate.read.asMono().flatMap(patchConnection -> transactions.transactional( + TransactionSynchronizationManager.forCurrentTransaction().flatMap(tx -> { + assertTrue(tx.isActualTransactionActive()); + Object moveConnection = tx.getResource(connectionFactory); + assertNotNull(moveConnection); + assertNotSame("the two writers require real independent connections", patchConnection, moveConnection); + return service.findById(branch.getId()).flatMap(stored -> { + stored.setParentId(parentB.getId()); + return service.save(stored); + }); + }).then(service.getRepository().findById(List.of(branch.getId(), leaf.getId())) + .collectMap(TestTreeSortEntity::getId)).doOnNext(moved::set))) + // This callback follows the move's transaction completion, before the name writer is released. + .then(Mono.fromRunnable(() -> gate.release.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST))); + try { + Mono.when(nameUpdate, treeMove).as(StepVerifier::create).expectComplete().verify(Duration.ofSeconds(10)); + service.getRepository().findById(List.of(branch.getId(), leaf.getId())).collectMap(TestTreeSortEntity::getId) + .as(StepVerifier::create).assertNext(after -> { + TestTreeSortEntity storedBranch = after.get(branch.getId()); + TestTreeSortEntity storedLeaf = after.get(leaf.getId()); + assertEquals("branch-after", storedBranch.getName()); + assertEquals(parentB.getId(), storedBranch.getParentId()); + assertTrue("branch path must match its persisted parent B", + storedBranch.getPath().startsWith(parentB.getPath() + "-")); + assertEquals(moved.get().get(branch.getId()).getPath(), storedBranch.getPath()); + assertEquals(moved.get().get(leaf.getId()).getPath(), storedLeaf.getPath()); + assertEquals(branch.getId(), storedLeaf.getParentId()); + assertTrue(storedLeaf.getPath().startsWith(storedBranch.getPath() + "-")); + }).expectComplete().verify(Duration.ofSeconds(10)); + } finally { + service.installGate(null); + } + } + + private static TestTreeSortEntity node(String name) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setName(name); + node.setDefaultTest("business-before"); + return node; + } + + public static class Gate { + private final String id; + private final String parent; + private final ConnectionFactory factory; + private final Sinks.One read = Sinks.one(); + private final Sinks.Empty release = Sinks.empty(); + public Gate(String id, String parent, ConnectionFactory factory) { + this.id = id; + this.parent = parent; + this.factory = factory; + } + Mono afterRead(TestTreeSortEntity entity) { + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(tx -> { + assertTrue(tx.isActualTransactionActive()); + assertEquals(parent, entity.getParentId()); + Object connection = tx.getResource(factory); + assertNotNull(connection); + read.emitValue(connection, Sinks.EmitFailureHandler.FAIL_FAST); + return release.asMono().thenReturn(entity); + }); + } + } + + public static class ControlledTreeService extends TestTreeSortEntityService { + private volatile Gate gate; + public void installGate(Gate gate) { this.gate = gate; } + @Override + public Mono findById(String id) { + return super.findById(id).flatMap(entity -> Mono.deferContextual(context -> { + Gate current = gate; + return current != null && current.id.equals(id) && context.hasKey(PATCH) + ? current.afterRead(entity) : Mono.just(entity); + })); + } + } + + @TestConfiguration + public static class Configuration { + @Bean public ControlledTreeService controlledTree() { return new ControlledTreeService(); } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java new file mode 100644 index 000000000..364a3ec53 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java @@ -0,0 +1,328 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.web.reactive.ReactiveServiceSaveController; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +/** 显式嵌套节点保留修改和创建语义,自动展开的后代只维护树字段。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=ExplicitTreeUpdateRegressionTest" +}) +public class ExplicitTreeUpdateRegressionTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private TestTreeSortEntityService service; + + @Test + public void monoPatchUpdatesExplicitExistingChildAndPreservesOmittedFields() { + verifyExistingChild(false); + } + + @Test + public void entityPatchUpdatesExplicitExistingChildAndPreservesOmittedFields() { + verifyExistingChild(true); + } + + @Test + public void monoPatchInsertsExplicitNewChild() { + verifyNewChild(false); + } + + @Test + public void entityPatchInsertsExplicitNewChild() { + verifyNewChild(true); + } + + @Test + public void monoNestedChildMovePreservesOmittedLeafBusinessState() { + verifyNestedChildMove(false); + } + + @Test + public void entityNestedChildMovePreservesOmittedLeafBusinessState() { + verifyNestedChildMove(true); + } + + @Test + public void monoNewChildValidationRollsBackTheWholePatch() { + verifyInvalidNewChild(false); + } + + @Test + public void entityNewChildValidationRollsBackTheWholePatch() { + verifyInvalidNewChild(true); + } + + @Test + public void monoNewParentKeepsExplicitExistingChildAndOmittedLeafPathsConsistent() { + verifyNewParentWithExistingChild(false); + } + + @Test + public void entityNewParentKeepsExplicitExistingChildAndOmittedLeafPathsConsistent() { + verifyNewParentWithExistingChild(true); + } + + @Test + public void publicPutPersistsBothExistingAndGeneratedIdChildren() { + TestTreeSortEntity root = node("root-before"); + TestTreeSortEntity child = node("child-before"); + child.setSortIndex(37L); + root.setChildren(List.of(child)); + save(root); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity childPatch = patch("child-after"); + childPatch.setId(child.getId()); + TestTreeSortEntity added = patch("http-new-" + IDGenerator.MD5.generate()); + added.setDefaultTest("new-business"); + patch.setChildren(List.of(childPatch, added)); + client().put().uri("/tree/{id}", root.getId()).contentType(MediaType.APPLICATION_JSON) + .bodyValue(patch).exchange().expectStatus().isOk().expectBody(Boolean.class).isEqualTo(true); + stored(List.of(root.getId(), child.getId())).as(StepVerifier::create).assertNext(after -> { + assertEquals("root-after", after.get(root.getId()).getName()); + assertEquals("child-after", after.get(child.getId()).getName()); + assertEquals("business-before", after.get(child.getId()).getDefaultTest()); + assertEquals(Long.valueOf(37), after.get(child.getId()).getSortIndex()); + }).expectComplete().verify(TIMEOUT); + findNewChild(added.getName(), root.getId()).as(StepVerifier::create).assertNext(after -> { + assertNotNull(after.getId()); + assertEquals("new-business", after.getDefaultTest()); + assertTrue(after.getPath().startsWith(root.getPath() + "-")); + assertEquals(Integer.valueOf(2), after.getLevel()); + }).expectComplete().verify(TIMEOUT); + } + + @Test + public void publicPutRejectsInvalidNewChildAndRollsBackParent() { + TestTreeSortEntity root = node("root-before"); + save(root); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity invalid = patch("http-invalid-" + IDGenerator.MD5.generate()); + invalid.setDefaultTest(""); + patch.setChildren(List.of(invalid)); + // 与生产相同的 CreateGroup 约束先通过正式新增入口验证,HTTP 不替换事件校验。 + service.insert(invalid).as(StepVerifier::create).expectError(ValidationException.class).verify(TIMEOUT); + client().put().uri("/tree/{id}", root.getId()).contentType(MediaType.APPLICATION_JSON) + .bodyValue(patch).exchange().expectStatus().value(status -> assertTrue(status >= 400)); + verifyRejectedPatch(root, invalid.getName()); + } + + @Test + public void missingTargetDoesNotCreateExplicitChildren() { + for (boolean entityOverload : List.of(false, true)) { + String missingId = IDGenerator.MD5.generate(); + TestTreeSortEntity patch = patch("missing-target"); + TestTreeSortEntity child = node("missing-child-" + IDGenerator.MD5.generate()); + patch.setChildren(List.of(child)); + update(missingId, patch, entityOverload).as(StepVerifier::create).expectNext(0).verifyComplete(); + service.getRepository().createQuery().where("name", child.getName()).count() + .as(StepVerifier::create).expectNext(0).verifyComplete(); + } + } + + private void verifyExistingChild(boolean entityOverload) { + TestTreeSortEntity outer = node("outer"); + TestTreeSortEntity root = node("root-before"); + TestTreeSortEntity child = node("child-before"); + child.setSortIndex(37L); + TestTreeSortEntity leaf = node("leaf-before"); + leaf.setSortIndex(84L); + outer.setChildren(List.of(root)); + root.setChildren(List.of(child)); + child.setChildren(List.of(leaf)); + save(outer); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity childPatch = patch("child-after"); + childPatch.setId(child.getId()); + patch.setChildren(List.of(childPatch)); + update(root.getId(), patch, entityOverload).then(stored(List.of(root.getId(), child.getId(), leaf.getId()))) + .as(StepVerifier::create).assertNext(after -> { + assertEquals("root-after", after.get(root.getId()).getName()); + assertEquals(outer.getId(), after.get(root.getId()).getParentId()); + assertEquals(root.getPath(), after.get(root.getId()).getPath()); + TestTreeSortEntity storedChild = after.get(child.getId()); + assertEquals("child-after", storedChild.getName()); + assertEquals("business-before", storedChild.getDefaultTest()); + assertEquals(root.getId(), storedChild.getParentId()); + assertEquals(child.getPath(), storedChild.getPath()); + assertEquals(child.getSortIndex(), storedChild.getSortIndex()); + assertEquals("leaf-before", after.get(leaf.getId()).getName()); + assertEquals(leaf.getPath(), after.get(leaf.getId()).getPath()); + assertEquals(leaf.getSortIndex(), after.get(leaf.getId()).getSortIndex()); + }).expectComplete().verify(TIMEOUT); + } + + private void verifyNewChild(boolean entityOverload) { + TestTreeSortEntity root = node("root-before"); + save(root); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity added = patch("new-child-" + IDGenerator.MD5.generate()); + added.setDefaultTest("new-business"); + patch.setChildren(List.of(added)); + update(root.getId(), patch, entityOverload).then(findNewChild(added.getName(), root.getId())) + .as(StepVerifier::create).assertNext(after -> { + assertNotNull(after.getId()); + assertEquals("new-business", after.getDefaultTest()); + assertEquals(root.getId(), after.getParentId()); + assertTrue(after.getPath().startsWith(root.getPath() + "-")); + assertEquals(Integer.valueOf(2), after.getLevel()); + }).expectComplete().verify(TIMEOUT); + } + + private void verifyNestedChildMove(boolean entityOverload) { + TestTreeSortEntity parentA = node("parent-a"); + TestTreeSortEntity parentB = node("parent-b"); + TestTreeSortEntity branch = node("branch-before"); + branch.setSortIndex(37L); + TestTreeSortEntity leaf = node("leaf-before"); + leaf.setSortIndex(84L); + parentA.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + service.save(List.of(parentA, parentB)).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + TestTreeSortEntity patch = patch("parent-b-after"); + TestTreeSortEntity childPatch = patch("branch-after"); + childPatch.setId(branch.getId()); + patch.setChildren(List.of(childPatch)); + update(parentB.getId(), patch, entityOverload).then(stored(List.of(branch.getId(), leaf.getId()))) + .as(StepVerifier::create).assertNext(after -> { + TestTreeSortEntity moved = after.get(branch.getId()); + assertEquals("branch-after", moved.getName()); + assertEquals("business-before", moved.getDefaultTest()); + assertEquals(parentB.getId(), moved.getParentId()); + assertTrue(moved.getPath().startsWith(parentB.getPath() + "-")); + assertEquals(branch.getSortIndex(), moved.getSortIndex()); + TestTreeSortEntity storedLeaf = after.get(leaf.getId()); + assertEquals(branch.getId(), storedLeaf.getParentId()); + assertTrue(storedLeaf.getPath().startsWith(moved.getPath() + "-")); + assertEquals(Integer.valueOf(3), storedLeaf.getLevel()); + assertEquals("leaf-before", storedLeaf.getName()); + assertEquals("business-before", storedLeaf.getDefaultTest()); + assertEquals(leaf.getSortIndex(), storedLeaf.getSortIndex()); + }).expectComplete().verify(TIMEOUT); + } + + private void verifyNewParentWithExistingChild(boolean entityOverload) { + TestTreeSortEntity root = node("root-before"); + TestTreeSortEntity branch = node("branch-before"); + TestTreeSortEntity leaf = node("leaf-before"); + root.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + save(root); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity newParent = patch("new-parent-" + IDGenerator.MD5.generate()); + newParent.setDefaultTest("new-business"); + TestTreeSortEntity branchPatch = patch("branch-after"); + branchPatch.setId(branch.getId()); + newParent.setChildren(List.of(branchPatch)); + patch.setChildren(List.of(newParent)); + update(root.getId(), patch, entityOverload).then(findNewChild(newParent.getName(), root.getId())) + .flatMap(parent -> stored(List.of(branch.getId(), leaf.getId())).map(after -> { + TestTreeSortEntity storedBranch = after.get(branch.getId()); + TestTreeSortEntity storedLeaf = after.get(leaf.getId()); + assertEquals(parent.getId(), storedBranch.getParentId()); + assertEquals("branch-after", storedBranch.getName()); + assertEquals("business-before", storedBranch.getDefaultTest()); + assertTrue(storedBranch.getPath().startsWith(parent.getPath() + "-")); + assertEquals(Integer.valueOf(3), storedBranch.getLevel()); + assertEquals(branch.getId(), storedLeaf.getParentId()); + assertEquals("leaf-before", storedLeaf.getName()); + assertTrue(storedLeaf.getPath().startsWith(storedBranch.getPath() + "-")); + assertEquals(Integer.valueOf(4), storedLeaf.getLevel()); + return true; + })).as(StepVerifier::create).expectNext(true).expectComplete().verify(TIMEOUT); + } + + private void verifyInvalidNewChild(boolean entityOverload) { + TestTreeSortEntity root = node("root-before"); + save(root); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity invalid = patch("invalid-" + IDGenerator.MD5.generate()); + invalid.setDefaultTest(""); + patch.setChildren(List.of(invalid)); + service.insert(invalid).as(StepVerifier::create).expectError(ValidationException.class).verify(TIMEOUT); + update(root.getId(), patch, entityOverload).as(StepVerifier::create) + .expectError(ValidationException.class).verify(TIMEOUT); + verifyRejectedPatch(root, invalid.getName()); + } + + private void verifyRejectedPatch(TestTreeSortEntity root, String invalidName) { + service.getRepository().findById(root.getId()).as(StepVerifier::create).assertNext(after -> { + assertEquals("root-before", after.getName()); + assertEquals(root.getPath(), after.getPath()); + }).expectComplete().verify(TIMEOUT); + service.getRepository().createQuery().where("name", invalidName).count() + .as(StepVerifier::create).expectNext(0).verifyComplete(); + } + + private Mono update(String id, TestTreeSortEntity patch, boolean entityOverload) { + return entityOverload ? service.updateById(id, patch) : service.updateById(id, Mono.just(patch)); + } + + private Mono> stored(List ids) { + return service.getRepository().findById(ids).collectMap(TestTreeSortEntity::getId); + } + + private Mono findNewChild(String name, String parentId) { + return service.getRepository().createQuery().where("name", name).and("parentId", parentId).fetchOne(); + } + + private void save(TestTreeSortEntity root) { + service.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + } + + private WebTestClient client() { + return WebTestClient.bindToController(new TreeController(service)).build(); + } + + private static TestTreeSortEntity node(String name) { + TestTreeSortEntity node = patch(name); + node.setId(IDGenerator.MD5.generate()); + node.setDefaultTest("business-before"); + return node; + } + + private static TestTreeSortEntity patch(String name) { + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName(name); + return patch; + } + + @TestComponent + @RestController + @RequestMapping("/tree") + public static class TreeController implements ReactiveServiceSaveController { + private final TestTreeSortEntityService service; + + public TreeController(TestTreeSortEntityService service) { + this.service = service; + } + + @Override + public TestTreeSortEntityService getService() { + return service; + } + } +} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java index 330a89606..4af9d5eb4 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java @@ -14,12 +14,12 @@ import org.hswebframework.web.system.authorization.api.enums.DimensionUserFeature; import org.springframework.util.DigestUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; import javax.persistence.Column; import javax.persistence.Index; import javax.persistence.Table; import jakarta.validation.constraints.NotBlank; +import java.nio.charset.StandardCharsets; import java.sql.JDBCType; @Getter @@ -82,13 +82,15 @@ public class DimensionUserEntity extends GenericEntity { @Schema(description = "关联时间", accessMode = Schema.AccessMode.READ_ONLY) private Long relationTime; + /** 保留公开 ID;新成员使用 UTF-8 字节长度前缀,避免业务字段中的分隔字符产生歧义。 */ public void generateId() { if (ObjectUtils.isEmpty(getId())) { - String id = DigestUtils - .md5DigestAsHex(String.format("%s-%s-%s", - dimensionTypeId, - dimensionId, userId).getBytes()); - setId(id); + StringBuilder key = new StringBuilder(); + for (String value : new String[]{dimensionTypeId, dimensionId, userId}) { + String part = String.valueOf(value); + key.append(part.getBytes(StandardCharsets.UTF_8).length).append(':').append(part); + } + setId(DigestUtils.md5DigestAsHex(key.toString().getBytes(StandardCharsets.UTF_8))); } } diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java new file mode 100644 index 000000000..1ee507226 --- /dev/null +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java @@ -0,0 +1,54 @@ +package org.hswebframework.web.system.authorization.api.entity; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** 成员身份按 UTF-8 字节长度编码三个字段,分隔字符不会合并不同自然键。 */ +public class DimensionUserIdentityTest { + @Test + public void delimiterContainingTuplesHaveDifferentIds() { + DimensionUserEntity first = member("a-b", "c", "user"); + DimensionUserEntity second = member("a", "b-c", "user"); + first.generateId(); + second.generateId(); + assertNotEquals(first.getId(), second.getId()); + } + + @Test + public void unicodeIdUsesUtf8ByteLengthPrefixes() { + DimensionUserEntity member = member("现场-运维", "片区|A:01", "用户-一"); + member.generateId(); + // 13:现场-运维11:片区|A:0110:用户-一 的 UTF-8 MD5。 + assertEquals("3133224d24611e79e511cb90d47e9c09", member.getId()); + } + + @Test + public void sameTupleKeepsItsIdWhenRelationChanges() { + DimensionUserEntity first = member("field-team", "district|A:01", "user-1"); + first.generateId(); + first.setRelation("manager"); + first.generateId(); + assertEquals("d2a7f6fd3daeb10bd68021d4dc75ac23", first.getId()); + DimensionUserEntity second = member("field-team", "district|A:01", "user-1"); + second.setRelation("member"); + second.generateId(); + assertEquals(first.getId(), second.getId()); + } + + @Test + public void callerProvidedIdRemainsUnchanged() { + DimensionUserEntity member = member("role", "worker", "user"); + member.setId("legacy-public-member-id"); + member.generateId(); + assertEquals("legacy-public-member-id", member.getId()); + } + + private static DimensionUserEntity member(String type, String dimension, String user) { + DimensionUserEntity member = new DimensionUserEntity(); + member.setDimensionTypeId(type); + member.setDimensionId(dimension); + member.setUserId(user); + return member; + } +} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java index 79556f511..f8d5b966c 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java @@ -1,13 +1,17 @@ package org.hswebframework.web.system.authorization.defaults.service; import lombok.extern.slf4j.Slf4j; +import org.hswebframework.ezorm.core.param.TermType; import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException; import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; import org.hswebframework.ezorm.rdb.mapping.ReactiveUpdate; import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; import org.hswebframework.web.crud.events.EntityCreatedEvent; import org.hswebframework.web.crud.events.EntityDeletedEvent; import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.crud.events.EntityPrepareCreateEvent; +import org.hswebframework.web.crud.events.EntityPrepareSaveEvent; import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.crud.service.GenericReactiveCrudService; import org.hswebframework.web.event.AsyncEvent; @@ -22,6 +26,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; +import org.springframework.util.ObjectUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.function.Function3; @@ -40,6 +45,105 @@ public class DefaultDimensionUserService extends GenericReactiveCrudService event) { + event.async(prepareMemberIdentity(event.getEntity())); + } + + @EventListener + public void prepareMemberIdentity(EntityPrepareSaveEvent event) { + event.async(prepareMemberIdentity(event.getEntity())); + } + + private Mono prepareMemberIdentity(List members) { + // prepare 早于 SQL 物化;每片一次查询,service 和 repository 共享身份规则。 + Map> resolvedIds = new HashMap<>(); + return Flux.fromIterable(members).buffer(200) + .concatMap(batch -> resolveMemberIdentity(batch, resolvedIds)).then(); + } + + private Mono resolveMemberIdentity(List members, + Map> resolvedIds) { + Set ids = new LinkedHashSet<>(); + Set> keys = new LinkedHashSet<>(); + for (DimensionUserEntity member : members) { + if (!ObjectUtils.isEmpty(member.getId())) { + ids.add(member.getId()); + } + if (hasMemberKey(member)) { + keys.add(memberKey(member)); + if (ObjectUtils.isEmpty(member.getId())) { + // 候选 ID 只用于同一批查询,不能提前成为调用方显式 ID 或抢占旧自然键。 + DimensionUserEntity candidate = member.copyTo(DimensionUserEntity.class); + candidate.generateId(); + ids.add(candidate.getId()); + } + } + } + if (ids.isEmpty() && keys.isEmpty()) { + // 不完整的新输入仍由原写入校验及数据库约束处理,不能使用 null 条件匹配其他行。 + return Mono.empty(); + } + ReactiveQuery query = createQuery(); + if (!ids.isEmpty()) { + query.in(DimensionUserEntity::getId, ids); + } + for (List key : keys) { + query.orNest().is(DimensionUserEntity::getDimensionTypeId, key.get(0)) + .and(DimensionUserEntity::getDimensionId, TermType.eq, key.get(1)) + .and(DimensionUserEntity::getUserId, TermType.eq, key.get(2)).end(); + } + return query.fetch().collectList().doOnNext(existing -> { + Map byId = new HashMap<>(); + Map, DimensionUserEntity> byMember = new HashMap<>(); + for (DimensionUserEntity row : existing) { + byId.put(row.getId(), row); + byMember.putIfAbsent(memberKey(row), row); + } + for (DimensionUserEntity member : members) { + DimensionUserEntity row = byId.get(member.getId()); + // 先检查传入 ID;稀疏保存只校验已提供的身份列,不回填业务字段。 + if (row != null && !matchesProvidedIdentity(row, member)) { + throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); + } + if (row == null && hasMemberKey(member)) { + row = byMember.get(memberKey(member)); + } + if (row != null) { + member.setId(row.getId()); + } else if (hasMemberKey(member)) { + member.generateId(); + DimensionUserEntity occupied = byId.get(member.getId()); + if (occupied != null && !matchesProvidedIdentity(occupied, member)) { + throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); + } + } + if (row != null || hasMemberKey(member)) { + List key = memberKey(row == null ? member : row); + List previous = resolvedIds.putIfAbsent(member.getId(), key); + // 一次 prepare 全部完成后才写 SQL;跨查询片也不能让不同成员共用公开 ID。 + if (previous != null && !previous.equals(key)) { + throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); + } + } + } + }).then(); + } + + private boolean hasMemberKey(DimensionUserEntity member) { + return member.getDimensionTypeId() != null && member.getDimensionId() != null && member.getUserId() != null; + } + + private List memberKey(DimensionUserEntity member) { + return Arrays.asList(member.getDimensionTypeId(), member.getDimensionId(), member.getUserId()); + } + + private boolean matchesProvidedIdentity(DimensionUserEntity stored, DimensionUserEntity input) { + return (input.getDimensionTypeId() == null || Objects.equals(stored.getDimensionTypeId(), input.getDimensionTypeId())) + && (input.getDimensionId() == null || Objects.equals(stored.getDimensionId(), input.getDimensionId())) + && (input.getUserId() == null || Objects.equals(stored.getUserId(), input.getUserId())); + } + //处理用户被删除时,同步删除维度绑定信息 @EventListener public void handleUserDeleteEntity(UserDeletedEvent event) { diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties index 65ff7fc0b..e692b51c7 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties @@ -1,2 +1,3 @@ error.user_already_exists=User already exists -error.user_not_found=The user does not exist or the id does not meet the rule \ No newline at end of file +error.user_not_found=The user does not exist or the id does not meet the rule +error.dimension_user_id_conflict=The member id belongs to another dimension or user: {0} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties index 84e18030c..fbcbd6c30 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties @@ -1,2 +1,3 @@ error.user_already_exists=\u7528\u6237\u5DF2\u5B58\u5728 -error.user_not_found=\u7528\u6237\u4E0D\u5B58\u5728\u6216ID\u4E0D\u7B26\u5408\u89C4\u5219:[{0}] \ No newline at end of file +error.user_not_found=\u7528\u6237\u4E0D\u5B58\u5728\u6216ID\u4E0D\u7B26\u5408\u89C4\u5219:[{0}] +error.dimension_user_id_conflict=\u6210\u5458ID\u5DF2\u5C5E\u4E8E\u5176\u4ED6\u7EF4\u5EA6\u6216\u7528\u6237: {0} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java new file mode 100644 index 000000000..517de44ad --- /dev/null +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java @@ -0,0 +1,375 @@ +package org.hswebframework.web.system.authorization.defaults.service; + +import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException; +import org.hswebframework.web.exception.BusinessException; +import org.hswebframework.web.crud.events.EntityBeforeQueryEvent; +import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.system.authorization.api.entity.DimensionUserEntity; +import org.hswebframework.web.system.authorization.defaults.service.reactive.ReactiveTestApplication; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; + +/** 正式 ORM 写入事件统一确定成员身份,兼容旧公开 ID 和所有 service/repository 入口。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {ReactiveTestApplication.class, DimensionUserIdentityRegressionTest.Configuration.class}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=DimensionUserIdentityRegressionTest" +}) +public class DimensionUserIdentityRegressionTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private DefaultDimensionUserService service; + + @Autowired + private QueryCounter queries; + + @Test + public void allEntryPointsStoreTheGeneratedTupleIdBeforeSqlExecution() { + for (Write entry : Write.values()) { + DimensionUserEntity input = uniqueMember(); + DimensionUserEntity expected = member(input.getDimensionTypeId(), input.getDimensionId(), input.getUserId()); + expected.generateId(); + write(entry, input).then(findTuple(input)).as(StepVerifier::create).assertNext(stored -> { + assertEquals(entry.name(), expected.getId(), stored.getId()); + assertEquals("dimension", stored.getDimensionName()); + assertEquals("user", stored.getUserName()); + }).expectComplete().verify(TIMEOUT); + } + } + + @Test + public void everySaveEntryPointReusesLegacyIdForEmptyAndGeneratedInputIds() { + for (Write entry : Write.values()) { + if (entry.create) { + continue; + } + for (boolean preGenerated : List.of(false, true)) { + DimensionUserEntity legacy = seedLegacyMember(); + DimensionUserEntity input = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); + input.setRelationName("relation-after"); + if (preGenerated) { + input.generateId(); + } + write(entry, input).then(findTuple(input)).as(StepVerifier::create).assertNext(stored -> { + assertEquals(entry.name(), legacy.getId(), stored.getId()); + assertEquals("relation-after", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(input).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + } + + @Test + public void everyCreateEntryPointStillRejectsAnExistingTuple() { + for (Write entry : Write.values()) { + if (!entry.create) { + continue; + } + DimensionUserEntity legacy = seedLegacyMember(); + DimensionUserEntity duplicate = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); + duplicate.setRelationName("duplicate-must-not-update"); + write(entry, duplicate).as(StepVerifier::create).expectError(DuplicateKeyException.class).verify(TIMEOUT); + findTuple(legacy).as(StepVerifier::create).assertNext(stored -> { + assertEquals(legacy.getId(), stored.getId()); + assertEquals("relation-before", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + + @Test + public void differentDelimiterContainingTuplesDoNotOverwriteEachOther() { + String token = IDGenerator.MD5.generate().substring(0, 12); + String user = "user-" + token; + DimensionUserEntity first = member(token + "-team", "district", user); + DimensionUserEntity second = member(token, "team-district", user); + first.generateId(); + second.generateId(); + service.save(first).then(service.save(second)).then(service.createQuery() + .where(DimensionUserEntity::getUserId, user).fetch().collectMap(DimensionUserEntity::getDimensionTypeId)) + .as(StepVerifier::create).assertNext(rows -> { + assertEquals(2, rows.size()); + assertEquals("district", rows.get(first.getDimensionTypeId()).getDimensionId()); + assertEquals("team-district", rows.get(second.getDimensionTypeId()).getDimensionId()); + assertNotEquals(rows.get(first.getDimensionTypeId()).getId(), rows.get(second.getDimensionTypeId()).getId()); + }).expectComplete().verify(TIMEOUT); + } + + @Test + public void everyEntryPointRejectsExplicitIdForAnotherTupleBeforeNaturalKeyResolution() { + for (Write entry : Write.values()) { + DimensionUserEntity first = seedLegacyMember(); + DimensionUserEntity second = seedLegacyMember(); + DimensionUserEntity attack = member(second.getDimensionTypeId(), second.getDimensionId(), second.getUserId()); + attack.setId(first.getId()); + attack.setRelationName("must-not-overwrite"); + write(entry, attack).as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); + service.findById(List.of(first.getId(), second.getId())).collectMap(DimensionUserEntity::getId) + .as(StepVerifier::create).assertNext(rows -> { + assertEquals(2, rows.size()); + assertEquals(first.getDimensionTypeId(), rows.get(first.getId()).getDimensionTypeId()); + assertEquals(second.getDimensionTypeId(), rows.get(second.getId()).getDimensionTypeId()); + assertEquals("relation-before", rows.get(first.getId()).getRelationName()); + assertEquals("relation-before", rows.get(second.getId()).getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(second).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + + @Test + public void implicitGeneratedIdCannotOverwriteAnotherTuplesCallerProvidedId() { + for (Write entry : Write.values()) { + DimensionUserEntity input = uniqueMember(); + DimensionUserEntity candidate = member(input.getDimensionTypeId(), input.getDimensionId(), input.getUserId()); + candidate.generateId(); + DimensionUserEntity occupied = uniqueMember(); + occupied.setId(candidate.getId()); + service.getRepository().insert(Mono.just(occupied)).as(StepVerifier::create).expectNext(1).verifyComplete(); + assertNull(input.getId()); + write(entry, input).as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); + service.findById(occupied.getId()).as(StepVerifier::create).assertNext(stored -> { + assertEquals(occupied.getDimensionTypeId(), stored.getDimensionTypeId()); + assertEquals(occupied.getDimensionId(), stored.getDimensionId()); + assertEquals(occupied.getUserId(), stored.getUserId()); + assertEquals("relation-before", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(input).as(StepVerifier::create).expectNext(0).verifyComplete(); + } + } + + @Test + public void differentTuplesCannotResolveToOnePublicIdWithinACollection() { + verifyBatchIdentityConflict(false); + } + + @Test + public void differentTuplesCannotResolveToOnePublicIdAcrossQueryBuffers() { + verifyBatchIdentityConflict(true); + } + + @Test + public void legacyNaturalTupleTakesPriorityOverAnOccupiedImplicitCandidateId() { + for (Write entry : Write.values()) { + if (entry.create) { + continue; + } + DimensionUserEntity legacy = seedLegacyMember(); + DimensionUserEntity candidate = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); + candidate.generateId(); + DimensionUserEntity occupied = uniqueMember(); + occupied.setId(candidate.getId()); + service.getRepository().insert(Mono.just(occupied)).as(StepVerifier::create).expectNext(1).verifyComplete(); + DimensionUserEntity input = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); + input.setRelationName("legacy-rebind-after"); + write(entry, input).then(findTuple(legacy)).as(StepVerifier::create).assertNext(stored -> { + assertEquals(legacy.getId(), stored.getId()); + assertEquals("legacy-rebind-after", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + service.findById(occupied.getId()).as(StepVerifier::create).assertNext(stored -> { + assertEquals(occupied.getDimensionTypeId(), stored.getDimensionTypeId()); + assertEquals(occupied.getDimensionId(), stored.getDimensionId()); + assertEquals(occupied.getUserId(), stored.getUserId()); + assertEquals("relation-before", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); + tupleCount(occupied).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + + @Test + public void incompleteNewTupleCannotResolveToAnExistingMember() { + DimensionUserEntity legacy = seedLegacyMember(); + for (Write entry : Write.values()) { + DimensionUserEntity incomplete = new DimensionUserEntity(); + incomplete.setUserId(legacy.getUserId()); + incomplete.setDimensionName("dimension"); + incomplete.setUserName("user"); + incomplete.setRelationName("must-not-overwrite"); + write(entry, incomplete).as(StepVerifier::create).expectError().verify(TIMEOUT); + service.findById(legacy.getId()).as(StepVerifier::create).assertNext(stored -> { + assertEquals(legacy.getDimensionTypeId(), stored.getDimensionTypeId()); + assertEquals(legacy.getDimensionId(), stored.getDimensionId()); + assertEquals("relation-before", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); + } + } + + @Test + public void bulkServiceAndRepositorySavesKeepExactTupleIdentityWithBoundedReads() { + for (boolean rawRepository : List.of(false, true)) { + String token = IDGenerator.MD5.generate().substring(0, 12); + DimensionUserEntity first = member(token, "first", "first-user-" + token); + first.setId("legacy-first-" + token); + DimensionUserEntity second = member(token + "-other", "second", "second-user-" + token); + second.setId("legacy-second-" + token); + service.getRepository().insertBatch(Mono.just(List.of(first, second))) + .as(StepVerifier::create).expectNext(2).verifyComplete(); + DimensionUserEntity firstPatch = member(first.getDimensionTypeId(), first.getDimensionId(), first.getUserId()); + firstPatch.setRelationName("bulk-first-after"); + DimensionUserEntity secondPatch = member(second.getDimensionTypeId(), second.getDimensionId(), second.getUserId()); + secondPatch.generateId(); + secondPatch.setRelationName("bulk-second-after"); + DimensionUserEntity crossTuple = member(first.getDimensionTypeId(), second.getDimensionId(), first.getUserId()); + List members = new ArrayList<>(List.of(firstPatch, secondPatch, crossTuple)); + for (int i = 0; i < 202; i++) { + members.add(member(token, "bulk-" + i, "bulk-user-" + token + "-" + i)); + } + queries.count.set(0); + Mono operation = rawRepository ? service.getRepository().save(members) : service.save(members); + operation.then().as(StepVerifier::create).expectComplete().verify(TIMEOUT); + assertTrue("205 member writes must not perform one identity SELECT per member", queries.count.get() <= 12); + findTuple(firstPatch).as(StepVerifier::create).assertNext(stored -> { + assertEquals(first.getId(), stored.getId()); + assertEquals("bulk-first-after", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + findTuple(secondPatch).as(StepVerifier::create).assertNext(stored -> { + assertEquals(second.getId(), stored.getId()); + assertEquals("bulk-second-after", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + findTuple(crossTuple).as(StepVerifier::create).assertNext(stored -> { + assertNotEquals(first.getId(), stored.getId()); + assertNotEquals(second.getId(), stored.getId()); + assertEquals(first.getDimensionTypeId(), stored.getDimensionTypeId()); + assertEquals(second.getDimensionId(), stored.getDimensionId()); + }).expectComplete().verify(TIMEOUT); + service.createQuery().in(DimensionUserEntity::getDimensionTypeId, + List.of(first.getDimensionTypeId(), second.getDimensionTypeId())).count() + .as(StepVerifier::create).expectNext(205).verifyComplete(); + } + } + + @Test + public void everyEntryPointKeepsAnUnusedCallerProvidedPublicId() { + for (Write entry : Write.values()) { + DimensionUserEntity member = uniqueMember(); + member.setId("public-" + IDGenerator.MD5.generate()); + String expectedId = member.getId(); + write(entry, member).then(findTuple(member)).as(StepVerifier::create).assertNext(stored -> + assertEquals(entry.name(), expectedId, stored.getId())).expectComplete().verify(TIMEOUT); + } + } + + private void verifyBatchIdentityConflict(boolean acrossBuffer) { + for (boolean rawRepository : List.of(false, true)) { + DimensionUserEntity first = uniqueMember(); + DimensionUserEntity candidate = member(first.getDimensionTypeId(), first.getDimensionId(), first.getUserId()); + candidate.generateId(); + DimensionUserEntity second = uniqueMember(); + second.setId(candidate.getId()); + List inputs = new ArrayList<>(); + inputs.add(first); + if (acrossBuffer) { + for (int i = 0; i < 202; i++) { + inputs.add(uniqueMember()); + } + } + inputs.add(second); + Mono operation = rawRepository ? service.getRepository().save(inputs) : service.save(inputs); + operation.then().as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); + service.createQuery().in(DimensionUserEntity::getUserId, + inputs.stream().map(DimensionUserEntity::getUserId).collect(java.util.stream.Collectors.toList())) + .count().as(StepVerifier::create).expectNext(0).verifyComplete(); + assertEquals("relation-before", first.getRelationName()); + assertEquals("relation-before", second.getRelationName()); + } + } + + private DimensionUserEntity seedLegacyMember() { + DimensionUserEntity legacy = uniqueMember(); + legacy.setId("legacy-" + IDGenerator.MD5.generate()); + service.getRepository().insert(Mono.just(legacy)).as(StepVerifier::create).expectNext(1).verifyComplete(); + return legacy; + } + + private Mono findTuple(DimensionUserEntity member) { + return service.createQuery().where(DimensionUserEntity::getDimensionTypeId, member.getDimensionTypeId()) + .and(DimensionUserEntity::getDimensionId, member.getDimensionId()) + .and(DimensionUserEntity::getUserId, member.getUserId()).fetchOne(); + } + + private Mono tupleCount(DimensionUserEntity member) { + return service.createQuery().where(DimensionUserEntity::getDimensionTypeId, member.getDimensionTypeId()) + .and(DimensionUserEntity::getDimensionId, member.getDimensionId()) + .and(DimensionUserEntity::getUserId, member.getUserId()).count(); + } + + private Mono write(Write entry, DimensionUserEntity member) { + switch (entry) { + case SERVICE_SAVE_ENTITY: return service.save(member).then(); + case SERVICE_SAVE_COLLECTION: return service.save(List.of(member)).then(); + case SERVICE_SAVE_PUBLISHER: return service.save(Mono.just(member)).then(); + case REPOSITORY_SAVE_ENTITY: return service.getRepository().save(member).then(); + case REPOSITORY_SAVE_COLLECTION: return service.getRepository().save(List.of(member)).then(); + case REPOSITORY_SAVE_PUBLISHER: return service.getRepository().save(Mono.just(member)).then(); + case SERVICE_INSERT_ENTITY: return service.insert(member).then(); + case SERVICE_INSERT_PUBLISHER: return service.insert(Mono.just(member)).then(); + case SERVICE_INSERT_BATCH: return service.insertBatch(Mono.just(List.of(member))).then(); + case REPOSITORY_INSERT_PUBLISHER: return service.getRepository().insert(Mono.just(member)).then(); + case REPOSITORY_INSERT_BATCH: return service.getRepository().insertBatch(Mono.just(List.of(member))).then(); + default: throw new AssertionError(entry); + } + } + + private static DimensionUserEntity uniqueMember() { + String token = IDGenerator.MD5.generate(); + return member(token.substring(0, 16), "dimension-" + token, "user-" + token); + } + + private static DimensionUserEntity member(String type, String dimension, String user) { + DimensionUserEntity member = new DimensionUserEntity(); + member.setDimensionTypeId(type); + member.setDimensionId(dimension); + member.setDimensionName("dimension"); + member.setUserId(user); + member.setUserName("user"); + member.setRelationName("relation-before"); + return member; + } + + static class QueryCounter { + final AtomicInteger count = new AtomicInteger(); + + @EventListener + public void countQuery(EntityBeforeQueryEvent event) { + count.incrementAndGet(); + } + } + + @TestConfiguration + static class Configuration { + @Bean + QueryCounter queryCounter() { + return new QueryCounter(); + } + } + + private enum Write { + SERVICE_SAVE_ENTITY(false), SERVICE_SAVE_COLLECTION(false), SERVICE_SAVE_PUBLISHER(false), + REPOSITORY_SAVE_ENTITY(false), REPOSITORY_SAVE_COLLECTION(false), REPOSITORY_SAVE_PUBLISHER(false), + SERVICE_INSERT_ENTITY(true), SERVICE_INSERT_PUBLISHER(true), SERVICE_INSERT_BATCH(true), + REPOSITORY_INSERT_PUBLISHER(true), REPOSITORY_INSERT_BATCH(true); + + final boolean create; + + Write(boolean create) { + this.create = create; + } + } +} From 7050c332bc015a882ece97ef8ea16820d3b158d5 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sun, 11 Oct 2026 17:50:13 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix(=E6=A0=91CRUD):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AD=90=E7=BA=A7=E8=B7=AF=E5=BE=84=E5=8F=8A?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=88=A0=E9=99=A4=E9=87=8D=E6=8F=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 44 ++++--- .../crud/service/TreeSortServiceHelper.java | 16 ++- .../ConcurrentParentPatchRegressionTest.java | 51 ++++++++ .../NewTreeNodePathRegressionTest.java | 116 ++++++++++++++++++ 4 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/NewTreeNodePathRegressionTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 7ae24f0ec..b1efeeb69 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -258,23 +258,35 @@ default Mono updateById(K id, Mono entityPublisher) { data.setId(id); ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); return helper.prepareUpdate(Flux.just(data)) - .concatMap(node -> { - E patch = helper.getUpdateData(node); - if (patch != null) { - if (helper.isNewNode(node)) { - // 使用服务入口,让业务 ID、创建校验及实体事件参与原事务。 - return save(patch).map(SaveResult::getTotal); + .collectList() + .flatMap(nodes -> getRepository().updateById(id, helper.getUpdateData(data)) + .flatMap(updated -> { + // 目标始终是 UPDATE;并发删除导致零修改时,不能新增目标或继续写 children。 + if (updated == 0) { + return Mono.just(0); } - // 不把完整快照回写,避免覆盖其他事务修改的独立业务字段。 - return getRepository().updateById(node.getId(), patch); - } - return getRepository().createUpdate() - .set("path", node.getPath()) - .set("level", node.getLevel()) - .where("id", node.getId()) - .execute(); - }) - .reduce(Math::addExact); + return Flux.fromIterable(nodes) + .filter(node -> !Objects.equals(id, node.getId())) + .concatMap(node -> { + E patch = helper.getUpdateData(node); + if (patch != null) { + if (helper.isNewNode(node)) { + // 保留业务 save 扩展,再按实际持久路径维护尚未写入的后代。 + return save(patch).flatMap(result -> getRepository().findById(patch.getId()) + .doOnNext(helper::updateSavedNodePath) + .thenReturn(result.getTotal())); + } + // 不把完整快照回写,避免覆盖其他事务修改的独立业务字段。 + return getRepository().updateById(node.getId(), patch); + } + return getRepository().createUpdate() + .set("path", node.getPath()) + .set("level", node.getLevel()) + .where("id", node.getId()) + .execute(); + }) + .reduce(updated, Math::addExact); + })); })).defaultIfEmpty(0); return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index a8cb34652..ba57fc32e 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -209,6 +209,14 @@ protected boolean isNewNode(E node) { return !oldData.containsKey(node.getId()); } + /** 新节点的业务 save 重新准备路径后,只同步尚待写入后代的树字段。 */ + void updateSavedNodePath(E saved) { + E prepared = allData.get(saved.getId()); + prepared.setPath(saved.getPath()); + prepared.setLevel(saved.getLevel()); + refactorChildPath(saved.getId(), saved.getPath(), this::refactor); + } + /** 当前完整保存明确移根,且原持久化节点具有父级。 */ protected boolean isMovingToRoot(E node) { E previous = oldData.get(node.getId()); @@ -356,12 +364,8 @@ private void refactorPath(E data, Function> childGetter) { //新增节点 else if (parentId != null) { if (oldParent != null) { - String parentPath = oldParent.getPath() + "-"; - // 新节点经过服务 save 扩展再次准备时,保留已与当前父级一致的直接子路径。 - if (data.getPath() == null || !data.getPath().startsWith(parentPath) - || data.getPath().indexOf('-', parentPath.length()) >= 0) { - data.setPath(parentPath + RandomUtil.randomChar(4)); - } + // 普通新增不能信任调用方传入的路径,即使形状符合当前父级也必须重新分配。 + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); } } } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java index 78fcf4c5e..0e006ae18 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java @@ -53,6 +53,57 @@ public void entityNamePatchPreservesTheOtherTransactionsCommittedParentAndPaths( verifyNamePatchAfterParentMove(true); } + @Test + public void monoUpdateCannotReinsertTargetDeletedAfterItsInitialRead() { + verifyUpdateAfterTargetDeletion(false); + } + + @Test + public void entityUpdateCannotReinsertTargetDeletedAfterItsInitialRead() { + verifyUpdateAfterTargetDeletion(true); + } + + private void verifyUpdateAfterTargetDeletion(boolean entityOverload) { + TestTreeSortEntity target = node("target-before-delete"); + service.save(target).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + Gate gate = new Gate(target.getId(), null, connectionFactory); + service.installGate(gate); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + TransactionalOperator transactions = TransactionalOperator.create(manager, definition); + + TestTreeSortEntity patch = node("target-after-delete-" + target.getId()); + TestTreeSortEntity child = node("deleted-target-child-" + target.getId()); + patch.setChildren(List.of(child)); + AtomicReference updated = new AtomicReference<>(); + Mono update = transactions.transactional(entityOverload ? service.updateById(target.getId(), patch) + : service.updateById(target.getId(), Mono.just(patch))) + .contextWrite(context -> context.put(PATCH, true)).doOnNext(updated::set).then(); + Mono deletion = gate.read.asMono().flatMap(updateConnection -> transactions.transactional( + TransactionSynchronizationManager.forCurrentTransaction().flatMap(tx -> { + assertTrue(tx.isActualTransactionActive()); + Object deleteConnection = tx.getResource(connectionFactory); + assertNotNull(deleteConnection); + assertNotSame("deletion and update require real independent connections", updateConnection, deleteConnection); + return service.deleteById(target.getId()).doOnNext(deleted -> assertEquals(Integer.valueOf(1), deleted)); + }))) + // 放行发生在删除事务正式完成之后,第二次图读取必须看到目标已不存在。 + .then(Mono.fromRunnable(() -> gate.release.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST))); + try { + Mono.when(update, deletion).as(StepVerifier::create).expectComplete().verify(Duration.ofSeconds(10)); + service.getRepository().createQuery().in("name", List.of(patch.getName(), child.getName())).count() + .as(StepVerifier::create).assertNext(count -> { + assertEquals("update-only target must stay absent; update count=" + updated.get(), Integer.valueOf(0), count); + assertEquals(Integer.valueOf(0), updated.get()); + }).verifyComplete(); + service.getRepository().findById(target.getId()).as(StepVerifier::create).verifyComplete(); + } finally { + gate.release.tryEmitEmpty(); + service.installGate(null); + } + } + private void verifyNamePatchAfterParentMove(boolean entityOverload) { TestTreeSortEntity parentA = node("parent-a"); TestTreeSortEntity parentB = node("parent-b"); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/NewTreeNodePathRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/NewTreeNodePathRegressionTest.java new file mode 100644 index 000000000..700aab52a --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/NewTreeNodePathRegressionTest.java @@ -0,0 +1,116 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** 普通新增必须分配自己的树路径,不能借用兄弟路径扩大子树查询或删除。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=NewTreeNodePathRegressionTest" +}) +public class NewTreeNodePathRegressionTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private TestTreeSortEntityService service; + + @Test + public void entitySaveReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(service::save); + } + + @Test + public void publisherSaveReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(node -> service.save(Mono.just(node))); + } + + @Test + public void collectionSaveReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(node -> service.save(List.of(node))); + } + + @Test + public void entityInsertReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(service::insert); + } + + @Test + public void publisherInsertReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(node -> service.insert(Mono.just(node))); + } + + @Test + public void batchInsertReplacesCopiedSiblingPathBeforeQueryAndDelete() { + verifyIndependentPath(node -> service.insertBatch(Mono.just(List.of(node)))); + } + + private void verifyIndependentPath(Function> writer) { + TestTreeSortEntity root = node("root"); + TestTreeSortEntity sibling = node("existing-sibling"); + TestTreeSortEntity leaf = node("existing-leaf"); + sibling.setSortIndex(37L); + leaf.setSortIndex(84L); + root.setChildren(List.of(sibling)); + sibling.setChildren(List.of(leaf)); + service.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + TestTreeSortEntity added = node("new-sibling"); + added.setParentId(root.getId()); + added.setPath(sibling.getPath()); + added.setLevel(sibling.getLevel()); + writer.apply(added) + .then(service.getRepository().findById(added.getId())) + .flatMap(persisted -> service.queryIncludeChildren(List.of(added.getId())).collectList() + .flatMap(included -> service.deleteById(added.getId()) + .flatMap(deleted -> service.getRepository() + .findById(List.of(root.getId(), sibling.getId(), leaf.getId(), added.getId())) + .collectMap(TestTreeSortEntity::getId) + .doOnNext(after -> { + Set includedIds = included.stream().map(TestTreeSortEntity::getId) + .collect(Collectors.toSet()); + // 先执行真实子树删除,再一起检查查询结果和数据库,不能靠内存路径断言代替 SQL 边界。 + assertEquals("new-node query must exclude siblings; deleted=" + deleted + + ", remaining=" + after.keySet(), Set.of(added.getId()), includedIds); + assertEquals(Integer.valueOf(1), deleted); + assertNotEquals(sibling.getPath(), persisted.getPath()); + assertTrue(persisted.getPath().startsWith(root.getPath() + "-")); + assertEquals(Integer.valueOf(2), persisted.getLevel()); + assertEquals(Set.of(root.getId(), sibling.getId(), leaf.getId()), after.keySet()); + assertEquals(root.getPath(), after.get(root.getId()).getPath()); + assertEquals(sibling.getPath(), after.get(sibling.getId()).getPath()); + assertEquals(leaf.getPath(), after.get(leaf.getId()).getPath()); + assertEquals("existing-sibling", after.get(sibling.getId()).getName()); + assertEquals("existing-leaf", after.get(leaf.getId()).getName()); + assertEquals("business-before", after.get(sibling.getId()).getDefaultTest()); + assertEquals("business-before", after.get(leaf.getId()).getDefaultTest()); + assertEquals(Long.valueOf(37), after.get(sibling.getId()).getSortIndex()); + assertEquals(Long.valueOf(84), after.get(leaf.getId()).getSortIndex()); + })))) + .as(StepVerifier::create).expectNextCount(1).expectComplete().verify(TIMEOUT); + } + + private static TestTreeSortEntity node(String name) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setId(IDGenerator.MD5.generate()); + node.setName(name); + node.setDefaultTest("business-before"); + return node; + } +} From 2c7ae8a272f04031e34246d47d987aefe1d53ddb Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Sun, 11 Oct 2026 19:56:13 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix(=E6=A0=91CRUD):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E7=A8=80=E7=96=8F=E6=9B=B4=E6=96=B0=E8=A6=86=E7=9B=96=E5=B7=B2?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=A0=91=E7=A7=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crud/service/TreeSortServiceHelper.java | 16 +- ...urrentPreparedTreePatchRegressionTest.java | 282 ++++++++++++++++++ 2 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentPreparedTreePatchRegressionTest.java diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index ba57fc32e..76c94d9e8 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -194,8 +194,20 @@ private void completeUpdateInput() { protected E getUpdateData(E node) { E patch = updateData.get(node.getId()); if (patch != null) { - patch.setPath(node.getPath()); - patch.setLevel(node.getLevel()); + E previous = oldData.get(node.getId()); + // 业务 patch 不回写未变化的隐式树字段,避免覆盖另一个事务已提交的移动。 + // 显式树输入(包括同值)及准备后的实际变化仍提交完整 path/level。 + boolean treeFieldsRequired = previous == null + || patch.getParentId() != null + || patch.getPath() != null + || patch.getLevel() != null + || CollectionUtils.isNotEmpty(patch.getChildren()) + || !Objects.equals(previous.getPath(), node.getPath()) + || !Objects.equals(previous.getLevel(), node.getLevel()); + if (treeFieldsRequired) { + patch.setPath(node.getPath()); + patch.setLevel(node.getLevel()); + } if (isNewNode(node)) { patch.setSortIndex(node.getSortIndex()); } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentPreparedTreePatchRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentPreparedTreePatchRegressionTest.java new file mode 100644 index 000000000..0cd5660a4 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentPreparedTreePatchRegressionTest.java @@ -0,0 +1,282 @@ +package org.hswebframework.web.crud.service; + +import io.r2dbc.spi.ConnectionFactory; +import org.hswebframework.web.api.crud.entity.TreeSupportEntity; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.ReactiveTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.reactive.TransactionalOperator; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** + * 验证子树快照读取之后,名称修改仍保留另一独立事务已经提交的移动。 + * 仅暂停测试服务的真实查询下游;树准备、SQL、事件和事务均使用正式实现。 + * 每次测试独立安装和清理 gate,不改变现有首次读取窗口测试。 + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, ConcurrentPreparedTreePatchRegressionTest.Configuration.class}, + properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=ConcurrentPreparedTreePatchRegressionTest", + "spring.r2dbc.pool.enabled=true", + "spring.r2dbc.pool.initial-size=2", + "spring.r2dbc.pool.max-size=4" + }) +public class ConcurrentPreparedTreePatchRegressionTest { + private static final String PATCH = ConcurrentPreparedTreePatchRegressionTest.class.getName(); + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired + @Qualifier("preparedTree") + private ControlledTreeService service; + + @Autowired + private ConnectionFactory connectionFactory; + + @Autowired + @Qualifier("connectionFactoryTransactionManager") + private ReactiveTransactionManager manager; + + @Test + public void monoNamePatchPreservesMoveCommittedAfterItsSubtreeSnapshot() { + verifyNamePatchAfterPreparedSubtreeMove(false, false); + } + + @Test + public void entityNamePatchPreservesMoveCommittedAfterItsSubtreeSnapshot() { + verifyNamePatchAfterPreparedSubtreeMove(true, false); + } + + @Test + public void monoNamePatchWithEmptyChildrenPreservesMoveCommittedAfterItsSubtreeSnapshot() { + verifyNamePatchAfterPreparedSubtreeMove(false, true); + } + + @Test + public void entityNamePatchWithEmptyChildrenPreservesMoveCommittedAfterItsSubtreeSnapshot() { + verifyNamePatchAfterPreparedSubtreeMove(true, true); + } + + private void verifyNamePatchAfterPreparedSubtreeMove(boolean entityOverload, boolean emptyChildren) { + TestTreeSortEntity parentA = node("parent-a"); + TestTreeSortEntity targetRoot = node("target-root"); + TestTreeSortEntity targetBranch = node("target-branch"); + TestTreeSortEntity target = node("target"); + TestTreeSortEntity branch = node("branch-before"); + TestTreeSortEntity leaf = node("leaf-before"); + branch.setSortIndex(37L); + leaf.setSortIndex(84L); + parentA.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + targetRoot.setChildren(List.of(targetBranch)); + targetBranch.setChildren(List.of(target)); + service.save(List.of(parentA, targetRoot)) + .as(StepVerifier::create).expectNextCount(1).verifyComplete(); + List ids = List.of(parentA.getId(), targetRoot.getId(), targetBranch.getId(), + target.getId(), branch.getId(), leaf.getId()); + AtomicReference> before = new AtomicReference<>(); + stored(ids).as(StepVerifier::create).assertNext(before::set).verifyComplete(); + SubtreeGate gate = new SubtreeGate(before.get().get(branch.getId()), before.get().get(leaf.getId()), + connectionFactory); + service.installGate(gate); + DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); + definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + TransactionalOperator transactions = TransactionalOperator.create(manager, definition); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("branch-after"); + if (emptyChildren) { + // updateById 的空 children 不删除或替换后代,也不能成为回写旧树字段的理由。 + patch.setChildren(List.of()); + } + Mono update = entityOverload ? service.updateById(branch.getId(), patch) + : service.updateById(branch.getId(), Mono.just(patch)); + Mono nameUpdate = transactions.transactional(update) + .contextWrite(context -> context.put(PATCH, true)) + .doOnNext(total -> assertEquals(Integer.valueOf(1), total)).then(); + AtomicReference> moved = new AtomicReference<>(); + Mono treeMove = gate.read.asMono().flatMap(patchConnection -> transactions.transactional( + moveSubtreeOnIndependentConnection(patchConnection, branch.getId(), target.getId()) + .then(stored(ids)).doOnNext(tree -> { + assertTreeChild(tree.get(target.getId()), tree.get(branch.getId())); + assertTreeChild(tree.get(branch.getId()), tree.get(leaf.getId())); + assertEquals(Integer.valueOf(4), tree.get(branch.getId()).getLevel()); + assertEquals(Integer.valueOf(5), tree.get(leaf.getId()).getLevel()); + moved.set(tree); + }))) + // 事务的完成信号包含正式提交;只有此后才放行已持有旧子树结果的名称修改。 + .then(Mono.fromRunnable(() -> gate.release.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST))); + try { + Mono.when(nameUpdate, treeMove) + .as(StepVerifier::create).expectComplete().verify(TIMEOUT); + assertTrue("the gate must pause a real prepared subtree read", gate.paused.get()); + assertNotNull(moved.get()); + stored(ids).as(StepVerifier::create).assertNext(after -> { + TestTreeSortEntity storedBranch = after.get(branch.getId()); + TestTreeSortEntity storedLeaf = after.get(leaf.getId()); + assertEquals("branch-after", storedBranch.getName()); + assertEquals(target.getId(), storedBranch.getParentId()); + assertEquals(moved.get().get(branch.getId()).getPath(), storedBranch.getPath()); + assertEquals(moved.get().get(branch.getId()).getLevel(), storedBranch.getLevel()); + assertEquals(moved.get().get(leaf.getId()).getPath(), storedLeaf.getPath()); + assertEquals(moved.get().get(leaf.getId()).getLevel(), storedLeaf.getLevel()); + assertTreeChild(after.get(target.getId()), storedBranch); + assertTreeChild(storedBranch, storedLeaf); + assertEquals(before.get().get(leaf.getId()).getName(), storedLeaf.getName()); + assertEquals(before.get().get(branch.getId()).getDefaultTest(), storedBranch.getDefaultTest()); + assertEquals(before.get().get(leaf.getId()).getDefaultTest(), storedLeaf.getDefaultTest()); + assertEquals(Long.valueOf(37), storedBranch.getSortIndex()); + assertEquals(Long.valueOf(84), storedLeaf.getSortIndex()); + for (String unchanged : List.of(parentA.getId(), targetRoot.getId(), targetBranch.getId(), target.getId())) { + assertTreeState(before.get().get(unchanged), after.get(unchanged)); + } + }).verifyComplete(); + service.queryIncludeChildren(List.of(branch.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(branch.getId(), leaf.getId()), ids(nodes))).verifyComplete(); + service.queryIncludeChildren(List.of(parentA.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> + assertEquals(Set.of(parentA.getId()), ids(nodes))).verifyComplete(); + service.queryIncludeChildren(List.of(targetRoot.getId())).collectList() + .as(StepVerifier::create).assertNext(nodes -> assertEquals( + Set.of(targetRoot.getId(), targetBranch.getId(), target.getId(), branch.getId(), leaf.getId()), + ids(nodes))).verifyComplete(); + } finally { + gate.release.tryEmitEmpty(); + service.installGate(null); + // 精确删除本测试的六个实体,避免清理依赖被测树路径或另一个删除问题。 + service.getRepository().createDelete().in("id", ids).execute() + .as(StepVerifier::create).expectNext(6).verifyComplete(); + } + } + + private Mono moveSubtreeOnIndependentConnection(Object patchConnection, String branchId, String targetId) { + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(transaction -> { + assertTrue(transaction.isActualTransactionActive()); + Object moveConnection = transaction.getResource(connectionFactory); + assertNotNull(moveConnection); + assertNotSame("the writers must use independent transaction connections", patchConnection, moveConnection); + return service.findById(branchId).flatMap(branch -> { + branch.setParentId(targetId); + return service.save(branch); + }); + }); + } + + private Mono> stored(List ids) { + return service.getRepository().findById(ids).collectMap(TestTreeSortEntity::getId); + } + + private static TestTreeSortEntity node(String name) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setName(name); + node.setDefaultTest("business-before"); + return node; + } + + private static Set ids(Collection nodes) { + return nodes.stream().map(TestTreeSortEntity::getId).collect(Collectors.toSet()); + } + + private static void assertTreeChild(TestTreeSortEntity parent, TestTreeSortEntity child) { + assertEquals(parent.getId(), child.getParentId()); + assertEquals(parent.getPath(), TreeSupportEntity.getParentPath(child.getPath())); + assertEquals(Integer.valueOf(parent.getLevel() + 1), child.getLevel()); + } + + private static void assertTreeState(TestTreeSortEntity before, TestTreeSortEntity after) { + assertEquals(before.getParentId(), after.getParentId()); + assertEquals(before.getPath(), after.getPath()); + assertEquals(before.getLevel(), after.getLevel()); + assertEquals(before.getName(), after.getName()); + assertEquals(before.getDefaultTest(), after.getDefaultTest()); + assertEquals(before.getSortIndex(), after.getSortIndex()); + } + + /** 每次场景持有一个 gate,只暂停带 PATCH context 的第一次真实子树查询结果。 */ + public static class SubtreeGate { + private final TestTreeSortEntity branch; + private final TestTreeSortEntity leaf; + private final ConnectionFactory factory; + private final AtomicBoolean paused = new AtomicBoolean(); + private final Sinks.One read = Sinks.one(); + private final Sinks.Empty release = Sinks.empty(); + + private SubtreeGate(TestTreeSortEntity branch, TestTreeSortEntity leaf, ConnectionFactory factory) { + this.branch = branch; + this.leaf = leaf; + this.factory = factory; + } + + private Flux afterRead(List nodes) { + assertEquals(Set.of(branch.getId(), leaf.getId()), ids(nodes)); + Map snapshot = nodes.stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + assertTreeState(branch, snapshot.get(branch.getId())); + assertTreeState(leaf, snapshot.get(leaf.getId())); + return TransactionSynchronizationManager.forCurrentTransaction().flatMapMany(transaction -> { + assertTrue(transaction.isActualTransactionActive()); + Object connection = transaction.getResource(factory); + assertNotNull(connection); + read.emitValue(connection, Sinks.EmitFailureHandler.FAIL_FAST); + return release.asMono().thenMany(Flux.fromIterable(nodes)); + }); + } + } + + /** 使用正式服务准备树,只有测试事务的完整查询结果被延迟交给 helper。 */ + public static class ControlledTreeService extends TestTreeSortEntityService { + private volatile SubtreeGate gate; + + public void installGate(SubtreeGate gate) { + this.gate = gate; + } + + @Override + public Flux queryIncludeChildren(Collection ids) { + return Flux.deferContextual(context -> { + SubtreeGate current = gate; + Flux queried = super.queryIncludeChildren(ids); + if (current != null && context.hasKey(PATCH) && ids.contains(current.branch.getId()) + && current.paused.compareAndSet(false, true)) { + // 本场景只有 branch 和 leaf;收集真实结果之后暂停,不拦首次存在性读取。 + return queried.collectList().flatMapMany(current::afterRead); + } + return queried; + }); + } + } + + @TestConfiguration + public static class Configuration { + @Bean + public ControlledTreeService preparedTree() { + return new ControlledTreeService(); + } + } +} From bc5317e5b85b24270c6458114edd44dc6bf36e0f Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Mon, 12 Oct 2026 10:14:45 +0800 Subject: [PATCH 10/17] =?UTF-8?q?test(=E6=A0=91CRUD):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=A0=91=E6=93=8D=E4=BD=9C=E4=B8=8E=E4=BA=8B=E5=8A=A1=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 31 ++ .../service/ReactiveTreeCrudCoverageTest.java | 341 ++++++++++++++++++ .../service/SyncTreeCrudRegressionTest.java | 318 +++++++++++++++- .../web/crud/service/TreeCrudTestSupport.java | 185 ++++++++++ 4 files changed, 870 insertions(+), 5 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 237cb31cf..916a9a181 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,37 @@ 5. 坐等审查 6. 合并 +## 通用树集成测试 + +树 CRUD 的测试使用真实 JDBC/R2DBC 仓储、实体事件和原事务。期望关系根据测试输入的 +`parentId` 独立推导,不调用生产树准备 helper 生成期望值;回滚比较完整持久化快照, +取消执行后等待事务 `afterCompletion` 再查询数据库。 + +实体事件失败与调用方失败使用独立分支。事件失败分支必须由真实 +`EntityCreatedEvent`、`EntitySavedEvent` 或 `EntityDeletedEvent` 的监听器拒绝触发, +不能额外抛出调用方异常使测试误通过。同步删除的 after 监听器还核对原事务内 SQL +已执行,再验证事件拒绝使整棵子树及调用方先前写入一起恢复。 + +用例入口: + +1. `SyncTreeCrudRegressionTest`:乱序多根 `insert(Collection)`、小批次 `save(List)` + 深层移动与遗漏后代、后批实体事件/调用方失败,以及重叠 seed 的 `deleteById(Collection)`。 +2. `ReactiveTreeCrudCoverageTest`:两种 `updateById` 的现有缺父级/循环拒绝规则; + 同步与响应式共用 forest 夹具逐层验证查询 children、明确指定的排序、默认排序索引。 +3. `ReactiveTreeCrudCoverageTest`:buffer=2 子树删除在后批 SQL/事件开始后取消, + 确认整棵树与调用方写入一起回滚。 + +这些用例维持现有入口差异:同步树准备仅覆盖 `insert(Collection)`/`save(List)`, +不把普通同步 `updateById` 定义为结构修图入口;没有指定排序的查询不保证顺序, +同步删除不要求叶先执行。路径容量边界按实际实体 schema 验证,不扩大 schema。 + +先完成一批连贯用例,再集中运行 `hsweb-commons-crud` 相关集成测试。定向验证命令: +`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=SyncTreeCrudRegressionTest,ReactiveTreeCrudCoverageTest -Dsurefire.failIfNoSpecifiedTests=false test`。 +同步删除的正式实体事件依赖 easy-orm 同步 DSL 的默认 mapping/repository 上下文和执行级 +before/after/error 共享上下文。该上游修复位于 easy-orm `4.2`、`4.2.2-SNAPSHOT`, +须先发布可消费依赖,或在干净 CI 中从上游源码正式构建后再验收本仓库;开发机本地安装 +不代表上游已发布。集中运行结果记录在对应 PR 的验证证据中。 + # BUG 如果知道导致bug的位置,你可以直接修改后`pull request`,也可以提交[issues](https://github.com/hs-web/hsweb-framework/issues/new).我们会尽快解决. diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java new file mode 100644 index 000000000..084687abe --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java @@ -0,0 +1,341 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityDeletedEvent; +import org.hswebframework.web.crud.utils.TransactionUtils; +import org.hswebframework.web.exception.ValidationException; +import org.hswebframework.web.id.IDGenerator; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.reactive.TransactionSynchronization; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** 真实 R2DBC 覆盖非法图、完整查询树、排序与多批次取消;保持公开入口已有契约。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, ReactiveTreeCrudCoverageTest.EventConfiguration.class}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=ReactiveTreeCrudCoverageTest" +}) +public class ReactiveTreeCrudCoverageTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired + private CoverageTreeService service; + + @Autowired + private DeleteEvents events; + + @After + public void resetEvents() { + events.cancellation = null; + } + + @Test + public void monoPatchRejectsIllegalGraphsAndRollsBackCallerWrites() { + verifyIllegalGraphs(false); + } + + @Test + public void entityPatchRejectsIllegalGraphsAndRollsBackCallerWrites() { + verifyIllegalGraphs(true); + } + + private void verifyIllegalGraphs(boolean entityOverload) { + for (String scenario : List.of("missing", "self", "descendant", "nested-ancestor")) { + TreeCrudTestSupport.Forest forest = insertForest(); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + String targetId = forest.id("east-left"); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("target changed"); + if ("nested-ancestor".equals(scenario)) { + TestTreeSortEntity ancestor = new TestTreeSortEntity(); + ancestor.setId(forest.id("east")); + ancestor.setName("ancestor changed"); + patch.setChildren(List.of(ancestor)); + } else { + patch.setParentId("missing".equals(scenario) ? IDGenerator.RANDOM.generate() + : "self".equals(scenario) ? targetId : forest.id("deep-leaf")); + } + TestTreeSortEntity callerRow = TreeCrudTestSupport.node(IDGenerator.RANDOM.generate(), + null, "caller row", 1L); + Mono invalid = update(targetId, patch, entityOverload); + Mono caller = service.getRepository().save(callerRow) + .then(service.getRepository().createUpdate().set("name", "must roll back") + .where("id", forest.id("west")).execute()) + .then(invalid); + + TransactionUtils.tryRunInTransaction(caller, new DefaultTransactionDefinition()) + .as(StepVerifier::create).expectErrorMatches(error -> error instanceof ValidationException + && error.getMessage().contains("missing".equals(scenario) + ? "tree_entity_parent_id_not_exist" : "tree_entity_cyclic_dependency")) + .verify(TIMEOUT); + + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + service.findById(callerRow.getId()).as(StepVerifier::create).expectComplete().verify(TIMEOUT); + } + } + + @Test + public void queriesReturnCompleteForestFilteredRootsAndFullChildren() { + TreeCrudTestSupport.Forest forest = insertForest(); + service.queryResultToTree(Mono.just(TreeCrudTestSupport.query(forest.group))) + .as(StepVerifier::create).assertNext(roots -> + TreeCrudTestSupport.assertShape(roots, forest.parents, forest.rootIds())) + .expectComplete().verify(TIMEOUT); + String branch = forest.id("east-left"); + Set subtree = TreeCrudTestSupport.subtree(forest.parents, branch); + Map expected = TreeCrudTestSupport.subset(forest.parents, subtree); + QueryParamEntity filtered = TreeCrudTestSupport.query(forest.group); + filtered.toQuery().in("id", subtree); + service.queryResultToTree(filtered).as(StepVerifier::create).assertNext(roots -> + TreeCrudTestSupport.assertShape(roots, expected, Set.of(branch))).expectComplete().verify(TIMEOUT); + service.queryIncludeChildrenTree(QueryParamEntity.of("id", branch)).as(StepVerifier::create) + .assertNext(roots -> TreeCrudTestSupport.assertShape(roots, expected, Set.of(branch))) + .expectComplete().verify(TIMEOUT); + service.queryResultToTree(QueryParamEntity.of("id", IDGenerator.RANDOM.generate())) + .as(StepVerifier::create).assertNext(roots -> assertTrue(roots.isEmpty())) + .expectComplete().verify(TIMEOUT); + service.queryIncludeChildrenTree(QueryParamEntity.of("id", IDGenerator.RANDOM.generate())) + .as(StepVerifier::create).assertNext(roots -> assertTrue(roots.isEmpty())) + .expectComplete().verify(TIMEOUT); + } + + @Test + public void nestedInsertGeneratesDefaultSortAndExplicitReorderControlsQueryOrder() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "sort root", null); + TestTreeSortEntity left = TreeCrudTestSupport.node(group, null, "sort left", null); + TestTreeSortEntity right = TreeCrudTestSupport.node(group, null, "sort right", null); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, null, "sort leaf", null); + root.setChildren(List.of(left, right)); + left.setChildren(List.of(leaf)); + service.insert(root).as(StepVerifier::create).expectNext(4).expectComplete().verify(TIMEOUT); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(group)); + Map stored = storedGroup(group).stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + assertEquals(Long.valueOf(1), stored.get(root.getId()).getSortIndex()); + assertEquals(Long.valueOf(101), stored.get(left.getId()).getSortIndex()); + assertEquals(Long.valueOf(102), stored.get(right.getId()).getSortIndex()); + assertEquals(Long.valueOf(10101), stored.get(leaf.getId()).getSortIndex()); + TestTreeSortEntity leftPatch = new TestTreeSortEntity(); + leftPatch.setSortIndex(102L); + TestTreeSortEntity rightPatch = new TestTreeSortEntity(); + rightPatch.setSortIndex(101L); + service.updateById(left.getId(), leftPatch).then(service.updateById(right.getId(), Mono.just(rightPatch))) + .as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + before.get(left.getId()).set(5, 102L); + before.get(right.getId()).set(5, 101L); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(group))); + + QueryParamEntity ordered = TreeCrudTestSupport.query(group); + ordered.toQuery().orderByAsc("sortIndex").orderByAsc("id"); + service.queryResultToTree(ordered).as(StepVerifier::create).assertNext(roots -> { + assertEquals(List.of(right.getId(), left.getId()), roots.get(0).getChildren().stream() + .map(TestTreeSortEntity::getId).collect(Collectors.toList())); + assertEquals(leaf.getId(), TreeCrudTestSupport.returnedNode(roots, left.getId()).getChildren().get(0).getId()); + }).expectComplete().verify(TIMEOUT); + } + + @Test + public void monoNewChildrenUsePersistedParentSort() { + verifyNewChildSort(false); + } + + @Test + public void entityNewChildrenUsePersistedParentSort() { + verifyNewChildSort(true); + } + + private void verifyNewChildSort(boolean entityOverload) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "parent before", 37L); + service.insert(root).as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + TestTreeSortEntity first = TreeCrudTestSupport.node(group, null, "first child", null); + TestTreeSortEntity second = TreeCrudTestSupport.node(group, null, "second child", null); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("parent after"); + patch.setChildren(List.of(first, second)); + update(root.getId(), patch, entityOverload).as(StepVerifier::create) + .expectNext(3).expectComplete().verify(TIMEOUT); + Map actual = storedGroup(group).stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + assertEquals(Set.of(root.getId(), first.getId(), second.getId()), actual.keySet()); + assertEquals(Long.valueOf(37), actual.get(root.getId()).getSortIndex()); + assertEquals("parent after", actual.get(root.getId()).getName()); + assertEquals(root.getPath(), actual.get(root.getId()).getPath()); + assertEquals(root.getDefaultTest(), actual.get(root.getId()).getDefaultTest()); + assertEquals(Long.valueOf(3701), actual.get(first.getId()).getSortIndex()); + assertEquals(Long.valueOf(3702), actual.get(second.getId()).getSortIndex()); + for (TestTreeSortEntity child : List.of(first, second)) { + TestTreeSortEntity persisted = actual.get(child.getId()); + assertEquals(root.getId(), persisted.getParentId()); + assertEquals(Integer.valueOf(2), persisted.getLevel()); + assertTrue(persisted.getPath().startsWith(root.getPath() + "-")); + assertEquals(child.getDefaultTest(), persisted.getDefaultTest()); + } + } + + @Test + public void laterDeleteBatchCancellationRollsBackWholeTreeAndCallerWrites() { + TreeCrudTestSupport.Forest forest = insertForest(); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + DeleteCancellation cancellation = new DeleteCancellation(forest.group, + TreeCrudTestSupport.subtree(forest.parents, forest.id("east"))); + events.cancellation = cancellation; + TestTreeSortEntity callerRow = TreeCrudTestSupport.node(IDGenerator.RANDOM.generate(), + null, "caller row", 1L); + Mono caller = service.getRepository().save(callerRow) + .then(service.getRepository().createUpdate().set("name", "caller changed surviving root") + .where("id", forest.id("west")).execute()) + .then(service.deleteById(Flux.just(forest.id("east"), forest.id("east-left")))); + TransactionUtils.tryRunInTransaction(caller, new DefaultTransactionDefinition()) + .takeUntilOther(cancellation.started.asMono()).as(StepVerifier::create) + .expectComplete().verify(TIMEOUT); + + // 取消不是错误终止;必须等待数据库事务完成回滚,再通过新的查询读取状态。 + cancellation.completion.asMono().as(StepVerifier::create) + .expectNext(TransactionSynchronization.STATUS_ROLLED_BACK).expectComplete().verify(TIMEOUT); + assertEquals(2, cancellation.batches.get()); + assertEquals(4, Set.copyOf(cancellation.deletedIds).size()); + assertTrue(cancellation.expectedSubtree.containsAll(cancellation.deletedIds)); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + service.findById(callerRow.getId()).as(StepVerifier::create).expectComplete().verify(TIMEOUT); + } + + private TreeCrudTestSupport.Forest insertForest() { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + service.insertBatch(Mono.just(forest.childFirst())).as(StepVerifier::create) + .expectNext(forest.nodes.size()).expectComplete().verify(TIMEOUT); + TreeCrudTestSupport.assertPersistedTree(storedGroup(forest.group), forest.parents); + return forest; + } + + private List storedGroup(String group) { + return service.query(TreeCrudTestSupport.query(group)).collectList().block(TIMEOUT); + } + + private Mono update(String id, TestTreeSortEntity patch, boolean entityOverload) { + return entityOverload ? service.updateById(id, patch) : service.updateById(id, Mono.just(patch)); + } + + static class CoverageTreeService extends GenericReactiveCrudService + implements ReactiveTreeSortEntityService { + CoverageTreeService(ReactiveRepository repository) { + super(repository); + } + + @Override + public IDGenerator getIDGenerator() { + return IDGenerator.MD5; + } + + @Override + public void setChildren(TestTreeSortEntity entity, List children) { + entity.setChildren(children); + } + + @Override + public int getBufferSize() { + return 2; + } + + @Override + public ReactiveQuery applyTreeScope(TestTreeSortEntity node, + ReactiveQuery query) { + return query.and("groupId", node.getGroupId()); + } + } + + /** 控制器只接入真实 DeletedEvent,第二批 SQL 已删除行后才发取消信号。 */ + static class DeleteEvents { + private final ReactiveRepository repository; + private volatile DeleteCancellation cancellation; + + DeleteEvents(ReactiveRepository repository) { + this.repository = repository; + } + + @EventListener + public void deleted(EntityDeletedEvent event) { + DeleteCancellation current = cancellation; + if (event.getEntityType() == TestTreeSortEntity.class && current != null + && event.getEntity().stream().anyMatch(node -> current.group.equals(node.getGroupId()))) { + event.async(Mono.defer(() -> { + assertTrue(event.getEntity().size() <= 2); + current.deletedIds.addAll(TreeCrudTestSupport.ids(event.getEntity())); + if (current.batches.incrementAndGet() != 2) { + return Mono.empty(); + } + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(manager -> { + assertTrue("delete events must share the SQL transaction", manager.isActualTransactionActive()); + manager.registerSynchronization(new TransactionSynchronization() { + @Override + public Mono afterCompletion(int status) { + return Mono.fromRunnable(() -> current.completion.emitValue(status, + Sinks.EmitFailureHandler.FAIL_FAST)); + } + }); + return repository.createQuery().in("id", List.copyOf(current.deletedIds)).count(); + }).flatMap(remaining -> { + assertEquals("both deleted SQL batches must already be visible", Integer.valueOf(0), remaining); + return Mono.fromRunnable(() -> current.started.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST)) + .then(Mono.never()); + }); + })); + } + } + } + + static class DeleteCancellation { + private final String group; + private final Set expectedSubtree; + private final AtomicInteger batches = new AtomicInteger(); + private final List deletedIds = new ArrayList<>(); + private final Sinks.Empty started = Sinks.empty(); + private final Sinks.One completion = Sinks.one(); + + DeleteCancellation(String group, Set expectedSubtree) { + this.group = group; + this.expectedSubtree = expectedSubtree; + } + } + + @TestConfiguration + static class EventConfiguration { + @Bean + CoverageTreeService coverageTreeService(ReactiveRepository repository) { + return new CoverageTreeService(repository); + } + + @Bean + DeleteEvents coverageDeleteEvents(ReactiveRepository repository) { + return new DeleteEvents(repository); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java index 73056e380..0df71c808 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java @@ -1,14 +1,19 @@ package org.hswebframework.web.crud.service; import org.hswebframework.ezorm.rdb.executor.SyncSqlExecutor; +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.annotation.EnableEasyormRepository; import org.hswebframework.web.crud.configuration.EasyormConfiguration; import org.hswebframework.web.crud.configuration.EntityFactoryConfiguration; import org.hswebframework.web.crud.configuration.JdbcSqlExecutorConfiguration; import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityCreatedEvent; +import org.hswebframework.web.crud.events.EntityDeletedEvent; import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.crud.sql.DefaultJdbcExecutor; import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.exception.ValidationException; import org.junit.After; import org.junit.Test; import org.junit.runner.RunWith; @@ -29,7 +34,12 @@ import org.springframework.transaction.support.TransactionTemplate; import reactor.core.publisher.Mono; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -55,6 +65,11 @@ public class SyncTreeCrudRegressionTest { @After public void resetFailure() { events.rejectedId = null; + events.rejectedCreateId = null; + events.rejectedDeleteId = null; + events.createdBatches.clear(); + events.savedBatches.clear(); + events.deletedBatches.clear(); } @Test @@ -138,6 +153,251 @@ public void listRootMoveRollsBackWithSavedEventOrCallerFailure() { } } + @Test + public void collectionInsertAndListSaveKeepChildFirstForestAndDeepOmittedDescendants() { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + List before = storedGroup(forest.group); + TreeCrudTestSupport.assertPersistedTree(before, forest.parents); + Map> business = TreeCrudTestSupport.businessSnapshot(before); + Set movedIds = TreeCrudTestSupport.subtree(forest.parents, forest.id("east-left")); + Map> untouched = TreeCrudTestSupport.snapshot(before.stream() + .filter(node -> !movedIds.contains(node.getId())).collect(Collectors.toList())); + TestTreeSortEntity moved = stored(forest.id("east-left")); + moved.setParentId(forest.id("west-branch")); + + assertEquals(movedIds.size(), service.save(List.of(moved)).getTotal()); + + Map expected = new LinkedHashMap<>(forest.parents); + expected.put(moved.getId(), forest.id("west-branch")); + List after = storedGroup(forest.group); + TreeCrudTestSupport.assertPersistedTree(after, expected); + assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); + assertEquals(untouched, TreeCrudTestSupport.snapshot(after.stream() + .filter(node -> !movedIds.contains(node.getId())).collect(Collectors.toList()))); + assertEquals(TreeCrudTestSupport.subtree(expected, forest.id("west")), + TreeCrudTestSupport.ids(service.queryIncludeChildren(List.of(forest.id("west"))))); + } + + @Test + public void smallBatchInsertAndListSaveRollbackEarlierSqlAndCallerWrites() { + for (String operation : List.of("insert", "save")) { + for (String failure : List.of("event", "caller")) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + TestTreeSortEntity original = TreeCrudTestSupport.node(forest.group, null, "original", 999L); + service.insert(original); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + TestTreeSortEntity callerRow = node(null, "caller row"); + if ("event".equals(failure)) { + if ("insert".equals(operation)) { + events.rejectedCreateId = forest.id("deep-leaf"); + } else { + events.rejectedId = forest.id("deep-leaf"); + } + } + events.createdBatches.clear(); + events.savedBatches.clear(); + + IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .execute(status -> { + service.getRepository().save(callerRow); + service.createUpdate().set("name", "caller changed original") + .where("id", original.getId()).execute(); + if ("insert".equals(operation)) { + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + } else { + assertEquals(forest.nodes.size(), service.save(forest.childFirst()).getTotal()); + } + if ("caller".equals(failure)) { + throw new IllegalStateException("reject caller tree transaction"); + } + return 0; + })); + + assertEquals("event".equals(failure) + ? "insert".equals(operation) ? "reject later created tree event" : "reject saved tree event" + : "reject caller tree transaction", rejected.getMessage()); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + assertFalse(service.findById(callerRow.getId()).isPresent()); + List batches = ("insert".equals(operation) + ? events.createdBatches : events.savedBatches).get(forest.group); + assertNotNull(batches); + assertTrue("failure must occur after earlier SQL batches", batches.size() >= 2); + assertTrue(batches.stream().allMatch(size -> size <= service.getBufferSize())); + resetFailure(); + } + } + } + + @Test + public void listDeepMoveRollsBackOmittedDescendantsAfterLaterEventOrCallerFailure() { + for (String failure : List.of("event", "caller")) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + TestTreeSortEntity callerRow = node(null, "caller row"); + TestTreeSortEntity moved = stored(forest.id("east-left")); + moved.setParentId(forest.id("west-branch")); + if ("event".equals(failure)) { + events.rejectedId = forest.id("deep-leaf"); + } + events.savedBatches.clear(); + + IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .execute(status -> { + service.getRepository().save(callerRow); + service.createUpdate().set("name", "caller changed root") + .where("id", forest.id("east")).execute(); + assertEquals(4, service.save(List.of(moved)).getTotal()); + if ("caller".equals(failure)) { + throw new IllegalStateException("reject caller deep move"); + } + return 0; + })); + + assertEquals("event".equals(failure) ? "reject saved tree event" : "reject caller deep move", + rejected.getMessage()); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + assertFalse(service.findById(callerRow.getId()).isPresent()); + assertEquals(2, events.savedBatches.get(forest.group).size()); + resetFailure(); + } + } + + @Test + public void collectionInsertAndListSaveRejectInvalidGraphsInOriginalTransaction() { + for (String operation : List.of("insert-missing", "insert-cycle", "save-missing", "save-cycle")) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + TestTreeSortEntity callerRow = node(null, "caller row"); + List invalid; + if (operation.startsWith("save")) { + TestTreeSortEntity root = stored(forest.id("east")); + root.setParentId(operation.endsWith("missing") ? IDGenerator.RANDOM.generate() + : forest.id("deep-leaf")); + invalid = List.of(root); + } else { + TestTreeSortEntity first = TreeCrudTestSupport.node(forest.group, null, "invalid first", 1L); + TestTreeSortEntity second = TreeCrudTestSupport.node(forest.group, first.getId(), "invalid second", 2L); + first.setParentId(operation.endsWith("missing") ? IDGenerator.RANDOM.generate() : second.getId()); + invalid = List.of(second, first); + } + + ValidationException rejected = assertThrows(ValidationException.class, () -> + new TransactionTemplate(transactionManager).execute(status -> { + service.getRepository().save(callerRow); + service.createUpdate().set("name", "must roll back") + .where("id", forest.id("east")).execute(); + return operation.startsWith("insert") ? service.insert(invalid) : service.save(invalid); + })); + + assertTrue(rejected.getMessage().contains(operation.endsWith("missing") + ? "tree_entity_parent_id_not_exist" : "tree_entity_cyclic_dependency")); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + assertFalse(service.findById(callerRow.getId()).isPresent()); + } + } + + @Test + public void collectionDeleteHandlesOverlappingAndMissingSeedsAndKeepsOtherTree() { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + Set deletedIds = TreeCrudTestSupport.subtree(forest.parents, forest.id("east")); + Set survivingIds = TreeCrudTestSupport.subtree(forest.parents, forest.id("west")); + Map> surviving = TreeCrudTestSupport.snapshot(service.findById(survivingIds)); + + assertEquals(deletedIds.size(), service.deleteById(List.of(forest.id("east"), + forest.id("east-left"), IDGenerator.RANDOM.generate()))); + + List after = storedGroup(forest.group); + TreeCrudTestSupport.assertPersistedTree(after, TreeCrudTestSupport.subset(forest.parents, survivingIds)); + assertEquals(surviving, TreeCrudTestSupport.snapshot(after)); + assertEquals(0, service.deleteById(List.of(IDGenerator.RANDOM.generate()))); + assertEquals(0, service.deleteById(List.of())); + } + + @Test + public void collectionDeleteRollsBackSqlAndCallerWritesWithRealEvents() { + for (String failure : List.of("event", "caller")) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + TestTreeSortEntity callerRow = node(null, "caller row"); + if ("event".equals(failure)) { + events.rejectedDeleteId = forest.id("east-left"); + } + IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> new TransactionTemplate(transactionManager) + .execute(status -> { + service.getRepository().save(callerRow); + service.createUpdate().set("name", "caller changed survivor") + .where("id", forest.id("west")).execute(); + assertEquals(7, service.deleteById(List.of(forest.id("east"), + forest.id("east-left"), IDGenerator.RANDOM.generate()))); + if ("caller".equals(failure)) { + throw new IllegalStateException("reject caller deletion"); + } + return 0; + })); + assertEquals("event".equals(failure) ? "reject deleted tree event" : "reject caller deletion", + rejected.getMessage()); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + assertFalse(service.findById(callerRow.getId()).isPresent()); + assertTrue("real deletion event must run after SQL", events.deletedBatches.containsKey(forest.group)); + resetFailure(); + } + } + + @Test + public void queriesReturnCompleteForestFilteredRootsAndFullChildren() { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + assertEquals(forest.nodes.size(), service.insert(forest.childFirst())); + TreeCrudTestSupport.assertShape(service.queryResultToTree(TreeCrudTestSupport.query(forest.group)), + forest.parents, forest.rootIds()); + String branch = forest.id("east-left"); + Set subtree = TreeCrudTestSupport.subtree(forest.parents, branch); + Map expected = TreeCrudTestSupport.subset(forest.parents, subtree); + QueryParamEntity filtered = TreeCrudTestSupport.query(forest.group); + filtered.toQuery().in("id", subtree); + TreeCrudTestSupport.assertShape(service.queryResultToTree(filtered), expected, Set.of(branch)); + TreeCrudTestSupport.assertShape(service.queryIncludeChildrenTree(QueryParamEntity.of("id", branch)), + expected, Set.of(branch)); + assertTrue(service.queryResultToTree(QueryParamEntity.of("id", IDGenerator.RANDOM.generate())).isEmpty()); + assertTrue(service.queryIncludeChildrenTree(QueryParamEntity.of("id", IDGenerator.RANDOM.generate())).isEmpty()); + } + + @Test + public void nestedInsertGeneratesDefaultSortAndExplicitReorderControlsQueryOrder() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "sort root", null); + TestTreeSortEntity left = TreeCrudTestSupport.node(group, null, "sort left", null); + TestTreeSortEntity right = TreeCrudTestSupport.node(group, null, "sort right", null); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, null, "sort leaf", null); + root.setChildren(List.of(left, right)); + left.setChildren(List.of(leaf)); + service.insert(root); + assertEquals(Long.valueOf(1), stored(root.getId()).getSortIndex()); + assertEquals(Long.valueOf(101), stored(left.getId()).getSortIndex()); + assertEquals(Long.valueOf(102), stored(right.getId()).getSortIndex()); + assertEquals(Long.valueOf(10101), stored(leaf.getId()).getSortIndex()); + + TestTreeSortEntity reorderedLeft = stored(left.getId()); + TestTreeSortEntity reorderedRight = stored(right.getId()); + reorderedLeft.setSortIndex(102L); + reorderedRight.setSortIndex(101L); + assertEquals(2, service.save(List.of(reorderedLeft, reorderedRight)).getTotal()); + QueryParamEntity ordered = TreeCrudTestSupport.query(group); + ordered.toQuery().orderByAsc("sortIndex").orderByAsc("id"); + List roots = service.queryResultToTree(ordered); + assertEquals(List.of(right.getId(), left.getId()), roots.get(0).getChildren().stream() + .map(TestTreeSortEntity::getId).collect(Collectors.toList())); + assertEquals(leaf.getId(), TreeCrudTestSupport.returnedNode(roots, left.getId()).getChildren().get(0).getId()); + } + + private List storedGroup(String group) { + return service.query(TreeCrudTestSupport.query(group)); + } + private List insertTree() { TestTreeSortEntity root = node(null, "root"); TestTreeSortEntity branch = node(root.getId(), "branch"); @@ -179,16 +439,64 @@ public IDGenerator getIDGenerator() { public void setChildren(TestTreeSortEntity entity, List children) { entity.setChildren(children); } + + @Override + public int getBufferSize() { + return 2; + } } static class SavedEvents { + private final SyncRepository repository; private volatile String rejectedId; + private volatile String rejectedCreateId; + private volatile String rejectedDeleteId; + private final Map> createdBatches = new LinkedHashMap<>(); + private final Map> savedBatches = new LinkedHashMap<>(); + private final Map> deletedBatches = new LinkedHashMap<>(); + + SavedEvents(SyncRepository repository) { + this.repository = repository; + } + + private void record(Map> batches, List nodes) { + if (!nodes.isEmpty() && nodes.get(0).getGroupId() != null) { + batches.computeIfAbsent(nodes.get(0).getGroupId(), ignored -> new ArrayList<>()).add(nodes.size()); + } + } + + @EventListener + public void created(EntityCreatedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + record(createdBatches, event.getEntity()); + if (event.getEntity().stream().anyMatch(entity -> entity.getId().equals(rejectedCreateId))) { + event.async(Mono.error(new IllegalStateException("reject later created tree event"))); + } + } + } @EventListener public void saved(EntitySavedEvent event) { - if (event.getEntityType() == TestTreeSortEntity.class - && event.getEntity().stream().anyMatch(entity -> entity.getId().equals(rejectedId))) { - event.async(Mono.error(new IllegalStateException("reject saved tree event"))); + if (event.getEntityType() == TestTreeSortEntity.class) { + record(savedBatches, event.getEntity()); + if (event.getEntity().stream().anyMatch(entity -> entity.getId().equals(rejectedId))) { + event.async(Mono.error(new IllegalStateException("reject saved tree event"))); + } + } + } + + @EventListener + public void deleted(EntityDeletedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + record(deletedBatches, event.getEntity()); + if (event.getEntity().stream().anyMatch(entity -> entity.getId().equals(rejectedDeleteId))) { + event.async(Mono.fromRunnable(() -> { + int remaining = repository.createQuery() + .in("id", TreeCrudTestSupport.ids(event.getEntity())).count(); + assertEquals("deletion event must observe SQL in the original transaction", 0, remaining); + throw new IllegalStateException("reject deleted tree event"); + })); + } } } } @@ -210,8 +518,8 @@ JdbcTreeService jdbcTreeService() { } @Bean - SavedEvents savedEvents() { - return new SavedEvents(); + SavedEvents savedEvents(SyncRepository repository) { + return new SavedEvents(repository); } } } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java new file mode 100644 index 000000000..38c1d65c3 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java @@ -0,0 +1,185 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.api.crud.entity.QueryParamEntity; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +/** JDBC 与 R2DBC 共用业务森林;期望只来自预先声明的父关系,不复用生产树算法。 */ +final class TreeCrudTestSupport { + private TreeCrudTestSupport() { + } + + static TestTreeSortEntity node(String group, String parent, String name, Long sort) { + TestTreeSortEntity node = new TestTreeSortEntity(); + node.setId(IDGenerator.RANDOM.generate()); + node.setGroupId(group); + node.setParentId(parent); + node.setName(name); + node.setDefaultTest("business-" + name); + node.setSortIndex(sort); + return node; + } + + static QueryParamEntity query(String group) { + return QueryParamEntity.of("groupId", group).noPaging(); + } + + static Set ids(Collection nodes) { + return nodes.stream().map(TestTreeSortEntity::getId).collect(Collectors.toSet()); + } + + static Map> snapshot(Collection nodes) { + Map> snapshot = new LinkedHashMap<>(); + for (TestTreeSortEntity node : nodes) { + snapshot.put(node.getId(), Arrays.asList(node.getParentId(), node.getPath(), node.getLevel(), + node.getName(), node.getDefaultTest(), node.getSortIndex(), node.getGroupId())); + } + return snapshot; + } + + static Map> businessSnapshot(Collection nodes) { + Map> snapshot = new LinkedHashMap<>(); + for (TestTreeSortEntity node : nodes) { + snapshot.put(node.getId(), Arrays.asList(node.getName(), node.getDefaultTest(), + node.getSortIndex(), node.getGroupId())); + } + return snapshot; + } + + static Set subtree(Map parents, String root) { + Set result = new LinkedHashSet<>(); + result.add(root); + boolean changed; + do { + changed = false; + for (Map.Entry node : parents.entrySet()) { + if (result.contains(node.getValue())) { + changed |= result.add(node.getKey()); + } + } + } while (changed); + return result; + } + + static Map subset(Map parents, Collection ids) { + Map result = new LinkedHashMap<>(); + for (String id : ids) { + result.put(id, parents.get(id)); + } + return result; + } + + static void assertPersistedTree(Collection nodes, Map parents) { + assertEquals(parents.keySet(), ids(nodes)); + Map actual = nodes.stream() + .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); + for (TestTreeSortEntity node : nodes) { + assertEquals(parents.get(node.getId()), node.getParentId()); + int level = 1; + Set ancestors = new HashSet<>(); + for (String parent = parents.get(node.getId()); parent != null; parent = parents.get(parent)) { + assertTrue("expected parent is outside the fixture", parents.containsKey(parent)); + assertTrue("expected graph contains a cycle", ancestors.add(parent)); + level++; + } + assertEquals(Integer.valueOf(level), node.getLevel()); + assertNotNull(node.getPath()); + assertEquals(4 + (level - 1) * 5, node.getPath().length()); + if (node.getParentId() != null) { + TestTreeSortEntity parent = actual.get(node.getParentId()); + assertTrue(node.getPath().startsWith(parent.getPath() + "-")); + } + } + } + + static void assertShape(List roots, Map parents, Set rootIds) { + assertEquals(rootIds, ids(roots)); + Set visited = new HashSet<>(); + Queue pending = new ArrayDeque<>(roots); + for (TestTreeSortEntity node = pending.poll(); node != null; node = pending.poll()) { + assertTrue("node repeated in the returned tree", visited.add(node.getId())); + assertTrue("unexpected node in returned tree", parents.containsKey(node.getId())); + assertEquals(parents.get(node.getId()), node.getParentId()); + String nodeId = node.getId(); + Set expectedChildren = parents.entrySet().stream() + .filter(entry -> nodeId.equals(entry.getValue())) + .map(Map.Entry::getKey).collect(Collectors.toSet()); + List children = node.getChildren(); + assertEquals(expectedChildren, children == null ? Set.of() : ids(children)); + if (children != null) { + pending.addAll(children); + } + } + assertEquals(parents.keySet(), visited); + } + + static TestTreeSortEntity returnedNode(List roots, String id) { + Queue pending = new ArrayDeque<>(roots); + for (TestTreeSortEntity node = pending.poll(); node != null; node = pending.poll()) { + if (id.equals(node.getId())) { + return node; + } + if (node.getChildren() != null) { + pending.addAll(node.getChildren()); + } + } + throw new AssertionError("missing returned tree node " + id); + } + + static final class Forest { + final String group = IDGenerator.RANDOM.generate(); + final Map named = new LinkedHashMap<>(); + final Map parents = new LinkedHashMap<>(); + final List nodes = new ArrayList<>(); + + Forest() { + add("east", null, 10L); + add("west", null, 20L); + add("east-left", "east", 101L); + add("east-right", "east", 102L); + add("middle", "east-left", 103L); + add("deep", "middle", 104L); + add("deep-leaf", "deep", 105L); + add("right-leaf", "east-right", 106L); + add("west-branch", "west", 201L); + add("west-leaf", "west-branch", 202L); + } + + private void add(String name, String parentName, long sort) { + String parentId = parentName == null ? null : id(parentName); + TestTreeSortEntity node = node(group, parentId, name, sort); + named.put(name, node); + nodes.add(node); + parents.put(node.getId(), parentId); + } + + String id(String name) { + return named.get(name).getId(); + } + + Set rootIds() { + return Set.of(id("east"), id("west")); + } + + List childFirst() { + return List.of(named.get("deep-leaf"), named.get("west-leaf"), named.get("middle"), + named.get("right-leaf"), named.get("west-branch"), named.get("east-right"), + named.get("east-left"), named.get("deep"), named.get("west"), named.get("east")); + } + } +} From d3379b5e493d1f74d701cc602864aa7b6f15c4b2 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Mon, 12 Oct 2026 11:39:06 +0800 Subject: [PATCH 11/17] =?UTF-8?q?refactor(CRUD):=20=E5=B0=86=E6=99=AE?= =?UTF-8?q?=E9=80=9ADSL=E4=BA=8B=E5=8A=A1=E8=BE=B9=E7=95=8C=E4=BA=A4?= =?UTF-8?q?=E8=BF=98=E4=B8=9A=E5=8A=A1=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 25 ++- .../web/crud/service/ReactiveCrudService.java | 9 +- .../web/crud/CountingReactiveSqlExecutor.java | 62 ++++++ .../web/crud/PlainCrudTransactionTest.java | 205 ++++++++++++++++-- ...icReactiveCacheSupportCrudServiceTest.java | 81 ++++++- .../service/ReactiveTreeCrudCoverageTest.java | 185 +++++++++++++++- ...opedReactiveTreeSortEntityServiceTest.java | 8 +- 7 files changed, 540 insertions(+), 35 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CountingReactiveSqlExecutor.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 916a9a181..8b8456273 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,13 @@ 5. 坐等审查 6. 合并 -## 通用树集成测试 +## 事务边界与通用树集成测试 + +普通 `ReactiveCrudService.createUpdate/createDelete` 返回仓储 DSL,不隐式开启事务。 +需要将 SQL、实体事件及其他写入一起回滚时,业务 Service 应对完整业务 Publisher 明确声明 +事务;命名 CRUD 方法保持其事务注解。基础树 insert/save/update/delete 的复合操作由 +程序式事务保持完整原子性,直接调用非代理树 Service 也应在后批失败或取消时整体回滚。 +普通缓存 DSL 保持既有 hook 行为:修改数量大于零时清除缓存,零修改时不清除。 树 CRUD 的测试使用真实 JDBC/R2DBC 仓储、实体事件和原事务。期望关系根据测试输入的 `parentId` 独立推导,不调用生产树准备 helper 生成期望值;回滚比较完整持久化快照, @@ -24,18 +30,23 @@ 2. `ReactiveTreeCrudCoverageTest`:两种 `updateById` 的现有缺父级/循环拒绝规则; 同步与响应式共用 forest 夹具逐层验证查询 children、明确指定的排序、默认排序索引。 3. `ReactiveTreeCrudCoverageTest`:buffer=2 子树删除在后批 SQL/事件开始后取消, - 确认整棵树与调用方写入一起回滚。 + 确认整棵树与调用方写入一起回滚;非代理树复合操作在后批失败或取消时恢复完整快照, + 追加 hook 不重复执行原 seed SQL。 +4. `PlainCrudTransactionTest`:Spring 代理业务事务内的真实事件失败与 SQL 后取消; + 无调用方事务的普通 DSL 不承诺隐式回滚,追加 hook 和 SQL 执行次数保持仓储行为。 +5. `GenericReactiveCacheSupportCrudServiceTest`:有无调用方事务时,普通缓存 DSL 的 + 正修改/零修改结果、缓存清除和追加 hook 顺序;`ScopedReactiveTreeSortEntityServiceTest` + 验证普通更新的调用方事务与树删除自身事务。 这些用例维持现有入口差异:同步树准备仅覆盖 `insert(Collection)`/`save(List)`, 不把普通同步 `updateById` 定义为结构修图入口;没有指定排序的查询不保证顺序, 同步删除不要求叶先执行。路径容量边界按实际实体 schema 验证,不扩大 schema。 -先完成一批连贯用例,再集中运行 `hsweb-commons-crud` 相关集成测试。定向验证命令: -`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=SyncTreeCrudRegressionTest,ReactiveTreeCrudCoverageTest -Dsurefire.failIfNoSpecifiedTests=false test`。 +`hsweb-commons-crud` 相关集成测试的定向验证命令: +`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=PlainCrudTransactionTest,GenericReactiveCacheSupportCrudServiceTest,ScopedReactiveTreeSortEntityServiceTest,SyncTreeCrudRegressionTest,ReactiveTreeCrudCoverageTest -Dsurefire.failIfNoSpecifiedTests=false test`。 同步删除的正式实体事件依赖 easy-orm 同步 DSL 的默认 mapping/repository 上下文和执行级 -before/after/error 共享上下文。该上游修复位于 easy-orm `4.2`、`4.2.2-SNAPSHOT`, -须先发布可消费依赖,或在干净 CI 中从上游源码正式构建后再验收本仓库;开发机本地安装 -不代表上游已发布。集中运行结果记录在对应 PR 的验证证据中。 +before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含该通用修复的 +`4.2.2-SNAPSHOT`。运行结果记录在对应 PR 的验证证据中。 # BUG 如果知道导致bug的位置,你可以直接修改后`pull request`,也可以提交[issues](https://github.com/hs-web/hsweb-framework/issues/new).我们会尽快解决. diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java index 8147b6a66..db5217870 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveCrudService.java @@ -10,10 +10,8 @@ import org.hswebframework.web.api.crud.entity.TransactionManagers; import org.hswebframework.web.crud.query.QueryHelper; import org.hswebframework.web.crud.query.PagerQueryPolicy; -import org.hswebframework.web.crud.utils.TransactionUtils; import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.DefaultTransactionDefinition; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.context.ContextView; @@ -73,9 +71,7 @@ default ReactiveQuery createQuery() { * @return 动态更新接口 */ default ReactiveUpdate createUpdate() { - // DSL 构造方法返回的不是 Publisher;在 execute 时统一包住 SQL 和实体事件。 - return getRepository().createUpdate().onExecute((update, operation) -> - TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition())); + return getRepository().createUpdate(); } /** @@ -92,8 +88,7 @@ default ReactiveUpdate createUpdate() { * @return 动态更新接口 */ default ReactiveDelete createDelete() { - return getRepository().createDelete().onExecute((delete, operation) -> - TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition())); + return getRepository().createDelete(); } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CountingReactiveSqlExecutor.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CountingReactiveSqlExecutor.java new file mode 100644 index 000000000..cfdc4ba10 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/CountingReactiveSqlExecutor.java @@ -0,0 +1,62 @@ +package org.hswebframework.web.crud; + +import org.hswebframework.ezorm.core.meta.Feature; +import org.hswebframework.ezorm.rdb.executor.SqlRequest; +import org.hswebframework.ezorm.rdb.executor.reactive.ReactiveSqlExecutor; +import org.hswebframework.ezorm.rdb.executor.wrapper.ResultWrapper; +import org.hswebframework.ezorm.rdb.metadata.RDBTableMetadata; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CopyOnWriteArrayList; + +/** 只记录交给真实执行器的写 SQL;不模拟 SQL、事务或实体事件。 */ +public final class CountingReactiveSqlExecutor implements ReactiveSqlExecutor, AutoCloseable { + private final RDBTableMetadata table; + private final ReactiveSqlExecutor delegate; + private final Feature previous; + private final List statements = new CopyOnWriteArrayList<>(); + + public CountingReactiveSqlExecutor(RDBTableMetadata table) { + this.table = table; + this.previous = table.getFeatures().get(ID_VALUE); + this.delegate = table.findFeatureNow(ID); + table.addFeature(this); + } + + public void reset() { + statements.clear(); + } + + public long count(String verb) { + String prefix = verb.toLowerCase(Locale.ROOT) + " "; + return statements.stream().filter(sql -> sql.trim().toLowerCase(Locale.ROOT).startsWith(prefix)).count(); + } + + @Override + public Mono update(Publisher requests) { + return delegate.update(Flux.from(requests).doOnNext(request -> statements.add(request.getSql()))); + } + + @Override + public Mono execute(Publisher requests) { + return delegate.execute(requests); + } + + @Override + public Flux select(Publisher requests, ResultWrapper wrapper) { + return delegate.select(requests, wrapper); + } + + @Override + public void close() { + if (previous == null) { + table.getFeatures().remove(ID_VALUE); + } else { + table.addFeature(previous); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java index 2a7aad023..6b2c93180 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java @@ -1,5 +1,7 @@ package org.hswebframework.web.crud; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.web.api.crud.entity.TransactionManagers; import org.hswebframework.web.crud.entity.CustomTestEntity; import org.hswebframework.web.crud.entity.TestEntity; import org.hswebframework.web.crud.events.EntityDeletedEvent; @@ -8,14 +10,18 @@ import org.hswebframework.web.crud.service.TestEntityService; import org.hswebframework.web.id.IDGenerator; import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.event.EventListener; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.NoTransactionException; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.reactive.TransactionSynchronization; import org.springframework.transaction.reactive.TransactionSynchronizationManager; import reactor.core.publisher.Mono; @@ -23,9 +29,14 @@ import reactor.test.StepVerifier; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.*; /** - * 使用现有普通 CRUD 服务验证 DSL 的 SQL 与异步实体事件属于同一事务。 + * 普通 DSL 沿用调用方事务;完整业务链经 Spring 代理明确声明事务。 * 事件控制器只装配在本测试上下文,不修改共享应用、实体或生产事务配置。 */ @RunWith(SpringRunner.class) @@ -46,25 +57,51 @@ public class PlainCrudTransactionTest { @Autowired private ControlledEntityEvents events; + @Autowired + private BusinessWrites business; + + @Autowired + private DatabaseOperator database; + + private CountingReactiveSqlExecutor sql; + + @Before + public void countRealSql() { + sql = new CountingReactiveSqlExecutor(database.getMetadata().getTable("s_test").orElseThrow()); + } + @After public void resetEvents() { events.failModifyId = null; + events.modifyCompletion = null; events.deleteCancellation = null; + events.callerId = null; + events.noTransactionId = null; + events.observedModify.set(0); + events.observedDelete.set(0); + sql.close(); } @Test public void updateRollsBackWhenModifyEventFails() { TestEntity entity = insert("before-update"); + TestEntity caller = callerRow(); events.failModifyId = entity.getId(); + events.callerId = caller.getId(); + events.modifyCompletion = Sinks.one(); + assertTrue("the business boundary must be a Spring transaction proxy", AopUtils.isAopProxy(business)); + sql.reset(); - service.createUpdate() - .set(TestEntity::getName, "after-update") - .where(TestEntity::getId, entity.getId()) - .execute() + business.update(entity.getId(), caller) .as(StepVerifier::create) .expectErrorMatches(error -> error == events.modifyFailure) .verify(VERIFY_TIMEOUT); + events.modifyCompletion.asMono().as(StepVerifier::create) + .expectNext(TransactionSynchronization.STATUS_ROLLED_BACK).expectComplete().verify(VERIFY_TIMEOUT); + assertEquals(1, sql.count("update")); + service.getRepository().findById(caller.getId()).as(StepVerifier::create).verifyComplete(); + service.findById(entity.getId()) .map(TestEntity::getName) .as(StepVerifier::create) @@ -77,12 +114,14 @@ public void updateRollsBackWhenModifyEventFails() { public void deleteRollsBackWhenCancelledAfterSqlAndEventStart() { TestEntity entity = insert("before-delete"); DeleteCancellation cancellation = new DeleteCancellation(entity.getId()); + TestEntity caller = callerRow(); events.deleteCancellation = cancellation; + events.callerId = caller.getId(); + assertTrue("the business boundary must be a Spring transaction proxy", AopUtils.isAopProxy(business)); + sql.reset(); // 删除事件在当前事务中确认行已消失后发信号,再由下游取消挂起的执行流。 - service.createDelete() - .where(TestEntity::getId, entity.getId()) - .execute() + business.delete(entity.getId(), caller) .takeUntilOther(cancellation.started.asMono()) .as(StepVerifier::create) .expectComplete() @@ -95,6 +134,9 @@ public void deleteRollsBackWhenCancelledAfterSqlAndEventStart() { .expectComplete() .verify(VERIFY_TIMEOUT); + assertEquals(1, sql.count("delete")); + service.getRepository().findById(caller.getId()).as(StepVerifier::create).verifyComplete(); + service.findById(entity.getId()) .map(TestEntity::getName) .as(StepVerifier::create) @@ -103,6 +145,83 @@ public void deleteRollsBackWhenCancelledAfterSqlAndEventStart() { .verify(VERIFY_TIMEOUT); } + @Test + public void ordinaryDslWithoutBusinessTransactionPreservesHooksAndExecutesOnce() { + TestEntity entity = insert("ordinary-before"); + events.noTransactionId = entity.getId(); + List hooks = new ArrayList<>(); + sql.reset(); + service.createUpdate().set("name", "ordinary-after").where("id", entity.getId()) + .onExecute((update, operation) -> verifyNoTransaction().then(Mono.defer(() -> { + hooks.add("update-before"); + return operation; + })).doOnNext(count -> hooks.add("update-after:" + count))) + .execute().as(StepVerifier::create).expectNext(1).verifyComplete(); + assertEquals(List.of("update-before", "update-after:1"), hooks); + assertEquals(1, sql.count("update")); + assertEquals(1, events.observedModify.get()); + service.getRepository().findById(entity.getId()).map(TestEntity::getName) + .as(StepVerifier::create).expectNext("ordinary-after").verifyComplete(); + + hooks.clear(); + sql.reset(); + service.createDelete().where("id", entity.getId()) + .onExecute((delete, operation) -> verifyNoTransaction().then(Mono.defer(() -> { + hooks.add("delete-before"); + return operation; + })).doOnNext(count -> hooks.add("delete-after:" + count))) + .execute().as(StepVerifier::create).expectNext(1).verifyComplete(); + assertEquals(List.of("delete-before", "delete-after:1"), hooks); + assertEquals(1, sql.count("delete")); + assertEquals(1, events.observedDelete.get()); + service.getRepository().findById(entity.getId()).as(StepVerifier::create).verifyComplete(); + + sql.reset(); + AtomicInteger zeroHooks = new AtomicInteger(); + service.createUpdate().set("name", "absent").where("id", entity.getId()) + .onExecute((update, operation) -> verifyNoTransaction().then(operation) + .doOnNext(count -> { assertEquals(Integer.valueOf(0), count); zeroHooks.incrementAndGet(); })) + .execute().then(service.createDelete().where("id", entity.getId()) + .onExecute((delete, operation) -> verifyNoTransaction().then(operation) + .doOnNext(count -> { assertEquals(Integer.valueOf(0), count); zeroHooks.incrementAndGet(); })) + .execute()).as(StepVerifier::create).expectNext(0).verifyComplete(); + assertEquals(2, zeroHooks.get()); + assertEquals(1, sql.count("update")); + assertEquals(1, sql.count("delete")); + assertEquals(1, events.observedModify.get()); + assertEquals(1, events.observedDelete.get()); + } + + @Test + public void ordinaryDslAfterEventFailureDoesNotPromiseImplicitRollback() { + TestEntity entity = insert("autocommit-before"); + events.noTransactionId = entity.getId(); + events.failModifyId = entity.getId(); + sql.reset(); + service.createUpdate().set("name", "autocommit-after").where("id", entity.getId()) + .execute().as(StepVerifier::create).expectErrorMatches(error -> error == events.modifyFailure) + .verify(VERIFY_TIMEOUT); + assertEquals(1, events.observedModify.get()); + assertEquals(1, sql.count("update")); + service.getRepository().findById(entity.getId()).map(TestEntity::getName) + .as(StepVerifier::create).expectNext("autocommit-after").verifyComplete(); + } + + private static Mono verifyNoTransaction() { + return TransactionSynchronizationManager.forCurrentTransaction() + .map(manager -> !manager.isActualTransactionActive()) + .onErrorReturn(NoTransactionException.class, true) + .doOnNext(absent -> assertTrue("ordinary DSL must not open a Spring transaction", absent)).then(); + } + + private TestEntity callerRow() { + CustomTestEntity caller = new CustomTestEntity(); + caller.setId(IDGenerator.RANDOM.generate()); + caller.setName("caller row"); + caller.setAge(2); + return caller; + } + private TestEntity insert(String name) { CustomTestEntity entity = new CustomTestEntity(); entity.setId(IDGenerator.RANDOM.generate()); @@ -122,6 +241,32 @@ static class EventConfiguration { ControlledEntityEvents controlledEntityEvents(TestEntityService service) { return new ControlledEntityEvents(service); } + + @Bean + BusinessWrites businessWrites(TestEntityService service) { + return new BusinessWrites(service); + } + } + + /** 返回完整的业务 Publisher;事务由此代理边界管理,不放在 DSL 工厂。 */ + public static class BusinessWrites { + private final TestEntityService service; + + BusinessWrites(TestEntityService service) { + this.service = service; + } + + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono update(String id, TestEntity caller) { + return service.getRepository().insert(Mono.just(caller)) + .then(service.createUpdate().set("name", "after-update").where("id", id).execute()); + } + + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) + public Mono delete(String id, TestEntity caller) { + return service.getRepository().insert(Mono.just(caller)) + .then(service.createDelete().where("id", id).execute()); + } } /** 每个用例只控制自己插入的行;其他测试及实体事件使用原有行为。 */ @@ -129,6 +274,11 @@ static class ControlledEntityEvents { private final TestEntityService service; private final IllegalStateException modifyFailure = new IllegalStateException("modify event failed"); private volatile String failModifyId; + private volatile Sinks.One modifyCompletion; + private volatile String callerId; + private volatile String noTransactionId; + private final AtomicInteger observedModify = new AtomicInteger(); + private final AtomicInteger observedDelete = new AtomicInteger(); private volatile DeleteCancellation deleteCancellation; ControlledEntityEvents(TestEntityService service) { @@ -137,13 +287,29 @@ static class ControlledEntityEvents { @EventListener public void modified(EntityModifyEvent event) { + if (event.getAfter().stream().anyMatch(entity -> entity.getId().equals(noTransactionId))) { + event.async(verifyNoTransaction().doOnSuccess(ignored -> observedModify.incrementAndGet())); + } if (event.getAfter().stream().anyMatch(entity -> entity.getId().equals(failModifyId))) { - event.async(Mono.error(modifyFailure)); + Sinks.One completion = modifyCompletion; + event.async((completion == null ? Mono.empty() : + TransactionSynchronizationManager.forCurrentTransaction().flatMap(manager -> { + assertTrue(manager.isActualTransactionActive()); + manager.registerSynchronization(completion(completion)); + return service.getRepository().findById(callerId) + .switchIfEmpty(Mono.error(new AssertionError("caller write must share the transaction"))) + .then(service.getRepository().findById(failModifyId)) + .switchIfEmpty(Mono.error(new AssertionError("modify event must observe the updated row"))) + .doOnNext(entity -> assertEquals("after-update", entity.getName())).then(); + })).then(Mono.error(modifyFailure))); } } @EventListener public void deleted(EntityDeletedEvent event) { + if (event.getEntity().stream().anyMatch(entity -> entity.getId().equals(noTransactionId))) { + event.async(verifyNoTransaction().doOnSuccess(ignored -> observedDelete.incrementAndGet())); + } DeleteCancellation cancellation = deleteCancellation; if (cancellation != null && event.getEntity().stream() .anyMatch(entity -> entity.getId().equals(cancellation.id))) { @@ -157,14 +323,10 @@ private Mono awaitCancellation(DeleteCancellation cancellation) { if (!manager.isActualTransactionActive()) { return Mono.error(new AssertionError("delete event requires the SQL transaction")); } - manager.registerSynchronization(new TransactionSynchronization() { - @Override - public Mono afterCompletion(int status) { - return Mono.fromRunnable(() -> cancellation.completion.emitValue( - status, Sinks.EmitFailureHandler.FAIL_FAST)); - } - }); - return service.getRepository().findById(cancellation.id).hasElement(); + manager.registerSynchronization(completion(cancellation.completion)); + return service.getRepository().findById(callerId) + .switchIfEmpty(Mono.error(new AssertionError("caller write must share the transaction"))) + .then(service.getRepository().findById(cancellation.id).hasElement()); }) .flatMap(exists -> { if (exists) { @@ -174,6 +336,15 @@ public Mono afterCompletion(int status) { .then(Mono.never()); }); } + + private TransactionSynchronization completion(Sinks.One signal) { + return new TransactionSynchronization() { + @Override + public Mono afterCompletion(int status) { + return Mono.fromRunnable(() -> signal.emitValue(status, Sinks.EmitFailureHandler.FAIL_FAST)); + } + }; + } } /** 信号分离事件启动和事务回滚完成,避免在 SQL 执行前取消或提前读取数据库。 */ diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java index 76191f785..f9fa62cc4 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/GenericReactiveCacheSupportCrudServiceTest.java @@ -1,15 +1,27 @@ package org.hswebframework.web.crud.service; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; +import org.hswebframework.web.crud.CountingReactiveSqlExecutor; import org.hswebframework.web.crud.TestApplication; import org.hswebframework.web.crud.entity.TestEntity; +import org.hswebframework.web.crud.utils.TransactionUtils; +import org.hswebframework.web.id.IDGenerator; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.support.DefaultTransactionDefinition; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + import static org.junit.Assert.*; @SpringBootTest(classes = TestApplication.class, args = "--hsweb.cache.type=guava") @@ -20,6 +32,73 @@ public class GenericReactiveCacheSupportCrudServiceTest { @Autowired private TestCacheEntityService entityService; + @Autowired + private DatabaseOperator database; + + private CountingReactiveSqlExecutor sql; + + @Before + public void countRealSql() { + sql = new CountingReactiveSqlExecutor(database.getMetadata().getTable("s_test").orElseThrow()); + } + + @After + public void restoreSqlExecutor() { + sql.close(); + } + + @Test + public void ordinaryCacheDslKeepsExistingClearAndAppendedHookBehavior() { + for (boolean callerTransaction : List.of(false, true)) { + for (String operation : List.of("update", "delete")) { + TestEntity entity = TestEntity.of("cache-hook-before", 10, "cache marker"); + entityService.insert(entity).as(StepVerifier::create).expectNext(1).verifyComplete(); + String sentinel = "sentinel:" + IDGenerator.RANDOM.generate(); + entityService.getCache().put(sentinel, Mono.just(entity)).as(StepVerifier::create).verifyComplete(); + List hooks = new ArrayList<>(); + sql.reset(); + cacheDsl(operation, entity.getId(), sentinel, false, hooks, callerTransaction) + .as(StepVerifier::create).expectNext(1).verifyComplete(); + assertEquals(List.of("before", "after:1"), hooks); + assertEquals(1, sql.count(operation)); + if ("update".equals(operation)) { + entityService.getRepository().findById(entity.getId()).map(TestEntity::getName) + .as(StepVerifier::create).expectNext("cache-hook-after").verifyComplete(); + } else { + entityService.getRepository().findById(entity.getId()).as(StepVerifier::create).verifyComplete(); + } + + // 零修改不能清除已有缓存;后追加 hook 仍执行一次并收到零结果。 + entityService.getCache().put(sentinel, Mono.just(entity)).as(StepVerifier::create).verifyComplete(); + hooks.clear(); + sql.reset(); + cacheDsl(operation, IDGenerator.RANDOM.generate(), sentinel, true, hooks, callerTransaction) + .as(StepVerifier::create).expectNext(0).verifyComplete(); + assertEquals(List.of("before", "after:0"), hooks); + assertEquals(1, sql.count(operation)); + entityService.getCache().getMono(sentinel).map(TestEntity::getId) + .as(StepVerifier::create).expectNext(entity.getId()).verifyComplete(); + } + } + } + + private Mono cacheDsl(String operation, String id, String sentinel, boolean cacheRetained, + List hooks, boolean callerTransaction) { + Function, Mono> observe = execution -> Mono.defer(() -> { + hooks.add("before"); + return execution.flatMap(count -> entityService.getCache().getMono(sentinel).hasElement() + .doOnNext(present -> assertEquals("cache hook must run before the appended hook", cacheRetained, present)) + .thenReturn(count).doOnNext(result -> hooks.add("after:" + result))); + }); + Mono execution = "update".equals(operation) + ? entityService.createUpdate().set("name", "cache-hook-after").where("id", id) + .onExecute((update, original) -> observe.apply(original)).execute() + : entityService.createDelete().where("id", id) + .onExecute((delete, original) -> observe.apply(original)).execute(); + return callerTransaction + ? TransactionUtils.tryRunInTransaction(execution, new DefaultTransactionDefinition()) : execution; + } + @Test public void test() { @@ -124,4 +203,4 @@ public void test2() { .verifyComplete(); } -} \ No newline at end of file +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java index 084687abe..8f6cf4991 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java @@ -2,17 +2,24 @@ import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.CountingReactiveSqlExecutor; import org.hswebframework.web.crud.entity.TestTreeSortEntity; import org.hswebframework.web.crud.events.EntityDeletedEvent; +import org.hswebframework.web.crud.events.EntityCreatedEvent; +import org.hswebframework.web.crud.events.EntitySavedEvent; +import org.hswebframework.web.crud.events.EntityModifyEvent; import org.hswebframework.web.crud.utils.TransactionUtils; import org.hswebframework.web.exception.ValidationException; import org.hswebframework.web.id.IDGenerator; import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.aop.support.AopUtils; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -51,9 +58,21 @@ public class ReactiveTreeCrudCoverageTest { @Autowired private DeleteEvents events; + @Autowired + private DatabaseOperator database; + + private CountingReactiveSqlExecutor sql; + + @Before + public void countRealSql() { + sql = new CountingReactiveSqlExecutor(database.getMetadata().getTable("test_tree_sort").orElseThrow()); + } + @After public void resetEvents() { events.cancellation = null; + events.writeFailure = null; + sql.close(); } @Test @@ -228,6 +247,94 @@ public void laterDeleteBatchCancellationRollsBackWholeTreeAndCallerWrites() { service.findById(callerRow.getId()).as(StepVerifier::create).expectComplete().verify(TIMEOUT); } + @Test + public void nonProxiedTreeWritesKeepTheirOwnTransaction() { + CoverageTreeService direct = new CoverageTreeService(service.getRepository()); + assertFalse(AopUtils.isAopProxy(direct)); + for (String operation : List.of("insert", "save", "update")) { + TreeCrudTestSupport.Forest forest = "insert".equals(operation) + ? new TreeCrudTestSupport.Forest() : insertForest(); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + WriteFailure failure = new WriteFailure(forest.group, operation); + events.writeFailure = failure; + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setId(forest.id("east-left")); + patch.setGroupId(forest.group); + patch.setParentId(forest.id("west")); + patch.setName("moved branch"); + Mono write = "insert".equals(operation) ? direct.insertBatch(Mono.just(forest.childFirst())) + : "save".equals(operation) ? direct.save(Flux.just(patch)) + : direct.updateById(patch.getId(), Mono.just(patch)); + + write.as(StepVerifier::create).expectErrorMatches(error -> error == failure.rejection).verify(TIMEOUT); + failure.completion.asMono().as(StepVerifier::create) + .expectNext(TransactionSynchronization.STATUS_ROLLED_BACK).expectComplete().verify(TIMEOUT); + assertEquals(2, failure.batches.get()); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + events.writeFailure = null; + } + } + + @Test + public void nonProxiedDeleteLaterBatchFailureAndCancellationRestoreCompleteTree() { + for (boolean reject : List.of(true, false)) { + TreeCrudTestSupport.Forest forest = insertForest(); + Map> before = TreeCrudTestSupport.snapshot(storedGroup(forest.group)); + CoverageTreeService direct = new CoverageTreeService(service.getRepository()); + assertFalse(AopUtils.isAopProxy(direct)); + DeleteCancellation control = new DeleteCancellation(forest.group, + TreeCrudTestSupport.subtree(forest.parents, forest.id("east")), reject); + events.cancellation = control; + sql.reset(); + Mono delete = direct.createDelete().in("id", List.of(forest.id("east"), + forest.id("east-left"))).execute(); + if (reject) { + delete.as(StepVerifier::create).expectErrorMatches(error -> error == control.rejection).verify(TIMEOUT); + } else { + delete.takeUntilOther(control.started.asMono()).as(StepVerifier::create) + .expectComplete().verify(TIMEOUT); + } + control.completion.asMono().as(StepVerifier::create) + .expectNext(TransactionSynchronization.STATUS_ROLLED_BACK).expectComplete().verify(TIMEOUT); + assertEquals(2, control.batches.get()); + assertEquals(2, sql.count("delete")); + assertEquals(4, Set.copyOf(control.deletedIds).size()); + assertTrue(control.expectedSubtree.containsAll(control.deletedIds)); + assertEquals(before, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + events.cancellation = null; + } + } + + @Test + public void nonProxiedDeleteKeepsAppendedHooksAndExecutesOnlyExpectedBatches() { + TreeCrudTestSupport.Forest forest = insertForest(); + CoverageTreeService direct = new CoverageTreeService(service.getRepository()); + Set subtree = TreeCrudTestSupport.subtree(forest.parents, forest.id("east")); + Set survivors = TreeCrudTestSupport.subtree(forest.parents, forest.id("west")); + Map> retained = TreeCrudTestSupport.snapshot(service.getRepository() + .findById(survivors).collectList().block(TIMEOUT)); + List hooks = new ArrayList<>(); + sql.reset(); + direct.createDelete().in("id", List.of(forest.id("east"), forest.id("east-left"))) + .onExecute((delete, operation) -> Mono.defer(() -> { + hooks.add("before"); + return operation.doOnNext(count -> hooks.add("after:" + count)); + })).execute().as(StepVerifier::create).expectNext(subtree.size()).expectComplete().verify(TIMEOUT); + assertEquals(List.of("before", "after:" + subtree.size()), hooks); + assertEquals((subtree.size() + 1) / 2, sql.count("delete")); + assertEquals(retained, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + sql.reset(); + AtomicInteger zeroHook = new AtomicInteger(); + direct.createDelete().where("id", forest.id("east")) + .onExecute((delete, operation) -> operation.doOnNext(count -> { + assertEquals(Integer.valueOf(0), count); + zeroHook.incrementAndGet(); + })).execute().as(StepVerifier::create).expectNext(0).expectComplete().verify(TIMEOUT); + assertEquals(1, zeroHook.get()); + assertEquals(0, sql.count("delete")); + assertEquals(retained, TreeCrudTestSupport.snapshot(storedGroup(forest.group))); + } + private TreeCrudTestSupport.Forest insertForest() { TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); service.insertBatch(Mono.just(forest.childFirst())).as(StepVerifier::create) @@ -272,15 +379,68 @@ public ReactiveQuery applyTreeScope(TestTreeSortEntity node, } } - /** 控制器只接入真实 DeletedEvent,第二批 SQL 已删除行后才发取消信号。 */ + /** 只接入真实实体 after 事件;后批 SQL 已持久化后才拒绝或发取消信号。 */ static class DeleteEvents { private final ReactiveRepository repository; private volatile DeleteCancellation cancellation; + private volatile WriteFailure writeFailure; DeleteEvents(ReactiveRepository repository) { this.repository = repository; } + @EventListener + public void created(EntityCreatedEvent event) { + WriteFailure current = writeFailure; + if (event.getEntityType() == TestTreeSortEntity.class && current != null + && "insert".equals(current.operation) + && event.getEntity().stream().anyMatch(node -> current.group.equals(node.getGroupId()))) { + event.async(rejectSecondWrite(current, event.getEntity())); + } + } + + @EventListener + public void saved(EntitySavedEvent event) { + WriteFailure current = writeFailure; + if (event.getEntityType() == TestTreeSortEntity.class && current != null + && "save".equals(current.operation) + && event.getEntity().stream().anyMatch(node -> current.group.equals(node.getGroupId()))) { + event.async(rejectSecondWrite(current, event.getEntity())); + } + } + + @EventListener + public void modified(EntityModifyEvent event) { + WriteFailure current = writeFailure; + if (event.getEntityType() == TestTreeSortEntity.class && current != null + && "update".equals(current.operation) + && event.getAfter().stream().anyMatch(node -> current.group.equals(node.getGroupId()))) { + event.async(rejectSecondWrite(current, event.getAfter())); + } + } + + private Mono rejectSecondWrite(WriteFailure current, List batch) { + return Mono.defer(() -> { + if (current.batches.incrementAndGet() != 2) { + return Mono.empty(); + } + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(manager -> { + assertTrue("non-proxied compound writes must have a real transaction", manager.isActualTransactionActive()); + manager.registerSynchronization(new TransactionSynchronization() { + @Override + public Mono afterCompletion(int status) { + return Mono.fromRunnable(() -> current.completion.emitValue(status, + Sinks.EmitFailureHandler.FAIL_FAST)); + } + }); + return repository.createQuery().in("id", TreeCrudTestSupport.ids(batch)).count(); + }).flatMap(count -> { + assertEquals("the rejecting event must observe persisted SQL", Integer.valueOf(batch.size()), count); + return Mono.error(current.rejection); + }); + }); + } + @EventListener public void deleted(EntityDeletedEvent event) { DeleteCancellation current = cancellation; @@ -304,6 +464,9 @@ public Mono afterCompletion(int status) { return repository.createQuery().in("id", List.copyOf(current.deletedIds)).count(); }).flatMap(remaining -> { assertEquals("both deleted SQL batches must already be visible", Integer.valueOf(0), remaining); + if (current.reject) { + return Mono.error(current.rejection); + } return Mono.fromRunnable(() -> current.started.emitEmpty(Sinks.EmitFailureHandler.FAIL_FAST)) .then(Mono.never()); }); @@ -319,10 +482,30 @@ static class DeleteCancellation { private final List deletedIds = new ArrayList<>(); private final Sinks.Empty started = Sinks.empty(); private final Sinks.One completion = Sinks.one(); + private final boolean reject; + private final IllegalStateException rejection = new IllegalStateException("reject later delete event"); DeleteCancellation(String group, Set expectedSubtree) { + this(group, expectedSubtree, false); + } + + DeleteCancellation(String group, Set expectedSubtree, boolean reject) { this.group = group; this.expectedSubtree = expectedSubtree; + this.reject = reject; + } + } + + static class WriteFailure { + private final String group; + private final String operation; + private final AtomicInteger batches = new AtomicInteger(); + private final Sinks.One completion = Sinks.one(); + private final IllegalStateException rejection = new IllegalStateException("reject later tree write event"); + + WriteFailure(String group, String operation) { + this.group = group; + this.operation = operation; } } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index e09e7fc1c..9f91a5999 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -214,8 +214,12 @@ public void dslSqlAndRealEntityEventsRollbackTogether() { root.setChildren(List.of(child)); scoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); String before = root.getName(); - scoped.createUpdate().set("name", "reject-dsl-" + id()).where("id", root.getId()).execute() - .as(StepVerifier::create).expectError(IllegalStateException.class).verify(); + // 普通 DSL 的完整 SQL 与 after 事件链由调用方明确声明事务。 + TransactionUtils.tryRunInTransaction( + scoped.createUpdate().set("name", "reject-dsl-" + id()).where("id", root.getId()).execute(), + new DefaultTransactionDefinition()) + .as(StepVerifier::create).expectErrorMatches(error -> error instanceof IllegalStateException + && "reject modified tree".equals(error.getMessage())).verify(); scoped.findById(root.getId()).as(StepVerifier::create) .assertNext(existing -> assertEquals(before, existing.getName())).verifyComplete(); scoped.createUpdate().set("name", "reject-delete-" + id()).where("id", root.getId()).execute() From a8b556fe9005dd14da020969b6925d56a2c03eab Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Mon, 12 Oct 2026 12:22:20 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix(=E6=88=90=E5=91=98=E7=BB=91=E5=AE=9A)?= =?UTF-8?q?:=20=E6=81=A2=E5=A4=8D=E5=B7=B2=E5=8F=91=E5=B8=83=E6=88=90?= =?UTF-8?q?=E5=91=98ID=E4=B8=8E=E5=8E=9F=E6=9C=8D=E5=8A=A1=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 9 + .../api/entity/DimensionUserEntity.java | 14 +- .../api/entity/DimensionUserIdentityTest.java | 35 +- .../service/DefaultDimensionUserService.java | 106 +---- .../messages_en.properties | 1 - .../messages_zh.properties | 1 - .../DimensionUserIdentityRegressionTest.java | 368 +++--------------- 7 files changed, 88 insertions(+), 446 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b8456273..f8fbe686f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,15 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含该通用修复的 `4.2.2-SNAPSHOT`。运行结果记录在对应 PR 的验证证据中。 +## 成员 ID 兼容性验证 + +`DimensionUserEntity.generateId()` 保持原有三字段以 `-` 拼接后的 MD5 规则;已提供的 +ID 保持不变。`DimensionUserIdentityTest` 固定验证旧 ID, +`DimensionUserIdentityRegressionTest` 使用真实数据库和绑定事件验证按已知 ID 保存、 +重复保存更新、查询及解绑;完整认证绑定行为同时由 `DefaultDimensionUserServiceTest` 验证。 +定向验证命令: +`mvn -pl hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default -am -Dtest=DimensionUserIdentityTest,DimensionUserIdentityRegressionTest,DefaultDimensionUserServiceTest -Dsurefire.failIfNoSpecifiedTests=false test`。 + # BUG 如果知道导致bug的位置,你可以直接修改后`pull request`,也可以提交[issues](https://github.com/hs-web/hsweb-framework/issues/new).我们会尽快解决. diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java index 4af9d5eb4..330a89606 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/main/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserEntity.java @@ -14,12 +14,12 @@ import org.hswebframework.web.system.authorization.api.enums.DimensionUserFeature; import org.springframework.util.DigestUtils; import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; import javax.persistence.Column; import javax.persistence.Index; import javax.persistence.Table; import jakarta.validation.constraints.NotBlank; -import java.nio.charset.StandardCharsets; import java.sql.JDBCType; @Getter @@ -82,15 +82,13 @@ public class DimensionUserEntity extends GenericEntity { @Schema(description = "关联时间", accessMode = Schema.AccessMode.READ_ONLY) private Long relationTime; - /** 保留公开 ID;新成员使用 UTF-8 字节长度前缀,避免业务字段中的分隔字符产生歧义。 */ public void generateId() { if (ObjectUtils.isEmpty(getId())) { - StringBuilder key = new StringBuilder(); - for (String value : new String[]{dimensionTypeId, dimensionId, userId}) { - String part = String.valueOf(value); - key.append(part.getBytes(StandardCharsets.UTF_8).length).append(':').append(part); - } - setId(DigestUtils.md5DigestAsHex(key.toString().getBytes(StandardCharsets.UTF_8))); + String id = DigestUtils + .md5DigestAsHex(String.format("%s-%s-%s", + dimensionTypeId, + dimensionId, userId).getBytes()); + setId(id); } } diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java index 1ee507226..8e632c620 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-api/src/test/java/org/hswebframework/web/system/authorization/api/entity/DimensionUserIdentityTest.java @@ -4,36 +4,17 @@ import static org.junit.Assert.*; -/** 成员身份按 UTF-8 字节长度编码三个字段,分隔字符不会合并不同自然键。 */ +/** 保持公开成员 ID 的原有生成与保留契约。 */ public class DimensionUserIdentityTest { @Test - public void delimiterContainingTuplesHaveDifferentIds() { - DimensionUserEntity first = member("a-b", "c", "user"); - DimensionUserEntity second = member("a", "b-c", "user"); - first.generateId(); - second.generateId(); - assertNotEquals(first.getId(), second.getId()); - } - - @Test - public void unicodeIdUsesUtf8ByteLengthPrefixes() { - DimensionUserEntity member = member("现场-运维", "片区|A:01", "用户-一"); + public void ordinaryTupleUsesLegacyMd5() { + DimensionUserEntity member = member("role", "operator", "user-1"); member.generateId(); - // 13:现场-运维11:片区|A:0110:用户-一 的 UTF-8 MD5。 - assertEquals("3133224d24611e79e511cb90d47e9c09", member.getId()); - } - - @Test - public void sameTupleKeepsItsIdWhenRelationChanges() { - DimensionUserEntity first = member("field-team", "district|A:01", "user-1"); - first.generateId(); - first.setRelation("manager"); - first.generateId(); - assertEquals("d2a7f6fd3daeb10bd68021d4dc75ac23", first.getId()); - DimensionUserEntity second = member("field-team", "district|A:01", "user-1"); - second.setRelation("member"); - second.generateId(); - assertEquals(first.getId(), second.getId()); + // role-operator-user-1 的旧 MD5。 + assertEquals("c2906b52da24edf0524f7b42229236e6", member.getId()); + member.setRelation("manager"); + member.generateId(); + assertEquals("c2906b52da24edf0524f7b42229236e6", member.getId()); } @Test diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java index f8d5b966c..67cbd270c 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java @@ -1,17 +1,13 @@ package org.hswebframework.web.system.authorization.defaults.service; import lombok.extern.slf4j.Slf4j; -import org.hswebframework.ezorm.core.param.TermType; import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException; import org.hswebframework.ezorm.rdb.mapping.ReactiveDelete; -import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; import org.hswebframework.ezorm.rdb.mapping.ReactiveUpdate; import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; import org.hswebframework.web.crud.events.EntityCreatedEvent; import org.hswebframework.web.crud.events.EntityDeletedEvent; import org.hswebframework.web.crud.events.EntityModifyEvent; -import org.hswebframework.web.crud.events.EntityPrepareCreateEvent; -import org.hswebframework.web.crud.events.EntityPrepareSaveEvent; import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.crud.service.GenericReactiveCrudService; import org.hswebframework.web.event.AsyncEvent; @@ -26,7 +22,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; -import org.springframework.util.ObjectUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.function.Function3; @@ -45,105 +40,6 @@ public class DefaultDimensionUserService extends GenericReactiveCrudService event) { - event.async(prepareMemberIdentity(event.getEntity())); - } - - @EventListener - public void prepareMemberIdentity(EntityPrepareSaveEvent event) { - event.async(prepareMemberIdentity(event.getEntity())); - } - - private Mono prepareMemberIdentity(List members) { - // prepare 早于 SQL 物化;每片一次查询,service 和 repository 共享身份规则。 - Map> resolvedIds = new HashMap<>(); - return Flux.fromIterable(members).buffer(200) - .concatMap(batch -> resolveMemberIdentity(batch, resolvedIds)).then(); - } - - private Mono resolveMemberIdentity(List members, - Map> resolvedIds) { - Set ids = new LinkedHashSet<>(); - Set> keys = new LinkedHashSet<>(); - for (DimensionUserEntity member : members) { - if (!ObjectUtils.isEmpty(member.getId())) { - ids.add(member.getId()); - } - if (hasMemberKey(member)) { - keys.add(memberKey(member)); - if (ObjectUtils.isEmpty(member.getId())) { - // 候选 ID 只用于同一批查询,不能提前成为调用方显式 ID 或抢占旧自然键。 - DimensionUserEntity candidate = member.copyTo(DimensionUserEntity.class); - candidate.generateId(); - ids.add(candidate.getId()); - } - } - } - if (ids.isEmpty() && keys.isEmpty()) { - // 不完整的新输入仍由原写入校验及数据库约束处理,不能使用 null 条件匹配其他行。 - return Mono.empty(); - } - ReactiveQuery query = createQuery(); - if (!ids.isEmpty()) { - query.in(DimensionUserEntity::getId, ids); - } - for (List key : keys) { - query.orNest().is(DimensionUserEntity::getDimensionTypeId, key.get(0)) - .and(DimensionUserEntity::getDimensionId, TermType.eq, key.get(1)) - .and(DimensionUserEntity::getUserId, TermType.eq, key.get(2)).end(); - } - return query.fetch().collectList().doOnNext(existing -> { - Map byId = new HashMap<>(); - Map, DimensionUserEntity> byMember = new HashMap<>(); - for (DimensionUserEntity row : existing) { - byId.put(row.getId(), row); - byMember.putIfAbsent(memberKey(row), row); - } - for (DimensionUserEntity member : members) { - DimensionUserEntity row = byId.get(member.getId()); - // 先检查传入 ID;稀疏保存只校验已提供的身份列,不回填业务字段。 - if (row != null && !matchesProvidedIdentity(row, member)) { - throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); - } - if (row == null && hasMemberKey(member)) { - row = byMember.get(memberKey(member)); - } - if (row != null) { - member.setId(row.getId()); - } else if (hasMemberKey(member)) { - member.generateId(); - DimensionUserEntity occupied = byId.get(member.getId()); - if (occupied != null && !matchesProvidedIdentity(occupied, member)) { - throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); - } - } - if (row != null || hasMemberKey(member)) { - List key = memberKey(row == null ? member : row); - List previous = resolvedIds.putIfAbsent(member.getId(), key); - // 一次 prepare 全部完成后才写 SQL;跨查询片也不能让不同成员共用公开 ID。 - if (previous != null && !previous.equals(key)) { - throw new BusinessException("error.dimension_user_id_conflict", 400, member.getId()); - } - } - } - }).then(); - } - - private boolean hasMemberKey(DimensionUserEntity member) { - return member.getDimensionTypeId() != null && member.getDimensionId() != null && member.getUserId() != null; - } - - private List memberKey(DimensionUserEntity member) { - return Arrays.asList(member.getDimensionTypeId(), member.getDimensionId(), member.getUserId()); - } - - private boolean matchesProvidedIdentity(DimensionUserEntity stored, DimensionUserEntity input) { - return (input.getDimensionTypeId() == null || Objects.equals(stored.getDimensionTypeId(), input.getDimensionTypeId())) - && (input.getDimensionId() == null || Objects.equals(stored.getDimensionId(), input.getDimensionId())) - && (input.getUserId() == null || Objects.equals(stored.getUserId(), input.getUserId())); - } - //处理用户被删除时,同步删除维度绑定信息 @EventListener public void handleUserDeleteEntity(UserDeletedEvent event) { @@ -238,4 +134,4 @@ private Mono clearUserCache(List entities) { .flatMap(list -> ClearUserAuthorizationCacheEvent.of(list).publish(eventPublisher)); } -} \ No newline at end of file +} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties index e692b51c7..abf147040 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en.properties @@ -1,3 +1,2 @@ error.user_already_exists=User already exists error.user_not_found=The user does not exist or the id does not meet the rule -error.dimension_user_id_conflict=The member id belongs to another dimension or user: {0} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties index fbcbd6c30..4e3d19cd3 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh.properties @@ -1,3 +1,2 @@ error.user_already_exists=\u7528\u6237\u5DF2\u5B58\u5728 error.user_not_found=\u7528\u6237\u4E0D\u5B58\u5728\u6216ID\u4E0D\u7B26\u5408\u89C4\u5219:[{0}] -error.dimension_user_id_conflict=\u6210\u5458ID\u5DF2\u5C5E\u4E8E\u5176\u4ED6\u7EF4\u5EA6\u6216\u7528\u6237: {0} diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java index 517de44ad..8149dcbc0 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/test/java/org/hswebframework/web/system/authorization/defaults/service/DimensionUserIdentityRegressionTest.java @@ -1,10 +1,9 @@ package org.hswebframework.web.system.authorization.defaults.service; -import org.hswebframework.ezorm.rdb.exception.DuplicateKeyException; -import org.hswebframework.web.exception.BusinessException; -import org.hswebframework.web.crud.events.EntityBeforeQueryEvent; import org.hswebframework.web.id.IDGenerator; import org.hswebframework.web.system.authorization.api.entity.DimensionUserEntity; +import org.hswebframework.web.system.authorization.api.event.DimensionBindEvent; +import org.hswebframework.web.system.authorization.api.event.DimensionUnbindEvent; import org.hswebframework.web.system.authorization.defaults.service.reactive.ReactiveTestApplication; import org.junit.Test; import org.junit.runner.RunWith; @@ -18,13 +17,12 @@ import reactor.test.StepVerifier; import java.time.Duration; -import java.util.ArrayList; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.CopyOnWriteArrayList; import static org.junit.Assert.*; -/** 正式 ORM 写入事件统一确定成员身份,兼容旧公开 ID 和所有 service/repository 入口。 */ +/** 真实数据库与绑定事件链保持旧 ID 的保存、查询及解绑行为。 */ @RunWith(SpringRunner.class) @SpringBootTest(classes = {ReactiveTestApplication.class, DimensionUserIdentityRegressionTest.Configuration.class}, properties = { "spring.r2dbc.generate-unique-name=true", @@ -37,265 +35,55 @@ public class DimensionUserIdentityRegressionTest { private DefaultDimensionUserService service; @Autowired - private QueryCounter queries; + private BindingEvents events; @Test - public void allEntryPointsStoreTheGeneratedTupleIdBeforeSqlExecution() { - for (Write entry : Write.values()) { - DimensionUserEntity input = uniqueMember(); - DimensionUserEntity expected = member(input.getDimensionTypeId(), input.getDimensionId(), input.getUserId()); - expected.generateId(); - write(entry, input).then(findTuple(input)).as(StepVerifier::create).assertNext(stored -> { - assertEquals(entry.name(), expected.getId(), stored.getId()); - assertEquals("dimension", stored.getDimensionName()); - assertEquals("user", stored.getUserName()); - }).expectComplete().verify(TIMEOUT); - } - } - - @Test - public void everySaveEntryPointReusesLegacyIdForEmptyAndGeneratedInputIds() { - for (Write entry : Write.values()) { - if (entry.create) { - continue; - } - for (boolean preGenerated : List.of(false, true)) { - DimensionUserEntity legacy = seedLegacyMember(); - DimensionUserEntity input = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); - input.setRelationName("relation-after"); - if (preGenerated) { - input.generateId(); - } - write(entry, input).then(findTuple(input)).as(StepVerifier::create).assertNext(stored -> { - assertEquals(entry.name(), legacy.getId(), stored.getId()); - assertEquals("relation-after", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(input).as(StepVerifier::create).expectNext(1).verifyComplete(); - } - } + public void generatedLegacyIdSurvivesSaveQueryAndUnbind() { + DimensionUserEntity member = member(); + member.generateId(); + verifySaveQueryAndUnbind(member); } @Test - public void everyCreateEntryPointStillRejectsAnExistingTuple() { - for (Write entry : Write.values()) { - if (!entry.create) { - continue; - } - DimensionUserEntity legacy = seedLegacyMember(); - DimensionUserEntity duplicate = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); - duplicate.setRelationName("duplicate-must-not-update"); - write(entry, duplicate).as(StepVerifier::create).expectError(DuplicateKeyException.class).verify(TIMEOUT); - findTuple(legacy).as(StepVerifier::create).assertNext(stored -> { - assertEquals(legacy.getId(), stored.getId()); - assertEquals("relation-before", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); - } - } - - @Test - public void differentDelimiterContainingTuplesDoNotOverwriteEachOther() { - String token = IDGenerator.MD5.generate().substring(0, 12); - String user = "user-" + token; - DimensionUserEntity first = member(token + "-team", "district", user); - DimensionUserEntity second = member(token, "team-district", user); - first.generateId(); - second.generateId(); - service.save(first).then(service.save(second)).then(service.createQuery() - .where(DimensionUserEntity::getUserId, user).fetch().collectMap(DimensionUserEntity::getDimensionTypeId)) - .as(StepVerifier::create).assertNext(rows -> { - assertEquals(2, rows.size()); - assertEquals("district", rows.get(first.getDimensionTypeId()).getDimensionId()); - assertEquals("team-district", rows.get(second.getDimensionTypeId()).getDimensionId()); - assertNotEquals(rows.get(first.getDimensionTypeId()).getId(), rows.get(second.getDimensionTypeId()).getId()); - }).expectComplete().verify(TIMEOUT); - } - - @Test - public void everyEntryPointRejectsExplicitIdForAnotherTupleBeforeNaturalKeyResolution() { - for (Write entry : Write.values()) { - DimensionUserEntity first = seedLegacyMember(); - DimensionUserEntity second = seedLegacyMember(); - DimensionUserEntity attack = member(second.getDimensionTypeId(), second.getDimensionId(), second.getUserId()); - attack.setId(first.getId()); - attack.setRelationName("must-not-overwrite"); - write(entry, attack).as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); - service.findById(List.of(first.getId(), second.getId())).collectMap(DimensionUserEntity::getId) - .as(StepVerifier::create).assertNext(rows -> { - assertEquals(2, rows.size()); - assertEquals(first.getDimensionTypeId(), rows.get(first.getId()).getDimensionTypeId()); - assertEquals(second.getDimensionTypeId(), rows.get(second.getId()).getDimensionTypeId()); - assertEquals("relation-before", rows.get(first.getId()).getRelationName()); - assertEquals("relation-before", rows.get(second.getId()).getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(second).as(StepVerifier::create).expectNext(1).verifyComplete(); - } - } - - @Test - public void implicitGeneratedIdCannotOverwriteAnotherTuplesCallerProvidedId() { - for (Write entry : Write.values()) { - DimensionUserEntity input = uniqueMember(); - DimensionUserEntity candidate = member(input.getDimensionTypeId(), input.getDimensionId(), input.getUserId()); - candidate.generateId(); - DimensionUserEntity occupied = uniqueMember(); - occupied.setId(candidate.getId()); - service.getRepository().insert(Mono.just(occupied)).as(StepVerifier::create).expectNext(1).verifyComplete(); - assertNull(input.getId()); - write(entry, input).as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); - service.findById(occupied.getId()).as(StepVerifier::create).assertNext(stored -> { - assertEquals(occupied.getDimensionTypeId(), stored.getDimensionTypeId()); - assertEquals(occupied.getDimensionId(), stored.getDimensionId()); - assertEquals(occupied.getUserId(), stored.getUserId()); - assertEquals("relation-before", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(input).as(StepVerifier::create).expectNext(0).verifyComplete(); - } - } - - @Test - public void differentTuplesCannotResolveToOnePublicIdWithinACollection() { - verifyBatchIdentityConflict(false); - } - - @Test - public void differentTuplesCannotResolveToOnePublicIdAcrossQueryBuffers() { - verifyBatchIdentityConflict(true); - } - - @Test - public void legacyNaturalTupleTakesPriorityOverAnOccupiedImplicitCandidateId() { - for (Write entry : Write.values()) { - if (entry.create) { - continue; - } - DimensionUserEntity legacy = seedLegacyMember(); - DimensionUserEntity candidate = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); - candidate.generateId(); - DimensionUserEntity occupied = uniqueMember(); - occupied.setId(candidate.getId()); - service.getRepository().insert(Mono.just(occupied)).as(StepVerifier::create).expectNext(1).verifyComplete(); - DimensionUserEntity input = member(legacy.getDimensionTypeId(), legacy.getDimensionId(), legacy.getUserId()); - input.setRelationName("legacy-rebind-after"); - write(entry, input).then(findTuple(legacy)).as(StepVerifier::create).assertNext(stored -> { - assertEquals(legacy.getId(), stored.getId()); - assertEquals("legacy-rebind-after", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - service.findById(occupied.getId()).as(StepVerifier::create).assertNext(stored -> { - assertEquals(occupied.getDimensionTypeId(), stored.getDimensionTypeId()); - assertEquals(occupied.getDimensionId(), stored.getDimensionId()); - assertEquals(occupied.getUserId(), stored.getUserId()); - assertEquals("relation-before", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); - tupleCount(occupied).as(StepVerifier::create).expectNext(1).verifyComplete(); - } - } - - @Test - public void incompleteNewTupleCannotResolveToAnExistingMember() { - DimensionUserEntity legacy = seedLegacyMember(); - for (Write entry : Write.values()) { - DimensionUserEntity incomplete = new DimensionUserEntity(); - incomplete.setUserId(legacy.getUserId()); - incomplete.setDimensionName("dimension"); - incomplete.setUserName("user"); - incomplete.setRelationName("must-not-overwrite"); - write(entry, incomplete).as(StepVerifier::create).expectError().verify(TIMEOUT); - service.findById(legacy.getId()).as(StepVerifier::create).assertNext(stored -> { - assertEquals(legacy.getDimensionTypeId(), stored.getDimensionTypeId()); - assertEquals(legacy.getDimensionId(), stored.getDimensionId()); - assertEquals("relation-before", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - tupleCount(legacy).as(StepVerifier::create).expectNext(1).verifyComplete(); - } - } - - @Test - public void bulkServiceAndRepositorySavesKeepExactTupleIdentityWithBoundedReads() { - for (boolean rawRepository : List.of(false, true)) { - String token = IDGenerator.MD5.generate().substring(0, 12); - DimensionUserEntity first = member(token, "first", "first-user-" + token); - first.setId("legacy-first-" + token); - DimensionUserEntity second = member(token + "-other", "second", "second-user-" + token); - second.setId("legacy-second-" + token); - service.getRepository().insertBatch(Mono.just(List.of(first, second))) - .as(StepVerifier::create).expectNext(2).verifyComplete(); - DimensionUserEntity firstPatch = member(first.getDimensionTypeId(), first.getDimensionId(), first.getUserId()); - firstPatch.setRelationName("bulk-first-after"); - DimensionUserEntity secondPatch = member(second.getDimensionTypeId(), second.getDimensionId(), second.getUserId()); - secondPatch.generateId(); - secondPatch.setRelationName("bulk-second-after"); - DimensionUserEntity crossTuple = member(first.getDimensionTypeId(), second.getDimensionId(), first.getUserId()); - List members = new ArrayList<>(List.of(firstPatch, secondPatch, crossTuple)); - for (int i = 0; i < 202; i++) { - members.add(member(token, "bulk-" + i, "bulk-user-" + token + "-" + i)); - } - queries.count.set(0); - Mono operation = rawRepository ? service.getRepository().save(members) : service.save(members); - operation.then().as(StepVerifier::create).expectComplete().verify(TIMEOUT); - assertTrue("205 member writes must not perform one identity SELECT per member", queries.count.get() <= 12); - findTuple(firstPatch).as(StepVerifier::create).assertNext(stored -> { - assertEquals(first.getId(), stored.getId()); - assertEquals("bulk-first-after", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - findTuple(secondPatch).as(StepVerifier::create).assertNext(stored -> { - assertEquals(second.getId(), stored.getId()); - assertEquals("bulk-second-after", stored.getRelationName()); - }).expectComplete().verify(TIMEOUT); - findTuple(crossTuple).as(StepVerifier::create).assertNext(stored -> { - assertNotEquals(first.getId(), stored.getId()); - assertNotEquals(second.getId(), stored.getId()); - assertEquals(first.getDimensionTypeId(), stored.getDimensionTypeId()); - assertEquals(second.getDimensionId(), stored.getDimensionId()); - }).expectComplete().verify(TIMEOUT); - service.createQuery().in(DimensionUserEntity::getDimensionTypeId, - List.of(first.getDimensionTypeId(), second.getDimensionTypeId())).count() - .as(StepVerifier::create).expectNext(205).verifyComplete(); - } - } - - @Test - public void everyEntryPointKeepsAnUnusedCallerProvidedPublicId() { - for (Write entry : Write.values()) { - DimensionUserEntity member = uniqueMember(); - member.setId("public-" + IDGenerator.MD5.generate()); - String expectedId = member.getId(); - write(entry, member).then(findTuple(member)).as(StepVerifier::create).assertNext(stored -> - assertEquals(entry.name(), expectedId, stored.getId())).expectComplete().verify(TIMEOUT); - } - } - - private void verifyBatchIdentityConflict(boolean acrossBuffer) { - for (boolean rawRepository : List.of(false, true)) { - DimensionUserEntity first = uniqueMember(); - DimensionUserEntity candidate = member(first.getDimensionTypeId(), first.getDimensionId(), first.getUserId()); - candidate.generateId(); - DimensionUserEntity second = uniqueMember(); - second.setId(candidate.getId()); - List inputs = new ArrayList<>(); - inputs.add(first); - if (acrossBuffer) { - for (int i = 0; i < 202; i++) { - inputs.add(uniqueMember()); - } - } - inputs.add(second); - Mono operation = rawRepository ? service.getRepository().save(inputs) : service.save(inputs); - operation.then().as(StepVerifier::create).expectError(BusinessException.class).verify(TIMEOUT); - service.createQuery().in(DimensionUserEntity::getUserId, - inputs.stream().map(DimensionUserEntity::getUserId).collect(java.util.stream.Collectors.toList())) - .count().as(StepVerifier::create).expectNext(0).verifyComplete(); - assertEquals("relation-before", first.getRelationName()); - assertEquals("relation-before", second.getRelationName()); - } - } - - private DimensionUserEntity seedLegacyMember() { - DimensionUserEntity legacy = uniqueMember(); - legacy.setId("legacy-" + IDGenerator.MD5.generate()); - service.getRepository().insert(Mono.just(legacy)).as(StepVerifier::create).expectNext(1).verifyComplete(); - return legacy; + public void providedIdSurvivesSaveQueryAndUnbind() { + DimensionUserEntity member = member(); + member.setId("public-" + IDGenerator.MD5.generate()); + verifySaveQueryAndUnbind(member); + } + + private void verifySaveQueryAndUnbind(DimensionUserEntity member) { + String expectedId = member.getId(); + service.save(member).then(findTuple(member)).as(StepVerifier::create).assertNext(stored -> { + assertEquals(expectedId, stored.getId()); + assertEquals("dimension", stored.getDimensionName()); + assertEquals("user", stored.getUserName()); + assertEquals("relation-before", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + assertEquals(1, events.bound.stream().filter(event -> matches(member, event.getType(), + event.getDimensionId(), event.getUserId())).count()); + + member.setRelationName("relation-after"); + service.save(member).then(service.findById(expectedId)).as(StepVerifier::create).assertNext(stored -> { + assertEquals(expectedId, stored.getId()); + assertEquals(member.getDimensionTypeId(), stored.getDimensionTypeId()); + assertEquals(member.getDimensionId(), stored.getDimensionId()); + assertEquals(member.getUserId(), stored.getUserId()); + assertEquals("relation-after", stored.getRelationName()); + }).expectComplete().verify(TIMEOUT); + assertEquals(2, events.bound.stream().filter(event -> matches(member, event.getType(), + event.getDimensionId(), event.getUserId())).count()); + tupleCount(member).as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + + service.deleteById(expectedId).as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + assertEquals(1, events.unbound.stream().filter(event -> matches(member, event.getType(), + event.getDimensionId(), event.getUserId())).count()); + service.findById(expectedId).as(StepVerifier::create).expectComplete().verify(TIMEOUT); + tupleCount(member).as(StepVerifier::create).expectNext(0).expectComplete().verify(TIMEOUT); + } + + private static boolean matches(DimensionUserEntity member, String type, String dimension, List users) { + return member.getDimensionTypeId().equals(type) && member.getDimensionId().equals(dimension) + && users.equals(List.of(member.getUserId())); } private Mono findTuple(DimensionUserEntity member) { @@ -310,66 +98,38 @@ private Mono tupleCount(DimensionUserEntity member) { .and(DimensionUserEntity::getUserId, member.getUserId()).count(); } - private Mono write(Write entry, DimensionUserEntity member) { - switch (entry) { - case SERVICE_SAVE_ENTITY: return service.save(member).then(); - case SERVICE_SAVE_COLLECTION: return service.save(List.of(member)).then(); - case SERVICE_SAVE_PUBLISHER: return service.save(Mono.just(member)).then(); - case REPOSITORY_SAVE_ENTITY: return service.getRepository().save(member).then(); - case REPOSITORY_SAVE_COLLECTION: return service.getRepository().save(List.of(member)).then(); - case REPOSITORY_SAVE_PUBLISHER: return service.getRepository().save(Mono.just(member)).then(); - case SERVICE_INSERT_ENTITY: return service.insert(member).then(); - case SERVICE_INSERT_PUBLISHER: return service.insert(Mono.just(member)).then(); - case SERVICE_INSERT_BATCH: return service.insertBatch(Mono.just(List.of(member))).then(); - case REPOSITORY_INSERT_PUBLISHER: return service.getRepository().insert(Mono.just(member)).then(); - case REPOSITORY_INSERT_BATCH: return service.getRepository().insertBatch(Mono.just(List.of(member))).then(); - default: throw new AssertionError(entry); - } - } - - private static DimensionUserEntity uniqueMember() { + private static DimensionUserEntity member() { String token = IDGenerator.MD5.generate(); - return member(token.substring(0, 16), "dimension-" + token, "user-" + token); - } - - private static DimensionUserEntity member(String type, String dimension, String user) { DimensionUserEntity member = new DimensionUserEntity(); - member.setDimensionTypeId(type); - member.setDimensionId(dimension); + member.setDimensionTypeId("role"); + member.setDimensionId("dimension-" + token); member.setDimensionName("dimension"); - member.setUserId(user); + member.setUserId("user-" + token); member.setUserName("user"); member.setRelationName("relation-before"); return member; } - static class QueryCounter { - final AtomicInteger count = new AtomicInteger(); + static class BindingEvents { + final List bound = new CopyOnWriteArrayList<>(); + final List unbound = new CopyOnWriteArrayList<>(); + + @EventListener + public void bound(DimensionBindEvent event) { + bound.add(event); + } @EventListener - public void countQuery(EntityBeforeQueryEvent event) { - count.incrementAndGet(); + public void unbound(DimensionUnbindEvent event) { + unbound.add(event); } } @TestConfiguration static class Configuration { @Bean - QueryCounter queryCounter() { - return new QueryCounter(); - } - } - - private enum Write { - SERVICE_SAVE_ENTITY(false), SERVICE_SAVE_COLLECTION(false), SERVICE_SAVE_PUBLISHER(false), - REPOSITORY_SAVE_ENTITY(false), REPOSITORY_SAVE_COLLECTION(false), REPOSITORY_SAVE_PUBLISHER(false), - SERVICE_INSERT_ENTITY(true), SERVICE_INSERT_PUBLISHER(true), SERVICE_INSERT_BATCH(true), - REPOSITORY_INSERT_PUBLISHER(true), REPOSITORY_INSERT_BATCH(true); - - final boolean create; - - Write(boolean create) { - this.create = create; + BindingEvents bindingEvents() { + return new BindingEvents(); } } } From 1cadc45c35b67572c5624586a4a03dbb2058bf88 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Mon, 12 Oct 2026 14:03:16 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix(=E6=A0=91CRUD):=20=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E7=A8=80=E7=96=8F=E6=9B=B4=E6=96=B0=E8=BE=93=E5=85=A5=E4=B8=8E?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=9B=BE=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../crud/service/TreeSortServiceHelper.java | 15 +++ .../ExplicitTreeUpdateRegressionTest.java | 93 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 76c94d9e8..eeb7d744c 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -55,9 +55,16 @@ public Flux prepare(Flux source) { protected Flux prepare(Flux source, boolean partialUpdate) { Flux cache = source .flatMapIterable(root -> { + // 展开和持久图补齐只修改工作副本,复用输入或重新订阅仍保留原稀疏意图。 + Map workingCopies = partialUpdate ? copyUpdateInput(root) : Collections.emptyMap(); + if (partialUpdate) { + root = workingCopies.get(root); + } Map patches = partialUpdate ? copyUpdateInput(root) : Collections.emptyMap(); List expanded = TreeSupportEntity.expandTree2List(root, getIdGenerator()); if (partialUpdate) { + // 沿用新增节点的 ID 回填;其他生成字段不能成为下次调用的显式输入。 + workingCopies.forEach((input, working) -> input.setId(working.getId())); updateRoots.add(root); for (E node : expanded) { E patch = patches.get(node); @@ -121,6 +128,12 @@ private Map copyUpdateInput(E root) { queue.addAll(node.getChildren()); } } + for (Map.Entry entry : copies.entrySet()) { + List children = entry.getKey().getChildren(); + if (children != null) { + applyChildren(entry.getValue(), children.stream().map(copies::get).collect(Collectors.toList())); + } + } return copies; } @@ -194,6 +207,8 @@ private void completeUpdateInput() { protected E getUpdateData(E node) { E patch = updateData.get(node.getId()); if (patch != null) { + // 入口可能传入调用方稀疏对象,树字段统一取当前已准备的工作节点。 + node = allData.get(node.getId()); E previous = oldData.get(node.getId()); // 业务 patch 不回写未变化的隐式树字段,避免覆盖另一个事务已提交的移动。 // 显式树输入(包括同值)及准备后的实际变化仍提交完整 path/level。 diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java index 364a3ec53..0e206e295 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java @@ -19,8 +19,10 @@ import reactor.test.StepVerifier; import java.time.Duration; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.*; @@ -86,6 +88,26 @@ public void entityNewParentKeepsExplicitExistingChildAndOmittedLeafPathsConsiste verifyNewParentWithExistingChild(true); } + @Test + public void monoNamePatchObjectReusePreservesCommittedBusinessAndMove() { + verifyReusedNamePatch(false, false); + } + + @Test + public void entityNamePatchObjectReusePreservesCommittedBusinessAndMove() { + verifyReusedNamePatch(true, false); + } + + @Test + public void monoNamePatchPublisherResubscriptionPreservesCommittedBusinessAndMove() { + verifyReusedNamePatch(false, true); + } + + @Test + public void entityNamePatchPublisherResubscriptionPreservesCommittedBusinessAndMove() { + verifyReusedNamePatch(true, true); + } + @Test public void publicPutPersistsBothExistingAndGeneratedIdChildren() { TestTreeSortEntity root = node("root-before"); @@ -175,6 +197,77 @@ private void verifyExistingChild(boolean entityOverload) { }).expectComplete().verify(TIMEOUT); } + private void verifyReusedNamePatch(boolean entityOverload, boolean reusePublisher) { + TestTreeSortEntity oldRoot = node("old-root"); + TestTreeSortEntity targetRoot = node("target-root"); + TestTreeSortEntity targetBranch = node("target-branch"); + TestTreeSortEntity target = node("target"); + TestTreeSortEntity branch = node("branch-before"); + TestTreeSortEntity leaf = node("leaf-before"); + branch.setSortIndex(37L); + leaf.setSortIndex(84L); + oldRoot.setChildren(List.of(branch)); + branch.setChildren(List.of(leaf)); + targetRoot.setChildren(List.of(targetBranch)); + targetBranch.setChildren(List.of(target)); + List ids = List.of(oldRoot.getId(), targetRoot.getId(), targetBranch.getId(), + target.getId(), branch.getId(), leaf.getId()); + Map movedParents = new LinkedHashMap<>(); + movedParents.put(oldRoot.getId(), null); + movedParents.put(targetRoot.getId(), null); + movedParents.put(targetBranch.getId(), targetRoot.getId()); + movedParents.put(target.getId(), targetBranch.getId()); + movedParents.put(branch.getId(), target.getId()); + movedParents.put(leaf.getId(), branch.getId()); + service.save(List.of(oldRoot, targetRoot)) + .as(StepVerifier::create).expectNextCount(1).expectComplete().verify(TIMEOUT); + try { + TestTreeSortEntity namePatch = patch("branch-after"); + Mono firstUpdate = update(branch.getId(), namePatch, entityOverload); + firstUpdate.as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + stored(ids).as(StepVerifier::create).assertNext(after -> { + assertEquals("branch-after", after.get(branch.getId()).getName()); + assertEquals("business-before", after.get(branch.getId()).getDefaultTest()); + assertEquals(oldRoot.getId(), after.get(branch.getId()).getParentId()); + }).expectComplete().verify(TIMEOUT); + + // 每个订阅完成后再发起下一次操作,业务修改和移动已在独立事务中提交。 + TestTreeSortEntity businessPatch = new TestTreeSortEntity(); + businessPatch.setDefaultTest("business-after"); + update(branch.getId(), businessPatch, entityOverload) + .as(StepVerifier::create).expectNext(1).expectComplete().verify(TIMEOUT); + service.findById(branch.getId()).flatMap(existing -> { + existing.setParentId(target.getId()); + return service.save(existing); + }).as(StepVerifier::create).expectNextCount(1).expectComplete().verify(TIMEOUT); + AtomicReference>> committed = new AtomicReference<>(); + stored(ids).as(StepVerifier::create).assertNext(moved -> { + TreeCrudTestSupport.assertPersistedTree(moved.values(), movedParents); + assertEquals("branch-after", moved.get(branch.getId()).getName()); + assertEquals("business-after", moved.get(branch.getId()).getDefaultTest()); + assertEquals("leaf-before", moved.get(leaf.getId()).getName()); + assertEquals("business-before", moved.get(leaf.getId()).getDefaultTest()); + assertEquals(Long.valueOf(37), moved.get(branch.getId()).getSortIndex()); + assertEquals(Long.valueOf(84), moved.get(leaf.getId()).getSortIndex()); + committed.set(TreeCrudTestSupport.snapshot(moved.values())); + }).expectComplete().verify(TIMEOUT); + + // 分别复用调用方对象创建新操作、以及重新订阅原操作;两者仍只有名称修改意图。 + Mono repeated = reusePublisher ? firstUpdate : update(branch.getId(), namePatch, entityOverload); + repeated.as(StepVerifier::create).expectNextCount(1).expectComplete().verify(TIMEOUT); + stored(ids).as(StepVerifier::create).assertNext(after -> { + assertEquals("business-after", after.get(branch.getId()).getDefaultTest()); + assertEquals(target.getId(), after.get(branch.getId()).getParentId()); + TreeCrudTestSupport.assertPersistedTree(after.values(), movedParents); + assertEquals(committed.get(), TreeCrudTestSupport.snapshot(after.values())); + }).expectComplete().verify(TIMEOUT); + } finally { + // 清理固定六个输入实体,避免依赖被测子树查询和删除路径。 + service.getRepository().createDelete().in("id", ids).execute() + .as(StepVerifier::create).expectNext(6).expectComplete().verify(TIMEOUT); + } + } + private void verifyNewChild(boolean entityOverload) { TestTreeSortEntity root = node("root-before"); save(root); From e373b685173d6fffeac6fb4367c5929058c94745 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Mon, 12 Oct 2026 20:43:12 +0800 Subject: [PATCH 14/17] =?UTF-8?q?refactor(=E6=A0=91CRUD):=20=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E9=80=9A=E7=94=A8=E5=88=A0=E9=99=A4=E5=87=86=E5=A4=87?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=B9=B6=E9=87=8D=E8=AF=BB=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=AD=90=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReactiveTreeSortEntityService.java | 31 ++- ...opedReactiveTreeSortEntityServiceTest.java | 198 ++++++++++++++++++ 2 files changed, 221 insertions(+), 8 deletions(-) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index b1efeeb69..8c2be8361 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -333,20 +333,35 @@ default boolean isRootNode(E entity) { return ObjectUtils.isEmpty(entity.getParentId()) || "-1".equals(String.valueOf(entity.getId())); } + /** + * 在当前树删除事务内准备删除条件,完成后才读取当前节点及子树。 + * 默认不执行任何操作;扩展实现不应修改查询参数或提前展开子树。 + * + * @param queryParam 原始删除条件 + * @return 准备完成信号 + */ + default Mono prepareTreeDelete(QueryParamEntity queryParam) { + return Mono.empty(); + } + @Override default ReactiveDelete createDelete() { return ReactiveCrudService.super.createDelete().onExecute((delete, executor) -> TransactionUtils.tryRunInTransaction( // 完整实体用于分组和层级排序;叶先有界分片,每批SQL与事件完成后才删除父级。 // 全部批次仍参与同一个原事务,后批事件或调用方失败必须整体回滚。 - queryIncludeChildren(delete.toQueryParam(QueryParamEntity::new)) - .sort(Comparator.comparing((E node) -> node.getLevel(), - Comparator.nullsFirst(Comparator.naturalOrder())).reversed()) - .map(TreeSupportEntity::getId) - .buffer(getBufferSize()) - .concatMap(ids -> getRepository().createDelete().in("id", ids).execute()) - .reduce(Math::addExact) - .defaultIfEmpty(0), + Mono.defer(() -> { + QueryParamEntity queryParam = delete.toQueryParam(QueryParamEntity::new); + return prepareTreeDelete(queryParam) + .thenMany(Flux.defer(() -> queryIncludeChildren(queryParam))) + .sort(Comparator.comparing((E node) -> node.getLevel(), + Comparator.nullsFirst(Comparator.naturalOrder())).reversed()) + .map(TreeSupportEntity::getId) + .buffer(getBufferSize()) + .concatMap(ids -> getRepository().createDelete().in("id", ids).execute()) + .reduce(Math::addExact) + .defaultIfEmpty(0); + }), new DefaultTransactionDefinition())); } } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index 9f91a5999..5026bda56 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -1,8 +1,12 @@ package org.hswebframework.web.crud.service; +import io.r2dbc.spi.ConnectionFactory; + import org.hswebframework.ezorm.rdb.mapping.ReactiveQuery; import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; +import org.hswebframework.ezorm.rdb.operator.DatabaseOperator; import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.CountingReactiveSqlExecutor; import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.entity.TestTreeSortEntity; import org.hswebframework.web.crud.events.EntityBeforeCreateEvent; @@ -24,11 +28,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.event.EventListener; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.transaction.reactive.TransactionSynchronizationManager; import org.springframework.transaction.support.DefaultTransactionDefinition; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; import reactor.test.StepVerifier; +import java.time.Duration; import java.util.Collection; import java.util.List; import java.util.Map; @@ -38,6 +45,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -61,6 +69,123 @@ public class ScopedReactiveTreeSortEntityServiceTest { @Autowired private BatchedTreeService batched; + @Autowired + private PreparedTreeService prepared; + + @Autowired + private DatabaseOperator database; + + @Test + public void deletePreparationWaitsBeforeExpansionAndUsesCurrentScopedSubtree() { + String groupA = id(); + String groupB = id(); + TestTreeSortEntity rootA = node(groupA, null, "aaaa"); + TestTreeSortEntity rootB = node(groupA, null, "dddd"); + TestTreeSortEntity branch = node(groupA, rootA.getId(), "aaaa-bbbb"); + TestTreeSortEntity leaf = node(groupA, branch.getId(), "aaaa-bbbb-cccc"); + leaf.setLevel(3); + TestTreeSortEntity otherRoot = node(groupB, null, "aaaa"); + TestTreeSortEntity otherChild = node(groupB, otherRoot.getId(), "aaaa-bbbb"); + List original = List.of(rootA, rootB, branch, leaf, otherRoot, otherChild); + prepared.getRepository().save(original) + .as(StepVerifier::create).expectNextCount(1).expectComplete().verify(Duration.ofSeconds(10)); + Map otherBefore = stored(prepared, groupB).block(Duration.ofSeconds(10)); + Sinks.Empty arrived = Sinks.empty(); + Sinks.Empty release = Sinks.empty(); + TestTreeSortEntity move = new TestTreeSortEntity(); + move.setParentId(rootB.getId()); + prepared.configure(query -> Mono.fromRunnable(() -> arrived.tryEmitEmpty()) + .then(release.asMono()).then(prepared.updateById(branch.getId(), move)).then()); + Mono deletion = prepared.createDelete().where("id", rootA.getId()).execute(); + assertEquals(0, prepared.getPreparationCount()); + assertEquals(0, prepared.getExpansionCount()); + try (CountingReactiveSqlExecutor sql = new CountingReactiveSqlExecutor( + database.getMetadata().getTable("test_tree_sort").orElseThrow())) { + StepVerifier.create(deletion).then(() -> { + arrived.asMono().block(Duration.ofSeconds(10)); + assertEquals(1, prepared.getPreparationCount()); + assertEquals("queryIncludeChildren must wait for preparation completion", 0, prepared.getExpansionCount()); + assertEquals(0L, sql.count("delete")); + assertNull(listener.beforeDeleteIds.get(groupA)); + assertNull(listener.deletedIds.get(groupA)); + release.tryEmitEmpty(); + }).expectNext(1).expectComplete().verify(Duration.ofSeconds(10)); + assertEquals(1, prepared.getExpansionCount()); + assertEquals(1L, sql.count("delete")); + assertEquals(List.of(rootA.getId()), listener.beforeDeleteIds.get(groupA)); + assertEquals(List.of(rootA.getId()), listener.deletedIds.get(groupA)); + stored(prepared, groupA).as(StepVerifier::create).assertNext(tree -> { + assertEquals(Set.of(rootB.getId(), branch.getId(), leaf.getId()), tree.keySet()); + assertEquals(rootB.getId(), tree.get(branch.getId()).getParentId()); + assertEquals(branch.getId(), tree.get(leaf.getId()).getParentId()); + assertTreeStructure(tree.values()); + }).expectComplete().verify(Duration.ofSeconds(10)); + stored(prepared, groupB).as(StepVerifier::create) + .assertNext(tree -> assertSnapshot(otherBefore, tree)).expectComplete().verify(Duration.ofSeconds(10)); + } finally { + release.tryEmitEmpty(); + prepared.configure(query -> Mono.empty()); + prepared.getRepository().createDelete().in("id", original.stream().map(TestTreeSortEntity::getId) + .collect(Collectors.toList())).execute().block(Duration.ofSeconds(10)); + } + } + + @Test + public void failedDeletePreparationRollsBackWritesWithoutExpansionSqlOrEvents() { + String group = id(); + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity child = node(group, root.getId(), null); + prepared.save(List.of(root, child)).as(StepVerifier::create).expectNextCount(1).expectComplete().verify(Duration.ofSeconds(10)); + Map before = stored(prepared, group).block(Duration.ofSeconds(10)); + IllegalStateException failure = new IllegalStateException("reject tree delete preparation"); + prepared.configure(query -> prepared.getRepository().createUpdate().set("name", "must roll back") + .where("id", root.getId()).execute().then(Mono.error(failure))); + try (CountingReactiveSqlExecutor sql = new CountingReactiveSqlExecutor( + database.getMetadata().getTable("test_tree_sort").orElseThrow())) { + prepared.createDelete().where("id", root.getId()).execute().as(StepVerifier::create) + .expectErrorMatches(error -> error == failure).verify(Duration.ofSeconds(10)); + assertEquals(1, prepared.getPreparationCount()); + assertEquals(0, prepared.getExpansionCount()); + assertEquals(0L, sql.count("delete")); + assertNull(listener.beforeDeleteIds.get(group)); + assertNull(listener.deletedIds.get(group)); + stored(prepared, group).as(StepVerifier::create) + .assertNext(after -> assertSnapshot(before, after)).expectComplete().verify(Duration.ofSeconds(10)); + } finally { + prepared.configure(query -> Mono.empty()); + prepared.getRepository().createDelete().where("groupId", group).execute().block(Duration.ofSeconds(10)); + } + } + + @Test + public void eachDeleteSubscriptionRepeatsPreparationAndReadsCurrentSeedsIncludingEmptyScope() { + String group = id(); + prepared.configure(query -> Mono.empty()); + Mono deletion = prepared.createDelete().where("groupId", group).execute(); + assertEquals(0, prepared.getPreparationCount()); + assertEquals(0, prepared.getExpansionCount()); + try { + for (int subscription = 1; subscription <= 2; subscription++) { + TestTreeSortEntity root = node(group, null, null); + TestTreeSortEntity child = node(group, root.getId(), null); + prepared.save(List.of(root, child)).as(StepVerifier::create).expectNextCount(1).expectComplete().verify(Duration.ofSeconds(10)); + deletion.as(StepVerifier::create).expectNext(2).expectComplete().verify(Duration.ofSeconds(10)); + assertEquals(subscription, prepared.getPreparationCount()); + assertEquals(subscription, prepared.getExpansionCount()); + stored(prepared, group).as(StepVerifier::create).assertNext(tree -> assertTrue(tree.isEmpty())) + .expectComplete().verify(Duration.ofSeconds(10)); + } + deletion.as(StepVerifier::create).expectNext(0).expectComplete().verify(Duration.ofSeconds(10)); + assertEquals(3, prepared.getPreparationCount()); + assertEquals(3, prepared.getExpansionCount()); + assertEquals(4, listener.beforeDeleteIds.get(group).size()); + assertEquals(4, listener.deletedIds.get(group).size()); + } finally { + prepared.configure(query -> Mono.empty()); + prepared.getRepository().createDelete().where("groupId", group).execute().block(Duration.ofSeconds(10)); + } + } + @Test public void samePathsInDifferentGroupsStayIsolatedForParentsChildrenAndDeletion() { String groupA = id(); @@ -766,10 +891,65 @@ public int getBufferSize() { } } + /** 只观察并暂停正式通用树执行链,不模拟查询、SQL或事务。 */ + static class PreparedTreeService extends ScopedTreeService { + private final ConnectionFactory connectionFactory; + private final AtomicInteger preparations = new AtomicInteger(); + private final AtomicInteger expansions = new AtomicInteger(); + private final AtomicReference preparedConnection = new AtomicReference<>(); + private Function> preparation = query -> Mono.empty(); + + PreparedTreeService(ReactiveRepository repository, + ConnectionFactory connectionFactory) { + super(repository); + this.connectionFactory = connectionFactory; + } + + public void configure(Function> preparation) { + this.preparation = preparation; + preparations.set(0); + expansions.set(0); + preparedConnection.set(null); + } + + // Spring代理上的字段不是target状态,fixture配置和观察都通过bean方法访问。 + public int getPreparationCount() { + return preparations.get(); + } + + public int getExpansionCount() { + return expansions.get(); + } + + @Override + public Mono prepareTreeDelete(QueryParamEntity queryParam) { + preparations.incrementAndGet(); + return TransactionSynchronizationManager.forCurrentTransaction().flatMap(transaction -> { + assertTrue(transaction.isActualTransactionActive()); + Object connection = transaction.getResource(connectionFactory); + assertNotNull(connection); + preparedConnection.set(connection); + return preparation.apply(queryParam); + }); + } + + @Override + public Flux queryIncludeChildren(QueryParamEntity queryParam) { + expansions.incrementAndGet(); + return TransactionSynchronizationManager.forCurrentTransaction().flatMapMany(transaction -> { + assertTrue(transaction.isActualTransactionActive()); + assertSame(preparedConnection.get(), transaction.getResource(connectionFactory)); + return super.queryIncludeChildren(queryParam); + }); + } + } + static class FailureListener { private final ReactiveRepository repository; private final AtomicInteger observedRoots = new AtomicInteger(); private final Map> deleteBatches = new ConcurrentHashMap<>(); + private final Map> beforeDeleteIds = new ConcurrentHashMap<>(); + private final Map> deletedIds = new ConcurrentHashMap<>(); FailureListener(ReactiveRepository repository) { this.repository = repository; @@ -869,6 +1049,9 @@ public void afterSave(EntitySavedEvent event) { @EventListener public void beforeDelete(EntityBeforeDeleteEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.async(recordDeletedNodes(event.getEntity(), beforeDeleteIds)); + } if (event.getEntityType() != TestTreeSortEntity.class || event.getEntity().stream().noneMatch(node -> node.getName().startsWith("verify-delete-batch-") || node.getName().startsWith("reject-delete-batch-later-"))) { @@ -889,11 +1072,20 @@ public void beforeDelete(EntityBeforeDeleteEvent event) { @EventListener public void afterDelete(EntityDeletedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.async(recordDeletedNodes(event.getEntity(), deletedIds)); + } if (event.getEntityType() == TestTreeSortEntity.class && event.getEntity().stream().anyMatch(node -> node.getName().startsWith("reject-delete-"))) { event.async(Mono.error(new IllegalStateException("reject deleted tree"))); } } + + private Mono recordDeletedNodes(List nodes, Map> target) { + return Mono.fromRunnable(() -> nodes.stream().filter(node -> node.getGroupId() != null) + .forEach(node -> target.computeIfAbsent(node.getGroupId(), ignored -> new CopyOnWriteArrayList<>()) + .add(node.getId()))); + } } @TestConfiguration @@ -908,6 +1100,12 @@ BatchedTreeService batchedTreeService(ReactiveRepository repository, + ConnectionFactory connectionFactory) { + return new PreparedTreeService(repository, connectionFactory); + } + @Bean FailureListener treeFailureListener(ReactiveRepository repository) { return new FailureListener(repository); From 2cc826f32154ebc4520bd3075e97a32721902b63 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Tue, 13 Oct 2026 10:12:31 +0800 Subject: [PATCH 15/17] =?UTF-8?q?test(=E6=A0=91CRUD):=20=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E4=BF=9D=E5=AD=98=E4=B8=8E=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=A0=A1=E9=AA=8C=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 52 +++ .../ReactiveTreeSortEntityService.java | 12 +- .../crud/service/TreeSortEntityService.java | 4 + .../ExplicitTreeUpdateRegressionTest.java | 196 +++++++++++- ...eeEntityEventValidationRegressionTest.java | 301 ++++++++++++++++++ 5 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeEntityEventValidationRegressionTest.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8fbe686f..945ee43c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,58 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含该通用修复的 `4.2.2-SNAPSHOT`。运行结果记录在对应 PR 的验证证据中。 +## 树 Save 与部分更新的契约 + +树 save 按提交实体的完整树关系保存:已有节点的 `parentId=null` 表示移根, +共用 helper 维护 path/level 和原有后代;父级清空 SQL 与正式 Modify/Save 事件参与同一 +事务。遗漏 children 不自动删除后代。业务列的 null 仍遵循现有 ORM 忽略 null、生成 +默认值等规则,这一契约不提供整实体的全量 Replace。 + +公开 `PATCH /资源` 是批量 Save/upsert,已有 ID 保存、不存在的 ID 新增; +`PUT /资源/{id}` 是正式 `updateById` 部分更新入口,null 沿用持久父级和业务字段。 +既有节点的名称等稀疏修改应使用更新入口,完整 Save 必须表达本次需要的树关系。 +同步树准备覆盖 `insert(Collection)` / `save(List)`,普通同步 `updateById` 不提供 +结构修图;响应式树 `updateById` 维护显式关系变化及遗漏后代的必要树字段。 + +实现位于 `hsweb-commons/hsweb-commons-crud` 的 `TreeSortServiceHelper`、 +`TreeSortEntityService`、`ReactiveTreeSortEntityService`;公开路由由 +`ReactiveServiceSaveController` 提供。`ExplicitTreeUpdateRegressionTest` 通过真实 +R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保留关系,及 PATCH +完整 null 父级移根、保留遗漏后代和同批不存在 ID 新增。 + +## 树 updateById 的业务校验边界 + +响应式树 `updateById` 对目标及显式既有 child 保持纯 UPDATE,使用正式 Modify 的 +完整 before/after 事件;目标已被另一事务删除时返回零,不能以 Save/UPSERT 重新 +创建目标或继续创建 child。显式新 child 经过服务 Save;自动补齐的后代只维护必要树字段。 + +`save(Publisher)` 覆写只拥有 Save 入口。原来在该覆写中校验既有节点更新的业务,需要 +将更新规则迁移到 `EntityBeforeModifyEvent.getAfter()`;新增和 Save 分别监听 +`EntityBeforeCreateEvent` / `EntityBeforeSaveEvent`,共同调用业务模块自己的校验函数。 +既有 Save 覆写不会自动成为 UPDATE 的校验入口。 + +`TreeEntityEventValidationRegressionTest` 的 8 个真实 Spring / R2DBC H2 用例已通过: +两种 updateById overload 的目标拒绝、显式既有 child 拒绝和新 child 拒绝,以及直接 +Create/Save 共用校验。正式 Modify after 包含持久业务列和完整树字段,child 拒绝前 +读取实际已写父节点,拒绝后原事务恢复整个持久快照。 + +框架本次定向集成验证共 15 组、109 个用例:108 通过、1 个既有跳过、0 失败、0 错误; +跳过项为 `EntityEventListenerTest.testInsertError`。验证期间输入、依赖和执行入口保持 +稳定,结果检查通过。这一结果只覆盖框架选定范围;普通树并发边界见下节。 +以上契约的定向复验入口: +`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=ExplicitTreeUpdateRegressionTest,SyncTreeCrudRegressionTest,ScopedReactiveTreeSortEntityServiceTest,TreeEntityEventValidationRegressionTest -Dsurefire.failIfNoSpecifiedTests=false test`。 + +## 普通树删除的并发边界 + +响应式普通树在子树展开后按 ID 快照执行叶先分批删除。没有 parent 外键或共同领域 +约束时,另一事务在展开与删除 SQL 之间提交的新 child 可能不在删除快照内,并留下 +失去父节点的记录;框架当前不保证普通树并发新增/删除的关系完整性。 + +当前方案不新增通用锁,也不扩大已有领域锁策略。业务需要该并发保证时,应让关联 +写入和删除遵守同一领域约束;已有 `prepareTreeDelete` 领域锁的保证仅适用于共同 +遵守该约束的操作。框架已验证的复合事务、完整实体事件、分组隔离、叶先分批、取消 +与回滚,不能外推为所有普通树的并发关系保证。 + ## 成员 ID 兼容性验证 `DimensionUserEntity.generateId()` 保持原有三字段以 `-` 拼接后的 MD5 规则;已提供的 diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 8c2be8361..c643d5b22 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -7,6 +7,7 @@ import org.hswebframework.ezorm.rdb.operator.dml.Terms; import org.hswebframework.web.api.crud.entity.*; import org.hswebframework.web.id.IDGenerator; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; import org.hswebframework.web.crud.utils.TransactionUtils; import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; @@ -203,6 +204,10 @@ default int getBufferSize() { return 200; } + /** + * 按提交实体保存完整树关系,已有节点的 null parentId 表示移根。 + * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 + */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) @@ -248,8 +253,11 @@ default Mono save(E data) { /** * 部分更新忽略 null;显式 children 参与修改和新增,遗漏后代仅维护树字段。 - * 已有节点使用 ORM Modify 完整 before/after 事件,新节点经过服务 save 保留业务扩展。 - * 显式移根使用完整 save 或维护树生命周期的 DSL。 + * 目标及显式已有 child 始终执行 UPDATE,通过 ORM Modify 完整 before/after 事件校验。 + * 业务更新规则应监听 {@link EntityBeforeModifyEvent} 的 after;save(Publisher) 覆写只拥有 Save 入口。 + * 新 child 经过服务 save,使用对应的 BeforeSave/BeforeCreate 规则;任一拒绝回滚整个树更新。 + * 目标并发消失时返回零,不使用 Save/UPSERT 重建目标,也不继续写入 children。 + * String parentId 空串明确表达移根,null 沿用持久父级。 */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java index 3b8f8d0c1..21b49a8d5 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java @@ -86,6 +86,10 @@ default int insert(Collection entityPublisher) { .orElse(0); } + /** + * 按提交实体保存完整树关系,已有节点的 null parentId 表示移根。 + * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 + */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) default SaveResult save(List entities) { diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java index 0e206e295..4a2431933 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java @@ -2,6 +2,10 @@ import org.hswebframework.web.crud.TestApplication; import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; +import org.hswebframework.web.crud.events.EntityBeforeSaveEvent; +import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.crud.web.reactive.ReactiveServiceSaveController; import org.hswebframework.web.exception.ValidationException; import org.hswebframework.web.id.IDGenerator; @@ -10,6 +14,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestComponent; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.reactive.server.WebTestClient; @@ -22,13 +29,15 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.*; /** 显式嵌套节点保留修改和创建语义,自动展开的后代只维护树字段。 */ @RunWith(SpringRunner.class) -@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class}, properties = { +@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class, + ExplicitTreeUpdateRegressionTest.HttpEventConfiguration.class}, properties = { "spring.r2dbc.generate-unique-name=true", "spring.r2dbc.name=ExplicitTreeUpdateRegressionTest" }) @@ -38,6 +47,9 @@ public class ExplicitTreeUpdateRegressionTest { @Autowired private TestTreeSortEntityService service; + @Autowired + private HttpTreeEventListener httpEvents; + @Test public void monoPatchUpdatesExplicitExistingChildAndPreservesOmittedFields() { verifyExistingChild(false); @@ -108,6 +120,132 @@ public void entityNamePatchPublisherResubscriptionPreservesCommittedBusinessAndM verifyReusedNamePatch(true, true); } + @Test + public void publicPutSparseNameUpdatePreservesParentAndOmittedSubtree() { + TestTreeSortEntity root = node("root-before"); + TestTreeSortEntity child = node("child-before"); + TestTreeSortEntity leaf = node("leaf-before"); + child.setSortIndex(37L); + leaf.setSortIndex(84L); + leaf.setDefaultTest("leaf-business"); + root.setChildren(List.of(child)); + child.setChildren(List.of(leaf)); + save(root); + List ids = List.of(root.getId(), child.getId(), leaf.getId()); + Map before = stored(ids).block(TIMEOUT); + Map> beforeSnapshot = TreeCrudTestSupport.snapshot(before.values()); + for (boolean explicitNull : List.of(false, true)) { + Map payload = new LinkedHashMap<>(); + payload.put("name", "renamed-put-" + explicitNull); + payload.put("defaultTest", null); + if (explicitNull) { + payload.put("parentId", null); + } + httpEvents.reset(); + client().put().uri("/tree/{id}", child.getId()).contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload).exchange().expectStatus().isOk() + .expectBody(Boolean.class).isEqualTo(true); + stored(ids).as(StepVerifier::create).assertNext(after -> { + TestTreeSortEntity updated = after.get(child.getId()); + assertEquals("renamed-put-" + explicitNull, updated.getName()); + assertEquals("business-before", updated.getDefaultTest()); + assertEquals(root.getId(), updated.getParentId()); + assertEquals(before.get(child.getId()).getPath(), updated.getPath()); + assertEquals(Integer.valueOf(2), updated.getLevel()); + assertEquals(Long.valueOf(37), updated.getSortIndex()); + Map> afterSnapshot = TreeCrudTestSupport.snapshot(after.values()); + assertEquals(beforeSnapshot.get(root.getId()), afterSnapshot.get(root.getId())); + assertEquals(beforeSnapshot.get(leaf.getId()), afterSnapshot.get(leaf.getId())); + assertEquals(afterSnapshot.get(child.getId()), httpEvents.getBeforeModify(child.getId())); + assertEquals(afterSnapshot.get(child.getId()), httpEvents.getModified(child.getId())); + assertNull("existing PUT must use Modify rather than Save", httpEvents.getBeforeSave(child.getId())); + assertNull(httpEvents.getSaved(child.getId())); + }).expectComplete().verify(TIMEOUT); + service.queryIncludeChildren(List.of(root.getId())).count() + .as(StepVerifier::create).expectNext(3L).verifyComplete(); + } + } + + @Test + public void publicPatchFullNullParentMovesSubtreeAndCreatesMissingId() { + TestTreeSortEntity root = node("root-before"); + TestTreeSortEntity child = node("child-before"); + TestTreeSortEntity leaf = node("leaf-before"); + child.setSortIndex(37L); + leaf.setSortIndex(84L); + leaf.setDefaultTest("leaf-business"); + root.setChildren(List.of(child)); + child.setChildren(List.of(leaf)); + save(root); + Map before = stored(List.of(root.getId(), child.getId(), leaf.getId())) + .block(TIMEOUT); + Map> beforeSnapshot = TreeCrudTestSupport.snapshot(before.values()); + TestTreeSortEntity complete = before.get(child.getId()); + complete.setParentId(null); + complete.setName("moved-root"); + TestTreeSortEntity added = node("http-created-root"); + service.findById(added.getId()).as(StepVerifier::create).verifyComplete(); + httpEvents.reset(); + // 完整表达提交节点的树关系;遗漏 children 仍保留原后代,不作为删除清单。 + client().patch().uri("/tree").contentType(MediaType.APPLICATION_JSON) + .bodyValue(List.of(fullSavePayload(complete), fullSavePayload(added))) + .exchange().expectStatus().isOk(); + stored(List.of(root.getId(), child.getId(), leaf.getId(), added.getId())) + .as(StepVerifier::create).assertNext(after -> { + assertEquals(4, after.size()); + TestTreeSortEntity moved = after.get(child.getId()); + TestTreeSortEntity movedLeaf = after.get(leaf.getId()); + TestTreeSortEntity created = after.get(added.getId()); + assertNull(moved.getParentId()); + assertEquals("moved-root", moved.getName()); + assertEquals("business-before", moved.getDefaultTest()); + assertEquals(Long.valueOf(37), moved.getSortIndex()); + assertEquals(Integer.valueOf(1), moved.getLevel()); + assertFalse(moved.getPath().contains("-")); + assertNotEquals(beforeSnapshot.get(child.getId()).get(1), moved.getPath()); + assertEquals(child.getId(), movedLeaf.getParentId()); + assertTrue(movedLeaf.getPath().startsWith(moved.getPath() + "-")); + assertEquals(Integer.valueOf(2), movedLeaf.getLevel()); + assertEquals("leaf-before", movedLeaf.getName()); + assertEquals("leaf-business", movedLeaf.getDefaultTest()); + assertEquals(Long.valueOf(84), movedLeaf.getSortIndex()); + assertNull(created.getParentId()); + assertEquals("http-created-root", created.getName()); + assertEquals("business-before", created.getDefaultTest()); + assertEquals(Integer.valueOf(1), created.getLevel()); + assertFalse(created.getPath().contains("-")); + Map> afterSnapshot = TreeCrudTestSupport.snapshot(after.values()); + assertEquals(beforeSnapshot.get(root.getId()), afterSnapshot.get(root.getId())); + for (String id : List.of(child.getId(), leaf.getId(), added.getId())) { + assertEquals(afterSnapshot.get(id), httpEvents.getBeforeSave(id)); + assertEquals(afterSnapshot.get(id), httpEvents.getSaved(id)); + } + assertNull(httpEvents.getBeforeSave(root.getId())); + assertNotNull("root clear retains the formal Modify boundary", httpEvents.getModified(child.getId())); + assertNull(httpEvents.getBeforeModify(child.getId()).get(0)); + assertNull(httpEvents.getModified(child.getId()).get(0)); + assertEquals(moved.getPath(), httpEvents.getModified(child.getId()).get(1)); + assertEquals(Integer.valueOf(1), httpEvents.getModified(child.getId()).get(2)); + }).expectComplete().verify(TIMEOUT); + service.queryIncludeChildren(List.of(root.getId())).count() + .as(StepVerifier::create).expectNext(1L).verifyComplete(); + service.queryIncludeChildren(List.of(child.getId())).count() + .as(StepVerifier::create).expectNext(2L).verifyComplete(); + } + + private static Map fullSavePayload(TestTreeSortEntity entity) { + Map payload = new LinkedHashMap<>(); + payload.put("id", entity.getId()); + payload.put("name", entity.getName()); + payload.put("defaultTest", entity.getDefaultTest()); + payload.put("groupId", entity.getGroupId()); + payload.put("parentId", entity.getParentId()); + payload.put("path", entity.getPath()); + payload.put("level", entity.getLevel()); + payload.put("sortIndex", entity.getSortIndex()); + return payload; + } + @Test public void publicPutPersistsBothExistingAndGeneratedIdChildren() { TestTreeSortEntity root = node("root-before"); @@ -403,6 +541,62 @@ private static TestTreeSortEntity patch(String name) { return patch; } + /** HTTP 入口经过真实仓储后,核对正式事件提供的完整树与业务字段。 */ + public static class HttpTreeEventListener { + private final Map> beforeModify = new ConcurrentHashMap<>(); + private final Map> modified = new ConcurrentHashMap<>(); + private final Map> beforeSave = new ConcurrentHashMap<>(); + private final Map> saved = new ConcurrentHashMap<>(); + + public void reset() { + beforeModify.clear(); + modified.clear(); + beforeSave.clear(); + saved.clear(); + } + + public List getBeforeModify(String id) { return beforeModify.get(id); } + public List getModified(String id) { return modified.get(id); } + public List getBeforeSave(String id) { return beforeSave.get(id); } + public List getSaved(String id) { return saved.get(id); } + + @EventListener + public void beforeModify(EntityBeforeModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + beforeModify.putAll(TreeCrudTestSupport.snapshot(event.getAfter())); + } + } + + @EventListener + public void modified(EntityModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + modified.putAll(TreeCrudTestSupport.snapshot(event.getAfter())); + } + } + + @EventListener + public void beforeSave(EntityBeforeSaveEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + beforeSave.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + + @EventListener + public void saved(EntitySavedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + saved.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + } + + @TestConfiguration + public static class HttpEventConfiguration { + @Bean + public HttpTreeEventListener httpTreeEventListener() { + return new HttpTreeEventListener(); + } + } + @TestComponent @RestController @RequestMapping("/tree") diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeEntityEventValidationRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeEntityEventValidationRegressionTest.java new file mode 100644 index 000000000..239b487e0 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeEntityEventValidationRegressionTest.java @@ -0,0 +1,301 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.web.bean.FastBeanCopier; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityBeforeCreateEvent; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; +import org.hswebframework.web.crud.events.EntityBeforeSaveEvent; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** 正式实体事件共用业务规则,纯 UPDATE 及显式 child 拒绝保持完整原事务回滚。 */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, TreeEntityEventValidationRegressionTest.Configuration.class}, + properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=TreeEntityEventValidationRegressionTest" + }) +public class TreeEntityEventValidationRegressionTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String REJECTION = "business validation"; + + @Autowired private ValidatingTreeService service; + @Autowired private BusinessValidationListener listener; + + @Test + public void monoTargetRejectionUsesCompleteModifyAfter() { + verifyTargetRejection(false); + } + + @Test + public void entityTargetRejectionUsesCompleteModifyAfter() { + verifyTargetRejection(true); + } + + @Test + public void monoExistingChildRejectionRollsBackWrittenParent() { + verifyChildRejection(false, false); + } + + @Test + public void entityExistingChildRejectionRollsBackWrittenParent() { + verifyChildRejection(true, false); + } + + @Test + public void monoNewChildRejectionRollsBackWrittenParent() { + verifyChildRejection(false, true); + } + + @Test + public void entityNewChildRejectionRollsBackWrittenParent() { + verifyChildRejection(true, true); + } + + @Test + public void saveRejectsThroughTheSameBusinessValidation() { + TestTreeSortEntity rejected = node(IDGenerator.MD5.generate(), "forbidden-save"); + reset(); + expectRejection(service.save(rejected)); + assertEquals(rejected.getName(), listener.getBeforeSave(rejected.getId()).getName()); + service.findById(rejected.getId()).as(StepVerifier::create).verifyComplete(); + } + + @Test + public void insertRejectsThroughTheSameBusinessValidation() { + TestTreeSortEntity rejected = node(IDGenerator.MD5.generate(), "forbidden-create"); + reset(); + expectRejection(service.insert(rejected)); + assertEquals(rejected.getName(), listener.getBeforeCreate(rejected.getId()).getName()); + service.findById(rejected.getId()).as(StepVerifier::create).verifyComplete(); + } + + private void verifyTargetRejection(boolean entityOverload) { + List tree = savedTree(); + TestTreeSortEntity target = tree.get(1); + Map> before = snapshot(target.getGroupId()); + reset(); + TestTreeSortEntity patch = patch("forbidden-target"); + + expectRejection(update(target.getId(), patch, entityOverload)); + + TestTreeSortEntity afterInput = listener.getBeforeModify(target.getId()); + assertCompleteExistingAfter(target, "forbidden-target", afterInput); + assertEquals(before, snapshot(target.getGroupId())); + assertNull("existing UPDATE must not invoke Save", listener.getBeforeSave(target.getId())); + } + + private void verifyChildRejection(boolean entityOverload, boolean newChild) { + List tree = savedTree(); + TestTreeSortEntity target = tree.get(1); + TestTreeSortEntity child = tree.get(2); + Map> before = snapshot(target.getGroupId()); + reset(); + service.expectWrittenParent(target.getId()); + TestTreeSortEntity patch = patch("root-after"); + TestTreeSortEntity childPatch = patch(newChild ? "forbidden-new-child" : "forbidden-existing-child"); + if (newChild) { + childPatch.setGroupId(target.getGroupId()); + childPatch.setDefaultTest("business-before"); + } else { + childPatch.setId(child.getId()); + } + patch.setChildren(List.of(childPatch)); + + expectRejection(update(target.getId(), patch, entityOverload)); + + TestTreeSortEntity writtenParent = service.getParentAtRejection(); + assertNotNull("child validation must read the preceding real parent UPDATE", writtenParent); + assertEquals(target.getId(), writtenParent.getId()); + assertEquals("root-after", writtenParent.getName()); + assertEquals(target.getPath(), writtenParent.getPath()); + assertCompleteExistingAfter(target, "root-after", listener.getBeforeModify(target.getId())); + if (newChild) { + assertNotNull(childPatch.getId()); + TestTreeSortEntity saveInput = listener.getBeforeSave(childPatch.getId()); + assertNotNull("explicit new child must use the formal Save boundary", saveInput); + assertEquals("forbidden-new-child", saveInput.getName()); + assertEquals("business-before", saveInput.getDefaultTest()); + assertEquals(target.getId(), saveInput.getParentId()); + assertTrue(saveInput.getPath().startsWith(target.getPath() + "-")); + assertEquals(Integer.valueOf(target.getLevel() + 1), saveInput.getLevel()); + service.findById(childPatch.getId()).as(StepVerifier::create).verifyComplete(); + } else { + assertCompleteExistingAfter(child, "forbidden-existing-child", listener.getBeforeModify(child.getId())); + assertNull("existing child UPDATE must not invoke Save", listener.getBeforeSave(child.getId())); + } + assertEquals("the original transaction must restore all business and tree columns", before, + snapshot(target.getGroupId())); + } + + private List savedTree() { + String group = IDGenerator.MD5.generate(); + TestTreeSortEntity outer = node(group, "outer-before"); + TestTreeSortEntity root = node(group, "root-before"); + TestTreeSortEntity child = node(group, "child-before"); + TestTreeSortEntity leaf = node(group, "leaf-before"); + outer.setChildren(List.of(root)); + root.setChildren(List.of(child)); + child.setChildren(List.of(leaf)); + service.save(outer).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + return service.findById(List.of(outer.getId(), root.getId(), child.getId(), leaf.getId())) + .collectMap(TestTreeSortEntity::getId) + .map(stored -> List.of(stored.get(outer.getId()), stored.get(root.getId()), + stored.get(child.getId()), stored.get(leaf.getId()))) + .block(TIMEOUT); + } + + private Map> snapshot(String group) { + return service.getRepository().createQuery().where("groupId", group).fetch() + .collectMap(TestTreeSortEntity::getId, entity -> Arrays.asList(entity.getParentId(), entity.getPath(), + entity.getLevel(), entity.getSortIndex(), entity.getName(), entity.getDefaultTest(), entity.getGroupId())) + .block(TIMEOUT); + } + + private Mono update(String id, TestTreeSortEntity patch, boolean entityOverload) { + return entityOverload ? service.updateById(id, patch) : service.updateById(id, Mono.just(patch)); + } + + private void reset() { + listener.reset(); + service.expectWrittenParent(null); + } + + private static void expectRejection(Mono operation) { + operation.as(StepVerifier::create).expectErrorMatches(error -> + error instanceof IllegalArgumentException && REJECTION.equals(error.getMessage())).verify(TIMEOUT); + } + + private static void assertCompleteExistingAfter(TestTreeSortEntity previous, String name, + TestTreeSortEntity afterInput) { + assertNotNull("formal Modify must provide complete after input", afterInput); + assertEquals(previous.getId(), afterInput.getId()); + assertEquals(name, afterInput.getName()); + assertEquals(previous.getDefaultTest(), afterInput.getDefaultTest()); + assertEquals(previous.getGroupId(), afterInput.getGroupId()); + assertEquals(previous.getParentId(), afterInput.getParentId()); + assertEquals(previous.getPath(), afterInput.getPath()); + assertEquals(previous.getLevel(), afterInput.getLevel()); + assertEquals(previous.getSortIndex(), afterInput.getSortIndex()); + } + + private static TestTreeSortEntity node(String group, String name) { + TestTreeSortEntity entity = patch(name); + entity.setId(IDGenerator.MD5.generate()); + entity.setGroupId(group); + entity.setDefaultTest("business-before"); + entity.setSortIndex(17L); + return entity; + } + + private static TestTreeSortEntity patch(String name) { + TestTreeSortEntity entity = new TestTreeSortEntity(); + entity.setName(name); + return entity; + } + + private static TestTreeSortEntity copy(TestTreeSortEntity entity) { + return FastBeanCopier.copy(entity, new TestTreeSortEntity()); + } + + /** 业务规则由 Service 持有,正式 Create/Save/Modify 监听器共用同一函数。 */ + public static class ValidatingTreeService extends TestTreeSortEntityService { + private volatile String expectedParentId; + private final AtomicReference parentAtRejection = new AtomicReference<>(); + + public void expectWrittenParent(String id) { + expectedParentId = id; + parentAtRejection.set(null); + } + + public TestTreeSortEntity getParentAtRejection() { + return parentAtRejection.get(); + } + + public Mono validateEntities(List entities) { + return Flux.fromIterable(entities).concatMap(entity -> { + if (!entity.getName().startsWith("forbidden-")) { + return Mono.empty(); + } + String parentId = expectedParentId; + Mono parentRead = parentId == null ? Mono.empty() + : findById(parentId).doOnNext(parent -> parentAtRejection.set(copy(parent))) + .switchIfEmpty(Mono.error(new AssertionError("updated parent must exist"))).then(); + return parentRead.then(Mono.error(new IllegalArgumentException(REJECTION))); + }).then(); + } + } + + public static class BusinessValidationListener { + private final ValidatingTreeService service; + private final Map beforeCreate = new ConcurrentHashMap<>(); + private final Map beforeSave = new ConcurrentHashMap<>(); + private final Map beforeModify = new ConcurrentHashMap<>(); + + public BusinessValidationListener(ValidatingTreeService service) { + this.service = service; + } + + public void reset() { + beforeCreate.clear(); + beforeSave.clear(); + beforeModify.clear(); + } + + public TestTreeSortEntity getBeforeCreate(String id) { return beforeCreate.get(id); } + public TestTreeSortEntity getBeforeSave(String id) { return beforeSave.get(id); } + public TestTreeSortEntity getBeforeModify(String id) { return beforeModify.get(id); } + + @EventListener + public void beforeCreate(EntityBeforeCreateEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.getEntity().forEach(entity -> beforeCreate.put(entity.getId(), copy(entity))); + event.async(service.validateEntities(event.getEntity())); + } + } + + @EventListener + public void beforeSave(EntityBeforeSaveEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.getEntity().forEach(entity -> beforeSave.put(entity.getId(), copy(entity))); + event.async(service.validateEntities(event.getEntity())); + } + } + + @EventListener + public void beforeModify(EntityBeforeModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + event.getAfter().forEach(entity -> beforeModify.put(entity.getId(), copy(entity))); + event.async(service.validateEntities(event.getAfter())); + } + } + } + + @TestConfiguration + public static class Configuration { + @Bean public ValidatingTreeService eventValidationTreeService() { return new ValidatingTreeService(); } + @Bean public BusinessValidationListener businessValidationListener(ValidatingTreeService service) { + return new BusinessValidationListener(service); + } + } +} From 3bf1a764f66fd0b417345d0784f6d845ebde22d8 Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Tue, 13 Oct 2026 11:20:38 +0800 Subject: [PATCH 16/17] =?UTF-8?q?fix(=E6=A0=91CRUD)!:=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=A0=B9=E6=A0=87=E8=AE=B0=E5=8F=8A=E5=90=8C=E6=AD=A5=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=9B=B4=E6=96=B0=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 Save 重载复用现有树准备;updateById 保持纯 UPDATE 和正式 Modify 事件。补齐根标记、保存入口、缺失目标与原事务回滚的真实数据库验证。 BREAKING CHANGE: 同步树 updateById 不再调用 save(E) 覆写,既有更新校验应迁移到正式 Modify 实体事件;save(E) 和 save(Collection) 执行完整树关系保存。 --- CONTRIBUTING.md | 24 +- .../ReactiveTreeSortEntityService.java | 2 +- .../crud/service/TreeSortEntityService.java | 28 +- .../crud/service/TreeSortServiceHelper.java | 1 + .../service/SyncTreeCrudRegressionTest.java | 2 +- .../SyncTreeEntryPointContractTest.java | 316 ++++++++++++++++++ .../web/crud/service/TreeCrudTestSupport.java | 90 ++++- .../service/TreeRootMarkerRegressionTest.java | 168 ++++++++++ 8 files changed, 615 insertions(+), 16 deletions(-) create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java create mode 100644 hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 945ee43c9..daa0c7238 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ 取消执行后等待事务 `afterCompletion` 再查询数据库。 实体事件失败与调用方失败使用独立分支。事件失败分支必须由真实 -`EntityCreatedEvent`、`EntitySavedEvent` 或 `EntityDeletedEvent` 的监听器拒绝触发, +`EntityCreatedEvent`、`EntitySavedEvent`、`EntityModifyEvent` 或 `EntityDeletedEvent` 的监听器拒绝触发, 不能额外抛出调用方异常使测试误通过。同步删除的 after 监听器还核对原事务内 SQL 已执行,再验证事件拒绝使整棵子树及调用方先前写入一起恢复。 @@ -38,7 +38,7 @@ 正修改/零修改结果、缓存清除和追加 hook 顺序;`ScopedReactiveTreeSortEntityServiceTest` 验证普通更新的调用方事务与树删除自身事务。 -这些用例维持现有入口差异:同步树准备仅覆盖 `insert(Collection)`/`save(List)`, +这些用例维持入口契约:同步树准备覆盖 `insert(Collection)` 及 `save(E)`、`save(Collection)`、`save(List)`, 不把普通同步 `updateById` 定义为结构修图入口;没有指定排序的查询不保证顺序, 同步删除不要求叶先执行。路径容量边界按实际实体 schema 验证,不扩大 schema。 @@ -58,7 +58,7 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含 公开 `PATCH /资源` 是批量 Save/upsert,已有 ID 保存、不存在的 ID 新增; `PUT /资源/{id}` 是正式 `updateById` 部分更新入口,null 沿用持久父级和业务字段。 既有节点的名称等稀疏修改应使用更新入口,完整 Save 必须表达本次需要的树关系。 -同步树准备覆盖 `insert(Collection)` / `save(List)`,普通同步 `updateById` 不提供 +同步树准备覆盖 `insert(Collection)` 及全部 Save 入口,普通同步 `updateById` 不提供 结构修图;响应式树 `updateById` 维护显式关系变化及遗漏后代的必要树字段。 实现位于 `hsweb-commons/hsweb-commons-crud` 的 `TreeSortServiceHelper`、 @@ -67,6 +67,20 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含 R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保留关系,及 PATCH 完整 null 父级移根、保留遗漏后代和同批不存在 ID 新增。 +根标记 `null`、空串和 `-1` 由 `parentId` 表达;实体自身 ID 为 `-1` 且拥有合法父级时, +仍按普通节点维护。共用 helper 使用服务的根节点判定排除根标记,缺失真实父级和循环 +继续沿用原有拒绝规则。 + +同步 `save(E)`、`save(Collection)` 委托现有 `save(List)`,维护同一完整树关系及遗漏 +后代。同步 `updateById` 直接执行仓储 UPDATE,缺失目标返回零、不新增,普通业务字段 +部分更新通过正式 Modify before/after 校验,该入口不承担同步结构 patch。已发布 +`save(E)` 覆写中的既有更新业务校验须迁移至正式 Modify 事件;Save 覆写只拥有保存入口。 + +`TreeRootMarkerRegressionTest` 与 `SyncTreeEntryPointContractTest` 使用真实 R2DBC / JDBC +H2、正式实体事件和共用持久树断言,覆盖根标记、三个同步 Save 入口、缺失/既有目标 +UPDATE,以及正式同步 Modify after 拒绝使完整目标快照及调用方先前写入在原事务中回滚。 +这两个测试类的 15 个用例均通过。 + ## 树 updateById 的业务校验边界 响应式树 `updateById` 对目标及显式既有 child 保持纯 UPDATE,使用正式 Modify 的 @@ -83,11 +97,11 @@ R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保 Create/Save 共用校验。正式 Modify after 包含持久业务列和完整树字段,child 拒绝前 读取实际已写父节点,拒绝后原事务恢复整个持久快照。 -框架本次定向集成验证共 15 组、109 个用例:108 通过、1 个既有跳过、0 失败、0 错误; +框架定向集成验证共 17 组、124 个用例:123 通过、1 个既有跳过、0 失败、0 错误; 跳过项为 `EntityEventListenerTest.testInsertError`。验证期间输入、依赖和执行入口保持 稳定,结果检查通过。这一结果只覆盖框架选定范围;普通树并发边界见下节。 以上契约的定向复验入口: -`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=ExplicitTreeUpdateRegressionTest,SyncTreeCrudRegressionTest,ScopedReactiveTreeSortEntityServiceTest,TreeEntityEventValidationRegressionTest -Dsurefire.failIfNoSpecifiedTests=false test`。 +`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=ExplicitTreeUpdateRegressionTest,SyncTreeCrudRegressionTest,ScopedReactiveTreeSortEntityServiceTest,TreeEntityEventValidationRegressionTest,TreeRootMarkerRegressionTest,SyncTreeEntryPointContractTest -Dsurefire.failIfNoSpecifiedTests=false test`。 ## 普通树删除的并发边界 diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index c643d5b22..116ff1d04 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -338,7 +338,7 @@ default Predicate createRootNodePredicate(TreeSupportEntity.TreeHelper } default boolean isRootNode(E entity) { - return ObjectUtils.isEmpty(entity.getParentId()) || "-1".equals(String.valueOf(entity.getId())); + return ObjectUtils.isEmpty(entity.getParentId()) || "-1".equals(String.valueOf(entity.getParentId())); } /** diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java index 21b49a8d5..414e7a697 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java @@ -1,23 +1,21 @@ package org.hswebframework.web.crud.service; import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; -import org.hswebframework.utils.RandomUtil; import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.api.crud.entity.TransactionManagers; import org.hswebframework.web.api.crud.entity.TreeSortSupportEntity; import org.hswebframework.web.api.crud.entity.TreeSupportEntity; import org.hswebframework.web.id.IDGenerator; -import org.reactivestreams.Publisher; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; import java.util.function.Predicate; import java.util.stream.Collectors; -import java.util.stream.Stream; /** * @param TreeSortSupportEntity @@ -86,6 +84,18 @@ default int insert(Collection entityPublisher) { .orElse(0); } + @Override + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) + default SaveResult save(E entity) { + return save(Collections.singletonList(entity)); + } + + @Override + @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) + default SaveResult save(Collection entities) { + return save(new ArrayList<>(entities)); + } + /** * 按提交实体保存完整树关系,已有节点的 null parentId 表示移根。 * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 @@ -116,11 +126,15 @@ default SaveResult save(List entities) { .orElse(SaveResult.of(0,0)); } + /** + * 仅更新已有目标,通过正式 Modify 实体事件执行业务校验,缺失目标返回零。 + * 不重算树关系;树关系变更应通过完整 Save 提交。 + */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.jdbcTransactionManager) default int updateById(K id, E entity) { entity.setId(id); - return this.save(entity).getTotal(); + return getRepository().updateById(id, entity); } @Override diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index eeb7d744c..8ddc6631b 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -291,6 +291,7 @@ private Mono checkParentId() { Set readyToCheck = thisTime .values() .stream() + .filter(value -> !isRootNode(value)) .map(TreeSupportEntity::getParentId) .filter(e -> !ObjectUtils.isEmpty(e) && !allData.containsKey(e)) .collect(Collectors.toSet()); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java index 0df71c808..b78f2ac5c 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java @@ -82,7 +82,7 @@ public void updateByIdNamePatchKeepsParentAndPersistedSubtree() { patch.setName("renamed branch"); patch.setDefaultTest(branch.getDefaultTest()); - // 此入口继承 save(E) 的普通仓储保存,不把它误当成 save(List) 的树准备路径。 + // 同步部分 UPDATE 保留原有树关系,此入口不扩展为结构 patch。 assertEquals(1, service.updateById(branch.getId(), patch)); TestTreeSortEntity after = stored(branch.getId()); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java new file mode 100644 index 000000000..907e586dc --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java @@ -0,0 +1,316 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.SyncRepository; +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * 同步树 Save 的静态类型入口共享完整关系,普通 updateById 保持纯 UPDATE。 + * 复用实际 JDBC/H2 配置和持久树断言,通过正式事件观察写入边界,不覆盖结构 patch。 + * + * @see TreeSortEntityService + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = {SyncTreeCrudRegressionTest.JdbcConfiguration.class, + SyncTreeEntryPointContractTest.EventConfiguration.class}) +@TestPropertySource(properties = {"easyorm.dialect=h2", "easyorm.default-schema=PUBLIC"}) +public class SyncTreeEntryPointContractTest { + @Autowired private SyncTreeCrudRegressionTest.JdbcTreeService service; + @Autowired private TreeCrudTestSupport.EntityEvents events; + @Autowired private RejectedModifyEvents rejectedModify; + + @Autowired + @Qualifier("transactionManager") + private PlatformTransactionManager transactionManager; + + @Test + public void entitySaveMovesBranchAndRecalculatesOmittedLeaf() { verifySaveMove("entity"); } + + @Test + public void collectionSaveMovesBranchAndRecalculatesOmittedLeaf() { verifySaveMove("collection"); } + + @Test + public void listSaveMovesBranchAndRecalculatesOmittedLeaf() { verifySaveMove("list"); } + + @Test + public void insertMinusOneParentRootKeepsNestedChild() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, "-1", "root", 1L); + TestTreeSortEntity child = TreeCrudTestSupport.node(group, null, "child", 101L); + root.setChildren(List.of(child)); + events.reset(); + // -1 是声明根标记,夹具没有 ID=-1 的虚拟父记录。 + service.insert(root); + List after = stored(group); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), "-1"); + parents.put(child.getId(), root.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents, Set.of(root.getId())); + Map> snapshots = TreeCrudTestSupport.snapshot(after); + for (String id : parents.keySet()) { + assertEquals(snapshots.get(id), events.getBeforeCreate(id)); + assertEquals(snapshots.get(id), events.getCreated(id)); + } + } + + @Test + public void listSaveMinusOneParentMovesExistingBranchAndOmittedLeafToRoot() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + assertEquals(3, service.insert(List.of(root, branch, leaf))); + List before = stored(group); + Map> business = TreeCrudTestSupport.businessSnapshot(before); + TestTreeSortEntity moving = service.findById(branch.getId()).orElseThrow(AssertionError::new); + assertNull(moving.getChildren()); + moving.setParentId("-1"); + events.reset(); + + SaveResult result = service.save(List.of(moving)); + + List after = stored(group); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), "-1"); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents, Set.of(root.getId(), branch.getId())); + assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); + assertEquals(2, result.getTotal()); + assertSavedSnapshots(after, Set.of(branch.getId(), leaf.getId())); + } + + @Test + public void missingUpdateTargetReturnsZeroWithoutCreatingRow() { + String group = IDGenerator.RANDOM.generate(); + String missingId = IDGenerator.RANDOM.generate(); + TestTreeSortEntity patch = TreeCrudTestSupport.node(group, null, "missing target", 1L); + events.reset(); + + int result = service.updateById(missingId, patch); + + List after = stored(group); + assertTrue("updateById must not create its missing target", service.findById(missingId).isEmpty()); + assertEquals(0, result); + assertTrue(after.isEmpty()); + assertNull(events.getBeforeSave(missingId)); + assertNull(events.getSaved(missingId)); + } + + @Test + public void existingNamePatchUsesCompleteModifyWithoutSave() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + assertEquals(3, service.insert(List.of(root, branch, leaf))); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("renamed branch"); + patch.setDefaultTest(branch.getDefaultTest()); + // 合法必填业务值不变,遗漏 groupId 与树字段保持稀疏更新,不扩展为结构 patch。 + events.reset(); + + int result = service.updateById(branch.getId(), patch); + + List after = stored(group); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), root.getId()); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents); + Map> snapshots = TreeCrudTestSupport.snapshot(after); + assertEquals(before.get(root.getId()), snapshots.get(root.getId())); + assertEquals(before.get(leaf.getId()), snapshots.get(leaf.getId())); + TestTreeSortEntity updated = service.findById(branch.getId()).orElseThrow(AssertionError::new); + assertEquals("renamed branch", updated.getName()); + assertEquals(branch.getDefaultTest(), updated.getDefaultTest()); + assertEquals(before.get(branch.getId()).get(1), updated.getPath()); + assertEquals(1, result); + assertEquals(snapshots.get(branch.getId()), events.getBeforeModify(branch.getId())); + assertEquals(snapshots.get(branch.getId()), events.getModified(branch.getId())); + assertNull(events.getBeforeSave(branch.getId())); + assertNull(events.getSaved(branch.getId())); + } + + @Test + public void modifiedEventRejectionRollsBackTargetAndCallerTransaction() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + assertEquals(3, service.insert(List.of(root, branch, leaf))); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity callerRow = TreeCrudTestSupport.node(group, null, "caller row", 2L); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setName("rejected branch name"); + patch.setDefaultTest(branch.getDefaultTest()); + List expectedTarget = new ArrayList<>(before.get(branch.getId())); + expectedTarget.set(3, patch.getName()); + events.reset(); + rejectedModify.arm(branch.getId(), callerRow.getId()); + + try { + IllegalStateException rejection = assertThrows(IllegalStateException.class, + () -> new TransactionTemplate(transactionManager).execute(status -> { + service.insert(callerRow); + return service.updateById(branch.getId(), patch); + })); + + assertEquals("reject modified tree event", rejection.getMessage()); + assertEquals(before.get(branch.getId()), rejectedModify.eventBefore); + assertEquals(expectedTarget, rejectedModify.eventAfter); + assertEquals("the rejecting Modify event must observe updated JDBC SQL", + expectedTarget, rejectedModify.persistedTarget); + assertEquals("the event must observe the caller write in the original transaction", + TreeCrudTestSupport.snapshot(List.of(callerRow)).get(callerRow.getId()), + rejectedModify.persistedCaller); + assertEquals(expectedTarget, events.getBeforeModify(branch.getId())); + assertEquals(expectedTarget, events.getModified(branch.getId())); + assertNull(events.getBeforeSave(branch.getId())); + assertNull(events.getSaved(branch.getId())); + assertEquals(before, TreeCrudTestSupport.snapshot(stored(group))); + assertTrue(service.findById(callerRow.getId()).isEmpty()); + } finally { + rejectedModify.disarm(); + } + } + + private void verifySaveMove(String entryPoint) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity oldRoot = TreeCrudTestSupport.node(group, null, "old root", 1L); + TestTreeSortEntity newRoot = TreeCrudTestSupport.node(group, null, "new root", 2L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, oldRoot.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + assertEquals(4, service.insert(List.of(oldRoot, newRoot, branch, leaf))); + List before = stored(group); + Map> business = TreeCrudTestSupport.businessSnapshot(before); + Map> beforeSnapshots = TreeCrudTestSupport.snapshot(before); + TestTreeSortEntity moving = service.findById(branch.getId()).orElseThrow(AssertionError::new); + assertNull(moving.getChildren()); + moving.setParentId(newRoot.getId()); + events.reset(); + + SaveResult result; + switch (entryPoint) { + case "entity": result = service.save(moving); break; + case "collection": + Collection collection = List.of(moving); + result = service.save(collection); + break; + case "list": + List list = List.of(moving); + result = service.save(list); + break; + default: throw new AssertionError(entryPoint); + } + + List after = stored(group); + Map parents = new LinkedHashMap<>(); + parents.put(oldRoot.getId(), null); + parents.put(newRoot.getId(), null); + parents.put(branch.getId(), newRoot.getId()); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents); + assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); + Map> afterSnapshots = TreeCrudTestSupport.snapshot(after); + assertEquals(beforeSnapshots.get(oldRoot.getId()), afterSnapshots.get(oldRoot.getId())); + assertEquals(beforeSnapshots.get(newRoot.getId()), afterSnapshots.get(newRoot.getId())); + assertEquals(2, result.getTotal()); + assertSavedSnapshots(after, Set.of(branch.getId(), leaf.getId())); + assertEquals(Set.of(newRoot.getId(), branch.getId(), leaf.getId()), + TreeCrudTestSupport.ids(service.queryIncludeChildren(List.of(newRoot.getId())))); + } + + private List stored(String group) { + return service.query(TreeCrudTestSupport.query(group)); + } + + private void assertSavedSnapshots(List after, Set savedIds) { + Map> snapshots = TreeCrudTestSupport.snapshot(after); + for (String id : savedIds) { + assertEquals(snapshots.get(id), events.getBeforeSave(id)); + assertEquals(snapshots.get(id), events.getSaved(id)); + } + } + + /** 在正式 Modify after 中读取原事务 SQL,拒绝只能由该实体事件触发。 */ + static class RejectedModifyEvents { + private final SyncRepository repository; + private String targetId; + private String callerId; + private List eventBefore; + private List eventAfter; + private List persistedTarget; + private List persistedCaller; + + RejectedModifyEvents(SyncRepository repository) { + this.repository = repository; + } + + void arm(String targetId, String callerId) { + this.targetId = targetId; + this.callerId = callerId; + eventBefore = null; + eventAfter = null; + persistedTarget = null; + persistedCaller = null; + } + + void disarm() { + targetId = null; + callerId = null; + } + + @EventListener + public void modified(EntityModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class && targetId != null + && event.getAfter().stream().anyMatch(node -> targetId.equals(node.getId()))) { + event.async(Mono.fromRunnable(() -> { + eventBefore = TreeCrudTestSupport.snapshot(event.getBefore()).get(targetId); + eventAfter = TreeCrudTestSupport.snapshot(event.getAfter()).get(targetId); + persistedTarget = TreeCrudTestSupport.snapshot(List.of(repository.findById(targetId) + .orElseThrow(AssertionError::new))).get(targetId); + persistedCaller = TreeCrudTestSupport.snapshot(List.of(repository.findById(callerId) + .orElseThrow(AssertionError::new))).get(callerId); + throw new IllegalStateException("reject modified tree event"); + })); + } + } + } + + @Configuration(proxyBeanMethods = false) + static class EventConfiguration { + @Bean TreeCrudTestSupport.EntityEvents syncEntryPointEvents() { + return new TreeCrudTestSupport.EntityEvents(); + } + + @Bean RejectedModifyEvents syncRejectedModifyEvents(SyncRepository repository) { + return new RejectedModifyEvents(repository); + } + } +} diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java index 38c1d65c3..8e4c26291 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java @@ -2,7 +2,14 @@ import org.hswebframework.web.api.crud.entity.QueryParamEntity; import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.events.EntityBeforeCreateEvent; +import org.hswebframework.web.crud.events.EntityBeforeModifyEvent; +import org.hswebframework.web.crud.events.EntityBeforeSaveEvent; +import org.hswebframework.web.crud.events.EntityCreatedEvent; +import org.hswebframework.web.crud.events.EntityModifyEvent; +import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.id.IDGenerator; +import org.springframework.context.event.EventListener; import java.util.ArrayDeque; import java.util.ArrayList; @@ -15,6 +22,7 @@ import java.util.Map; import java.util.Queue; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -85,6 +93,14 @@ static Map subset(Map parents, Collection nodes, Map parents) { + Set roots = parents.entrySet().stream().filter(entry -> entry.getValue() == null) + .map(Map.Entry::getKey).collect(Collectors.toSet()); + assertPersistedTree(nodes, parents, roots); + } + + /** 根节点由场景明确声明,保留原始 parentId 断言,不借用生产根标记判定。 */ + static void assertPersistedTree(Collection nodes, Map parents, + Set roots) { assertEquals(parents.keySet(), ids(nodes)); Map actual = nodes.stream() .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); @@ -92,15 +108,17 @@ static void assertPersistedTree(Collection nodes, Map ancestors = new HashSet<>(); - for (String parent = parents.get(node.getId()); parent != null; parent = parents.get(parent)) { + for (String ancestor = node.getId(); !roots.contains(ancestor); ) { + String parent = parents.get(ancestor); assertTrue("expected parent is outside the fixture", parents.containsKey(parent)); assertTrue("expected graph contains a cycle", ancestors.add(parent)); level++; + ancestor = parent; } assertEquals(Integer.valueOf(level), node.getLevel()); assertNotNull(node.getPath()); assertEquals(4 + (level - 1) * 5, node.getPath().length()); - if (node.getParentId() != null) { + if (!roots.contains(node.getId())) { TestTreeSortEntity parent = actual.get(node.getParentId()); assertTrue(node.getPath().startsWith(parent.getPath() + "-")); } @@ -141,6 +159,74 @@ static TestTreeSortEntity returnedNode(List roots, String id throw new AssertionError("missing returned tree node " + id); } + /** 同步与响应式真实实体事件的执行级快照,操作完成后由测试读取。 */ + static class EntityEvents { + private final Map> beforeCreate = new ConcurrentHashMap<>(); + private final Map> created = new ConcurrentHashMap<>(); + private final Map> beforeSave = new ConcurrentHashMap<>(); + private final Map> saved = new ConcurrentHashMap<>(); + private final Map> beforeModify = new ConcurrentHashMap<>(); + private final Map> modified = new ConcurrentHashMap<>(); + + public void reset() { + beforeCreate.clear(); + created.clear(); + beforeSave.clear(); + saved.clear(); + beforeModify.clear(); + modified.clear(); + } + + public List getBeforeCreate(String id) { return beforeCreate.get(id); } + public List getCreated(String id) { return created.get(id); } + public List getBeforeSave(String id) { return beforeSave.get(id); } + public List getSaved(String id) { return saved.get(id); } + public List getBeforeModify(String id) { return beforeModify.get(id); } + public List getModified(String id) { return modified.get(id); } + + @EventListener + public void beforeCreate(EntityBeforeCreateEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + beforeCreate.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + + @EventListener + public void created(EntityCreatedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + created.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + + @EventListener + public void beforeSave(EntityBeforeSaveEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + beforeSave.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + + @EventListener + public void saved(EntitySavedEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + saved.putAll(TreeCrudTestSupport.snapshot(event.getEntity())); + } + } + + @EventListener + public void beforeModify(EntityBeforeModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + beforeModify.putAll(TreeCrudTestSupport.snapshot(event.getAfter())); + } + } + + @EventListener + public void modified(EntityModifyEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class) { + modified.putAll(TreeCrudTestSupport.snapshot(event.getAfter())); + } + } + } + static final class Forest { final String group = IDGenerator.RANDOM.generate(); final Map named = new LinkedHashMap<>(); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java new file mode 100644 index 000000000..14b0d4f17 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java @@ -0,0 +1,168 @@ +package org.hswebframework.web.crud.service; + +import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; +import org.hswebframework.web.crud.TestApplication; +import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.id.IDGenerator; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.junit4.SpringRunner; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Signal; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.*; + +/** + * 根标记通过 parentId 表达,实体自身 ID 不改变合法父子关系。 + * 每个场景使用独立业务组、真实 R2DBC H2 和正式实体事件,期望来自声明的父关系。 + * + * @see ReactiveTreeSortEntityService + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = {TestApplication.class, TestTreeSortEntityService.class, + TreeRootMarkerRegressionTest.EventConfiguration.class}, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=TreeRootMarkerRegressionTest" +}) +public class TreeRootMarkerRegressionTest { + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @Autowired private TestTreeSortEntityService service; + @Autowired private TreeCrudTestSupport.EntityEvents events; + + @Test + public void insertNullParentRootKeepsNestedChild() { verifyRootInsert(null); } + + @Test + public void insertEmptyParentRootKeepsNestedChild() { verifyRootInsert(""); } + + @Test + public void insertMinusOneParentRootKeepsNestedChild() { verifyRootInsert("-1"); } + + @Test + public void saveNullParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove(null); } + + @Test + public void saveEmptyParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove(""); } + + @Test + public void saveMinusOneParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove("-1"); } + + @Test + public void minusOneEntityIdMovesBetweenLegalParentsAsOrdinaryLeaf() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity oldRoot = TreeCrudTestSupport.node(group, null, "old root", 1L); + TestTreeSortEntity newRoot = TreeCrudTestSupport.node(group, null, "new root", 2L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, oldRoot.getId(), "ordinary leaf", 101L); + leaf.setId("-1"); + List ids = List.of(oldRoot.getId(), newRoot.getId(), leaf.getId()); + try { + assertEquals(Integer.valueOf(3), service.insert(Flux.fromIterable(List.of(oldRoot, newRoot, leaf))).block(TIMEOUT)); + Map parents = new LinkedHashMap<>(); + parents.put(oldRoot.getId(), null); + parents.put(newRoot.getId(), null); + parents.put(leaf.getId(), oldRoot.getId()); + List before = stored(group); + TreeCrudTestSupport.assertPersistedTree(before, parents); + Map> business = TreeCrudTestSupport.businessSnapshot(before); + TestTreeSortEntity moving = service.findById(leaf.getId()).block(TIMEOUT); + moving.setParentId(newRoot.getId()); + events.reset(); + + Signal result = service.save(moving).materialize().block(TIMEOUT); + + List after = stored(group); + assertFalse("a legal parent move must succeed: " + result.getThrowable(), result.isOnError()); + parents.put(leaf.getId(), newRoot.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents); + assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); + assertSavedSnapshots(after, Set.of(leaf.getId())); + } finally { + // 固定 ID 反例只清理明确输入,避免被测树路径影响后续独立场景。 + service.getRepository().createDelete().in("id", ids).execute().block(TIMEOUT); + } + } + + private void verifyRootInsert(String marker) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, marker, "root", 1L); + TestTreeSortEntity child = TreeCrudTestSupport.node(group, null, "child", 101L); + root.setChildren(List.of(child)); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), marker); + parents.put(child.getId(), root.getId()); + events.reset(); + + Signal result = service.insert(root).materialize().block(TIMEOUT); + + List after = stored(group); + assertFalse("a declared root must insert: " + result.getThrowable(), result.isOnError()); + assertEquals(Integer.valueOf(2), result.get()); + TreeCrudTestSupport.assertPersistedTree(after, parents, Set.of(root.getId())); + Map> snapshots = TreeCrudTestSupport.snapshot(after); + for (String id : parents.keySet()) { + assertEquals(snapshots.get(id), events.getBeforeCreate(id)); + assertEquals(snapshots.get(id), events.getCreated(id)); + } + } + + private void verifyRootMove(String marker) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + assertEquals(Integer.valueOf(3), service.insert(Flux.fromIterable(List.of(root, branch, leaf))).block(TIMEOUT)); + List before = stored(group); + Map> business = TreeCrudTestSupport.businessSnapshot(before); + Map> snapshot = TreeCrudTestSupport.snapshot(before); + TestTreeSortEntity moving = service.findById(branch.getId()).block(TIMEOUT); + assertNull(moving.getChildren()); + moving.setParentId(marker); + events.reset(); + + Signal result = service.save(moving).materialize().block(TIMEOUT); + + List after = stored(group); + assertFalse("an existing branch must move to a declared root: " + result.getThrowable(), result.isOnError()); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), marker); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(after, parents, Set.of(root.getId(), branch.getId())); + assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); + assertEquals(snapshot.get(root.getId()), TreeCrudTestSupport.snapshot(after).get(root.getId())); + assertEquals(2, result.get().getTotal()); + assertSavedSnapshots(after, Set.of(branch.getId(), leaf.getId())); + assertEquals(Set.of(branch.getId(), leaf.getId()), + TreeCrudTestSupport.ids(service.queryIncludeChildren(List.of(branch.getId())).collectList().block(TIMEOUT))); + } + + private List stored(String group) { + return service.query(TreeCrudTestSupport.query(group)).collectList().block(TIMEOUT); + } + + private void assertSavedSnapshots(List after, Set savedIds) { + Map> snapshots = TreeCrudTestSupport.snapshot(after); + for (String id : savedIds) { + assertEquals(snapshots.get(id), events.getBeforeSave(id)); + assertEquals(snapshots.get(id), events.getSaved(id)); + } + } + + @TestConfiguration + public static class EventConfiguration { + @Bean public TreeCrudTestSupport.EntityEvents rootMarkerEvents() { + return new TreeCrudTestSupport.EntityEvents(); + } + } +} From 0748ee91580406bbfac305ca29eb5ede127ba74b Mon Sep 17 00:00:00 2001 From: zhou-hao Date: Tue, 20 Oct 2026 10:25:31 +0800 Subject: [PATCH 17/17] =?UTF-8?q?fix(=E6=A0=91=E7=BB=93=E6=9E=84):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=A9=BA=E7=88=B6=E7=BA=A7=E4=B8=8E=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E6=A0=B9=E6=A0=87=E8=AE=B0=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 35 +-- .../ReactiveTreeSortEntityService.java | 20 +- .../crud/service/TreeSortEntityService.java | 21 +- .../crud/service/TreeSortServiceHelper.java | 20 +- .../ExplicitTreeUpdateRegressionTest.java | 13 +- ...opedReactiveTreeSortEntityServiceTest.java | 29 ++- .../service/SyncTreeCrudRegressionTest.java | 6 +- .../SyncTreeEntryPointContractTest.java | 220 ++++++++++++++++-- .../SyncTreeSortServiceHelperTest.java | 4 +- .../web/crud/service/TreeCrudTestSupport.java | 77 ++++++ .../service/TreeRootMarkerRegressionTest.java | 196 +++++++++++++++- 11 files changed, 537 insertions(+), 104 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index daa0c7238..84e6ba0da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,14 +50,21 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含 ## 树 Save 与部分更新的契约 -树 save 按提交实体的完整树关系保存:已有节点的 `parentId=null` 表示移根, -共用 helper 维护 path/level 和原有后代;父级清空 SQL 与正式 Modify/Save 事件参与同一 -事务。遗漏 children 不自动删除后代。业务列的 null 仍遵循现有 ORM 忽略 null、生成 -默认值等规则,这一契约不提供整实体的全量 Replace。 +树 Save 和响应式 `updateById` 对已有节点的 `parentId=null` 或缺省沿用持久父级; +新增节点没有父级时仍为根。显式根标记默认使用空串或 `-1`,由服务现有的 +`isRootNode(E)` 扩展点判定,自定义标记沿用同一规则。历史 `parentId=null` 的根继续 +识别。嵌套 `children` 表达的父子关系优先于子节点自身输入或原持久父级。 + +共用 helper 只补齐已有 Save 节点未指定的父级,再维护 path/level 和原有后代; +不把完整 Save 改造成部分更新。遗漏 children 不自动删除后代。业务列的 null 仍遵循 +现有 ORM 忽略 null、生成默认值等规则,这一契约不提供整实体的全量 Replace。 +显式标记与最终业务输入一起通过正式 Save 写入,不先执行父级清空或路径预更新。 +BeforeSave 读取数据库时看到旧父级,同时接收最终准备好的移根及改名输入;Save 不额外 +发布 Modify 事件,Saved 事件拒绝仍回滚整个操作、所有批次及调用方事务中的先前写入。 公开 `PATCH /资源` 是批量 Save/upsert,已有 ID 保存、不存在的 ID 新增; `PUT /资源/{id}` 是正式 `updateById` 部分更新入口,null 沿用持久父级和业务字段。 -既有节点的名称等稀疏修改应使用更新入口,完整 Save 必须表达本次需要的树关系。 +既有节点的名称等稀疏修改可省略父级;移根必须明确提交服务识别的非 null 根标记。 同步树准备覆盖 `insert(Collection)` 及全部 Save 入口,普通同步 `updateById` 不提供 结构修图;响应式树 `updateById` 维护显式关系变化及遗漏后代的必要树字段。 @@ -65,9 +72,9 @@ before/after/error 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含 `TreeSortEntityService`、`ReactiveTreeSortEntityService`;公开路由由 `ReactiveServiceSaveController` 提供。`ExplicitTreeUpdateRegressionTest` 通过真实 R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保留关系,及 PATCH -完整 null 父级移根、保留遗漏后代和同批不存在 ID 新增。 +显式根标记移根、保留遗漏后代和同批不存在 ID 新增。 -根标记 `null`、空串和 `-1` 由 `parentId` 表达;实体自身 ID 为 `-1` 且拥有合法父级时, +根标记由 `parentId` 表达;实体自身 ID 为 `-1` 且拥有合法父级时, 仍按普通节点维护。共用 helper 使用服务的根节点判定排除根标记,缺失真实父级和循环 继续沿用原有拒绝规则。 @@ -79,7 +86,8 @@ R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保 `TreeRootMarkerRegressionTest` 与 `SyncTreeEntryPointContractTest` 使用真实 R2DBC / JDBC H2、正式实体事件和共用持久树断言,覆盖根标记、三个同步 Save 入口、缺失/既有目标 UPDATE,以及正式同步 Modify after 拒绝使完整目标快照及调用方先前写入在原事务中回滚。 -这两个测试类的 15 个用例均通过。 +新增及调整回归同时覆盖三个同步 Save 入口、响应式稀疏 Save、自定义根标记、嵌套父级 +优先级和正式 Save 事件的可见性及回滚;这些回归已纳入下述集中验证。 ## 树 updateById 的业务校验边界 @@ -97,11 +105,12 @@ UPDATE,以及正式同步 Modify after 拒绝使完整目标快照及调用方 Create/Save 共用校验。正式 Modify after 包含持久业务列和完整树字段,child 拒绝前 读取实际已写父节点,拒绝后原事务恢复整个持久快照。 -框架定向集成验证共 17 组、124 个用例:123 通过、1 个既有跳过、0 失败、0 错误; -跳过项为 `EntityEventListenerTest.testInsertError`。验证期间输入、依赖和执行入口保持 -稳定,结果检查通过。这一结果只覆盖框架选定范围;普通树并发边界见下节。 -以上契约的定向复验入口: -`mvn -pl hsweb-commons/hsweb-commons-crud -am -Dtest=ExplicitTreeUpdateRegressionTest,SyncTreeCrudRegressionTest,ScopedReactiveTreeSortEntityServiceTest,TreeEntityEventValidationRegressionTest,TreeRootMarkerRegressionTest,SyncTreeEntryPointContractTest -Dsurefire.failIfNoSpecifiedTests=false test`。 +树 Save 根标记变更后的集中 `clean install` 验证通过:24 类、178 项, +0 失败、0 错误、0 跳过,覆盖真实 JDBC / R2DBC 树回归及认证兼容切片。 +树回归的定向复验入口为: +`mvn -pl hsweb-commons/hsweb-commons-crud -am '-Dtest=*Tree*Test,PlainCrudTransactionTest,GenericReactiveCacheSupportCrudServiceTest' -Dsurefire.failIfNoSpecifiedTests=false test`。 +该范围包含 Save 事件可见性、跨批 / 调用方事务回滚和纯 UPDATE 兼容。 +普通树并发边界见下节。 ## 普通树删除的并发边界 diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java index 116ff1d04..79bcbd222 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/ReactiveTreeSortEntityService.java @@ -22,7 +22,9 @@ import java.util.function.Predicate; /** - * 树形结构的通用增删改查服务 + * 响应式树 CRUD,Save 准备关系与遗漏后代后通过正式仓储 Save 写入。 + * null 父级沿用已有关系,显式根标记由 isRootNode 判定;UPDATE 保持正式 Modify 事件。 + * 每次订阅独立准备树快照,复合操作及全部批次共享事务。 * * @param TreeSortSupportEntity * @param ID @@ -205,7 +207,7 @@ default int getBufferSize() { } /** - * 按提交实体保存完整树关系,已有节点的 null parentId 表示移根。 + * 保存树关系,已有节点的 null parentId 沿用持久父级;显式根标记表达移根。 * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 */ @Override @@ -217,17 +219,7 @@ default Mono save(Publisher entityPublisher) { ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); return helper.prepare(Flux.from(entityPublisher)) .buffer(getBufferSize()) - .concatMap(batch -> Flux.fromIterable(batch) - .filter(helper::isMovingToRoot) - // ORM save 忽略普通 null;仅既有节点显式移根时,在原 save 前清空父级。 - // 同时写入已准备的 path/level,modify 与随后的 save 事件共享原事务。 - .concatMap(node -> getRepository().createUpdate() - .set("path", node.getPath()) - .set("level", node.getLevel()) - .setNull("parentId") - .where("id", node.getId()) - .execute()) - .then(getRepository().save(batch))) + .concatMap(getRepository()::save) .reduce(SaveResult::merge); }); return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); @@ -257,7 +249,7 @@ default Mono save(E data) { * 业务更新规则应监听 {@link EntityBeforeModifyEvent} 的 after;save(Publisher) 覆写只拥有 Save 入口。 * 新 child 经过服务 save,使用对应的 BeforeSave/BeforeCreate 规则;任一拒绝回滚整个树更新。 * 目标并发消失时返回零,不使用 Save/UPSERT 重建目标,也不继续写入 children。 - * String parentId 空串明确表达移根,null 沿用持久父级。 + * 非 null 的根标记由 isRootNode 判定,null 沿用持久父级。 */ @Override @Transactional(rollbackFor = Throwable.class, transactionManager = TransactionManagers.reactiveTransactionManager) diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java index 414e7a697..2de2a05da 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java @@ -18,6 +18,10 @@ import java.util.stream.Collectors; /** + * 同步树 CRUD,Save 准备关系与遗漏后代后通过正式仓储 Save 写入。 + * null 父级沿用已有关系,显式移根标记由 isRootNode 判定;普通 UPDATE 不重算树。 + * 所有批次和实体事件参与业务调用方的 JDBC 事务。 + * * @param TreeSortSupportEntity * @param ID * @see GenericReactiveTreeSupportCrudService @@ -97,7 +101,7 @@ default SaveResult save(Collection entities) { } /** - * 按提交实体保存完整树关系,已有节点的 null parentId 表示移根。 + * 保存树关系,已有节点的 null parentId 沿用持久父级;显式根标记表达移根。 * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 */ @Override @@ -107,20 +111,7 @@ default SaveResult save(List entities) { return helper .prepare(Flux.fromIterable(entities)) .buffer(getBufferSize()) - .map(batch -> { - for (E node : batch) { - if (helper.isMovingToRoot(node)) { - // ORM save 忽略普通 null;显式移根与随后保存的路径、实体事件共享原事务。 - getRepository().createUpdate() - .set("path", node.getPath()) - .set("level", node.getLevel()) - .setNull("parentId") - .where("id", node.getId()) - .execute(); - } - } - return getRepository().save(batch); - }) + .map(getRepository()::save) .reduce(SaveResult::merge) .blockOptional() .orElse(SaveResult.of(0,0)); diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java index 8ddc6631b..1cc3c005e 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortServiceHelper.java @@ -20,6 +20,11 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +/** + * 为同步及响应式树写入准备父子关系与路径;每次操作创建独立实例。 + * Save 只补齐已有节点未指定的父级,部分 UPDATE 另外维护稀疏业务输入。 + * 根标记委派服务的 isRootNode,准备阶段不执行持久化写入。 + */ public abstract class TreeSortServiceHelper, PK> { //包含子节点的数据 @@ -169,6 +174,14 @@ private Mono init(Flux source, boolean partialUpdate) { if (partialUpdate) { completeUpdateInput(); + } else { + // null 不表达移根;嵌套 children 已在展开时赋予明确父级,优先保留。 + thisTime.values().forEach(node -> { + E previous = oldData.get(node.getId()); + if (node.getParentId() == null && previous != null) { + node.setParentId(previous.getParentId()); + } + }); } readyToSave.putAll(thisTime); @@ -244,13 +257,6 @@ void updateSavedNodePath(E saved) { refactorChildPath(saved.getId(), saved.getPath(), this::refactor); } - /** 当前完整保存明确移根,且原持久化节点具有父级。 */ - protected boolean isMovingToRoot(E node) { - E previous = oldData.get(node.getId()); - return node.getParentId() == null && previous != null - && !ObjectUtils.isEmpty(previous.getParentId()); - } - private void initChildren() { childrenMapping.clear(); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java index 4a2431933..5e8f3ee93 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java @@ -167,7 +167,7 @@ public void publicPutSparseNameUpdatePreservesParentAndOmittedSubtree() { } @Test - public void publicPatchFullNullParentMovesSubtreeAndCreatesMissingId() { + public void publicPatchExplicitRootMarkerMovesSubtreeAndCreatesMissingId() { TestTreeSortEntity root = node("root-before"); TestTreeSortEntity child = node("child-before"); TestTreeSortEntity leaf = node("leaf-before"); @@ -181,7 +181,7 @@ public void publicPatchFullNullParentMovesSubtreeAndCreatesMissingId() { .block(TIMEOUT); Map> beforeSnapshot = TreeCrudTestSupport.snapshot(before.values()); TestTreeSortEntity complete = before.get(child.getId()); - complete.setParentId(null); + complete.setParentId(""); complete.setName("moved-root"); TestTreeSortEntity added = node("http-created-root"); service.findById(added.getId()).as(StepVerifier::create).verifyComplete(); @@ -196,7 +196,7 @@ public void publicPatchFullNullParentMovesSubtreeAndCreatesMissingId() { TestTreeSortEntity moved = after.get(child.getId()); TestTreeSortEntity movedLeaf = after.get(leaf.getId()); TestTreeSortEntity created = after.get(added.getId()); - assertNull(moved.getParentId()); + assertEquals("", moved.getParentId()); assertEquals("moved-root", moved.getName()); assertEquals("business-before", moved.getDefaultTest()); assertEquals(Long.valueOf(37), moved.getSortIndex()); @@ -221,11 +221,8 @@ public void publicPatchFullNullParentMovesSubtreeAndCreatesMissingId() { assertEquals(afterSnapshot.get(id), httpEvents.getSaved(id)); } assertNull(httpEvents.getBeforeSave(root.getId())); - assertNotNull("root clear retains the formal Modify boundary", httpEvents.getModified(child.getId())); - assertNull(httpEvents.getBeforeModify(child.getId()).get(0)); - assertNull(httpEvents.getModified(child.getId()).get(0)); - assertEquals(moved.getPath(), httpEvents.getModified(child.getId()).get(1)); - assertEquals(Integer.valueOf(1), httpEvents.getModified(child.getId()).get(2)); + assertNull("Save must not add a Modify boundary", httpEvents.getModified(child.getId())); + assertNull(httpEvents.getBeforeModify(child.getId())); }).expectComplete().verify(TIMEOUT); service.queryIncludeChildren(List.of(root.getId())).count() .as(StepVerifier::create).expectNext(1L).verifyComplete(); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java index 5026bda56..73264d684 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -278,7 +278,7 @@ public void fullEntitySaveMovesWholeSubtreeToRoot() { branch.setChildren(List.of(leaf)); unscoped.save(root).as(StepVerifier::create).expectNextCount(1).verifyComplete(); unscoped.findById(branch.getId()).flatMap(existing -> { - existing.setParentId(null); + existing.setParentId(""); return unscoped.save(existing); }).then(unscoped.queryIncludeChildren(List.of(root.getId())).count()) .as(StepVerifier::create).expectNext(1L).verifyComplete(); @@ -287,7 +287,7 @@ public void fullEntitySaveMovesWholeSubtreeToRoot() { assertEquals(Set.of(branch.getId(), leaf.getId()), ids(nodes))).verifyComplete(); Mono.zip(unscoped.findById(branch.getId()), unscoped.findById(leaf.getId())) .as(StepVerifier::create).assertNext(nodes -> { - assertNull(nodes.getT1().getParentId()); + assertEquals("", nodes.getT1().getParentId()); assertEquals(Integer.valueOf(1), nodes.getT1().getLevel()); assertEquals(branch.getId(), nodes.getT2().getParentId()); assertEquals(Integer.valueOf(2), nodes.getT2().getLevel()); @@ -298,8 +298,8 @@ public void fullEntitySaveMovesWholeSubtreeToRoot() { } @Test - public void explicitRootClearAndTreeSaveRollbackWithEventsAndCallerTransaction() { - for (String failure : List.of("modify", "save", "caller")) { + public void explicitRootMarkerAndTreeSaveRollbackWithEventsAndCallerTransaction() { + for (String failure : List.of("save", "caller")) { TestTreeSortEntity root = node(null, null, null); TestTreeSortEntity branch = node(null, root.getId(), null); branch.setName("reject-root-" + failure + "-" + id()); @@ -310,7 +310,7 @@ public void explicitRootClearAndTreeSaveRollbackWithEventsAndCallerTransaction() String branchPath = branch.getPath(); String leafPath = leaf.getPath(); Mono move = unscoped.findById(branch.getId()).flatMap(existing -> { - existing.setParentId(null); + existing.setParentId(""); return unscoped.save(existing); }); if ("caller".equals(failure)) { @@ -460,7 +460,7 @@ public void savingParentAndChildTogetherKeepsOmittedGrandchildInMovedSubtree() { .flatMap(pair -> { TestTreeSortEntity moved = pair.getT1(); TestTreeSortEntity suppliedChild = pair.getT2(); - moved.setParentId(moveToRoot ? null : target.getId()); + moved.setParentId(moveToRoot ? "" : target.getId()); // 两节点来自数据库,children为空;孙节点必须由持久图加载并随路径修复。 assertNull(moved.getChildren()); assertNull(suppliedChild.getChildren()); @@ -470,7 +470,7 @@ public void savingParentAndChildTogetherKeepsOmittedGrandchildInMovedSubtree() { assertEquals(5, tree.size()); assertTreeStructure(tree.values()); TestTreeSortEntity moved = tree.get(branch.getId()); - assertEquals(moveToRoot ? null : target.getId(), moved.getParentId()); + assertEquals(moveToRoot ? "" : target.getId(), moved.getParentId()); assertEquals(Integer.valueOf(moveToRoot ? 1 : 2), moved.getLevel()); assertEquals(root.getPath(), tree.get(root.getId()).getPath()); assertEquals(target.getPath(), tree.get(target.getId()).getPath()); @@ -599,11 +599,11 @@ public void repeatedSaveSubscriptionReadsFreshTreeAndEmptyWritesKeepExistingSema if (subscription == 1) { // 在两次订阅间改变真实持久图,复用Publisher不得沿用第一次helper快照。 scoped.findById(branch.getId()).flatMap(existing -> { - existing.setParentId(null); + existing.setParentId(""); return scoped.save(existing); }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { assertTreeStructure(tree.values()); - assertNull(tree.get(branch.getId()).getParentId()); + assertEquals("", tree.get(branch.getId()).getParentId()); assertEquals(Integer.valueOf(2), tree.get(leaf.getId()).getLevel()); }).verifyComplete(); } @@ -820,7 +820,7 @@ private static void assertTreeStructure(Collection nodes) { Map tree = nodes.stream() .collect(Collectors.toMap(TestTreeSortEntity::getId, node -> node)); for (TestTreeSortEntity node : nodes) { - if (node.getParentId() == null) { + if (node.getParentId() == null || node.getParentId().isEmpty()) { assertEquals(Integer.valueOf(1), node.getLevel()); } else { TestTreeSortEntity parent = tree.get(node.getParentId()); @@ -1020,8 +1020,7 @@ public void afterCreate(EntityCreatedEvent event) { @EventListener public void afterModify(EntityModifyEvent event) { if (event.getEntityType() == TestTreeSortEntity.class - && event.getAfter().stream().anyMatch(node -> node.getName().startsWith("reject-dsl-") - || (node.getParentId() == null && node.getName().startsWith("reject-root-modify-")))) { + && event.getAfter().stream().anyMatch(node -> node.getName().startsWith("reject-dsl-"))) { event.async(Mono.error(new IllegalStateException("reject modified tree"))); } } @@ -1033,12 +1032,12 @@ public void afterSave(EntitySavedEvent event) { } event.async(completeBatch(event.getEntity())); for (TestTreeSortEntity node : event.getEntity()) { - if (node.getParentId() == null && node.getName().startsWith("reject-root-save-")) { + if ("".equals(node.getParentId()) && node.getName().startsWith("reject-root-save-")) { event.async(Mono.error(new IllegalStateException("reject saved root"))); } - if (node.getParentId() == null && node.getName().startsWith("verify-root-")) { + if ("".equals(node.getParentId()) && node.getName().startsWith("verify-root-")) { event.async(repository.findById(node.getId()).doOnNext(stored -> { - assertNull(stored.getParentId()); + assertEquals("", stored.getParentId()); assertEquals(node.getPath(), stored.getPath()); assertEquals(Integer.valueOf(1), stored.getLevel()); observedRoots.incrementAndGet(); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java index b78f2ac5c..0b394282d 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java @@ -99,13 +99,13 @@ public void listSaveMovesBranchAndOmittedLeafToRootInDatabase() { TestTreeSortEntity branch = stored(tree.get(1).getId()); TestTreeSortEntity leaf = stored(tree.get(2).getId()); assertNull(branch.getChildren()); - branch.setParentId(null); + branch.setParentId(""); assertEquals(2, service.save(List.of(branch)).getTotal()); TestTreeSortEntity moved = stored(branch.getId()); TestTreeSortEntity movedLeaf = stored(leaf.getId()); - assertNull("a root path must be committed with a cleared parent ID", moved.getParentId()); + assertEquals("a root path must be committed with its explicit marker", "", moved.getParentId()); assertEquals(Integer.valueOf(1), moved.getLevel()); assertFalse(moved.getPath().contains("-")); assertEquals(moved.getId(), movedLeaf.getParentId()); @@ -128,7 +128,7 @@ public void listRootMoveRollsBackWithSavedEventOrCallerFailure() { TestTreeSortEntity branch = stored(tree.get(1).getId()); TestTreeSortEntity leaf = stored(tree.get(2).getId()); TestTreeSortEntity moved = stored(branch.getId()); - moved.setParentId(null); + moved.setParentId(""); if ("event".equals(failure)) { events.rejectedId = branch.getId(); } diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java index 907e586dc..034f42d30 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java @@ -40,7 +40,8 @@ @TestPropertySource(properties = {"easyorm.dialect=h2", "easyorm.default-schema=PUBLIC"}) public class SyncTreeEntryPointContractTest { @Autowired private SyncTreeCrudRegressionTest.JdbcTreeService service; - @Autowired private TreeCrudTestSupport.EntityEvents events; + @Autowired private MarkerTreeService marked; + @Autowired private TreeCrudTestSupport.SaveEvents events; @Autowired private RejectedModifyEvents rejectedModify; @Autowired @@ -56,6 +57,34 @@ public class SyncTreeEntryPointContractTest { @Test public void listSaveMovesBranchAndRecalculatesOmittedLeaf() { verifySaveMove("list"); } + @Test + public void entitySparseAndFullNullParentSaveKeepExistingRelation() { verifyNullParentSave("entity"); } + + @Test + public void collectionSparseAndFullNullParentSaveKeepExistingRelation() { verifyNullParentSave("collection"); } + + @Test + public void listSparseAndFullNullParentSaveKeepExistingRelation() { verifyNullParentSave("list"); } + + @Test + public void sparseSaveKeepsHistoricalNullRootAndOmittedChild() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "historical root", 1L); + TestTreeSortEntity child = TreeCrudTestSupport.node(group, null, "child", 101L); + root.setChildren(List.of(child)); + service.save(root); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity input = new TestTreeSortEntity(); + input.setId(root.getId()); + input.setName("renamed historical root"); + input.setDefaultTest(root.getDefaultTest()); + service.save(input); + Map> after = TreeCrudTestSupport.snapshot(stored(group)); + assertNull(after.get(root.getId()).get(0)); + assertEquals(before.get(root.getId()).subList(0, 3), after.get(root.getId()).subList(0, 3)); + assertEquals(before.get(child.getId()), after.get(child.getId())); + } + @Test public void insertMinusOneParentRootKeepsNestedChild() { String group = IDGenerator.RANDOM.generate(); @@ -78,7 +107,90 @@ public void insertMinusOneParentRootKeepsNestedChild() { } @Test - public void listSaveMinusOneParentMovesExistingBranchAndOmittedLeafToRoot() { + public void allSaveEntriesMoveToExplicitRootMarkersWithOmittedLeaf() { + for (String entry : List.of("entity", "collection", "list")) { + for (String marker : List.of("", "-1")) { + verifyRootMove(service, entry, marker); + } + } + } + + @Test + public void customRootMarkerUsesExistingServiceExtension() { + verifyRootMove(marked, "list", "custom-root"); + } + + @Test + public void nestedSaveParentWinsOverNullRootMarkerAndOldParent() { + for (String parentInput : new String[]{null, "", "-1", "missing-parent"}) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "old root", 1L); + TestTreeSortEntity target = TreeCrudTestSupport.node(group, null, "new root", 2L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + service.insert(List.of(root, target, branch, leaf)); + TestTreeSortEntity childInput = service.findById(branch.getId()).orElseThrow(AssertionError::new); + childInput.setParentId(parentInput); + target.setChildren(List.of(childInput)); + events.reset(); + service.save(target); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(target.getId(), null); + parents.put(branch.getId(), target.getId()); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(stored(group), parents); + assertNoModify(Set.of(target.getId(), branch.getId(), leaf.getId())); + } + } + + @Test + public void explicitRootSavedRejectionAndCallerFailureRollbackAllBatches() { + for (String marker : List.of("", "-1")) { + for (boolean rejectEvent : List.of(false, true)) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + service.insert(forest.childFirst()); + Map> before = TreeCrudTestSupport.snapshot(stored(forest.group)); + TestTreeSortEntity input = service.findById(forest.id("east-left")).orElseThrow(AssertionError::new); + input.setParentId(marker); + input.setName("renamed moved branch"); + Set moved = TreeCrudTestSupport.subtree(forest.parents, input.getId()); + TestTreeSortEntity caller = TreeCrudTestSupport.node(forest.group, null, "caller row", 999L); + events.watch(input.getId(), moved, rejectEvent ? forest.id("deep-leaf") : null, caller.getId()); + try { + IllegalStateException rejection = assertThrows(IllegalStateException.class, + () -> new TransactionTemplate(transactionManager).execute(status -> { + service.insert(caller); + service.getRepository().createUpdate().set("name", "caller changed root") + .where("id", forest.id("east")).execute(); + service.save(input); + if (!rejectEvent) { + throw new IllegalStateException("reject caller root marker"); + } + return 0; + })); + assertEquals(rejectEvent ? "reject saved root marker" : "reject caller root marker", rejection.getMessage()); + assertEquals(before, TreeCrudTestSupport.snapshot(stored(forest.group))); + assertEquals(before.get(input.getId()), events.beforeDatabase); + assertEquals(marker, events.beforeInput.get(0)); + assertEquals("renamed moved branch", events.beforeInput.get(3)); + assertEquals(marker, events.savedDatabase.get(input.getId()).get(0)); + assertEquals("renamed moved branch", events.savedDatabase.get(input.getId()).get(3)); + assertEquals(moved, events.savedDatabase.keySet()); + assertTrue("the rejection must follow earlier SQL batches", events.savedBatches.size() >= 2); + assertTrue(events.savedBatches.stream().allMatch(batch -> batch.size() <= service.getBufferSize())); + if (rejectEvent) { + assertEquals(TreeCrudTestSupport.snapshot(List.of(caller)).get(caller.getId()), events.callerDatabase); + } + assertNoModify(moved); + } finally { + events.reset(); + } + } + } + } + + private void verifyRootMove(TreeSortEntityService saving, String entry, String marker) { String group = IDGenerator.RANDOM.generate(); TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); @@ -88,20 +200,77 @@ public void listSaveMinusOneParentMovesExistingBranchAndOmittedLeafToRoot() { Map> business = TreeCrudTestSupport.businessSnapshot(before); TestTreeSortEntity moving = service.findById(branch.getId()).orElseThrow(AssertionError::new); assertNull(moving.getChildren()); - moving.setParentId("-1"); - events.reset(); + moving.setParentId(marker); + moving.setName("renamed branch"); + business.get(branch.getId()).set(0, moving.getName()); + events.watch(branch.getId(), Set.of(branch.getId(), leaf.getId()), null, null); - SaveResult result = service.save(List.of(moving)); + SaveResult result = save(saving, entry, moving); List after = stored(group); Map parents = new LinkedHashMap<>(); parents.put(root.getId(), null); - parents.put(branch.getId(), "-1"); + parents.put(branch.getId(), marker); parents.put(leaf.getId(), branch.getId()); TreeCrudTestSupport.assertPersistedTree(after, parents, Set.of(root.getId(), branch.getId())); assertEquals(business, TreeCrudTestSupport.businessSnapshot(after)); assertEquals(2, result.getTotal()); assertSavedSnapshots(after, Set.of(branch.getId(), leaf.getId())); + assertEquals("BeforeSave must read the unchanged old database row", + TreeCrudTestSupport.snapshot(before).get(branch.getId()), events.beforeDatabase); + assertEquals(TreeCrudTestSupport.snapshot(after).get(branch.getId()), events.beforeInput); + assertEquals(TreeCrudTestSupport.snapshot(after).get(branch.getId()), events.savedDatabase.get(branch.getId())); + assertNoModify(Set.of(branch.getId(), leaf.getId())); + } + + private void verifyNullParentSave(String entry) { + for (boolean sparse : List.of(false, true)) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + service.insert(List.of(root, branch, leaf)); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity input = sparse ? new TestTreeSortEntity() + : service.findById(branch.getId()).orElseThrow(AssertionError::new); + input.setId(branch.getId()); + input.setParentId(null); + input.setName("renamed branch"); + // Save 仍验证 CreateGroup;合法必填业务列不因父级回归而改变。 + input.setDefaultTest(branch.getDefaultTest()); + events.watch(branch.getId(), Set.of(branch.getId()), null, null); + + assertEquals(1, save(service, entry, input).getTotal()); + + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), root.getId()); + parents.put(leaf.getId(), branch.getId()); + List after = stored(group); + TreeCrudTestSupport.assertPersistedTree(after, parents); + Map> snapshots = TreeCrudTestSupport.snapshot(after); + assertEquals(before.get(root.getId()), snapshots.get(root.getId())); + assertEquals(before.get(leaf.getId()), snapshots.get(leaf.getId())); + assertEquals(before.get(branch.getId()).subList(0, 3), snapshots.get(branch.getId()).subList(0, 3)); + assertEquals("renamed branch", snapshots.get(branch.getId()).get(3)); + assertEquals(before.get(branch.getId()), events.beforeDatabase); + assertEquals(root.getId(), events.beforeInput.get(0)); + assertEquals(snapshots.get(branch.getId()), events.savedDatabase.get(branch.getId())); + assertNoModify(Set.of(branch.getId(), leaf.getId())); + assertNull(events.getBeforeSave(leaf.getId())); + events.reset(); + } + } + + private SaveResult save(TreeSortEntityService saving, String entry, TestTreeSortEntity input) { + switch (entry) { + case "entity": return saving.save(input); + case "collection": + Collection collection = List.of(input); + return saving.save(collection); + case "list": return saving.save(List.of(input)); + default: throw new AssertionError(entry); + } } @Test @@ -215,19 +384,7 @@ private void verifySaveMove(String entryPoint) { moving.setParentId(newRoot.getId()); events.reset(); - SaveResult result; - switch (entryPoint) { - case "entity": result = service.save(moving); break; - case "collection": - Collection collection = List.of(moving); - result = service.save(collection); - break; - case "list": - List list = List.of(moving); - result = service.save(list); - break; - default: throw new AssertionError(entryPoint); - } + SaveResult result = save(service, entryPoint, moving); List after = stored(group); Map parents = new LinkedHashMap<>(); @@ -258,6 +415,22 @@ private void assertSavedSnapshots(List after, Set sa } } + private void assertNoModify(Set ids) { + for (String id : ids) { + assertNull("Save must not publish BeforeModify", events.getBeforeModify(id)); + assertNull("Save must not publish Modify", events.getModified(id)); + } + } + + /** 自定义根标记仍使用同一 JDBC 保存入口和正式事件。 */ + static class MarkerTreeService extends GenericTreeSupportCrudService { + @Override public IDGenerator getIDGenerator() { return IDGenerator.MD5; } + @Override public void setChildren(TestTreeSortEntity node, List children) { node.setChildren(children); } + @Override public boolean isRootNode(TestTreeSortEntity node) { + return "custom-root".equals(node.getParentId()) || super.isRootNode(node); + } + } + /** 在正式 Modify after 中读取原事务 SQL,拒绝只能由该实体事件触发。 */ static class RejectedModifyEvents { private final SyncRepository repository; @@ -305,8 +478,13 @@ public void modified(EntityModifyEvent event) { @Configuration(proxyBeanMethods = false) static class EventConfiguration { - @Bean TreeCrudTestSupport.EntityEvents syncEntryPointEvents() { - return new TreeCrudTestSupport.EntityEvents(); + @Bean TreeCrudTestSupport.SaveEvents syncEntryPointEvents(SyncRepository repository) { + return new TreeCrudTestSupport.SaveEvents(id -> Mono.fromSupplier(() -> + repository.findById(id).orElseThrow(AssertionError::new))); + } + + @Bean MarkerTreeService jdbcMarkerTreeService() { + return new MarkerTreeService(); } @Bean RejectedModifyEvents syncRejectedModifyEvents(SyncRepository repository) { diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java index f340af30f..4e386db15 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeSortServiceHelperTest.java @@ -55,7 +55,7 @@ public void sameBatchParentAndChildMoveKeepsOmittedGrandchildPathConsistent() { }); TestTreeSortEntity moved = copy(branch); TestTreeSortEntity suppliedChild = copy(child); - moved.setParentId(moveToRoot ? null : target.getId()); + moved.setParentId(moveToRoot ? "" : target.getId()); List prepared = new SyncTreeSortServiceHelper<>(service) .prepare(childFirst ? List.of(suppliedChild, moved) : List.of(moved, suppliedChild)); Map actual = prepared.stream() @@ -64,7 +64,7 @@ public void sameBatchParentAndChildMoveKeepsOmittedGrandchildPathConsistent() { TestTreeSortEntity actualBranch = actual.get(branch.getId()); TestTreeSortEntity actualChild = actual.get(child.getId()); TestTreeSortEntity actualGrandchild = actual.get(grandchild.getId()); - assertEquals(moveToRoot ? null : target.getId(), actualBranch.getParentId()); + assertEquals(moveToRoot ? "" : target.getId(), actualBranch.getParentId()); assertEquals(Integer.valueOf(moveToRoot ? 1 : 2), actualBranch.getLevel()); assertTrue(actualChild.getPath().startsWith(actualBranch.getPath() + "-")); assertTrue(actualGrandchild.getPath().startsWith(actualChild.getPath() + "-")); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java index 8e4c26291..60fd56edd 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java @@ -10,6 +10,8 @@ import org.hswebframework.web.crud.events.EntitySavedEvent; import org.hswebframework.web.id.IDGenerator; import org.springframework.context.event.EventListener; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import java.util.ArrayDeque; import java.util.ArrayList; @@ -23,6 +25,8 @@ import java.util.Queue; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; import java.util.stream.Collectors; import static org.junit.Assert.*; @@ -227,6 +231,79 @@ public void modified(EntityModifyEvent event) { } } + /** 在正式 Save 事件中通过真实仓储读取同一事务,分别观察旧库状态与最终输入。 */ + static class SaveEvents extends EntityEvents { + private final Function> findById; + private String targetId; + private Set watchedIds = Set.of(); + private String rejectedId; + private String callerId; + List beforeDatabase; + List beforeInput; + List callerDatabase; + final Map> savedDatabase = new ConcurrentHashMap<>(); + final List> savedBatches = new CopyOnWriteArrayList<>(); + + SaveEvents(Function> findById) { + this.findById = findById; + } + + void watch(String targetId, Set watchedIds, String rejectedId, String callerId) { + reset(); + this.targetId = targetId; + this.watchedIds = watchedIds; + this.rejectedId = rejectedId; + this.callerId = callerId; + } + + @Override + public void reset() { + super.reset(); + targetId = null; + watchedIds = Set.of(); + rejectedId = null; + callerId = null; + beforeDatabase = null; + beforeInput = null; + callerDatabase = null; + savedDatabase.clear(); + savedBatches.clear(); + } + + @EventListener + public void inspectBeforeSave(EntityBeforeSaveEvent event) { + if (event.getEntityType() == TestTreeSortEntity.class && targetId != null + && event.getEntity().stream().anyMatch(node -> targetId.equals(node.getId()))) { + beforeInput = snapshot(event.getEntity()).get(targetId); + event.async(findById.apply(targetId).doOnNext(stored -> + beforeDatabase = snapshot(List.of(stored)).get(targetId)).then()); + } + } + + @EventListener + public void inspectSaved(EntitySavedEvent event) { + if (event.getEntityType() != TestTreeSortEntity.class) { + return; + } + List ids = event.getEntity().stream().map(TestTreeSortEntity::getId) + .filter(watchedIds::contains).collect(Collectors.toList()); + if (ids.isEmpty()) { + return; + } + savedBatches.add(ids); + Mono inspection = Flux.fromIterable(ids).concatMap(findById) + .doOnNext(stored -> savedDatabase.putAll(snapshot(List.of(stored)))).then(); + if (ids.contains(rejectedId)) { + if (callerId != null) { + inspection = inspection.then(findById.apply(callerId).doOnNext(stored -> + callerDatabase = snapshot(List.of(stored)).get(callerId)).then()); + } + inspection = inspection.then(Mono.error(new IllegalStateException("reject saved root marker"))); + } + event.async(inspection); + } + } + static final class Forest { final String group = IDGenerator.RANDOM.generate(); final Map named = new LinkedHashMap<>(); diff --git a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java index 14b0d4f17..c0f5ed72e 100644 --- a/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java @@ -1,8 +1,10 @@ package org.hswebframework.web.crud.service; +import org.hswebframework.ezorm.rdb.mapping.ReactiveRepository; import org.hswebframework.ezorm.rdb.mapping.defaults.SaveResult; import org.hswebframework.web.crud.TestApplication; import org.hswebframework.web.crud.entity.TestTreeSortEntity; +import org.hswebframework.web.crud.utils.TransactionUtils; import org.hswebframework.web.id.IDGenerator; import org.junit.Test; import org.junit.runner.RunWith; @@ -11,8 +13,11 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.transaction.support.DefaultTransactionDefinition; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import reactor.core.publisher.Signal; +import reactor.test.StepVerifier; import java.time.Duration; import java.util.LinkedHashMap; @@ -38,7 +43,8 @@ public class TreeRootMarkerRegressionTest { private static final Duration TIMEOUT = Duration.ofSeconds(10); @Autowired private TestTreeSortEntityService service; - @Autowired private TreeCrudTestSupport.EntityEvents events; + @Autowired private MarkerTreeService marked; + @Autowired private TreeCrudTestSupport.SaveEvents events; @Test public void insertNullParentRootKeepsNestedChild() { verifyRootInsert(null); } @@ -50,7 +56,62 @@ public class TreeRootMarkerRegressionTest { public void insertMinusOneParentRootKeepsNestedChild() { verifyRootInsert("-1"); } @Test - public void saveNullParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove(null); } + public void sparseAndFullNullParentSaveKeepExistingBranchAndOmittedLeaf() { + for (boolean sparse : List.of(false, true)) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + service.insert(Flux.just(root, branch, leaf)).block(TIMEOUT); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity input = sparse ? new TestTreeSortEntity() + : service.findById(branch.getId()).block(TIMEOUT); + input.setId(branch.getId()); + input.setParentId(null); + input.setName("renamed branch"); + // Save 仍验证 CreateGroup;合法必填业务列不因父级回归而改变。 + input.setDefaultTest(branch.getDefaultTest()); + events.watch(branch.getId(), Set.of(branch.getId()), null, null); + + assertEquals(1, service.save(input).block(TIMEOUT).getTotal()); + + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), root.getId()); + parents.put(leaf.getId(), branch.getId()); + List after = stored(group); + TreeCrudTestSupport.assertPersistedTree(after, parents); + Map> snapshots = TreeCrudTestSupport.snapshot(after); + assertEquals(before.get(root.getId()), snapshots.get(root.getId())); + assertEquals(before.get(leaf.getId()), snapshots.get(leaf.getId())); + assertEquals(before.get(branch.getId()).subList(0, 3), snapshots.get(branch.getId()).subList(0, 3)); + assertEquals("renamed branch", snapshots.get(branch.getId()).get(3)); + assertEquals(before.get(branch.getId()), events.beforeDatabase); + assertEquals(root.getId(), events.beforeInput.get(0)); + assertEquals(snapshots.get(branch.getId()), events.savedDatabase.get(branch.getId())); + assertNoModify(Set.of(branch.getId(), leaf.getId())); + assertNull(events.getBeforeSave(leaf.getId())); + events.reset(); + } + } + + @Test + public void sparseSaveKeepsHistoricalNullRootAndOmittedChild() { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "historical root", 1L); + TestTreeSortEntity child = TreeCrudTestSupport.node(group, root.getId(), "child", 101L); + service.insert(Flux.just(root, child)).block(TIMEOUT); + Map> before = TreeCrudTestSupport.snapshot(stored(group)); + TestTreeSortEntity input = new TestTreeSortEntity(); + input.setId(root.getId()); + input.setName("renamed historical root"); + input.setDefaultTest(root.getDefaultTest()); + service.save(input).block(TIMEOUT); + Map> after = TreeCrudTestSupport.snapshot(stored(group)); + assertNull(after.get(root.getId()).get(0)); + assertEquals(before.get(root.getId()).subList(0, 3), after.get(root.getId()).subList(0, 3)); + assertEquals(before.get(child.getId()), after.get(child.getId())); + } @Test public void saveEmptyParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove(""); } @@ -58,6 +119,96 @@ public class TreeRootMarkerRegressionTest { @Test public void saveMinusOneParentMovesExistingBranchAndOmittedLeafToRoot() { verifyRootMove("-1"); } + @Test + public void customRootMarkerWorksForSaveAndPureUpdate() { + verifyRootMove("custom-root", marked); + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + marked.insert(Flux.just(root, branch, leaf)).block(TIMEOUT); + TestTreeSortEntity patch = new TestTreeSortEntity(); + patch.setParentId("custom-root"); + events.reset(); + assertEquals(Integer.valueOf(2), marked.updateById(branch.getId(), patch).block(TIMEOUT)); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(branch.getId(), "custom-root"); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(stored(group), parents, Set.of(root.getId(), branch.getId())); + assertNotNull(events.getModified(branch.getId())); + assertNull(events.getSaved(branch.getId())); + } + + @Test + public void nestedSaveParentWinsOverNullRootMarkerAndOldParent() { + for (String parentInput : new String[]{null, "", "-1", "missing-parent"}) { + String group = IDGenerator.RANDOM.generate(); + TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "old root", 1L); + TestTreeSortEntity target = TreeCrudTestSupport.node(group, null, "new root", 2L); + TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); + TestTreeSortEntity leaf = TreeCrudTestSupport.node(group, branch.getId(), "leaf", 10101L); + service.insert(Flux.just(root, target, branch, leaf)).block(TIMEOUT); + TestTreeSortEntity childInput = service.findById(branch.getId()).block(TIMEOUT); + childInput.setParentId(parentInput); + target.setChildren(List.of(childInput)); + events.reset(); + service.save(target).block(TIMEOUT); + Map parents = new LinkedHashMap<>(); + parents.put(root.getId(), null); + parents.put(target.getId(), null); + parents.put(branch.getId(), target.getId()); + parents.put(leaf.getId(), branch.getId()); + TreeCrudTestSupport.assertPersistedTree(stored(group), parents); + assertNoModify(Set.of(target.getId(), branch.getId(), leaf.getId())); + } + } + + @Test + public void explicitRootSavedRejectionAndCallerFailureRollbackAllBatches() { + for (String marker : List.of("", "-1")) { + for (boolean rejectEvent : List.of(false, true)) { + TreeCrudTestSupport.Forest forest = new TreeCrudTestSupport.Forest(); + service.insert(Flux.fromIterable(forest.childFirst())).block(TIMEOUT); + Map> before = TreeCrudTestSupport.snapshot(stored(forest.group)); + TestTreeSortEntity input = service.findById(forest.id("east-left")).block(TIMEOUT); + input.setParentId(marker); + input.setName("renamed moved branch"); + Set moved = TreeCrudTestSupport.subtree(forest.parents, input.getId()); + TestTreeSortEntity caller = TreeCrudTestSupport.node(forest.group, null, "caller row", 999L); + events.watch(input.getId(), moved, rejectEvent ? forest.id("deep-leaf") : null, caller.getId()); + try { + Mono operation = service.insert(caller) + .then(service.getRepository().createUpdate().set("name", "caller changed root") + .where("id", forest.id("east")).execute()) + .then(marked.save(input)); + if (!rejectEvent) { + operation = operation.then(Mono.error(new IllegalStateException("reject caller root marker"))); + } + TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()) + .as(StepVerifier::create).expectErrorMatches(error -> error instanceof IllegalStateException + && (rejectEvent ? "reject saved root marker" : "reject caller root marker") + .equals(error.getMessage())).verify(TIMEOUT); + assertEquals(before, TreeCrudTestSupport.snapshot(stored(forest.group))); + assertEquals(before.get(input.getId()), events.beforeDatabase); + assertEquals(marker, events.beforeInput.get(0)); + assertEquals("renamed moved branch", events.beforeInput.get(3)); + assertEquals(marker, events.savedDatabase.get(input.getId()).get(0)); + assertEquals("renamed moved branch", events.savedDatabase.get(input.getId()).get(3)); + assertEquals(moved, events.savedDatabase.keySet()); + assertTrue("the rejection must follow earlier SQL batches", events.savedBatches.size() >= 2); + assertTrue(events.savedBatches.stream().allMatch(batch -> batch.size() <= marked.getBufferSize())); + if (rejectEvent) { + assertEquals(TreeCrudTestSupport.snapshot(List.of(caller)).get(caller.getId()), events.callerDatabase); + } + assertNoModify(moved); + } finally { + events.reset(); + } + } + } + } + @Test public void minusOneEntityIdMovesBetweenLegalParentsAsOrdinaryLeaf() { String group = IDGenerator.RANDOM.generate(); @@ -117,6 +268,10 @@ private void verifyRootInsert(String marker) { } private void verifyRootMove(String marker) { + verifyRootMove(marker, service); + } + + private void verifyRootMove(String marker, ReactiveTreeSortEntityService saving) { String group = IDGenerator.RANDOM.generate(); TestTreeSortEntity root = TreeCrudTestSupport.node(group, null, "root", 1L); TestTreeSortEntity branch = TreeCrudTestSupport.node(group, root.getId(), "branch", 101L); @@ -128,9 +283,11 @@ private void verifyRootMove(String marker) { TestTreeSortEntity moving = service.findById(branch.getId()).block(TIMEOUT); assertNull(moving.getChildren()); moving.setParentId(marker); - events.reset(); + moving.setName("renamed branch"); + business.get(branch.getId()).set(0, moving.getName()); + events.watch(branch.getId(), Set.of(branch.getId(), leaf.getId()), null, null); - Signal result = service.save(moving).materialize().block(TIMEOUT); + Signal result = saving.save(moving).materialize().block(TIMEOUT); List after = stored(group); assertFalse("an existing branch must move to a declared root: " + result.getThrowable(), result.isOnError()); @@ -143,6 +300,10 @@ private void verifyRootMove(String marker) { assertEquals(snapshot.get(root.getId()), TreeCrudTestSupport.snapshot(after).get(root.getId())); assertEquals(2, result.get().getTotal()); assertSavedSnapshots(after, Set.of(branch.getId(), leaf.getId())); + assertEquals("BeforeSave must read the unchanged old database row", snapshot.get(branch.getId()), events.beforeDatabase); + assertEquals(TreeCrudTestSupport.snapshot(after).get(branch.getId()), events.beforeInput); + assertEquals(TreeCrudTestSupport.snapshot(after).get(branch.getId()), events.savedDatabase.get(branch.getId())); + assertNoModify(Set.of(branch.getId(), leaf.getId())); assertEquals(Set.of(branch.getId(), leaf.getId()), TreeCrudTestSupport.ids(service.queryIncludeChildren(List.of(branch.getId())).collectList().block(TIMEOUT))); } @@ -159,10 +320,33 @@ private void assertSavedSnapshots(List after, Set sa } } + private void assertNoModify(Set ids) { + for (String id : ids) { + assertNull("Save must not publish BeforeModify", events.getBeforeModify(id)); + assertNull("Save must not publish Modify", events.getModified(id)); + } + } + + /** 真实根标记扩展和小批次,沿用通用仓储与正式事件。 */ + static class MarkerTreeService extends GenericReactiveCrudService + implements ReactiveTreeSortEntityService { + MarkerTreeService(ReactiveRepository repository) { super(repository); } + @Override public IDGenerator getIDGenerator() { return IDGenerator.MD5; } + @Override public void setChildren(TestTreeSortEntity node, List children) { node.setChildren(children); } + @Override public int getBufferSize() { return 2; } + @Override public boolean isRootNode(TestTreeSortEntity node) { + return "custom-root".equals(node.getParentId()) || ReactiveTreeSortEntityService.super.isRootNode(node); + } + } + @TestConfiguration public static class EventConfiguration { - @Bean public TreeCrudTestSupport.EntityEvents rootMarkerEvents() { - return new TreeCrudTestSupport.EntityEvents(); + @Bean public TreeCrudTestSupport.SaveEvents rootMarkerEvents(ReactiveRepository repository) { + return new TreeCrudTestSupport.SaveEvents(repository::findById); + } + + @Bean public MarkerTreeService reactiveMarkerTreeService(ReactiveRepository repository) { + return new MarkerTreeService(repository); } } }