From 46f75a37aa8d7575adcd701c9e4066dcd943d6ea Mon Sep 17 00:00:00 2001 From: Liu Rui Date: Sun, 28 Jun 2026 11:47:38 +0800 Subject: [PATCH 1/4] Add field value codec for collection mapping --- README.md | 8 +- build.gradle.kts | 2 +- docs/API_CONTRACT.md | 1 + docs/QUARKUS.md | 4 +- docs/QUICKSTART.md | 7 +- .../core/orm/CriteriaSqlCompiler.java | 47 ++- .../orm/DefaultDatabaseValueConverter.java | 19 +- .../database/core/orm/EntityFieldMeta.java | 27 ++ .../muyun/database/core/orm/EntityMapper.java | 190 +----------- .../database/core/orm/FieldValueCodec.java | 233 ++++++++++++++ .../core/orm/CriteriaSqlCompilerTest.java | 36 ++- .../database/core/orm/EntityMapperTest.java | 285 ++++++++++++++++++ .../META-INF/quarkus-extension.properties | 2 +- samples/quarkus-minimal/build.gradle.kts | 2 +- samples/starter-minimal/build.gradle.kts | 2 +- 15 files changed, 651 insertions(+), 214 deletions(-) create mode 100644 muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/FieldValueCodec.java diff --git a/README.md b/README.md index 9b7205e..bded2d3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ MuYun-Database 是一个基于 `Jdbi` 的轻量数据库工具库,面向单表 ```groovy dependencies { - implementation("net.ximatai.muyun.database:muyun-database-jdbi:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-jdbi:3.26.12") } ``` @@ -64,7 +64,7 @@ UserEntity loaded = orm.findById(UserEntity.class, user.id); ```groovy dependencies { - implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.12") } ``` @@ -91,7 +91,7 @@ interface UserRepository extends EntityDao { ```groovy dependencies { - implementation("net.ximatai.muyun.database:muyun-database-quarkus:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-quarkus:3.26.12") } ``` @@ -127,7 +127,7 @@ Quarkus 使用独立注解 `net.ximatai.muyun.database.quarkus.MuYunRepository` ## 版本与模块 -- 当前版本 `3.26.11` 兼容 Java 21 及以上 +- 当前版本 `3.26.12` 兼容 Java 21 及以上 - `1.26.+` 兼容 Java 8,位于 `jdbi-jdk8` 分支 - 具体发布版本以仓库 release / Maven Central 为准 diff --git a/build.gradle.kts b/build.gradle.kts index 636bc20..37ed1ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,7 @@ val releasePublishModules = listOf( allprojects { group = "net.ximatai.muyun.database" // version = "1.0.0-SNAPSHOT" - version = "3.26.11" + version = "3.26.12" repositories { maven { url = uri("https://mirrors.cloud.tencent.com/repository/maven") } diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1876cf8..df06814 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -96,5 +96,6 @@ int upsert(T entity); 12. `ColumnType.JSON_SET` 必须通过 `@Column(type = ColumnType.JSON_SET)` 显式声明;默认 `Set` 推断结果仍为 `ColumnType.SET`。 13. `ColumnType.JSON_SET` 的元素按字符串处理:写入时忽略 `null` 元素、按集合语义去重、保留首次出现顺序;空集合写入为 `[]`,字段值为 `null` 时写入为 `null`。 14. `ColumnType.JSON_SET` 读取非法 JSON 数组或写入非法 JSON 数组字符串时直接拒绝,不做静默降级。 +15. `ColumnType.SET` / `ColumnType.JSON_SET` 字段声明了可识别泛型元素类型时,自定义 `DatabaseValueConverter` 可作用于集合元素;`JSON_SET` 底层仍保持 JSON 字符串数组语义。 下一步:若你在做历史项目改造,请按 [`REFACTOR_GUIDE.md`](REFACTOR_GUIDE.md) 的“推荐重构路径”执行。 diff --git a/docs/QUARKUS.md b/docs/QUARKUS.md index 6713be8..2a3beac 100644 --- a/docs/QUARKUS.md +++ b/docs/QUARKUS.md @@ -6,14 +6,14 @@ ```kotlin dependencies { - implementation("net.ximatai.muyun.database:muyun-database-quarkus:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-quarkus:3.26.12") } ``` 扩展 runtime 会声明对应的 deployment artifact: ```properties -deployment-artifact=net.ximatai.muyun.database:muyun-database-quarkus-deployment::jar:3.26.11 +deployment-artifact=net.ximatai.muyun.database:muyun-database-quarkus-deployment::jar:3.26.12 ``` ## 配置项 diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index cd32127..6e31a24 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -20,7 +20,7 @@ ```groovy dependencies { - implementation("net.ximatai.muyun.database:muyun-database-jdbi:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-jdbi:3.26.12") } ``` @@ -28,7 +28,7 @@ dependencies { net.ximatai.muyun.database muyun-database-jdbi - 3.26.11 + 3.26.12 ``` @@ -149,6 +149,7 @@ class ArticleEntity { 2. 写入时忽略 `null` 元素、按集合语义去重、保留首次出现顺序;空集合写入为 `[]`,字段值为 `null` 时写入为 `null`。 3. 读取或写入非法 JSON 数组字符串会直接失败,不会静默降级为单个元素。 4. 核心模块内置轻量 JSON 数组解析器;如需使用 Jackson 解析器,可额外引入 `muyun-database-core-json-jackson`。 +5. 若集合字段声明了可识别泛型元素类型,自定义 `DatabaseValueConverter` 可作用于 SET/JSON_SET 的集合元素。 ### 1.5 迁移控制(可选) @@ -168,7 +169,7 @@ orm.ensureTable(UserEntity.class, MigrationOptions.dryRunStrict()); ```groovy dependencies { - implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.12") } ``` diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompiler.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompiler.java index 192cab3..8481869 100644 --- a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompiler.java +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompiler.java @@ -52,14 +52,19 @@ public CompiledCriteria compile(Criteria criteria, CriteriaColumnResolver column Objects.requireNonNull(columnResolver, "columnResolver must not be null"); Objects.requireNonNull(dbType, "dbType must not be null"); - ClauseContext context = new ClauseContext(columnResolver, dbType); + ClauseContext context = new ClauseContext(columnResolver, dbType, null, valueConverter); String sql = compileGroup(criteria.getRoot(), context); return new CompiledCriteria(sql, context.params); } CompiledCriteria compile(Criteria criteria, EntityMeta meta, DBInfo.Type dbType) { + Objects.requireNonNull(criteria, "criteria must not be null"); Objects.requireNonNull(meta, "meta must not be null"); - return compile(criteria, meta::resolveColumnName, dbType); + Objects.requireNonNull(dbType, "dbType must not be null"); + + ClauseContext context = new ClauseContext(meta::resolveColumnName, dbType, meta, valueConverter); + String sql = compileGroup(criteria.getRoot(), context); + return new CompiledCriteria(sql, context.params); } private String compileGroup(CriteriaGroup group, ClauseContext context) { @@ -97,7 +102,7 @@ private String compileClause(CriteriaClause clause, ClauseContext context) { private String compare(CriteriaClause clause, ClauseContext context, String op) { String key = "p" + context.nextParamIndex(); - context.params.put(key, valueConverter.toDatabaseValue(firstValue(clause))); + context.params.put(key, context.toDatabaseValue(clause, firstValue(clause))); return resolveColumn(clause, context) + " " + op + " :" + key; } @@ -108,8 +113,8 @@ private String renderBetween(CriteriaClause clause, ClauseContext context) { String key = "p" + context.nextParamIndex(); String key1 = key + "_1"; String key2 = key + "_2"; - context.params.put(key1, valueConverter.toDatabaseValue(clause.getValues().get(0))); - context.params.put(key2, valueConverter.toDatabaseValue(clause.getValues().get(1))); + context.params.put(key1, context.toDatabaseValue(clause, clause.getValues().get(0))); + context.params.put(key2, context.toDatabaseValue(clause, clause.getValues().get(1))); return resolveColumn(clause, context) + " BETWEEN :" + key1 + " AND :" + key2; } @@ -122,7 +127,7 @@ private String renderIn(CriteriaClause clause, ClauseContext context) { for (int i = 0; i < clause.getValues().size(); i++) { String listKey = key + "_" + i; holders.add(":" + listKey); - context.params.put(listKey, valueConverter.toDatabaseValue(clause.getValues().get(i))); + context.params.put(listKey, context.toDatabaseValue(clause, clause.getValues().get(i))); } return resolveColumn(clause, context) + " IN (" + String.join(", ", holders) + ")"; } @@ -136,7 +141,7 @@ private String renderNotIn(CriteriaClause clause, ClauseContext context) { for (int i = 0; i < clause.getValues().size(); i++) { String listKey = key + "_" + i; holders.add(":" + listKey); - context.params.put(listKey, valueConverter.toDatabaseValue(clause.getValues().get(i))); + context.params.put(listKey, context.toDatabaseValue(clause, clause.getValues().get(i))); } return resolveColumn(clause, context) + " NOT IN (" + String.join(", ", holders) + ")"; } @@ -215,17 +220,43 @@ private Object firstValue(CriteriaClause clause) { private static class ClauseContext { private final CriteriaColumnResolver columnResolver; private final DBInfo.Type dbType; + private final EntityMeta meta; + private final DatabaseValueConverter valueConverter; private final Map params = new HashMap<>(); private int paramIndex; - private ClauseContext(CriteriaColumnResolver columnResolver, DBInfo.Type dbType) { + private ClauseContext(CriteriaColumnResolver columnResolver, + DBInfo.Type dbType, + EntityMeta meta, + DatabaseValueConverter valueConverter) { this.columnResolver = columnResolver; this.dbType = dbType; + this.meta = meta; + this.valueConverter = valueConverter; } private int nextParamIndex() { return paramIndex++; } + + private Object toDatabaseValue(CriteriaClause clause, Object value) { + EntityFieldMeta fieldMeta = resolveFieldMeta(clause.getField()); + if (fieldMeta == null) { + return valueConverter.toDatabaseValue(value); + } + return FieldValueCodec.toDatabaseValue(fieldMeta, value, valueConverter); + } + + private EntityFieldMeta resolveFieldMeta(String fieldOrColumn) { + if (meta == null) { + return null; + } + EntityFieldMeta byField = meta.findByFieldName(fieldOrColumn); + if (byField != null) { + return byField; + } + return meta.findByColumnName(fieldOrColumn); + } } @FunctionalInterface diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultDatabaseValueConverter.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultDatabaseValueConverter.java index 15b0f4b..840daa2 100644 --- a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultDatabaseValueConverter.java +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultDatabaseValueConverter.java @@ -48,22 +48,22 @@ public Object fromDatabaseValue(Object value, Class targetType) { } if (targetType == int.class || targetType == Integer.class) { - return ((Number) value).intValue(); + return asNumber(value).intValue(); } if (targetType == long.class || targetType == Long.class) { - return ((Number) value).longValue(); + return asNumber(value).longValue(); } if (targetType == double.class || targetType == Double.class) { - return ((Number) value).doubleValue(); + return asNumber(value).doubleValue(); } if (targetType == float.class || targetType == Float.class) { - return ((Number) value).floatValue(); + return asNumber(value).floatValue(); } if (targetType == short.class || targetType == Short.class) { - return ((Number) value).shortValue(); + return asNumber(value).shortValue(); } if (targetType == byte.class || targetType == Byte.class) { - return ((Number) value).byteValue(); + return asNumber(value).byteValue(); } if (targetType == boolean.class || targetType == Boolean.class) { @@ -103,4 +103,11 @@ public Object fromDatabaseValue(Object value, Class targetType) { return value; } + + private static Number asNumber(Object value) { + if (value instanceof Number number) { + return number; + } + return new BigDecimal(value.toString().trim()); + } } diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityFieldMeta.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityFieldMeta.java index c53a419..901b62c 100644 --- a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityFieldMeta.java +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityFieldMeta.java @@ -3,6 +3,10 @@ import net.ximatai.muyun.database.core.builder.ColumnType; import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Optional; public class EntityFieldMeta { private final Field field; @@ -10,6 +14,7 @@ public class EntityFieldMeta { private final String columnName; private final ColumnType columnType; private final boolean id; + private final Optional> collectionElementType; public EntityFieldMeta(Field field, String columnName, ColumnType columnType, boolean id) { this.field = field; @@ -17,6 +22,7 @@ public EntityFieldMeta(Field field, String columnName, ColumnType columnType, bo this.columnName = columnName; this.columnType = columnType; this.id = id; + this.collectionElementType = resolveCollectionElementType(field); this.field.setAccessible(true); } @@ -40,6 +46,27 @@ public Class getFieldType() { return field.getType(); } + public Optional> getCollectionElementType() { + return collectionElementType; + } + + private static Optional> resolveCollectionElementType(Field field) { + if (!Collection.class.isAssignableFrom(field.getType())) { + return Optional.empty(); + } + + Type genericType = field.getGenericType(); + if (!(genericType instanceof ParameterizedType parameterizedType)) { + return Optional.empty(); + } + + Type[] arguments = parameterizedType.getActualTypeArguments(); + if (arguments.length != 1 || !(arguments[0] instanceof Class elementType)) { + return Optional.empty(); + } + return Optional.of(elementType); + } + public Object read(Object target) { try { return field.get(target); diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityMapper.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityMapper.java index 8de6c9c..a6f68cb 100644 --- a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityMapper.java +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/EntityMapper.java @@ -1,12 +1,8 @@ package net.ximatai.muyun.database.core.orm; -import net.ximatai.muyun.database.core.builder.ColumnType; -import net.ximatai.muyun.database.core.internal.JsonArrayParser; -import net.ximatai.muyun.database.core.internal.JsonArrayParserLoader; - import java.lang.reflect.Constructor; -import java.lang.reflect.Modifier; -import java.util.*; +import java.util.HashMap; +import java.util.Map; public final class EntityMapper { @@ -34,7 +30,7 @@ public static Map toMap(EntityMeta meta, if (value == null && !includeNull) { continue; } - result.put(fieldMeta.getColumnName(), toDatabaseValue(fieldMeta, value, converter)); + result.put(fieldMeta.getColumnName(), FieldValueCodec.toDatabaseValue(fieldMeta, value, converter)); } return result; @@ -61,7 +57,7 @@ public static T fromMap(EntityMeta meta, continue; } - Object converted = convertValue(value, fieldMeta, converter); + Object converted = FieldValueCodec.fromDatabaseValue(value, fieldMeta, converter); fieldMeta.write(entity, converted); } @@ -81,18 +77,6 @@ private static Object findByColumn(Map row, String columnName) { return null; } - private static Object toDatabaseValue(EntityFieldMeta fieldMeta, - Object value, - DatabaseValueConverter valueConverter) { - if (fieldMeta.getColumnType() == ColumnType.SET) { - return toCsvSetValue(value); - } - if (fieldMeta.getColumnType() == ColumnType.JSON_SET) { - return toJsonSetValue(value); - } - return valueConverter.toDatabaseValue(value); - } - private static T instantiate(Class entityClass) { try { Constructor constructor = entityClass.getDeclaredConstructor(); @@ -106,170 +90,4 @@ private static T instantiate(Class entityClass) { ); } } - - private static Object convertValue(Object value, - EntityFieldMeta fieldMeta, - DatabaseValueConverter valueConverter) { - if (fieldMeta.getColumnType() == ColumnType.SET) { - return fromCsvSetValue(value, fieldMeta.getFieldType()); - } - if (fieldMeta.getColumnType() == ColumnType.JSON_SET) { - return fromJsonSetValue(value, fieldMeta.getFieldType()); - } - - Class targetType = fieldMeta.getFieldType(); - return valueConverter.fromDatabaseValue(value, targetType); - } - - private static String toCsvSetValue(Object value) { - if (value == null) { - return null; - } - LinkedHashSet normalized = normalizeToSet(value, true); - return normalized.isEmpty() ? "" : String.join(",", normalized); - } - - private static Object fromCsvSetValue(Object value, Class targetType) { - LinkedHashSet normalized = normalizeToSet(value, false); - return adaptCollection(normalized, targetType); - } - - @SuppressWarnings("unchecked") - private static Collection instantiateCollection(Class targetType) { - if (!Collection.class.isAssignableFrom(targetType) - || targetType.isInterface() - || Modifier.isAbstract(targetType.getModifiers())) { - return null; - } - try { - Constructor constructor = targetType.getDeclaredConstructor(); - constructor.setAccessible(true); - Object instance = constructor.newInstance(); - if (instance instanceof Collection) { - return (Collection) instance; - } - } catch (Exception ignored) { - // fall through and use default LinkedHashSet - } - return null; - } - - private static LinkedHashSet normalizeToSet(Object value, boolean strictCsvValue) { - LinkedHashSet normalized = new LinkedHashSet<>(); - if (value == null) { - return normalized; - } - if (value instanceof String text) { - if (text.isBlank()) { - return normalized; - } - for (String part : text.split(",")) { - addNormalized(normalized, part, strictCsvValue); - } - return normalized; - } - if (value instanceof Collection collection) { - for (Object item : collection) { - addNormalized(normalized, item, strictCsvValue); - } - return normalized; - } - addNormalized(normalized, value, strictCsvValue); - return normalized; - } - - private static void addNormalized(LinkedHashSet normalized, Object raw, boolean strictCsvValue) { - if (raw == null) { - return; - } - String text = String.valueOf(raw).trim(); - if (strictCsvValue && text.contains(",")) { - throw new IllegalArgumentException("SET value cannot contain ',' in CSV storage: " + text); - } - if (!text.isEmpty()) { - normalized.add(text); - } - } - - private static String toJsonSetValue(Object value) { - if (value == null) { - return null; - } - LinkedHashSet normalized = new LinkedHashSet<>(); - if (value instanceof Collection collection) { - for (Object item : collection) { - if (item != null) { - normalized.add(String.valueOf(item)); - } - } - } else if (value instanceof String text && !text.isBlank()) { - String content = text.trim(); - if (content.startsWith("[")) { - JsonArrayParser parser = JsonArrayParserLoader.get(); - return parser.serialize(parser.parse(content)); - } - normalized.add(content); - } else if (value != null) { - normalized.add(String.valueOf(value)); - } - JsonArrayParser parser = JsonArrayParserLoader.get(); - return parser.serialize(new ArrayList<>(normalized)); - } - - private static Object fromJsonSetValue(Object value, Class targetType) { - if (value == null) { - return null; - } - LinkedHashSet result = new LinkedHashSet<>(); - if (value instanceof String text) { - if (text.isBlank()) { - return adaptCollection(result, targetType); - } - JsonArrayParser parser = JsonArrayParserLoader.get(); - result.addAll(parser.parse(text)); - } else if (value instanceof Collection collection) { - for (Object item : collection) { - if (item != null) { - result.add(String.valueOf(item)); - } - } - } else { - result.add(String.valueOf(value)); - } - return adaptCollection(result, targetType); - } - - @SuppressWarnings("unchecked") - private static Object adaptCollection(LinkedHashSet elements, Class targetType) { - if (Collection.class.isAssignableFrom(targetType) && targetType.isInterface()) { - if (List.class.isAssignableFrom(targetType)) { - return new ArrayList<>(elements); - } - if (Queue.class.isAssignableFrom(targetType)) { - return new LinkedList<>(elements); - } - return elements; - } - if (targetType.isAssignableFrom(LinkedHashSet.class)) { - return elements; - } - if (targetType.isAssignableFrom(TreeSet.class) - || SortedSet.class.isAssignableFrom(targetType) - || NavigableSet.class.isAssignableFrom(targetType)) { - return new TreeSet<>(elements); - } - if (targetType.isAssignableFrom(ArrayList.class)) { - return new ArrayList<>(elements); - } - if (targetType.isAssignableFrom(LinkedList.class)) { - return new LinkedList<>(elements); - } - Collection customCollection = instantiateCollection(targetType); - if (customCollection != null) { - customCollection.addAll(elements); - return customCollection; - } - return elements; - } - } diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/FieldValueCodec.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/FieldValueCodec.java new file mode 100644 index 0000000..6a53fbf --- /dev/null +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/FieldValueCodec.java @@ -0,0 +1,233 @@ +package net.ximatai.muyun.database.core.orm; + +import net.ximatai.muyun.database.core.builder.ColumnType; +import net.ximatai.muyun.database.core.internal.JsonArrayParser; +import net.ximatai.muyun.database.core.internal.JsonArrayParserLoader; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.NavigableSet; +import java.util.Optional; +import java.util.Queue; +import java.util.SortedSet; +import java.util.TreeSet; + +final class FieldValueCodec { + + private FieldValueCodec() { + } + + static Object toDatabaseValue(EntityFieldMeta fieldMeta, + Object value, + DatabaseValueConverter valueConverter) { + if (fieldMeta.getColumnType() == ColumnType.SET) { + return toCsvSetValue(value, valueConverter); + } + if (fieldMeta.getColumnType() == ColumnType.JSON_SET) { + return toJsonSetValue(value, valueConverter); + } + return valueConverter.toDatabaseValue(value); + } + + static Object fromDatabaseValue(Object value, + EntityFieldMeta fieldMeta, + DatabaseValueConverter valueConverter) { + if (fieldMeta.getColumnType() == ColumnType.SET) { + return fromCsvSetValue(value, fieldMeta, valueConverter); + } + if (fieldMeta.getColumnType() == ColumnType.JSON_SET) { + return fromJsonSetValue(value, fieldMeta, valueConverter); + } + + Class targetType = fieldMeta.getFieldType(); + return valueConverter.fromDatabaseValue(value, targetType); + } + + private static String toCsvSetValue(Object value, DatabaseValueConverter valueConverter) { + if (value == null) { + return null; + } + LinkedHashSet normalized = normalizeToSet(value, true, valueConverter); + return normalized.isEmpty() ? "" : String.join(",", normalized); + } + + private static Object fromCsvSetValue(Object value, + EntityFieldMeta fieldMeta, + DatabaseValueConverter valueConverter) { + LinkedHashSet normalized = normalizeToSet(value, false, DatabaseValueConverter.DEFAULT); + return adaptCollection(convertElements(normalized, fieldMeta, valueConverter), fieldMeta.getFieldType()); + } + + private static LinkedHashSet normalizeToSet(Object value, + boolean strictCsvValue, + DatabaseValueConverter valueConverter) { + LinkedHashSet normalized = new LinkedHashSet<>(); + if (value == null) { + return normalized; + } + if (value instanceof String text) { + if (text.isBlank()) { + return normalized; + } + for (String part : text.split(",")) { + addNormalized(normalized, part, strictCsvValue); + } + return normalized; + } + if (value instanceof Collection collection) { + for (Object item : collection) { + addNormalized(normalized, convertCollectionItem(item, valueConverter), strictCsvValue); + } + return normalized; + } + addNormalized(normalized, valueConverter.toDatabaseValue(value), strictCsvValue); + return normalized; + } + + private static Object convertCollectionItem(Object item, DatabaseValueConverter valueConverter) { + if (item == null) { + return null; + } + return valueConverter.toDatabaseValue(item); + } + + private static void addNormalized(LinkedHashSet normalized, Object raw, boolean strictCsvValue) { + if (raw == null) { + return; + } + String text = String.valueOf(raw).trim(); + if (strictCsvValue && text.contains(",")) { + throw new IllegalArgumentException("SET value cannot contain ',' in CSV storage: " + text); + } + if (!text.isEmpty()) { + normalized.add(text); + } + } + + private static String toJsonSetValue(Object value, DatabaseValueConverter valueConverter) { + if (value == null) { + return null; + } + LinkedHashSet normalized = new LinkedHashSet<>(); + if (value instanceof Collection collection) { + for (Object item : collection) { + addJsonElement(normalized, convertCollectionItem(item, valueConverter)); + } + } else if (value instanceof String text && !text.isBlank()) { + String content = text.trim(); + if (content.startsWith("[")) { + JsonArrayParser parser = JsonArrayParserLoader.get(); + return parser.serialize(parser.parse(content)); + } + normalized.add(content); + } else if (value != null) { + normalized.add(String.valueOf(value)); + } + JsonArrayParser parser = JsonArrayParserLoader.get(); + return parser.serialize(new ArrayList<>(normalized)); + } + + private static void addJsonElement(LinkedHashSet normalized, Object raw) { + if (raw != null) { + normalized.add(String.valueOf(raw)); + } + } + + private static Object fromJsonSetValue(Object value, + EntityFieldMeta fieldMeta, + DatabaseValueConverter valueConverter) { + if (value == null) { + return null; + } + LinkedHashSet result = new LinkedHashSet<>(); + if (value instanceof String text) { + if (text.isBlank()) { + return adaptCollection(convertElements(result, fieldMeta, valueConverter), fieldMeta.getFieldType()); + } + JsonArrayParser parser = JsonArrayParserLoader.get(); + result.addAll(parser.parse(text)); + } else if (value instanceof Collection collection) { + for (Object item : collection) { + if (item != null) { + result.add(String.valueOf(item)); + } + } + } else { + result.add(String.valueOf(value)); + } + return adaptCollection(convertElements(result, fieldMeta, valueConverter), fieldMeta.getFieldType()); + } + + private static LinkedHashSet convertElements(LinkedHashSet elements, + EntityFieldMeta fieldMeta, + DatabaseValueConverter valueConverter) { + Optional> elementType = fieldMeta.getCollectionElementType(); + if (elementType.isEmpty()) { + return elements; + } + + LinkedHashSet converted = new LinkedHashSet<>(); + for (String element : elements) { + converted.add(valueConverter.fromDatabaseValue(element, elementType.get())); + } + return converted; + } + + @SuppressWarnings("unchecked") + private static Object adaptCollection(LinkedHashSet elements, Class targetType) { + if (Collection.class.isAssignableFrom(targetType) && targetType.isInterface()) { + if (List.class.isAssignableFrom(targetType)) { + return new ArrayList<>(elements); + } + if (Queue.class.isAssignableFrom(targetType)) { + return new LinkedList<>(elements); + } + return elements; + } + if (targetType.isAssignableFrom(LinkedHashSet.class)) { + return elements; + } + if (targetType.isAssignableFrom(TreeSet.class) + || SortedSet.class.isAssignableFrom(targetType) + || NavigableSet.class.isAssignableFrom(targetType)) { + return new TreeSet<>(elements); + } + if (targetType.isAssignableFrom(ArrayList.class)) { + return new ArrayList<>(elements); + } + if (targetType.isAssignableFrom(LinkedList.class)) { + return new LinkedList<>(elements); + } + Collection customCollection = instantiateCollection(targetType); + if (customCollection != null) { + customCollection.addAll(elements); + return customCollection; + } + return elements; + } + + @SuppressWarnings("unchecked") + private static Collection instantiateCollection(Class targetType) { + if (!Collection.class.isAssignableFrom(targetType) + || targetType.isInterface() + || Modifier.isAbstract(targetType.getModifiers())) { + return null; + } + try { + Constructor constructor = targetType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object instance = constructor.newInstance(); + if (instance instanceof Collection) { + return (Collection) instance; + } + } catch (Exception ignored) { + // fall through and use default LinkedHashSet + } + return null; + } +} diff --git a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompilerTest.java b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompilerTest.java index 28b5592..fb05186 100644 --- a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompilerTest.java +++ b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/CriteriaSqlCompilerTest.java @@ -5,8 +5,10 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -129,6 +131,30 @@ void shouldKeepEntityMetaCompilationBehavior() throws NoSuchFieldException { assertEquals(Map.of("p0", "A001"), compiled.getParams()); } + @Test + void shouldUseFieldCodecWhenCompilingWithEntityMeta() throws NoSuchFieldException { + EntityFieldMeta id = fieldMeta("id", "id", ColumnType.VARCHAR, true); + EntityFieldMeta statuses = fieldMeta("statuses", "statuses", ColumnType.SET, false); + EntityMeta meta = new EntityMeta( + StaticEntity.class, + "test_entity", + null, + null, + List.of(id, statuses), + id + ); + CriteriaSqlCompiler customCompiler = new CriteriaSqlCompiler(new TestStatusCodeConverter()); + + CompiledCriteria compiled = customCompiler.compile( + Criteria.of().eq("statuses", new LinkedHashSet<>(List.of(TestStatus.ENABLED, TestStatus.DISABLED))), + meta, + DBInfo.Type.POSTGRESQL + ); + + assertEquals("\"statuses\" = :p0", compiled.getSql()); + assertEquals(Map.of("p0", "enabled,disabled"), compiled.getParams()); + } + @Test void copyOfShouldSnapshotCriteria() { Criteria source = Criteria.of().eq("code", "A001"); @@ -185,13 +211,21 @@ private String resolveColumn(String field) { } private EntityFieldMeta fieldMeta(String fieldName, String columnName, boolean id) throws NoSuchFieldException { + return fieldMeta(fieldName, columnName, ColumnType.VARCHAR, id); + } + + private EntityFieldMeta fieldMeta(String fieldName, + String columnName, + ColumnType columnType, + boolean id) throws NoSuchFieldException { Field field = StaticEntity.class.getDeclaredField(fieldName); - return new EntityFieldMeta(field, columnName, ColumnType.VARCHAR, id); + return new EntityFieldMeta(field, columnName, columnType, id); } private static class StaticEntity { private String id; private String code; + private Set statuses; } private enum TestStatus { diff --git a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/EntityMapperTest.java b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/EntityMapperTest.java index 58e761a..9101e90 100644 --- a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/EntityMapperTest.java +++ b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/EntityMapperTest.java @@ -16,6 +16,7 @@ import java.util.TreeSet; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -129,6 +130,114 @@ void csvSetShouldDeserializeToListInterface() { assertEquals(List.of("a", "b"), entity.getTags()); } + @Test + void fieldMetaShouldExposeCollectionElementTypeWhenDeclared() { + EntityMeta listMeta = new EntityMetaResolver().resolve(JsonSetStatusListEntity.class); + EntityMeta setMeta = new EntityMetaResolver().resolve(CsvSetStatusEntity.class); + EntityMeta stringMeta = new EntityMetaResolver().resolve(JsonSetListInterfaceEntity.class); + EntityMeta rawMeta = new EntityMetaResolver().resolve(JsonSetRawListEntity.class); + EntityMeta objectMeta = new EntityMetaResolver().resolve(JsonSetRawEntity.class); + + assertEquals(TestStatus.class, listMeta.findByFieldName("statuses").getCollectionElementType().orElseThrow()); + assertEquals(TestStatus.class, setMeta.findByFieldName("statuses").getCollectionElementType().orElseThrow()); + assertEquals(String.class, stringMeta.findByFieldName("tags").getCollectionElementType().orElseThrow()); + assertFalse(rawMeta.findByFieldName("tags").getCollectionElementType().isPresent()); + assertFalse(objectMeta.findByFieldName("tags").getCollectionElementType().isPresent()); + } + + @Test + void jsonSetShouldUseConverterForListElements() { + EntityMeta meta = new EntityMetaResolver().resolve(JsonSetStatusListEntity.class); + JsonSetStatusListEntity entity = new JsonSetStatusListEntity(); + entity.setId("e-33"); + entity.setStatuses(List.of(TestStatus.ENABLED, TestStatus.DISABLED, TestStatus.ENABLED)); + + Map map = EntityMapper.toMap(meta, entity, false, true, new TestStatusCodeConverter()); + + assertEquals("[\"enabled\",\"disabled\"]", map.get("statuses")); + + JsonSetStatusListEntity loaded = EntityMapper.fromMap(meta, Map.of( + "id", "e-33", + "statuses", "[\"enabled\",\"disabled\"]" + ), JsonSetStatusListEntity.class, new TestStatusCodeConverter()); + + assertNotNull(loaded.getStatuses()); + assertEquals(ArrayList.class, loaded.getStatuses().getClass()); + assertEquals(List.of(TestStatus.ENABLED, TestStatus.DISABLED), loaded.getStatuses()); + } + + @Test + void csvSetShouldUseConverterForSetElements() { + EntityMeta meta = new EntityMetaResolver().resolve(CsvSetStatusEntity.class); + CsvSetStatusEntity entity = new CsvSetStatusEntity(); + entity.setId("e-34"); + LinkedHashSet statuses = new LinkedHashSet<>(); + statuses.add(TestStatus.ENABLED); + statuses.add(TestStatus.DISABLED); + entity.setStatuses(statuses); + + Map map = EntityMapper.toMap(meta, entity, false, true, new TestStatusCodeConverter()); + + assertEquals("enabled,disabled", map.get("statuses")); + + CsvSetStatusEntity loaded = EntityMapper.fromMap(meta, Map.of( + "id", "e-34", + "statuses", "enabled,disabled,enabled" + ), CsvSetStatusEntity.class, new TestStatusCodeConverter()); + + assertNotNull(loaded.getStatuses()); + assertEquals(LinkedHashSet.class, loaded.getStatuses().getClass()); + assertEquals(List.of(TestStatus.ENABLED, TestStatus.DISABLED), new ArrayList<>(loaded.getStatuses())); + } + + @Test + void jsonSetShouldUseDefaultConverterForEnumElements() { + EntityMeta meta = new EntityMetaResolver().resolve(JsonSetStatusListEntity.class); + JsonSetStatusListEntity entity = new JsonSetStatusListEntity(); + entity.setId("e-35"); + entity.setStatuses(List.of(TestStatus.ENABLED, TestStatus.DISABLED, TestStatus.ENABLED)); + + Map map = EntityMapper.toMap(meta, entity, false, true); + + assertEquals("[\"ENABLED\",\"DISABLED\"]", map.get("statuses")); + + JsonSetStatusListEntity loaded = EntityMapper.fromMap(meta, Map.of( + "id", "e-35", + "statuses", "[\"ENABLED\",\"DISABLED\"]" + ), JsonSetStatusListEntity.class); + + assertNotNull(loaded.getStatuses()); + assertEquals(List.of(TestStatus.ENABLED, TestStatus.DISABLED), loaded.getStatuses()); + } + + @Test + void csvSetShouldUseDefaultConverterForIntegerElements() { + EntityMeta meta = new EntityMetaResolver().resolve(CsvSetIntegerEntity.class); + + CsvSetIntegerEntity loaded = EntityMapper.fromMap(meta, Map.of( + "id", "e-36", + "ids", "1,2,1" + ), CsvSetIntegerEntity.class); + + assertNotNull(loaded.getIds()); + assertEquals(LinkedHashSet.class, loaded.getIds().getClass()); + assertEquals(List.of(1, 2), new ArrayList<>(loaded.getIds())); + } + + @Test + void jsonSetShouldUseDefaultConverterForIntegerElements() { + EntityMeta meta = new EntityMetaResolver().resolve(JsonSetIntegerListEntity.class); + + JsonSetIntegerListEntity loaded = EntityMapper.fromMap(meta, Map.of( + "id", "e-37", + "ids", "[\"1\",\"2\",\"1\"]" + ), JsonSetIntegerListEntity.class); + + assertNotNull(loaded.getIds()); + assertEquals(ArrayList.class, loaded.getIds().getClass()); + assertEquals(List.of(1, 2), loaded.getIds()); + } + @Test void jsonSetShouldSerializeSingleElement() { EntityMeta meta = new EntityMetaResolver().resolve(JsonSetEntity.class); @@ -398,4 +507,180 @@ public void setTags(Object tags) { this.tags = tags; } } + + @Table(name = "json_set_raw_list_entity") + public static class JsonSetRawListEntity { + @Id + @Column(length = 32) + private String id; + + @Column(name = "tags", type = ColumnType.JSON_SET) + @SuppressWarnings("rawtypes") + private List tags; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + @SuppressWarnings("rawtypes") + public List getTags() { + return tags; + } + + @SuppressWarnings("rawtypes") + public void setTags(List tags) { + this.tags = tags; + } + } + + @Table(name = "json_set_status_list_entity") + public static class JsonSetStatusListEntity { + @Id + @Column(length = 32) + private String id; + + @Column(name = "statuses", type = ColumnType.JSON_SET) + private List statuses; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public List getStatuses() { + return statuses; + } + + public void setStatuses(List statuses) { + this.statuses = statuses; + } + } + + @Table(name = "csv_set_status_entity") + public static class CsvSetStatusEntity { + @Id + @Column(length = 32) + private String id; + + @Column(name = "statuses", type = ColumnType.SET) + private Set statuses; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Set getStatuses() { + return statuses; + } + + public void setStatuses(Set statuses) { + this.statuses = statuses; + } + } + + @Table(name = "csv_set_integer_entity") + public static class CsvSetIntegerEntity { + @Id + @Column(length = 32) + private String id; + + @Column(name = "ids", type = ColumnType.SET) + private Set ids; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Set getIds() { + return ids; + } + + public void setIds(Set ids) { + this.ids = ids; + } + } + + @Table(name = "json_set_integer_list_entity") + public static class JsonSetIntegerListEntity { + @Id + @Column(length = 32) + private String id; + + @Column(name = "ids", type = ColumnType.JSON_SET) + private List ids; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public List getIds() { + return ids; + } + + public void setIds(List ids) { + this.ids = ids; + } + } + + private enum TestStatus { + ENABLED("enabled"), + DISABLED("disabled"); + + private final String code; + + TestStatus(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + static TestStatus fromCode(Object value) { + String code = String.valueOf(value); + for (TestStatus status : values()) { + if (status.code.equals(code)) { + return status; + } + } + throw new IllegalArgumentException("Unknown status code: " + code); + } + } + + private static class TestStatusCodeConverter implements DatabaseValueConverter { + @Override + public Object toDatabaseValue(Object value) { + if (value instanceof TestStatus status) { + return status.getCode(); + } + return DatabaseValueConverter.DEFAULT.toDatabaseValue(value); + } + + @Override + public Object fromDatabaseValue(Object value, Class targetType) { + if (targetType == TestStatus.class) { + return TestStatus.fromCode(value); + } + return DatabaseValueConverter.DEFAULT.fromDatabaseValue(value, targetType); + } + } } diff --git a/muyun-database-quarkus/src/main/resources/META-INF/quarkus-extension.properties b/muyun-database-quarkus/src/main/resources/META-INF/quarkus-extension.properties index 8ce5f37..c93e17e 100644 --- a/muyun-database-quarkus/src/main/resources/META-INF/quarkus-extension.properties +++ b/muyun-database-quarkus/src/main/resources/META-INF/quarkus-extension.properties @@ -1 +1 @@ -deployment-artifact=net.ximatai.muyun.database:muyun-database-quarkus-deployment::jar:3.26.11 +deployment-artifact=net.ximatai.muyun.database:muyun-database-quarkus-deployment::jar:3.26.12 diff --git a/samples/quarkus-minimal/build.gradle.kts b/samples/quarkus-minimal/build.gradle.kts index 38ed61d..796a11e 100644 --- a/samples/quarkus-minimal/build.gradle.kts +++ b/samples/quarkus-minimal/build.gradle.kts @@ -8,7 +8,7 @@ version = "0.0.1-SNAPSHOT" allprojects { group = "net.ximatai.muyun.database" - version = "3.26.11" + version = "3.26.12" } java { diff --git a/samples/starter-minimal/build.gradle.kts b/samples/starter-minimal/build.gradle.kts index 1a4209b..f193a0a 100644 --- a/samples/starter-minimal/build.gradle.kts +++ b/samples/starter-minimal/build.gradle.kts @@ -23,7 +23,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") implementation("org.postgresql:postgresql:42.7.8") - implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.11") + implementation("net.ximatai.muyun.database:muyun-database-spring-boot-starter:3.26.12") } tasks.test { From 4c5de11b5cb99a5aa9d46bff9a9ef280891e03e4 Mon Sep 17 00:00:00 2001 From: Liu Rui Date: Sun, 28 Jun 2026 11:53:22 +0800 Subject: [PATCH 2/4] Apply field codec to entity condition values --- .../core/orm/DefaultSimpleEntityManager.java | 17 +++++- .../orm/DefaultSimpleEntityManagerTest.java | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManager.java b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManager.java index 077580f..66665d1 100644 --- a/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManager.java +++ b/muyun-database-core/src/main/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManager.java @@ -370,15 +370,26 @@ private DBInfo.Type databaseType() { private Map resolveConditionColumns(EntityMeta meta, Map conditions) { Map resolved = new LinkedHashMap<>(); for (Map.Entry entry : conditions.entrySet()) { - String columnName = meta.resolveColumnName(entry.getKey()); - if (columnName == null || !SqlIdentifiers.isSafe(columnName)) { + EntityFieldMeta fieldMeta = resolveConditionField(meta, entry.getKey()); + if (fieldMeta == null || !SqlIdentifiers.isSafe(fieldMeta.getColumnName())) { throw new OrmException(OrmException.Code.INVALID_CRITERIA, "Unknown or unsafe condition field: " + entry.getKey()); } - resolved.put(columnName, valueConverter.toDatabaseValue(entry.getValue())); + resolved.put( + fieldMeta.getColumnName(), + FieldValueCodec.toDatabaseValue(fieldMeta, entry.getValue(), valueConverter) + ); } return resolved; } + private EntityFieldMeta resolveConditionField(EntityMeta meta, String fieldOrColumn) { + EntityFieldMeta byField = meta.findByFieldName(fieldOrColumn); + if (byField != null) { + return byField; + } + return meta.findByColumnName(fieldOrColumn); + } + int executeUpsertForTest(String schema, String tableName, Map body) { return executeUpsert(schema, tableName, body, operations.getPKName()); } diff --git a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManagerTest.java b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManagerTest.java index 935ea13..5387a52 100644 --- a/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManagerTest.java +++ b/muyun-database-core/src/test/java/net/ximatai/muyun/database/core/orm/DefaultSimpleEntityManagerTest.java @@ -5,13 +5,16 @@ import net.ximatai.muyun.database.core.annotation.Column; import net.ximatai.muyun.database.core.annotation.Id; import net.ximatai.muyun.database.core.annotation.Table; +import net.ximatai.muyun.database.core.builder.ColumnType; import net.ximatai.muyun.database.core.metadata.DBInfo; import net.ximatai.muyun.database.core.metadata.DBSchema; import org.junit.jupiter.api.Test; import java.sql.Array; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -38,6 +41,21 @@ void shouldResolveConditionalUpdateFieldsToColumns() { assertEquals(Map.of("tenant_id", "t-1", "role_name", "manager", "id", "r-1"), operations.where); } + @Test + void conditionalUpdateShouldUseFieldCodecForSetConditions() { + CapturingOperations operations = new CapturingOperations(); + DefaultSimpleEntityManager manager = new DefaultSimpleEntityManager(operations); + + RoleWithFlags role = new RoleWithFlags(); + role.setId("r-1"); + + assertEquals(1, manager.update(role, Map.of( + "flags", new LinkedHashSet<>(List.of(Flag.ENABLED, Flag.DISABLED)) + ))); + + assertEquals(Map.of("flags", "ENABLED,DISABLED", "id", "r-1"), operations.where); + } + @Test void conditionalUpdateShouldReturnZeroWhenNoRowsMatch() { CapturingOperations operations = new CapturingOperations(); @@ -64,6 +82,18 @@ void shouldResolveConditionalDeleteFieldsToColumns() { assertEquals(Map.of("tenant_id", "t-1", "id", "r-1"), operations.where); } + @Test + void conditionalDeleteShouldUseFieldCodecForSetConditions() { + CapturingOperations operations = new CapturingOperations(); + DefaultSimpleEntityManager manager = new DefaultSimpleEntityManager(operations); + + assertEquals(1, manager.deleteById(RoleWithFlags.class, "r-1", Map.of( + "flags", new LinkedHashSet<>(List.of(Flag.ENABLED, Flag.DISABLED)) + ))); + + assertEquals(Map.of("flags", "ENABLED,DISABLED", "id", "r-1"), operations.where); + } + @Test void conditionalDeleteShouldReturnZeroWhenNoRowsMatch() { CapturingOperations operations = new CapturingOperations(); @@ -275,6 +305,37 @@ public void setRoleName(String roleName) { } } + @Table(name = "role_with_flags", schema = "sample_schema") + static class RoleWithFlags { + @Id + @Column(length = 32) + private String id; + + @Column(name = "flags", type = ColumnType.SET) + private Set flags; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Set getFlags() { + return flags; + } + + public void setFlags(Set flags) { + this.flags = flags; + } + } + + enum Flag { + ENABLED, + DISABLED + } + @Table(name = "custom_id_entity", schema = "sample_schema") static class CustomIdEntity { @Id(name = "biz_id") From e08452a28953e780d491c89de39de1c5724378b4 Mon Sep 17 00:00:00 2001 From: Liu Rui Date: Sun, 28 Jun 2026 12:23:25 +0800 Subject: [PATCH 3/4] Fix Quarkus PostgreSQL test profile --- .../quarkus/it/PostgresTestResource.java | 10 ++++++- .../QuarkusPostgresMatrixIntegrationTest.java | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java index 8c3cca7..02ff0ea 100644 --- a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java +++ b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java @@ -13,7 +13,7 @@ public class PostgresTestResource implements QuarkusTestResourceLifecycleManager @Override public Map start() { - if (!DockerClientFactory.instance().isDockerAvailable()) { + if (!isDockerAvailable()) { if (Boolean.getBoolean("muyun.postgres.it.required")) { throw new IllegalStateException("PostgreSQL integration tests are required, but Docker is not available."); } @@ -38,6 +38,14 @@ public Map start() { return config; } + static boolean isDockerAvailable() { + try { + return DockerClientFactory.instance().isDockerAvailable(); + } catch (Throwable ignored) { + return false; + } + } + @Override public void stop() { if (postgres != null) { diff --git a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java index f61e9fa..91b5c3c 100644 --- a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java +++ b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java @@ -2,6 +2,8 @@ import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; @@ -35,6 +37,7 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; @QuarkusTest +@TestProfile(QuarkusPostgresMatrixIntegrationTest.PostgresProfile.class) @QuarkusTestResource(value = PostgresTestResource.class, restrictToAnnotatedClass = true) class QuarkusPostgresMatrixIntegrationTest { @@ -135,6 +138,30 @@ private static String describe(MigrationResult result) { .toString(); } + public static class PostgresProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + if (!shouldUsePostgresProfile()) { + return Map.of("muyun.test.postgres.enabled", "false"); + } + return Map.of( + "quarkus.datasource.db-kind", "postgresql", + "quarkus.datasource.devservices.enabled", "false", + "quarkus.datasource.jdbc.url", "jdbc:postgresql://localhost:1/muyun_quarkus", + "quarkus.datasource.username", "testuser", + "quarkus.datasource.password", "testpass", + "muyun.database.default-schema", "public", + "muyun.database.install-postgres-plugins", "true" + ); + } + + private boolean shouldUsePostgresProfile() { + return Boolean.getBoolean("muyun.postgres.it.required") + || "true".equalsIgnoreCase(System.getenv("CI")) + || "true".equalsIgnoreCase(System.getenv("GITHUB_ACTIONS")); + } + } + @ApplicationScoped @SuppressWarnings({"rawtypes", "unchecked"}) public static class PostgresTxService { From c90756f1ff83cbbc57e44eea9cea1337df7829c6 Mon Sep 17 00:00:00 2001 From: Liu Rui Date: Sun, 28 Jun 2026 12:35:55 +0800 Subject: [PATCH 4/4] Stabilize Quarkus PostgreSQL matrix test --- .../database/quarkus/it/PostgresTestResource.java | 14 +++++--------- .../it/QuarkusPostgresMatrixIntegrationTest.java | 4 +--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java index 02ff0ea..7eaa9fe 100644 --- a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java +++ b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/PostgresTestResource.java @@ -13,12 +13,12 @@ public class PostgresTestResource implements QuarkusTestResourceLifecycleManager @Override public Map start() { - if (!isDockerAvailable()) { - if (Boolean.getBoolean("muyun.postgres.it.required")) { - throw new IllegalStateException("PostgreSQL integration tests are required, but Docker is not available."); - } + if (!Boolean.getBoolean("muyun.postgres.it.required")) { return Map.of("muyun.test.postgres.enabled", "false"); } + if (!isDockerAvailable()) { + throw new IllegalStateException("PostgreSQL integration tests are required, but Docker is not available."); + } postgres = new PostgreSQLContainer<>("postgres:17-alpine") .withDatabaseName("muyun_quarkus") @@ -39,11 +39,7 @@ public Map start() { } static boolean isDockerAvailable() { - try { - return DockerClientFactory.instance().isDockerAvailable(); - } catch (Throwable ignored) { - return false; - } + return DockerClientFactory.instance().isDockerAvailable(); } @Override diff --git a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java index 91b5c3c..794d734 100644 --- a/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java +++ b/muyun-database-quarkus-integration-test/src/test/java/net/ximatai/muyun/database/quarkus/it/QuarkusPostgresMatrixIntegrationTest.java @@ -156,9 +156,7 @@ public Map getConfigOverrides() { } private boolean shouldUsePostgresProfile() { - return Boolean.getBoolean("muyun.postgres.it.required") - || "true".equalsIgnoreCase(System.getenv("CI")) - || "true".equalsIgnoreCase(System.getenv("GITHUB_ACTIONS")); + return Boolean.getBoolean("muyun.postgres.it.required"); } }