1
0

Compare commits

...

9 Commits

Author SHA1 Message Date
1b48081d5b build(deps): 统一管理 datasource-decorator 依赖版本并优化配置
- 在父 pom.xml 中统一管理 datasource-decorator.version 为 2.0.0
- 添加 xbatis 依赖管理,移除子模块中的重复配置
- 为 JPA 模块添加 p6spy 依赖,配置 SQL 日志格式
2026-01-07 16:28:06 +08:00
e4c0dc4884 refactor(jpa): 将测试数据中的字段名从 "in" 重命名为 "inside 2026-01-07 16:27:42 +08:00
8fc53e6fda feat(xbatis): 完成xbatis框架的适配 2026-01-07 16:21:00 +08:00
919664ba84 refactor(common): 将 Page 实体的 items 类型从 Stream 改为 List 2026-01-07 15:21:32 +08:00
f439381e04 refactor(service): 将查询条件解析逻辑封装到 QueryParser 中 2026-01-07 13:02:47 +08:00
657f9593ba refactor(jpa): 重命名 listPredicate 方法为 commonPredicates 2026-01-07 11:38:02 +08:00
af4be9db8f refactor(service): 将删除服务接口参数类型从 Iterable 改为 Set 2026-01-07 11:29:03 +08:00
d08f9db9ac refactor(exception): 将内部异常类抽取为公共异常类
将 SimpleServiceSupport 中的静态内部异常类抽取到 common 模块的独立异常类文件中,包括:
- IdNotFoundException: ID 未找到异常
- NotComparableException: 不可比较异常
- NotCollectionException: 非集合异常
- NotStringException: 非字符串异常
2026-01-07 11:17:03 +08:00
5bf6e9ecdc refactor(jpa): 将 Snowflake ID 生成逻辑抽取为公共工具类 2026-01-07 09:47:42 +08:00
37 changed files with 1579 additions and 458 deletions

22
pom.xml
View File

@@ -12,6 +12,7 @@
<modules>
<module>spring-boot-service-template-common</module>
<module>spring-boot-service-template-jpa</module>
<module>spring-boot-service-template-xbatis</module>
</modules>
<properties>
@@ -26,6 +27,7 @@
<querydsl.version>7.1</querydsl.version>
<mapstruct.version>1.6.3</mapstruct.version>
<mapstruct-plus.version>1.5.0</mapstruct-plus.version>
<datasource-decorator.version>2.0.0</datasource-decorator.version>
</properties>
<dependencies>
@@ -88,11 +90,31 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<version>${datasource-decorator.version}</version>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>datasource-proxy-spring-boot-starter</artifactId>
<version>${datasource-decorator.version}</version>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
<version>1.0.0</version>
</dependency>
<!-- xbatis -->
<dependency>
<groupId>cn.xbatis</groupId>
<artifactId>xbatis-spring-boot-parent</artifactId>
<version>1.9.6-spring-boot4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -1,5 +1,8 @@
package com.lanyuanxiaoyao.service.template.common.controller;
import com.lanyuanxiaoyao.service.template.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
/**
* 查询控制器接口,用于定义统一的查询实体详情和列表的接口规范
* <p>

View File

@@ -1,5 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.controller;
import com.lanyuanxiaoyao.service.template.common.entity.GlobalResponse;
/**
* 删除控制器接口,用于定义统一的删除实体对象的接口规范
* <p>

View File

@@ -1,5 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.controller;
import com.lanyuanxiaoyao.service.template.common.entity.GlobalResponse;
/**
* 保存控制器接口,用于定义统一的保存实体对象的接口规范
* <p>

View File

@@ -1,4 +1,4 @@
package com.lanyuanxiaoyao.service.template.common.controller;
package com.lanyuanxiaoyao.service.template.common.entity;
import java.util.List;
import java.util.Map;

View File

@@ -1,6 +1,6 @@
package com.lanyuanxiaoyao.service.template.common.service;
package com.lanyuanxiaoyao.service.template.common.entity;
import java.util.stream.Stream;
import java.util.List;
/**
* 分页数据封装类
@@ -27,5 +27,5 @@ import java.util.stream.Stream;
* @param items 数据流包含当前页的所有记录
* @param total 总记录数用于计算总页数和显示分页信息
*/
public record Page<ENTITY>(Stream<ENTITY> items, long total) {
public record Page<ENTITY>(List<ENTITY> items, long total) {
}

View File

@@ -1,5 +1,6 @@
package com.lanyuanxiaoyao.service.template.common.controller;
package com.lanyuanxiaoyao.service.template.common.entity;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
@@ -116,8 +117,8 @@ public record Query(
List<String> notNullEqual,
List<String> empty,
List<String> notEmpty,
Map<String, Object> equal,
Map<String, Object> notEqual,
Map<String, ? extends Serializable> equal,
Map<String, ? extends Serializable> notEqual,
Map<String, String> like,
Map<String, String> notLike,
Map<String, String> contain,
@@ -126,12 +127,12 @@ public record Query(
Map<String, String> notStartWith,
Map<String, String> endWith,
Map<String, String> notEndWith,
Map<String, Object> great,
Map<String, Object> less,
Map<String, Object> greatEqual,
Map<String, Object> lessEqual,
Map<String, List<Object>> inside,
Map<String, List<Object>> notInside,
Map<String, ? extends Serializable> great,
Map<String, ? extends Serializable> less,
Map<String, ? extends Serializable> greatEqual,
Map<String, ? extends Serializable> lessEqual,
Map<String, List<? extends Serializable>> inside,
Map<String, List<? extends Serializable>> notInside,
Map<String, Between> between,
Map<String, Between> notBetween
) {

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.exception;
public class IdNotFoundException extends RuntimeException {
public IdNotFoundException(Long id) {
super("ID为 %d 的资源不存在".formatted(id));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.exception;
public class NotCollectionException extends RuntimeException {
public NotCollectionException(String variable) {
super("变量 %s 不是集合".formatted(variable));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.exception;
public class NotComparableException extends RuntimeException {
public NotComparableException(String variable) {
super("变量 %s 不能比较".formatted(variable));
}
}

View File

@@ -0,0 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.exception;
public class NotStringException extends RuntimeException {
public NotStringException(String variable) {
super("变量 %s 不是字符串".formatted(variable));
}
}

View File

@@ -0,0 +1,68 @@
package com.lanyuanxiaoyao.service.template.common.helper;
import java.time.Instant;
public class SnowflakeHelper {
/**
* 起始的时间戳
*/
private final static long START_TIMESTAMP = 1;
/**
* 序列号占用的位数
*/
private final static long SEQUENCE_BIT = 11;
/**
* 序列号最大值
*/
private final static long MAX_SEQUENCE_BIT = ~(-1 << SEQUENCE_BIT);
/**
* 时间戳值向左位移
*/
private final static long TIMESTAMP_OFFSET = SEQUENCE_BIT;
/**
* 序列号
*/
private static long sequence = 0;
/**
* 上一次时间戳
*/
private static long lastTimestamp = -1;
public static synchronized long next() {
long currentTimestamp = nowTimestamp();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock have moved backwards.");
}
if (currentTimestamp == lastTimestamp) {
// 相同毫秒内, 序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE_BIT;
// 同一毫秒的序列数已经达到最大
if (sequence == 0) {
currentTimestamp = nextTimestamp();
}
} else {
// 不同毫秒内, 序列号置为0
sequence = 0;
}
lastTimestamp = currentTimestamp;
return (currentTimestamp - START_TIMESTAMP) << TIMESTAMP_OFFSET | sequence;
}
private static long nextTimestamp() {
long milli = nowTimestamp();
while (milli <= lastTimestamp) {
milli = nowTimestamp();
}
return milli;
}
private static long nowTimestamp() {
return Instant.now().toEpochMilli();
}
}

View File

@@ -0,0 +1,125 @@
package com.lanyuanxiaoyao.service.template.common.service;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
public abstract class QueryParser<O> {
protected abstract void nullEqual(Query.Queryable queryable);
protected abstract void notNullEqual(Query.Queryable queryable);
protected abstract void empty(Query.Queryable queryable);
protected abstract void notEmpty(Query.Queryable queryable);
protected abstract void equal(Query.Queryable queryable);
protected abstract void notEqual(Query.Queryable queryable);
protected abstract void like(Query.Queryable queryable);
protected abstract void notLike(Query.Queryable queryable);
protected abstract void contain(Query.Queryable queryable);
protected abstract void notContain(Query.Queryable queryable);
protected abstract void startWith(Query.Queryable queryable);
protected abstract void notStartWith(Query.Queryable queryable);
protected abstract void endWith(Query.Queryable queryable);
protected abstract void notEndWith(Query.Queryable queryable);
protected abstract void great(Query.Queryable queryable);
protected abstract void less(Query.Queryable queryable);
protected abstract void greatEqual(Query.Queryable queryable);
protected abstract void lessEqual(Query.Queryable queryable);
protected abstract void inside(Query.Queryable queryable);
protected abstract void notInside(Query.Queryable queryable);
protected abstract void between(Query.Queryable queryable);
protected abstract void notBetween(Query.Queryable queryable);
protected abstract O build();
public O build(Query.Queryable queryable) {
if (ObjectHelper.isNull(queryable)) {
return null;
}
if (ObjectHelper.isNotEmpty(queryable.nullEqual())) {
nullEqual(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notNullEqual())) {
notNullEqual(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.empty())) {
empty(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notEmpty())) {
notEmpty(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.equal())) {
equal(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notEqual())) {
notEqual(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.like())) {
like(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notLike())) {
notLike(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.contain())) {
contain(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notContain())) {
notContain(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.startWith())) {
startWith(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notStartWith())) {
notStartWith(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.endWith())) {
endWith(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notEndWith())) {
notEndWith(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.great())) {
great(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.less())) {
less(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.greatEqual())) {
greatEqual(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.lessEqual())) {
lessEqual(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.inside())) {
inside(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notInside())) {
notInside(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.between())) {
between(queryable);
}
if (ObjectHelper.isNotEmpty(queryable.notBetween())) {
notBetween(queryable);
}
return build();
}
}

View File

@@ -1,6 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.service;
import com.lanyuanxiaoyao.service.template.common.controller.Query;
import com.lanyuanxiaoyao.service.template.common.entity.Page;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import java.util.List;
import java.util.Set;

View File

@@ -1,5 +1,7 @@
package com.lanyuanxiaoyao.service.template.common.service;
import java.util.Set;
/**
* 删除服务接口,用于定义统一的删除实体对象的服务规范
* <p>
@@ -33,5 +35,5 @@ public interface RemoveService<ENTITY> {
* @param ids 需要删除的实体ID集合
* @throws Exception 删除过程中可能抛出的异常
*/
void remove(Iterable<Long> ids) throws Exception;
void remove(Set<Long> ids) throws Exception;
}

View File

@@ -46,6 +46,11 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>

View File

@@ -1,8 +1,8 @@
package com.lanyuanxiaoyao.service.template.jpa.controller;
import com.lanyuanxiaoyao.service.template.common.controller.GlobalResponse;
import com.lanyuanxiaoyao.service.template.common.controller.Query;
import com.lanyuanxiaoyao.service.template.common.controller.SimpleController;
import com.lanyuanxiaoyao.service.template.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.jpa.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.jpa.service.SimpleServiceSupport;
@@ -127,6 +127,7 @@ public abstract class SimpleControllerSupport<ENTITY extends SimpleEntity, SAVE_
var result = service.list(query);
return GlobalResponse.responseListData(
result.items()
.stream()
.map(entity -> {
try {
return mapper.apply(entity);

View File

@@ -1,7 +1,7 @@
package com.lanyuanxiaoyao.service.template.jpa.entity;
import com.lanyuanxiaoyao.service.template.common.helper.SnowflakeHelper;
import java.io.Serializable;
import java.time.Instant;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.IdentifierGenerator;
@@ -11,75 +11,10 @@ public class SnowflakeIdGenerator implements IdentifierGenerator {
@Override
public Serializable generate(SharedSessionContractImplementor session, Object object) {
try {
return Snowflake.next();
return SnowflakeHelper.next();
} catch (Exception e) {
log.error("Generate snowflake id failed", e);
throw new RuntimeException(e);
}
}
public static class Snowflake {
/**
* 起始的时间戳
*/
private final static long START_TIMESTAMP = 1;
/**
* 序列号占用的位数
*/
private final static long SEQUENCE_BIT = 11;
/**
* 序列号最大值
*/
private final static long MAX_SEQUENCE_BIT = ~(-1 << SEQUENCE_BIT);
/**
* 时间戳值向左位移
*/
private final static long TIMESTAMP_OFFSET = SEQUENCE_BIT;
/**
* 序列号
*/
private static long sequence = 0;
/**
* 上一次时间戳
*/
private static long lastTimestamp = -1;
public static synchronized long next() {
long currentTimestamp = nowTimestamp();
if (currentTimestamp < lastTimestamp) {
throw new RuntimeException("Clock have moved backwards.");
}
if (currentTimestamp == lastTimestamp) {
// 相同毫秒内, 序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE_BIT;
// 同一毫秒的序列数已经达到最大
if (sequence == 0) {
currentTimestamp = nextTimestamp();
}
} else {
// 不同毫秒内, 序列号置为0
sequence = 0;
}
lastTimestamp = currentTimestamp;
return (currentTimestamp - START_TIMESTAMP) << TIMESTAMP_OFFSET | sequence;
}
private static long nextTimestamp() {
long milli = nowTimestamp();
while (milli <= lastTimestamp) {
milli = nowTimestamp();
}
return milli;
}
private static long nowTimestamp() {
return Instant.now().toEpochMilli();
}
}
}

View File

@@ -1,8 +1,13 @@
package com.lanyuanxiaoyao.service.template.jpa.service;
import com.lanyuanxiaoyao.service.template.common.controller.Query;
import com.lanyuanxiaoyao.service.template.common.entity.Page;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import com.lanyuanxiaoyao.service.template.common.exception.IdNotFoundException;
import com.lanyuanxiaoyao.service.template.common.exception.NotCollectionException;
import com.lanyuanxiaoyao.service.template.common.exception.NotComparableException;
import com.lanyuanxiaoyao.service.template.common.exception.NotStringException;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.common.service.Page;
import com.lanyuanxiaoyao.service.template.common.service.QueryParser;
import com.lanyuanxiaoyao.service.template.common.service.SimpleService;
import com.lanyuanxiaoyao.service.template.jpa.entity.IdOnlyEntity;
import com.lanyuanxiaoyao.service.template.jpa.entity.SimpleEntity;
@@ -51,7 +56,7 @@ import org.springframework.data.domain.Sort;
* <h3>使用说明</h3>
* <p>子类可以重写以下方法:</p>
* <ul>
* <li>listPredicate(): 添加自定义的查询条件</li>
* <li>commonPredicates(): 添加自定义的查询条件</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
@@ -114,7 +119,7 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
*/
@Override
public Long count() {
return repository.count(this::listPredicate);
return repository.count(this::commonPredicates);
}
/**
@@ -127,7 +132,7 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
*/
@Override
public List<ENTITY> list() {
return repository.findAll(this::listPredicate);
return repository.findAll(this::commonPredicates);
}
/**
@@ -146,7 +151,7 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
}
return repository.findAll(
(root, query, builder) -> {
var predicate = listPredicate(root, query, builder);
var predicate = commonPredicates(root, query, builder);
var idsPredicate = builder.in(root.get(IdOnlyEntity.Fields.id)).value(ids);
return ObjectHelper.isNull(predicate)
? idsPredicate
@@ -155,313 +160,7 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
);
}
/**
* 解析字段路径
* <p>
* 支持多级字段路径解析,使用"."分隔多级字段。
* 例如: "user.name" 表示实体的user属性的name字段。
* </p>
*
* @param root JPA Criteria查询根节点
* @param column 字段路径字符串
* @param <Y> 字段类型
* @return 返回字段路径对象
* @throws IllegalArgumentException 当字段路径为空时抛出
*/
private <Y> Path<Y> column(Root<ENTITY> root, String column) {
if (ObjectHelper.isEmpty(column)) {
throw new IllegalArgumentException("Column cannot be blank");
}
var columns = column.split("\\.");
Path<Y> path = root.get(columns[0]);
for (int i = 1; i < columns.length; i++) {
path = path.get(columns[i]);
}
return path;
}
/**
* 处理字段值
* <p>
* 对于枚举类型字段,将字符串值转换为对应的枚举值。
* 对于LocalDateTime类型字段将字符串转换为时间对象。
* 其他类型直接返回原值。
* </p>
*
* @param column 字段路径
* @param value 字段值
* @param <Y> 字段类型
* @return 处理后的字段值
* @throws IllegalArgumentException 当枚举类型字段的值不是字符串时抛出
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private <Y> Object value(Path<Y> column, Object value) {
if (ObjectHelper.isNull(value)) {
return null;
}
var javaType = column.getJavaType();
if (javaType.isEnum()) {
if (value instanceof String enumName) {
var enumType = (Class<Enum>) javaType;
return Enum.valueOf(enumType, enumName);
} else {
throw new IllegalArgumentException("枚举类型字段需要 String 类型的值");
}
} else if (javaType.isAssignableFrom(LocalDateTime.class)) {
return LocalDateTime.parse(String.valueOf(value), DATE_TIME_FORMATTER);
}
return value;
}
/**
* 构建查询条件谓词列表
* <p>
* 根据Query.Queryable对象构建JPA Criteria查询的谓词列表。
* 支持多种查询条件类型,包括相等、不等、模糊匹配、范围查询等。
* </p>
*
* @param queryable 查询条件对象
* @param root JPA Criteria查询根节点
* @param query JPA Criteria查询对象
* @param builder JPA Criteria构建器
* @return 返回构建的谓词列表
*/
@SuppressWarnings("unchecked")
protected Predicate queryPredicates(Query.Queryable queryable, Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
var predicates = new ArrayList<Predicate>();
if (ObjectHelper.isNull(queryable)) {
return null;
}
if (ObjectHelper.isNotEmpty(queryable.nullEqual())) {
queryable.nullEqual().forEach(column -> predicates.add(builder.isNull(column(root, column))));
}
if (ObjectHelper.isNotEmpty(queryable.notNullEqual())) {
queryable.notNullEqual().forEach(column -> predicates.add(builder.isNotNull(column(root, column))));
}
if (ObjectHelper.isNotEmpty(queryable.empty())) {
queryable.empty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isEmpty(path));
});
}
if (ObjectHelper.isNotEmpty(queryable.notEmpty())) {
queryable.notEmpty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isNotEmpty(path));
});
}
if (ObjectHelper.isNotEmpty(queryable.equal())) {
queryable.equal().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.equal(path, value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.notEqual())) {
queryable.notEqual().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.notEqual(path, value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.like())) {
queryable.like().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value));
});
}
if (ObjectHelper.isNotEmpty(queryable.notLike())) {
queryable.notLike().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value));
});
}
if (ObjectHelper.isNotEmpty(queryable.contain())) {
queryable.contain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value + "%"));
});
}
if (ObjectHelper.isNotEmpty(queryable.notContain())) {
queryable.notContain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value + "%"));
});
}
if (ObjectHelper.isNotEmpty(queryable.startWith())) {
queryable.startWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value + "%"));
});
}
if (ObjectHelper.isNotEmpty(queryable.notStartWith())) {
queryable.notStartWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value + "%"));
});
}
if (ObjectHelper.isNotEmpty(queryable.endWith())) {
queryable.endWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value));
});
}
if (ObjectHelper.isNotEmpty(queryable.notEndWith())) {
queryable.notEndWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value));
});
}
if (ObjectHelper.isNotEmpty(queryable.great())) {
queryable.great().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThan(path, (Comparable<Object>) value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.less())) {
queryable.less().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThan(path, (Comparable<Object>) value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.greatEqual())) {
queryable.greatEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.lessEqual())) {
queryable.lessEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
if (ObjectHelper.isNotEmpty(queryable.inside())) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue())));
}
if (ObjectHelper.isNotEmpty(queryable.notInside())) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue()).not()));
}
if (ObjectHelper.isNotEmpty(queryable.between())) {
queryable.between().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())));
});
}
if (ObjectHelper.isNotEmpty(queryable.notBetween())) {
queryable.notBetween().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())).not());
});
}
return predicates.size() == 1
? predicates.get(0)
: builder.and(predicates.toArray(Predicate[]::new));
}
/**
* 检查字段类型是否可比较
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotComparableException 当字段类型不可比较时抛出
*/
private void checkComparable(Path<?> path, Object value, String column) {
if (!ObjectHelper.isComparable(path.getJavaType()) || !ObjectHelper.isComparable(value)) {
throw new NotComparableException(column);
}
}
/**
* 检查区间值是否可比较
*
* @param path 字段路径
* @param value 区间对象
* @param column 字段名称
* @throws NotComparableException 当区间值不可比较时抛出
*/
private void checkComparable(Path<?> path, Query.Queryable.Between value, String column) {
checkComparable(path, value.start(), column);
checkComparable(path, value.end(), column);
}
/**
* 检查字段类型是否为集合
*
* @param path 字段路径
* @param column 字段名称
* @throws NotCollectionException 当字段类型不是集合时抛出
*/
private void checkCollection(Path<?> path, String column) {
if (!ObjectHelper.isCollection(path.getJavaType())) {
throw new NotCollectionException(column);
}
}
/**
* 检查值是否为集合
*
* @param value 值对象
* @param column 字段名称
* @throws NotCollectionException 当值不是集合时抛出
*/
private void checkCollection(Object value, String column) {
if (!ObjectHelper.isCollection(value)) {
throw new NotCollectionException(column);
}
}
/**
* 检查字段类型是否为字符串
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotStringException 当字段类型不是字符串时抛出
*/
private void checkString(Path<?> path, Object value, String column) {
if (!ObjectHelper.isString(path.getJavaType()) || !ObjectHelper.isString(value)) {
throw new NotStringException(column);
}
}
/**
* 构建列表查询条件
* <p>
* 子类可以重写此方法以添加特定的查询条件。
* 默认返回null表示不添加额外条件。
* </p>
*
* @param root JPA Criteria查询根节点
* @param query JPA Criteria查询对象
* @param builder JPA Criteria构建器
* @return 返回查询条件谓词
*/
protected Predicate listPredicate(Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
protected Predicate commonPredicates(Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
return null;
}
@@ -494,15 +193,15 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
}
var result = repository.findAll(
(root, query, builder) -> {
var predicate = listPredicate(root, query, builder);
var queryPredicate = queryPredicates(listQuery.query(), root, query, builder);
var predicate = commonPredicates(root, query, builder);
var queryPredicate = new JpaQueryParser<>(root, query, builder).build(listQuery.query());
return ObjectHelper.isNull(predicate)
? queryPredicate
: builder.and(predicate, queryPredicate);
},
pageRequest
);
return new Page<>(result.get(), result.getTotalElements());
return new Page<>(result.get().toList(), result.getTotalElements());
}
/**
@@ -520,7 +219,7 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
}
return repository.findOne(
(root, query, builder) -> {
var predicate = listPredicate(root, query, builder);
var predicate = commonPredicates(root, query, builder);
var idPredicate = builder.equal(root.get(IdOnlyEntity.Fields.id), id);
return ObjectHelper.isNull(predicate)
? idPredicate
@@ -589,77 +288,344 @@ public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implemen
*/
@Transactional(rollbackOn = Throwable.class)
@Override
public void remove(Iterable<Long> ids) {
public void remove(Set<Long> ids) {
if (ObjectHelper.isNotEmpty(ids)) {
repository.deleteBatchByIds(ids);
}
}
/**
* ID未找到异常
* <p>
* 当根据ID查询实体但实体不存在时抛出此异常。
* </p>
*/
public static final class IdNotFoundException extends RuntimeException {
/**
* 构造函数带ID参数
*
* @param id 实体ID
*/
public IdNotFoundException(Long id) {
super("ID为 %d 的资源不存在".formatted(id));
}
}
@SuppressWarnings("unchecked")
private static final class JpaQueryParser<ENTITY> extends QueryParser<Predicate> {
private final Root<ENTITY> root;
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final CriteriaQuery<?> query;
private final CriteriaBuilder builder;
private final List<Predicate> predicates = new ArrayList<>();
/**
* 不可比较异常
* <p>
* 当尝试对不可比较的字段或值执行比较操作时抛出此异常。
* </p>
*/
public static final class NotComparableException extends RuntimeException {
/**
* 构造函数
*
* @param variable 变量名称
*/
public NotComparableException(String variable) {
super("变量 %s 不能比较".formatted(variable));
private JpaQueryParser(Root<ENTITY> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
this.root = root;
this.query = query;
this.builder = builder;
}
}
/**
* 非集合异常
* <p>
* 当尝试对非集合类型的字段或值执行集合操作时抛出此异常。
* </p>
*/
public static final class NotCollectionException extends RuntimeException {
/**
* 构造函数
* 解析字段路径
* <p>
* 支持多级字段路径解析,使用"."分隔多级字段。
* 例如: "user.name" 表示实体的user属性的name字段。
* </p>
*
* @param variable 变量名称
* @param root JPA Criteria查询根节点
* @param column 字段路径字符串
* @param <Y> 字段类型
* @return 返回字段路径对象
* @throws IllegalArgumentException 当字段路径为空时抛出
*/
public NotCollectionException(String variable) {
super("变量 %s 不是集合".formatted(variable));
private <Y> Path<Y> column(Root<ENTITY> root, String column) {
if (ObjectHelper.isEmpty(column)) {
throw new IllegalArgumentException("Column cannot be blank");
}
var columns = column.split("\\.");
Path<Y> path = root.get(columns[0]);
for (int i = 1; i < columns.length; i++) {
path = path.get(columns[i]);
}
return path;
}
}
/**
* 非字符串异常
* <p>
* 当尝试对非字符串类型的字段或值执行字符串操作时抛出此异常。
* </p>
*/
public static final class NotStringException extends RuntimeException {
/**
* 构造函数
* 处理字段值
* <p>
* 对于枚举类型字段,将字符串值转换为对应的枚举值。
* 对于LocalDateTime类型字段将字符串转换为时间对象。
* 其他类型直接返回原值。
* </p>
*
* @param variable 变量名称
* @param column 字段路径
* @param value 字段值
* @param <Y> 字段类型
* @return 处理后的字段值
* @throws IllegalArgumentException 当枚举类型字段的值不是字符串时抛出
*/
public NotStringException(String variable) {
super("变量 %s 不是字符串".formatted(variable));
@SuppressWarnings({"unchecked", "rawtypes"})
private <Y> Object value(Path<Y> column, Object value) {
if (ObjectHelper.isNull(value)) {
return null;
}
var javaType = column.getJavaType();
if (javaType.isEnum()) {
if (value instanceof String enumName) {
var enumType = (Class<Enum>) javaType;
return Enum.valueOf(enumType, enumName);
} else {
throw new IllegalArgumentException("枚举类型字段需要 String 类型的值");
}
} else if (javaType.isAssignableFrom(LocalDateTime.class)) {
return LocalDateTime.parse(String.valueOf(value), DATE_TIME_FORMATTER);
}
return value;
}
/**
* 检查字段类型是否可比较
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotComparableException 当字段类型不可比较时抛出
*/
private void checkComparable(Path<?> path, Object value, String column) {
if (!ObjectHelper.isComparable(path.getJavaType()) || !ObjectHelper.isComparable(value)) {
throw new NotComparableException(column);
}
}
/**
* 检查区间值是否可比较
*
* @param path 字段路径
* @param value 区间对象
* @param column 字段名称
* @throws NotComparableException 当区间值不可比较时抛出
*/
private void checkComparable(Path<?> path, Query.Queryable.Between value, String column) {
checkComparable(path, value.start(), column);
checkComparable(path, value.end(), column);
}
/**
* 检查字段类型是否为集合
*
* @param path 字段路径
* @param column 字段名称
* @throws NotCollectionException 当字段类型不是集合时抛出
*/
private void checkCollection(Path<?> path, String column) {
if (!ObjectHelper.isCollection(path.getJavaType())) {
throw new NotCollectionException(column);
}
}
/**
* 检查值是否为集合
*
* @param value 值对象
* @param column 字段名称
* @throws NotCollectionException 当值不是集合时抛出
*/
private void checkCollection(Object value, String column) {
if (!ObjectHelper.isCollection(value)) {
throw new NotCollectionException(column);
}
}
/**
* 检查字段类型是否为字符串
*
* @param path 字段路径
* @param value 比较值
* @param column 字段名称
* @throws NotStringException 当字段类型不是字符串时抛出
*/
private void checkString(Path<?> path, Object value, String column) {
if (!ObjectHelper.isString(path.getJavaType()) || !ObjectHelper.isString(value)) {
throw new NotStringException(column);
}
}
@Override
protected void nullEqual(Query.Queryable queryable) {
queryable.nullEqual().forEach(column -> predicates.add(builder.isNull(column(root, column))));
}
@Override
protected void notNullEqual(Query.Queryable queryable) {
queryable.notNullEqual().forEach(column -> predicates.add(builder.isNotNull(column(root, column))));
}
@Override
protected void empty(Query.Queryable queryable) {
queryable.empty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isEmpty(path));
});
}
@Override
protected void notEmpty(Query.Queryable queryable) {
queryable.notEmpty().forEach(column -> {
var path = this.<Collection<Object>>column(root, column);
checkCollection(path, column);
predicates.add(builder.isNotEmpty(path));
});
}
@Override
protected void equal(Query.Queryable queryable) {
queryable.equal().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.equal(path, value(path, value)));
});
}
@Override
protected void notEqual(Query.Queryable queryable) {
queryable.notEqual().forEach((column, value) -> {
var path = column(root, column);
predicates.add(builder.notEqual(path, value(path, value)));
});
}
@Override
protected void like(Query.Queryable queryable) {
queryable.like().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value));
});
}
@Override
protected void notLike(Query.Queryable queryable) {
queryable.notLike().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value));
});
}
@Override
protected void contain(Query.Queryable queryable) {
queryable.contain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value + "%"));
});
}
@Override
protected void notContain(Query.Queryable queryable) {
queryable.notContain().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value + "%"));
});
}
@Override
protected void startWith(Query.Queryable queryable) {
queryable.startWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, value + "%"));
});
}
@Override
protected void notStartWith(Query.Queryable queryable) {
queryable.notStartWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, value + "%"));
});
}
@Override
protected void endWith(Query.Queryable queryable) {
queryable.endWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.like(path, "%" + value));
});
}
@Override
protected void notEndWith(Query.Queryable queryable) {
queryable.notEndWith().forEach((column, value) -> {
var path = this.<String>column(root, column);
checkString(path, value, column);
predicates.add(builder.notLike(path, "%" + value));
});
}
@Override
protected void great(Query.Queryable queryable) {
queryable.great().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThan(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void less(Query.Queryable queryable) {
queryable.less().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThan(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void greatEqual(Query.Queryable queryable) {
queryable.greatEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.greaterThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void lessEqual(Query.Queryable queryable) {
queryable.lessEqual().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.lessThanOrEqualTo(path, (Comparable<Object>) value(path, value)));
});
}
@Override
protected void inside(Query.Queryable queryable) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue())));
}
@Override
protected void notInside(Query.Queryable queryable) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> predicates.add(builder.in(column(root, entry.getKey())).value(entry.getValue()).not()));
}
@Override
protected void between(Query.Queryable queryable) {
queryable.between().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())));
});
}
@Override
protected void notBetween(Query.Queryable queryable) {
queryable.notBetween().forEach((column, value) -> {
var path = this.<Comparable<Object>>column(root, column);
checkComparable(path, value, column);
predicates.add(builder.between(path, (Comparable<Object>) value(path, value.start()), (Comparable<Object>) value(path, value.end())).not());
});
}
@Override
protected Predicate build() {
return predicates.size() == 1
? predicates.get(0)
: builder.and(predicates.toArray(Predicate[]::new));
}
}
}

View File

@@ -90,7 +90,7 @@ public class TestApplication {
" \"members\": 0,\n" +
" \"createdTime\": \"2025-01-01 00:00:00\"\n" +
" },\n" +
" \"in\": {\n" +
" \"inside\": {\n" +
" \"name\": [\n" +
" \"Apple\",\n" +
" \"Banana\"\n" +

View File

@@ -9,7 +9,15 @@ spring:
password: test
driver-class-name: org.h2.Driver
jpa:
show-sql: true
generate-ddl: true
fenix:
print-banner: false
decorator:
datasource:
p6spy:
multiline: false
exclude-categories:
- commit
- result
- resultset
log-format: "%(category)|%(executionTime)|%(sqlSingleLine)"

View File

@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template</artifactId>
<version>1.1.0-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-service-template-xbatis</artifactId>
<dependencies>
<dependency>
<groupId>com.lanyuanxiaoyao</groupId>
<artifactId>spring-boot-service-template-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.xbatis</groupId>
<artifactId>xbatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,16 @@
package com.lanyuanxiaoyao.service.template.xbatis.configuration;
import cn.xbatis.core.incrementer.GeneratorFactory;
import cn.xbatis.core.mybatis.mapper.BasicMapper;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SnowflakeIdGenerator;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan(basePackageClasses = MybatisBasicMapper.class, markerInterface = BasicMapper.class)
public class MybatisConfiguration {
static {
GeneratorFactory.register("snowflake", new SnowflakeIdGenerator());
}
}

View File

@@ -0,0 +1,214 @@
package com.lanyuanxiaoyao.service.template.xbatis.controller;
import com.lanyuanxiaoyao.service.template.common.controller.SimpleController;
import com.lanyuanxiaoyao.service.template.common.entity.GlobalResponse;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.xbatis.service.SimpleServiceSupport;
import java.util.function.Function;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* 简单控制器支持类提供基础的CRUD操作实现
* <p>
* 该类实现了基本的增删改查功能,通过泛型支持不同类型的数据转换。
* 子类需要实现对应的Mapper函数来完成实体类与传输对象之间的转换。
* </p>
*
* <h3>设计特点</h3>
* <ul>
* <li>泛型设计,支持任意实体类型和数据转换</li>
* <li>统一的异常处理和事务管理</li>
* <li>支持条件查询、分页查询和详情查询</li>
* <li>提供抽象的Mapper方法便于子类实现数据转换逻辑</li>
* </ul>
*
* <h3>使用说明</h3>
* <p>子类需要实现以下抽象方法:</p>
* <ul>
* <li>saveItemMapper(): 保存项到实体的转换函数</li>
* <li>listItemMapper(): 实体到列表项的转换函数</li>
* <li>detailItemMapper(): 实体到详情项的转换函数</li>
* </ul>
*
* @param <ENTITY> 实体类型必须继承SimpleEntity
* @param <SAVE_ITEM> 保存项类型
* @param <LIST_ITEM> 列表项类型
* @param <DETAIL_ITEM> 详情项类型
*/
@Slf4j
public abstract class SimpleControllerSupport<ENTITY extends SimpleEntity, SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> implements SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM> {
protected final SimpleServiceSupport<ENTITY> service;
/**
* 构造函数
*
* @param service 简单服务支持类实例
*/
public SimpleControllerSupport(SimpleServiceSupport<ENTITY> service) {
this.service = service;
}
/**
* 保存实体对象
* <p>
* 将保存项转换为实体对象后保存返回保存后的实体ID。
* 支持新增和更新操作,通过事务保证数据一致性。
* </p>
*
* @param item 需要保存的项
* @return 返回保存后的实体ID响应对象格式{status: 0, message: "OK", data: 实体ID}
* @throws Exception 保存过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@PostMapping(SAVE)
@Override
public GlobalResponse<Long> save(@RequestBody SAVE_ITEM item) throws Exception {
var mapper = saveItemMapper();
return GlobalResponse.responseSuccess(service.save(mapper.apply(item)));
}
/**
* 获取所有实体列表
* <p>
* 查询所有记录,不带任何过滤条件,返回分页格式的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @return 返回实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list() throws Exception {
var mapper = listItemMapper();
var result = service.list();
return GlobalResponse.responseListData(
result
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.size()
);
}
/**
* 根据查询条件获取实体列表
* <p>
* 支持复杂的查询条件、排序和分页,返回符合条件的数据。
* 将实体对象转换为列表项对象后返回。
* </p>
*
* @param query 查询条件对象,包含过滤条件、排序规则和分页信息
* @return 返回符合条件的实体列表响应对象,格式:{status: 0, message: "OK", data: {items: [...], total: total}}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@PostMapping(LIST)
@Override
public GlobalResponse<GlobalResponse.ListItem<LIST_ITEM>> list(@RequestBody Query query) throws Exception {
if (ObjectHelper.isNull(query)) {
return GlobalResponse.responseListData();
}
var mapper = listItemMapper();
var result = service.list(query);
return GlobalResponse.responseListData(
result.items()
.stream()
.map(entity -> {
try {
return mapper.apply(entity);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList(),
result.total()
);
}
/**
* 根据ID获取实体详情
* <p>
* 根据主键ID查询单条记录的详细信息转换为详情项对象后返回。
* 如果记录不存在则抛出异常。
* </p>
*
* @param id 实体主键ID
* @return 返回实体详情响应对象,格式:{status: 0, message: "OK", data: 详情数据}
* @throws Exception 查询过程中可能抛出的异常
*/
@Transactional(readOnly = true)
@GetMapping(DETAIL)
@Override
public GlobalResponse<DETAIL_ITEM> detail(@PathVariable("id") Long id) throws Exception {
var mapper = detailItemMapper();
return GlobalResponse.responseSuccess(mapper.apply(service.detailOrThrow(id)));
}
/**
* 根据ID删除实体对象
* <p>
* 根据主键ID删除指定的记录执行成功后返回成功响应。
* 通过事务保证删除操作的一致性。
* </p>
*
* @param id 需要删除的实体主键ID
* @return 返回删除结果响应对象,格式:{status: 0, message: "OK", data: null}
* @throws Exception 删除过程中可能抛出的异常
*/
@Transactional(rollbackFor = Throwable.class)
@GetMapping(REMOVE)
@Override
public GlobalResponse<Object> remove(@PathVariable("id") Long id) throws Exception {
service.remove(id);
return GlobalResponse.responseSuccess();
}
/**
* 保存项映射器,将保存项转换为实体对象
* <p>
* 子类需要实现此方法,定义保存项到实体的转换逻辑。
* </p>
*
* @return Function<SAVE_ITEM, ENTITY> 保存项到实体的转换函数
*/
protected abstract Function<SAVE_ITEM, ENTITY> saveItemMapper();
/**
* 列表项映射器,将实体对象转换为列表项
* <p>
* 子类需要实现此方法,定义实体到列表项的转换逻辑。
* </p>
*
* @return Function<ENTITY, LIST_ITEM> 实体到列表项的转换函数
*/
protected abstract Function<ENTITY, LIST_ITEM> listItemMapper();
/**
* 详情项映射器,将实体对象转换为详情项
* <p>
* 子类需要实现此方法,定义实体到详情项的转换逻辑。
* </p>
*
* @return Function<ENTITY, DETAIL_ITEM> 实体到详情项的转换函数
*/
protected abstract Function<ENTITY, DETAIL_ITEM> detailItemMapper();
public interface Mapper<S, T> {
T map(S source) throws Exception;
}
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.IdAutoType;
import cn.xbatis.db.annotations.TableId;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString
@FieldNameConstants
public class IdOnlyEntity {
@TableId(value = IdAutoType.GENERATOR, generator = "snowflake")
private Long id;
}

View File

@@ -0,0 +1,19 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.TableField;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
public class SimpleEntity extends IdOnlyEntity {
@TableField(defaultValue = "{NOW}", defaultValueFillAlways = true)
private LocalDateTime createdTime;
@TableField(defaultValue = "{NOW}", defaultValueFillAlways = true, updateDefaultValue = "{NOW}", updateDefaultValueFillAlways = true)
private LocalDateTime modifiedTime;
}

View File

@@ -0,0 +1,11 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.core.incrementer.Generator;
import com.lanyuanxiaoyao.service.template.common.helper.SnowflakeHelper;
public class SnowflakeIdGenerator implements Generator<Long> {
@Override
public Long nextId(Class<?> entity) {
return SnowflakeHelper.next();
}
}

View File

@@ -0,0 +1,6 @@
package com.lanyuanxiaoyao.service.template.xbatis.mapper;
import cn.xbatis.core.mybatis.mapper.BasicMapper;
public interface MybatisBasicMapper extends BasicMapper {
}

View File

@@ -0,0 +1,248 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import cn.xbatis.core.mybatis.mapper.context.Pager;
import cn.xbatis.core.sql.MybatisCmdFactory;
import cn.xbatis.core.sql.executor.chain.QueryChain;
import com.lanyuanxiaoyao.service.template.common.entity.Page;
import com.lanyuanxiaoyao.service.template.common.entity.Query;
import com.lanyuanxiaoyao.service.template.common.exception.IdNotFoundException;
import com.lanyuanxiaoyao.service.template.common.helper.ObjectHelper;
import com.lanyuanxiaoyao.service.template.common.service.QueryParser;
import com.lanyuanxiaoyao.service.template.common.service.SimpleService;
import com.lanyuanxiaoyao.service.template.xbatis.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import db.sql.api.cmd.LikeMode;
import db.sql.api.impl.cmd.basic.OrderByDirection;
import db.sql.api.impl.cmd.struct.Where;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public abstract class SimpleServiceSupport<ENTITY extends SimpleEntity> implements SimpleService<ENTITY> {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 10;
protected final MybatisBasicMapper mapper;
private final Class<ENTITY> target;
public SimpleServiceSupport(Class<ENTITY> target, MybatisBasicMapper mapper) {
this.target = target;
this.mapper = mapper;
}
@Override
public Long save(ENTITY entity) {
return (long) mapper.save(entity);
}
@Override
public void save(Iterable<ENTITY> entities) {
mapper.save(entities);
}
@Override
public Long count() {
return (long) mapper.countAll(target);
}
@Override
public List<ENTITY> list() {
return mapper.listAll(target);
}
@Override
public List<ENTITY> list(Set<Long> ids) {
return mapper.listByIds(target, ids);
}
protected void commonPredicates(Where where) {
}
@Override
public Page<ENTITY> list(Query query) {
var chain = QueryChain.of(mapper, target);
var factory = chain.$();
var paging = Pager.<ENTITY>of(DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE);
if (ObjectHelper.isNotNull(query.page())) {
var index = Math.max(ObjectHelper.defaultIfNull(query.page().index(), DEFAULT_PAGE_INDEX), 1);
var size = Math.max(ObjectHelper.defaultIfNull(query.page().size(), DEFAULT_PAGE_SIZE), 1);
paging = Pager.of(index, size);
}
chain.paging(paging);
if (ObjectHelper.isNotEmpty(query.sort())) {
query.sort().forEach(sort -> chain.orderBy(OrderByDirection.valueOf(sort.direction().name()), sort.column()));
}
var where = chain.where();
commonPredicates(where);
new XBatisQueryParser<>(target, factory, where).build(query.query());
return new Page<>(chain.list(), chain.count());
}
private Optional<ENTITY> detailOptional(Long id) {
if (ObjectHelper.isNull(id)) {
return Optional.empty();
}
return Optional.ofNullable(mapper.getById(target, id));
}
@Override
public ENTITY detail(Long id) {
return detailOptional(id).orElse(null);
}
@Override
public ENTITY detailOrThrow(Long id) {
return detailOptional(id).orElseThrow(() -> new IdNotFoundException(id));
}
@Override
public void remove(Long id) {
mapper.deleteById(target, id);
}
@Override
public void remove(Set<Long> ids) {
mapper.deleteByIds(target, ids);
}
private static final class XBatisQueryParser<ENTITY> extends QueryParser<Void> {
private final Class<ENTITY> target;
private final MybatisCmdFactory factory;
private final Where where;
private XBatisQueryParser(Class<ENTITY> target, MybatisCmdFactory factory, Where where) {
this.target = target;
this.factory = factory;
this.where = where;
}
@Override
protected void nullEqual(Query.Queryable queryable) {
queryable.nullEqual().forEach(column -> where.isNull(factory.field(target, column)));
}
@Override
protected void notNullEqual(Query.Queryable queryable) {
queryable.notNullEqual().forEach(column -> where.isNotNull(factory.field(target, column)));
}
@Override
protected void empty(Query.Queryable queryable) {
throw new UnsupportedOperationException();
}
@Override
protected void notEmpty(Query.Queryable queryable) {
throw new UnsupportedOperationException();
}
@Override
protected void equal(Query.Queryable queryable) {
queryable.equal().forEach((column, value) -> where.eq(factory.field(target, column), value));
}
@Override
protected void notEqual(Query.Queryable queryable) {
queryable.notEqual().forEach((column, value) -> where.ne(factory.field(target, column), value));
}
@Override
protected void like(Query.Queryable queryable) {
queryable.like().forEach((column, value) -> where.like(LikeMode.NONE, factory.field(target, column), value));
}
@Override
protected void notLike(Query.Queryable queryable) {
queryable.notLike().forEach((column, value) -> where.notLike(LikeMode.NONE, factory.field(target, column), value));
}
@Override
protected void contain(Query.Queryable queryable) {
queryable.contain().forEach((column, value) -> where.like(factory.field(target, column), value));
}
@Override
protected void notContain(Query.Queryable queryable) {
queryable.notContain().forEach((column, value) -> where.notLike(factory.field(target, column), value));
}
@Override
protected void startWith(Query.Queryable queryable) {
queryable.startWith().forEach((column, value) -> where.like(LikeMode.LEFT, factory.field(target, column), value));
}
@Override
protected void notStartWith(Query.Queryable queryable) {
queryable.notStartWith().forEach((column, value) -> where.notLike(LikeMode.LEFT, factory.field(target, column), value));
}
@Override
protected void endWith(Query.Queryable queryable) {
queryable.endWith().forEach((column, value) -> where.like(LikeMode.RIGHT, factory.field(target, column), value));
}
@Override
protected void notEndWith(Query.Queryable queryable) {
queryable.notEndWith().forEach((column, value) -> where.notLike(LikeMode.RIGHT, factory.field(target, column), value));
}
@Override
protected void great(Query.Queryable queryable) {
queryable.great().forEach((column, value) -> where.gt(factory.field(target, column), value));
}
@Override
protected void less(Query.Queryable queryable) {
queryable.less().forEach((column, value) -> where.lt(factory.field(target, column), value));
}
@Override
protected void greatEqual(Query.Queryable queryable) {
queryable.greatEqual().forEach((column, value) -> where.gte(factory.field(target, column), value));
}
@Override
protected void lessEqual(Query.Queryable queryable) {
queryable.lessEqual().forEach((column, value) -> where.lte(factory.field(target, column), value));
}
@Override
protected void inside(Query.Queryable queryable) {
queryable.inside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> where.in(factory.field(target, entry.getKey()), entry.getValue()));
}
@Override
protected void notInside(Query.Queryable queryable) {
queryable.notInside()
.entrySet()
.stream()
.filter(entry -> ObjectHelper.isNotEmpty(entry.getValue()))
.forEach(entry -> where.notIn(factory.field(target, entry.getKey()), entry.getValue()));
}
@Override
protected void between(Query.Queryable queryable) {
queryable.between().forEach((column, value) -> where.between(factory.field(target, column), value.start(), value.end()));
}
@Override
protected void notBetween(Query.Queryable queryable) {
queryable.notBetween().forEach((column, value) -> where.notBetween(factory.field(target, column), value.start(), value.end()));
}
@Override
protected Void build() {
return null;
}
}
}

View File

@@ -0,0 +1,17 @@
create table if not exists Company
(
id bigint primary key,
name varchar(255) not null,
members int not null,
created_time timestamp not null,
modified_time timestamp not null
);
create table if not exists Employee
(
id bigint primary key,
name varchar(255) not null,
age int not null,
created_time timestamp not null,
modified_time timestamp not null
);

View File

@@ -0,0 +1,173 @@
package com.lanyuanxiaoyao.service.template.xbatis;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
@Slf4j
@MapperScan("com.lanyuanxiaoyao.service.template.xbatis.mapper")
@SpringBootApplication
public class TestApplication {
private static final String BASE_URL = "http://localhost:2490";
private static final RestTemplate REST_CLIENT = new RestTemplate();
private static final ObjectMapper MAPPER = new ObjectMapper();
public static void main(String[] args) {
SpringApplication.run(TestApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void runTests() {
// 增
var cid1 = saveItem("company", "{\"name\": \"Apple\",\"members\": 10}").get("data").asLong();
var cid2 = saveItem("company", "{\"name\": \"Banana\",\"members\": 20}").get("data").asLong();
var cid3 = saveItem("company", "{\"name\": \"Cheery\",\"members\": 20}").get("data").asLong();
// 查
var companies = listItems("company");
Assert.isTrue(companies.at("/data/items").size() == 3, "数量错误");
Assert.isTrue(companies.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies2 = listItems("company", "{\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies2.at("/data/items").size() == 2, "数量错误");
Assert.isTrue(companies2.at("/data/total").asLong() == 3, "返回数量错误");
// language=JSON
var companies3 = listItems("company", "{\n" +
" \"query\": {\n" +
" \"notNullEqual\": [\n" +
" \"name\"\n" +
" ],\n" +
" \"equal\": {\n" +
" \"name\": \"Apple\"\n" +
" },\n" +
" \"like\": {\n" +
" \"name\": \"Appl%\"\n" +
" },\n" +
" \"contain\": {\n" +
" \"name\": \"ple\"\n" +
" },\n" +
" \"startWith\": {\n" +
" \"name\": \"Appl\"\n" +
" },\n" +
" \"endWith\": {\n" +
" \"name\": \"le\"\n" +
" },\n" +
" \"less\": {\n" +
" \"members\": 50\n" +
" },\n" +
" \"greatEqual\": {\n" +
" \"members\": 0,\n" +
" \"createdTime\": \"2025-01-01 00:00:00\"\n" +
" },\n" +
" \"inside\": {\n" +
" \"name\": [\n" +
" \"Apple\",\n" +
" \"Banana\"\n" +
" ]\n" +
" },\n" +
" \"between\": {\n" +
" \"members\": {\n" +
" \"start\": 0,\n" +
" \"end\": 50\n" +
" }\n" +
" }\n" +
" },\n" +
" \"page\": {\n" +
" \"index\": 1,\n" +
" \"size\": 2\n" +
" }\n" +
"}");
Assert.isTrue(companies3.at("/data/items").size() == 1, "数量错误");
Assert.isTrue(companies3.at("/data/total").asLong() == 1, "返回数量错误");
var company1 = detailItem("company", cid1);
Assert.isTrue(cid1 == company1.at("/data/id").asLong(), "id错误");
Assert.isTrue("Apple".equals(company1.at("/data/name").asText()), "name错误");
// 改
var cid4 = saveItem("company", "{\"id\": %d, \"name\": \"Dog\"}".formatted(cid2)).get("data").asLong();
Assert.isTrue(cid2 == cid4, "id错误");
var company2 = detailItem("company", cid2);
Assert.isTrue("Dog".equals(company2.at("/data/name").asText()), "name错误");
// 删
removeItem("company", cid3);
Assert.isTrue(listItems("company").at("/data/items").size() == 2, "数量错误");
Assert.isTrue(listItems("company").at("/data/total").asLong() == 2, "返回数量错误");
log.info(listItems("company").toPrettyString());
System.exit(0);
}
private HttpHeaders headers() {
var headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private JsonNode saveItem(String path, String body) {
var response = REST_CLIENT.postForEntity(
"%s/%s/save".formatted(BASE_URL, path),
new HttpEntity<>(body, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path) {
var response = REST_CLIENT.getForEntity(
"%s/%s/list".formatted(BASE_URL, path),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode listItems(String path, String query) {
var response = REST_CLIENT.postForEntity(
"%s/%s/list".formatted(BASE_URL, path),
new HttpEntity<>(query, headers()),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private JsonNode detailItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/detail/%d".formatted(BASE_URL, path, id),
String.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
Assert.notNull(response.getBody(), "请求失败");
return MAPPER.readTree(response.getBody());
}
private void removeItem(String path, Long id) {
var response = REST_CLIENT.getForEntity(
"%s/%s/remove/%d".formatted(BASE_URL, path, id),
Void.class
);
Assert.isTrue(response.getStatusCode().is2xxSuccessful(), "请求失败");
}
}

View File

@@ -0,0 +1,70 @@
package com.lanyuanxiaoyao.service.template.xbatis.controller;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Company;
import com.lanyuanxiaoyao.service.template.xbatis.service.CompanyService;
import java.time.LocalDateTime;
import java.util.function.Function;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("company")
public class CompanyController extends SimpleControllerSupport<Company, CompanyController.SaveItem, CompanyController.ListItem, CompanyController.DetailItem> {
public CompanyController(CompanyService service) {
super(service);
}
@Override
protected Function<SaveItem, Company> saveItemMapper() {
return item -> {
var company = new Company();
company.setId(item.id());
company.setName(item.name());
company.setMembers(item.members());
return company;
};
}
@Override
protected Function<Company, ListItem> listItemMapper() {
return company -> new ListItem(
company.getId(),
company.getName(),
company.getMembers()
);
}
@Override
protected Function<Company, DetailItem> detailItemMapper() {
return company -> new DetailItem(
company.getId(),
company.getName(),
company.getMembers(),
company.getCreatedTime(),
company.getModifiedTime()
);
}
public record SaveItem(
Long id,
String name,
Integer members
) {
}
public record ListItem(
Long id,
String name,
Integer members
) {
}
public record DetailItem(
Long id,
String name,
Integer members,
LocalDateTime createdTime,
LocalDateTime modifiedTime
) {
}
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
public class Company extends SimpleEntity {
private String name;
private Integer members;
}

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.template.xbatis.entity;
import cn.xbatis.db.annotations.Table;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.FieldNameConstants;
@Getter
@Setter
@ToString(callSuper = true)
@FieldNameConstants
@Table
public class Employee extends SimpleEntity {
private String name;
private Integer age;
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Company;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.springframework.stereotype.Service;
@Service
public class CompanyService extends SimpleServiceSupport<Company> {
public CompanyService(MybatisBasicMapper mapper) {
super(Company.class, mapper);
}
}

View File

@@ -0,0 +1,12 @@
package com.lanyuanxiaoyao.service.template.xbatis.service;
import com.lanyuanxiaoyao.service.template.xbatis.entity.Employee;
import com.lanyuanxiaoyao.service.template.xbatis.mapper.MybatisBasicMapper;
import org.springframework.stereotype.Service;
@Service
public class EmployeeService extends SimpleServiceSupport<Employee> {
public EmployeeService(MybatisBasicMapper mapper) {
super(Employee.class, mapper);
}
}

View File

@@ -0,0 +1,22 @@
server:
port: 2490
spring:
application:
name: Test
datasource:
url: "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL;DATABASE_TO_LOWER=TRUE;INIT=runscript from '/Users/lanyuanxiaoyao/Project/IdeaProjects/spring-boot-service-template/spring-boot-service-template-xbatis/src/test/initial.sql'"
username: test
password: test
driver-class-name: org.h2.Driver
mybatis:
configuration:
banner: false
decorator:
datasource:
p6spy:
multiline: false
exclude-categories:
- commit
- result
- resultset
log-format: "%(category)|%(executionTime)|%(sqlSingleLine)"