diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 237cb31cf..84e6ba0da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,132 @@ 5. 坐等审查 6. 合并 +## 事务边界与通用树集成测试 + +普通 `ReactiveCrudService.createUpdate/createDelete` 返回仓储 DSL,不隐式开启事务。 +需要将 SQL、实体事件及其他写入一起回滚时,业务 Service 应对完整业务 Publisher 明确声明 +事务;命名 CRUD 方法保持其事务注解。基础树 insert/save/update/delete 的复合操作由 +程序式事务保持完整原子性,直接调用非代理树 Service 也应在后批失败或取消时整体回滚。 +普通缓存 DSL 保持既有 hook 行为:修改数量大于零时清除缓存,零修改时不清除。 + +树 CRUD 的测试使用真实 JDBC/R2DBC 仓储、实体事件和原事务。期望关系根据测试输入的 +`parentId` 独立推导,不调用生产树准备 helper 生成期望值;回滚比较完整持久化快照, +取消执行后等待事务 `afterCompletion` 再查询数据库。 + +实体事件失败与调用方失败使用独立分支。事件失败分支必须由真实 +`EntityCreatedEvent`、`EntitySavedEvent`、`EntityModifyEvent` 或 `EntityDeletedEvent` 的监听器拒绝触发, +不能额外抛出调用方异常使测试误通过。同步删除的 after 监听器还核对原事务内 SQL +已执行,再验证事件拒绝使整棵子树及调用方先前写入一起恢复。 + +用例入口: + +1. `SyncTreeCrudRegressionTest`:乱序多根 `insert(Collection)`、小批次 `save(List)` + 深层移动与遗漏后代、后批实体事件/调用方失败,以及重叠 seed 的 `deleteById(Collection)`。 +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(E)`、`save(Collection)`、`save(List)`, +不把普通同步 `updateById` 定义为结构修图入口;没有指定排序的查询不保证顺序, +同步删除不要求叶先执行。路径容量边界按实际实体 schema 验证,不扩大 schema。 + +`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 共享上下文。干净 CI 使用 easy-orm `4.2` 中包含该通用修复的 +`4.2.2-SNAPSHOT`。运行结果记录在对应 PR 的验证证据中。 + +## 树 Save 与部分更新的契约 + +树 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 沿用持久父级和业务字段。 +既有节点的名称等稀疏修改可省略父级;移根必须明确提交服务识别的非 null 根标记。 +同步树准备覆盖 `insert(Collection)` 及全部 Save 入口,普通同步 `updateById` 不提供 +结构修图;响应式树 `updateById` 维护显式关系变化及遗漏后代的必要树字段。 + +实现位于 `hsweb-commons/hsweb-commons-crud` 的 `TreeSortServiceHelper`、 +`TreeSortEntityService`、`ReactiveTreeSortEntityService`;公开路由由 +`ReactiveServiceSaveController` 提供。`ExplicitTreeUpdateRegressionTest` 通过真实 +R2DBC H2 / HTTP 和正式实体事件验证 PUT 遗漏或传 null 父级时保留关系,及 PATCH +显式根标记移根、保留遗漏后代和同批不存在 ID 新增。 + +根标记由 `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 拒绝使完整目标快照及调用方先前写入在原事务中回滚。 +新增及调整回归同时覆盖三个同步 Save 入口、响应式稀疏 Save、自定义根标记、嵌套父级 +优先级和正式 Save 事件的可见性及回滚;这些回归已纳入下述集中验证。 + +## 树 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 拒绝前 +读取实际已写父节点,拒绝后原事务恢复整个持久快照。 + +树 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 兼容。 +普通树并发边界见下节。 + +## 普通树删除的并发边界 + +响应式普通树在子树展开后按 ID 快照执行叶先分批删除。没有 parent 外键或共同领域 +约束时,另一事务在展开与删除 SQL 之间提交的新 child 可能不在删除快照内,并留下 +失去父节点的记录;框架当前不保证普通树并发新增/删除的关系完整性。 + +当前方案不新增通用锁,也不扩大已有领域锁策略。业务需要该并发保证时,应让关联 +写入和删除遵守同一领域约束;已有 `prepareTreeDelete` 领域锁的保证仅适用于共同 +遵守该约束的操作。框架已验证的复合事务、完整实体事件、分组隔离、叶先分批、取消 +与回滚,不能外推为所有普通树的并发关系保证。 + +## 成员 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-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..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 @@ -1,32 +1,30 @@ 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.crud.events.EntityBeforeModifyEvent; +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; /** - * 树形结构的通用增删改查服务 + * 响应式树 CRUD,Save 准备关系与遗漏后代后通过正式仓储 Save 写入。 + * null 父级沿用已有关系,显式根标记由 isRootNode 判定;UPDATE 保持正式 Modify 事件。 + * 每次订阅独立准备树快照,复合操作及全部批次共享事务。 * * @param TreeSortSupportEntity * @param ID @@ -95,16 +93,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 +122,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 +141,38 @@ default Flux queryIncludeParent(Flux entities) { */ @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) default Flux queryIncludeChildren(QueryParamEntity queryParam) { - Set duplicateCheck = new HashSet<>(); - - 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); + 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()); + } + + /** + * 为同一数据表中的独立树追加分组条件,祖先、子树查询及子树删除统一使用此扩展点。 + * 默认不追加条件,保持已有单树服务的行为。分组实现应只追加条件,不修改实体或执行查询; + * 传入实体必须包含分组字段,不能仅依靠随机 path 判断归属。 + * + * @param node 当前树节点 + * @param query 已创建的查询 + * @return 追加同组约束后的查询 + * @since 5.0.2 + */ + default ReactiveQuery applyTreeScope(E node, ReactiveQuery query) { + return query; } @Override @@ -188,29 +190,39 @@ 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() { return 200; } + /** + * 保存树关系,已有节点的 null parentId 沿用持久父级;显式根标记表达移根。 + * 遗漏 children 不自动删除后代;业务列的 null 沿用 ORM 保存规则。 + */ @Override @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(getRepository()::save) + .reduce(SaveResult::merge); + }); + return TransactionUtils.tryRunInTransaction(operation, new DefaultTransactionDefinition()); } @@ -231,16 +243,58 @@ default Mono save(E data) { return save(Flux.just(data)); } + /** + * 部分更新忽略 null;显式 children 参与修改和新增,遗漏后代仅维护树字段。 + * 目标及显式已有 child 始终执行 UPDATE,通过 ORM Modify 完整 before/after 事件校验。 + * 业务更新规则应监听 {@link EntityBeforeModifyEvent} 的 after;save(Publisher) 覆写只拥有 Save 入口。 + * 新 child 经过服务 save,使用对应的 BeforeSave/BeforeCreate 规则;任一拒绝回滚整个树更新。 + * 目标并发消失时返回零,不使用 Save/UPSERT 重建目标,也不继续写入 children。 + * 非 null 的根标记由 isRootNode 判定,null 沿用持久父级。 + */ @Override - @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + @Transactional(rollbackFor = Throwable.class, 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()); + Mono operation = entityPublisher.flatMap(data -> findById(id).flatMap(existing -> { + data.setId(id); + ReactiveTreeSortServiceHelper helper = new ReactiveTreeSortServiceHelper<>(this); + return helper.prepareUpdate(Flux.just(data)) + .collectList() + .flatMap(nodes -> getRepository().updateById(id, helper.getUpdateData(data)) + .flatMap(updated -> { + // 目标始终是 UPDATE;并发删除导致零修改时,不能新增目标或继续写 children。 + if (updated == 0) { + return Mono.just(0); + } + 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()); + } + + @Override + @Transactional(transactionManager = TransactionManagers.reactiveTransactionManager) + default Mono updateById(K id, E entity) { + return updateById(id, Mono.just(entity)); } @Override @@ -252,12 +306,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(); @@ -279,25 +330,38 @@ 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())); + } + + /** + * 在当前树删除事务内准备删除条件,完成后才读取当前节点及子树。 + * 默认不执行任何操作;扩展实现不应修改查询参数或提前展开子树。 + * + * @param queryParam 原始删除条件 + * @return 准备完成信号 + */ + default Mono prepareTreeDelete(QueryParamEntity queryParam) { + return Mono.empty(); } @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( + // 完整实体用于分组和层级排序;叶先有界分片,每批SQL与事件完成后才删除父级。 + // 全部批次仍参与同一个原事务,后批事件或调用方失败必须整体回滚。 + 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/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/TreeSortEntityService.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/service/TreeSortEntityService.java index 1977b69c0..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 @@ -1,25 +1,27 @@ 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; /** + * 同步树 CRUD,Save 准备关系与遗漏后代后通过正式仓储 Save 写入。 + * null 父级沿用已有关系,显式移根标记由 isRootNode 判定;普通 UPDATE 不重算树。 + * 所有批次和实体事件参与业务调用方的 JDBC 事务。 + * * @param TreeSortSupportEntity * @param ID * @see GenericReactiveTreeSupportCrudService @@ -86,23 +88,44 @@ 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 保存规则。 + */ @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(getRepository()::save) .reduce(SaveResult::merge) .blockOptional() .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 4050cd442..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 @@ -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; @@ -17,6 +20,11 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +/** + * 为同步及响应式树写入准备父子关系与路径;每次操作创建独立实例。 + * Save 只补齐已有节点未指定的父级,部分 UPDATE 另外维护稀疏业务输入。 + * 根标记委派服务的 isRootNode,准备阶段不执行持久化写入。 + */ public abstract class TreeSortServiceHelper, PK> { //包含子节点的数据 @@ -30,6 +38,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 +54,35 @@ 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 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); + patch.setId(node.getId()); + if (node != root) { + // children 的嵌套关系本身就是显式父级意图。 + patch.setParentId(node.getParentId()); + } + updateData.put(node.getId(), patch); + } + } + return expanded; + }) .collectList() .flatMapIterable(list -> { @@ -64,15 +103,46 @@ 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)) .thenMany(Flux.defer(() -> Flux.fromIterable(readyToSave.values()))) - .doOnNext(this::refactor); + .doOnNext(this::refactor) + // sort 稳定保留同层输入顺序;分批前保证父节点位于子节点之前。 + .sort(Comparator.comparing(TreeSupportEntity::getLevel, + Comparator.nullsFirst(Comparator.naturalOrder()))); + } + + @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()); + } + } + 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; } - private Mono init(Flux source) { + private Mono init(Flux source, boolean partialUpdate) { oldData = new LinkedHashMap<>(); thisTime = new LinkedHashMap<>(); allData = new LinkedHashMap<>(); @@ -102,6 +172,17 @@ private Mono init(Flux source) { } } + 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); allData.putAll(includeChildren); @@ -112,6 +193,70 @@ 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) { + // 入口可能传入调用方稀疏对象,树字段统一取当前已准备的工作节点。 + node = allData.get(node.getId()); + 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()); + } + // helper 已展开全部显式节点,逐节点保存不能再次递归写入 children。 + applyChildren(patch, Collections.emptyList()); + } + return patch; + } + + 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); + } + private void initChildren() { childrenMapping.clear(); @@ -152,6 +297,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()); @@ -185,60 +331,77 @@ 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 (newParentId != null && !newParentId.equals(parentId)) { - Consumer childConsumer = child -> { - //更新了父节点,但是同时也传入的对应的子节点 - E readyToUpdate = thisTime.get(child.getId()); - if (null != readyToUpdate) { - readyToUpdate.setPath(child.getPath()); - } - }; + // 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())); + } + } - //变更到了顶级节点 - if (isRootNode(data)) { - data.setPath(RandomUtil.randomChar(4)); - this.refactorChildPath(old.getId(), data.getPath(), childConsumer); - //重新保存所有子节点 - putChildToReadyToSave(childGetter, old); + 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) { + 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)) { + //变更到了顶级节点 + if (isRootNode(data)) { + data.setPath(RandomUtil.randomChar(4)); + this.refactorChildPath(old.getId(), data.getPath(), childConsumer); + //重新保存所有子节点 + putChildToReadyToSave(childGetter, old); - } 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); - } - } } 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 { + //父ID未变但祖先已移动;自身重建path时,遗漏输入的后代也必须保持一致。 + data.setPath(oldParent.getPath() + "-" + RandomUtil.randomChar(4)); + this.refactorChildPath(data.getId(), data.getPath(), childConsumer); + putChildToReadyToSave(childGetter, data); + } + } 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/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 new file mode 100644 index 000000000..6b2c93180 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/PlainCrudTransactionTest.java @@ -0,0 +1,360 @@ +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; +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.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; +import reactor.core.publisher.Sinks; +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.*; + +/** + * 普通 DSL 沿用调用方事务;完整业务链经 Spring 代理明确声明事务。 + * 事件控制器只装配在本测试上下文,不修改共享应用、实体或生产事务配置。 + */ +@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; + + @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(); + + 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) + .expectNext("before-update") + .expectComplete() + .verify(VERIFY_TIMEOUT); + } + + @Test + 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(); + + // 删除事件在当前事务中确认行已消失后发信号,再由下游取消挂起的执行流。 + business.delete(entity.getId(), caller) + .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); + + assertEquals(1, sql.count("delete")); + service.getRepository().findById(caller.getId()).as(StepVerifier::create).verifyComplete(); + + service.findById(entity.getId()) + .map(TestEntity::getName) + .as(StepVerifier::create) + .expectNext("before-delete") + .expectComplete() + .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()); + 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); + } + + @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()); + } + } + + /** 每个用例只控制自己插入的行;其他测试及实体事件使用原有行为。 */ + 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) { + this.service = service; + } + + @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))) { + 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))) { + 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(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) { + 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()); + }); + } + + 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 执行前取消或提前读取数据库。 */ + 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/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/ConcurrentParentPatchRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java new file mode 100644 index 000000000..0e006ae18 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ConcurrentParentPatchRegressionTest.java @@ -0,0 +1,209 @@ +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); + } + + @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"); + 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/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(); + } + } +} 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/ExplicitTreeUpdateRegressionTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java new file mode 100644 index 000000000..5e8f3ee93 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ExplicitTreeUpdateRegressionTest.java @@ -0,0 +1,612 @@ +package org.hswebframework.web.crud.service; + +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; +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.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; +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.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, + ExplicitTreeUpdateRegressionTest.HttpEventConfiguration.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; + + @Autowired + private HttpTreeEventListener httpEvents; + + @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 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 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 publicPatchExplicitRootMarkerMovesSubtreeAndCreatesMissingId() { + 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(""); + 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()); + assertEquals("", 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())); + 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(); + 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"); + 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 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); + 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; + } + + /** 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") + 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-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/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; + } +} 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..8f6cf4991 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ReactiveTreeCrudCoverageTest.java @@ -0,0 +1,524 @@ +package org.hswebframework.web.crud.service; + +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; +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; + + @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 + 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); + } + + @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) + .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()); + } + } + + /** 只接入真实实体 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; + 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); + if (current.reject) { + return Mono.error(current.rejection); + } + 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(); + 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; + } + } + + @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/ScopedReactiveTreeSortEntityServiceTest.java b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java new file mode 100644 index 000000000..73264d684 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/ScopedReactiveTreeSortEntityServiceTest.java @@ -0,0 +1,1113 @@ +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; +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; +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.hswebframework.web.exception.ValidationException; +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.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; +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.function.Function; +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 + @Qualifier("scopedTreeService") + private ScopedTreeService scoped; + + @Autowired + private TestTreeSortEntityService unscoped; + + @Autowired + private FailureListener listener; + + @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(); + 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(""); + 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 -> { + assertEquals("", 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 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()); + 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(""); + 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(); + // 普通 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() + .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(); + } + + @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(); + } + } + } + + @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 ? "" : 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 ? "" : 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(""); + return scoped.save(existing); + }).then(stored(scoped, group)).as(StepVerifier::create).assertNext(tree -> { + assertTreeStructure(tree.values()); + assertEquals("", 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(); + } + } + + @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); + } + + 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)); + for (TestTreeSortEntity node : nodes) { + if (node.getParentId() == null || node.getParentId().isEmpty()) { + 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()); + } + + 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 BatchedTreeService extends ScopedTreeService { + BatchedTreeService(ReactiveRepository repository) { + super(repository); + } + + @Override + public int getBufferSize() { + return 2; + } + } + + /** 只观察并暂停正式通用树执行链,不模拟查询、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; + } + + @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 + && event.getAfter().stream().anyMatch(node -> node.getName().startsWith("reject-dsl-"))) { + event.async(Mono.error(new IllegalStateException("reject modified tree"))); + } + } + + @EventListener + public void afterSave(EntitySavedEvent event) { + if (event.getEntityType() != TestTreeSortEntity.class) { + return; + } + event.async(completeBatch(event.getEntity())); + for (TestTreeSortEntity node : event.getEntity()) { + if ("".equals(node.getParentId()) && node.getName().startsWith("reject-root-save-")) { + event.async(Mono.error(new IllegalStateException("reject saved root"))); + } + if ("".equals(node.getParentId()) && node.getName().startsWith("verify-root-")) { + event.async(repository.findById(node.getId()).doOnNext(stored -> { + assertEquals("", stored.getParentId()); + assertEquals(node.getPath(), stored.getPath()); + assertEquals(Integer.valueOf(1), stored.getLevel()); + observedRoots.incrementAndGet(); + }).then()); + } + } + } + + @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-"))) { + 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) { + 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 + static class Configuration { + @Bean + ScopedTreeService scopedTreeService(ReactiveRepository repository) { + return new ScopedTreeService(repository); + } + + @Bean + BatchedTreeService batchedTreeService(ReactiveRepository repository) { + return new BatchedTreeService(repository); + } + + @Bean + PreparedTreeService preparedTreeService(ReactiveRepository repository, + ConnectionFactory connectionFactory) { + return new PreparedTreeService(repository, connectionFactory); + } + + @Bean + FailureListener treeFailureListener(ReactiveRepository repository) { + return new FailureListener(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 new file mode 100644 index 000000000..0b394282d --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeCrudRegressionTest.java @@ -0,0 +1,525 @@ +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; +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.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.*; + +/** 同步树入口使用真实 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; + events.rejectedCreateId = null; + events.rejectedDeleteId = null; + events.createdBatches.clear(); + events.savedBatches.clear(); + events.deletedBatches.clear(); + } + + @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()); + + // 同步部分 UPDATE 保留原有树关系,此入口不扩展为结构 patch。 + 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(""); + + assertEquals(2, service.save(List.of(branch)).getTotal()); + + TestTreeSortEntity moved = stored(branch.getId()); + TestTreeSortEntity movedLeaf = stored(leaf.getId()); + 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()); + 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(""); + 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()); + } + } + + @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"); + 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); + } + + @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) { + 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"); + })); + } + } + } + } + + @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(SyncRepository repository) { + return new SavedEvents(repository); + } + } +} 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..034f42d30 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/SyncTreeEntryPointContractTest.java @@ -0,0 +1,494 @@ +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 MarkerTreeService marked; + @Autowired private TreeCrudTestSupport.SaveEvents 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 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(); + 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 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); + 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(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 = save(saving, entry, moving); + + List after = stored(group); + 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(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 + 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 = save(service, entryPoint, moving); + + 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)); + } + } + + 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; + 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.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) { + return new RejectedModifyEvents(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 new file mode 100644 index 000000000..4e386db15 --- /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 ? "" : 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 ? "" : 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; + } +} 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..60fd56edd --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeCrudTestSupport.java @@ -0,0 +1,348 @@ +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.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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +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.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; +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) { + 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)); + for (TestTreeSortEntity node : nodes) { + assertEquals(parents.get(node.getId()), node.getParentId()); + int level = 1; + Set ancestors = new HashSet<>(); + 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 (!roots.contains(node.getId())) { + 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 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())); + } + } + } + + /** 在正式 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<>(); + 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")); + } + } +} 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); + } + } +} 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..c0f5ed72e --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/test/java/org/hswebframework/web/crud/service/TreeRootMarkerRegressionTest.java @@ -0,0 +1,352 @@ +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; +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 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; +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 MarkerTreeService marked; + @Autowired private TreeCrudTestSupport.SaveEvents events; + + @Test + public void insertNullParentRootKeepsNestedChild() { verifyRootInsert(null); } + + @Test + public void insertEmptyParentRootKeepsNestedChild() { verifyRootInsert(""); } + + @Test + public void insertMinusOneParentRootKeepsNestedChild() { verifyRootInsert("-1"); } + + @Test + 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(""); } + + @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(); + 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) { + 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); + 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); + 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 = saving.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("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))); + } + + 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)); + } + } + + 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.SaveEvents rootMarkerEvents(ReactiveRepository repository) { + return new TreeCrudTestSupport.SaveEvents(repository::findById); + } + + @Bean public MarkerTreeService reactiveMarkerTreeService(ReactiveRepository repository) { + return new MarkerTreeService(repository); + } + } +} 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..8e632c620 --- /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,35 @@ +package org.hswebframework.web.system.authorization.api.entity; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** 保持公开成员 ID 的原有生成与保留契约。 */ +public class DimensionUserIdentityTest { + @Test + public void ordinaryTupleUsesLegacyMd5() { + DimensionUserEntity member = member("role", "operator", "user-1"); + member.generateId(); + // role-operator-user-1 的旧 MD5。 + assertEquals("c2906b52da24edf0524f7b42229236e6", member.getId()); + member.setRelation("manager"); + member.generateId(); + assertEquals("c2906b52da24edf0524f7b42229236e6", member.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..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 @@ -134,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/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..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 @@ -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,11 @@ public class DimensionTypeResponse { private String name; public static DimensionTypeResponse of(DimensionType type) { - return of(type.getId(), type.getName()); + 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/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..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,2 +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 \ No newline at end of file +error.user_not_found=The user does not exist or the id does not meet the rule 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..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,2 +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}] \ No newline at end of file +error.user_not_found=\u7528\u6237\u4E0D\u5B58\u5728\u6216ID\u4E0D\u7B26\u5408\u89C4\u5219:[{0}] 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 58271e8a0..c299c51af 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 @@ -14,8 +14,11 @@ import reactor.test.StepVerifier; @RunWith(SpringRunner.class) -@SpringBootTest(classes = ReactiveTestApplication.class, - properties = "spring.r2dbc.generate-unique-name=true") +// 每个测试类使用独立数据库和认证状态,避免 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/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..8149dcbc0 --- /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,135 @@ +package org.hswebframework.web.system.authorization.defaults.service; + +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; +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.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.Assert.*; + +/** 真实数据库与绑定事件链保持旧 ID 的保存、查询及解绑行为。 */ +@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 BindingEvents events; + + @Test + public void generatedLegacyIdSurvivesSaveQueryAndUnbind() { + DimensionUserEntity member = member(); + member.generateId(); + verifySaveQueryAndUnbind(member); + } + + @Test + 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) { + 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 static DimensionUserEntity member() { + String token = IDGenerator.MD5.generate(); + DimensionUserEntity member = new DimensionUserEntity(); + member.setDimensionTypeId("role"); + member.setDimensionId("dimension-" + token); + member.setDimensionName("dimension"); + member.setUserId("user-" + token); + member.setUserName("user"); + member.setRelationName("relation-before"); + return member; + } + + 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 unbound(DimensionUnbindEvent event) { + unbound.add(event); + } + } + + @TestConfiguration + static class Configuration { + @Bean + BindingEvents bindingEvents() { + return new BindingEvents(); + } + } +} 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 5526ecfa6..33c20f828 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 @@ -41,7 +41,11 @@ import static org.mockito.Mockito.when; @RunWith(SpringRunner.class) -@SpringBootTest(classes = ReactiveTestApplication.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 4bd78be6f..465d3a433 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 @@ -16,7 +16,11 @@ import java.util.function.Supplier; @RunWith(SpringRunner.class) -@SpringBootTest(classes = ReactiveTestApplication.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 c0f6f25d2..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 @@ -21,8 +21,11 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; @RunWith(SpringRunner.class) -@WebFluxTest(value = WebFluxPermissionController.class, - properties = "spring.r2dbc.generate-unique-name=true") +// 每个测试类使用独立数据库和认证状态,避免 Spring 上下文复用缓存及 H2 默认库跨类共享。 +@WebFluxTest(controllers = WebFluxPermissionController.class, properties = { + "spring.r2dbc.generate-unique-name=true", + "spring.r2dbc.name=WebFluxPermissionControllerTest" +}) @ImportAutoConfiguration(value = { AuthorizationWebAutoConfiguration.class, AuthorizationServiceAutoConfiguration.class, 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..3d93a5c9d --- /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,69 @@ +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.DefaultDimensionType; +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()); + DimensionTypeResponse role = DimensionTypeResponse.of(DefaultDimensionType.role); + assertEquals("role", role.getId()); + assertEquals(DefaultDimensionType.role.getName(), 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; + } +}