From b411ab132184361f5e100f2cf9db5b0113c46636 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Mon, 13 Jul 2026 22:48:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81SQL=20=E4=B8=8E=20JEXL=20=E6=AD=A3=E7=A1=AE=E6=80=A7?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留声明集合语义并按数据库方言生成合法 SQL,同时确保 JEXL 配置变更后重建引擎。 --- .../core/conversion/ObjectConverter.java | 325 ++++++++++++------ .../kotlin/TestObjectConverterCollections.kt | 81 +++++ module/database/build.gradle.kts | 1 + .../taboolib/module/database/ActionInsert.kt | 108 +++++- .../module/database/ExecutableSource.kt | 14 +- .../database/ActionInsertDialectTest.kt | 150 ++++++++ module/script/script-jexl/build.gradle.kts | 3 +- .../kotlin/taboolib/expansion/JexlCompiler.kt | 57 +-- .../kotlin/taboolib/expansion/JexlHelper.kt | 22 +- .../taboolib/expansion/JexlCompilerTest.kt | 48 +++ 10 files changed, 645 insertions(+), 164 deletions(-) create mode 100644 module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt create mode 100644 module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt create mode 100644 module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt diff --git a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java index d7de6c048..017239305 100644 --- a/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java +++ b/module/basic/basic-configuration/src/main/java/com/electronwill/nightconfig/core/conversion/ObjectConverter.java @@ -316,9 +316,16 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class // --- Writes the value to the object's field, converting it if needed --- Class fieldType = field.getType(); try { - if (value instanceof UnmodifiableConfig && !(fieldType.isAssignableFrom(value.getClass()))) { + if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(fieldType)) { + // --- Reads as a map while preserving the declared map and generic value types --- + Map converted = convertMap(value, field.getGenericType(), fieldType); + AnnotationUtils.checkField(field, converted); + field.set(object, converted); + } else if ((value instanceof UnmodifiableConfig || value instanceof Map) && !(fieldType.isAssignableFrom(value.getClass()))) { // --- Read as a sub-object --- - final UnmodifiableConfig cfg = (UnmodifiableConfig) value; + final UnmodifiableConfig cfg = value instanceof UnmodifiableConfig + ? (UnmodifiableConfig) value + : configFromMap((Map) value); // Gets or creates the field and convert it (if null OR not preserved) Object fieldValue = field.get(object); if (fieldValue == null) { @@ -329,35 +336,10 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class convertToObject(cfg, fieldValue, field.getType()); } } else if (value instanceof Collection && Collection.class.isAssignableFrom(fieldType)) { - // --- Reads as a collection, maybe a list of objects with conversion --- - final Collection src = (Collection) value; - final Class srcBottomType = bottomElementType(src); - - final ParameterizedType genericType = (ParameterizedType) field.getGenericType(); - final List> dstTypes = elementTypes(genericType); - final Class dstBottomType = dstTypes.get(dstTypes.size() - 1); - - if (srcBottomType == null || dstBottomType == null || dstBottomType.isAssignableFrom(srcBottomType)) { - // Simple list, no conversion needed - AnnotationUtils.checkField(field, value); - field.set(object, value); - } else { - // List of objects => the bottom elements need conversion - // Uses the current field value if there is one, or create a new list - Collection dst = (Collection) field.get(object); - if (dst == null) { - if (fieldType == ArrayList.class || fieldType.isInterface() || Modifier.isAbstract(fieldType.getModifiers())) { - dst = new ArrayList<>(src.size());// allocates the right size - } else { - dst = (Collection) createInstance(fieldType); - } - field.set(object, dst); - } - // Converts the elements of the list - convertConfigsToObject(src, dst, dstTypes, 0); - // Applies the checks - AnnotationUtils.checkField(field, dst); - } + // --- Reads as a collection while preserving the declared collection and generic element types --- + Collection converted = convertCollection((Collection) value, field.getGenericType(), fieldType); + AnnotationUtils.checkField(field, converted); + field.set(object, converted); } else { // --- Read as a plain value --- if (value == null && AnnotationUtils.mustPreserve(field, clazz)) { @@ -382,61 +364,213 @@ private void convertToObject(UnmodifiableConfig config, Object object, Class } } - /** - * Gets the type of the "bottom element" of a list. - * For instance, for {@code LinkedList>>>} - * this method returns the class {@code Supplier}. - * - * @param genericType the generic list type - * @return the type of the elements of the most nested list - */ - private Class bottomElementType(ParameterizedType genericType) { - if (genericType != null && genericType.getActualTypeArguments().length > 0) { - Type parameter = genericType.getActualTypeArguments()[0]; - if (parameter instanceof ParameterizedType) { - ParameterizedType genericParameter = (ParameterizedType) parameter; - Class paramClass = (Class) genericParameter.getRawType(); - if (paramClass.isAssignableFrom(Collection.class)) { - return bottomElementType(genericParameter); - } else { - return paramClass; - } + private Collection convertCollection(Collection source, Type declaredType, Class declaredClass) { + Type elementType = collectionElementType(declaredType); + Collection destination = createCollection(declaredClass, elementType, source.size()); + for (Object element : source) { + destination.add(convertValue(element, elementType)); + } + return destination; + } + + private Object convertValue(Object value, Type declaredType) { + if (value == null) { + return null; + } + Class declaredClass = rawClass(declaredType); + if (declaredClass == Object.class) { + return value; + } + if (value instanceof Collection && Collection.class.isAssignableFrom(declaredClass)) { + return convertCollection((Collection) value, declaredType, declaredClass); + } + if ((value instanceof UnmodifiableConfig || value instanceof Map) && Map.class.isAssignableFrom(declaredClass)) { + return convertMap(value, declaredType, declaredClass); + } + if ((value instanceof UnmodifiableConfig || value instanceof Map) && isStructuredObjectType(declaredClass)) { + Object elementObject = createInstance(declaredClass); + UnmodifiableConfig elementConfig = value instanceof UnmodifiableConfig + ? (UnmodifiableConfig) value + : configFromMap((Map) value); + convertToObject(elementConfig, elementObject, declaredClass); + return elementObject; + } + Object unwrapped = ConfigSection.Companion.unwrap(value); + if (unwrapped == null || declaredClass.isAssignableFrom(unwrapped.getClass())) { + return unwrapped; + } + if (declaredClass.isEnum()) { + return EnumGetMethod.NAME_IGNORECASE.get(unwrapped, (Class) declaredClass); + } + if (unwrapped instanceof Number) { + Object number = convertNumber((Number) unwrapped, declaredClass); + if (number != null) { + return number; } - if ((parameter instanceof Class)) { - return (Class) parameter; + } + if (declaredClass == String.class && !(unwrapped instanceof Map) && !(unwrapped instanceof Collection)) { + return unwrapped.toString(); + } + throw new InvalidValueException("Unexpected element of type " + unwrapped.getClass() + " for " + declaredType); + } + + private boolean isStructuredObjectType(Class type) { + return type != String.class + && type != Boolean.class + && type != Character.class + && !Number.class.isAssignableFrom(type) + && !type.isEnum() + && !Collection.class.isAssignableFrom(type) + && !Map.class.isAssignableFrom(type); + } + + private Map convertMap(Object source, Type declaredType, Class declaredClass) { + Map sourceMap; + if (source instanceof UnmodifiableConfig) { + sourceMap = ((UnmodifiableConfig) source).valueMap(); + } else { + Object unwrapped = ConfigSection.Companion.unwrap(source); + if (!(unwrapped instanceof Map)) { + throw new InvalidValueException("Unexpected value of type " + source.getClass() + " for " + declaredType); } + sourceMap = (Map) unwrapped; } - return null; + Type keyType = Object.class; + Type valueType = Object.class; + Type resolvedType = boundedType(declaredType); + if (resolvedType instanceof ParameterizedType) { + Type[] typeArguments = ((ParameterizedType) resolvedType).getActualTypeArguments(); + if (typeArguments.length > 0) { + keyType = typeArguments[0]; + } + if (typeArguments.length > 1) { + valueType = typeArguments[1]; + } + } + Map destination = createMap(declaredClass); + for (Map.Entry entry : sourceMap.entrySet()) { + destination.put(convertValue(entry.getKey(), keyType), convertValue(entry.getValue(), valueType)); + } + return destination; } - private void detectElementTypes(ParameterizedType genericType, List> storage) { - if (genericType != null && genericType.getActualTypeArguments().length > 0) { - Type parameter = genericType.getActualTypeArguments()[0]; - if (parameter instanceof ParameterizedType) { - ParameterizedType genericParameter = (ParameterizedType) parameter; - Class paramClass = (Class) genericParameter.getRawType(); - storage.add(paramClass); - if (Collection.class.isAssignableFrom(paramClass)) { - detectElementTypes(genericParameter, storage); - } - } else if ((parameter instanceof Class)) { - storage.add((Class) parameter); + private UnmodifiableConfig configFromMap(Map source) { + Config config = Config.inMemory(); + for (Map.Entry entry : source.entrySet()) { + config.set(String.valueOf(entry.getKey()), entry.getValue()); + } + return config; + } + + private Collection createCollection(Class declaredClass, Type elementType, int size) { + if (!declaredClass.isInterface() && !Modifier.isAbstract(declaredClass.getModifiers())) { + return (Collection) createInstance((Class) declaredClass); + } + if (EnumSet.class.isAssignableFrom(declaredClass)) { + Class enumType = rawClass(elementType); + if (!enumType.isEnum()) { + throw new ReflectionException("Unable to determine enum type for " + declaredClass); + } + return (Collection) (Collection) EnumSet.noneOf((Class) enumType); + } + if ((NavigableSet.class.isAssignableFrom(declaredClass) || SortedSet.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(TreeSet.class)) { + return new TreeSet<>(); + } + if (Set.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(LinkedHashSet.class)) { + return new LinkedHashSet<>(Math.max(16, size)); + } + if ((Deque.class.isAssignableFrom(declaredClass) || Queue.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(LinkedList.class)) { + return new LinkedList<>(); + } + if (Collection.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(ArrayList.class)) { + return new ArrayList<>(size); + } + throw new ReflectionException("Unable to create compatible collection for " + declaredClass); + } + + private Map createMap(Class declaredClass) { + if (!declaredClass.isInterface() && !Modifier.isAbstract(declaredClass.getModifiers())) { + return (Map) createInstance((Class) declaredClass); + } + if ((NavigableMap.class.isAssignableFrom(declaredClass) || SortedMap.class.isAssignableFrom(declaredClass)) + && declaredClass.isAssignableFrom(TreeMap.class)) { + return new TreeMap<>(); + } + if (Map.class.isAssignableFrom(declaredClass) && declaredClass.isAssignableFrom(LinkedHashMap.class)) { + return new LinkedHashMap<>(); + } + throw new ReflectionException("Unable to create compatible map for " + declaredClass); + } + + private Type collectionElementType(Type declaredType) { + Type resolvedType = boundedType(declaredType); + if (resolvedType instanceof ParameterizedType) { + Type[] arguments = ((ParameterizedType) resolvedType).getActualTypeArguments(); + if (arguments.length > 0) { + return arguments[0]; } } + return Object.class; } - /** - * Returns a list of the generic parameters of a list. - * For instance, for {@code LinkedList>>>} - * this method returns a list containing {@code [Collection.class, Supplier.class]}. - * - * @param genericType the list generic type - * @return a list of the types of the list's elements - */ - private List> elementTypes(ParameterizedType genericType) { - List> storage = new ArrayList<>(); - detectElementTypes(genericType, storage); - return storage; + private Type boundedType(Type type) { + if (type instanceof WildcardType) { + WildcardType wildcardType = (WildcardType) type; + Type[] lowerBounds = wildcardType.getLowerBounds(); + if (lowerBounds.length > 0) { + return boundedType(lowerBounds[0]); + } + Type[] upperBounds = wildcardType.getUpperBounds(); + return upperBounds.length == 0 ? Object.class : boundedType(upperBounds[0]); + } + if (type instanceof TypeVariable) { + Type[] bounds = ((TypeVariable) type).getBounds(); + return bounds.length == 0 ? Object.class : boundedType(bounds[0]); + } + return type; + } + + private Class rawClass(Type type) { + if (type instanceof Class) { + return wrapPrimitive((Class) type); + } + if (type instanceof ParameterizedType) { + return rawClass(((ParameterizedType) type).getRawType()); + } + if (type instanceof WildcardType) { + Type[] upperBounds = ((WildcardType) type).getUpperBounds(); + return upperBounds.length == 0 ? Object.class : rawClass(upperBounds[0]); + } + if (type instanceof TypeVariable) { + Type[] bounds = ((TypeVariable) type).getBounds(); + return bounds.length == 0 ? Object.class : rawClass(bounds[0]); + } + return Object.class; + } + + private Class wrapPrimitive(Class type) { + if (!type.isPrimitive()) return type; + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == double.class) return Double.class; + if (type == float.class) return Float.class; + if (type == short.class) return Short.class; + if (type == byte.class) return Byte.class; + if (type == boolean.class) return Boolean.class; + if (type == char.class) return Character.class; + return type; + } + + private Object convertNumber(Number value, Class targetType) { + if (targetType == Integer.class) return value.intValue(); + if (targetType == Long.class) return value.longValue(); + if (targetType == Double.class) return value.doubleValue(); + if (targetType == Float.class) return value.floatValue(); + if (targetType == Short.class) return value.shortValue(); + if (targetType == Byte.class) return value.byteValue(); + return null; } /** @@ -458,43 +592,6 @@ private Class bottomElementType(Collection list) { return null; } - /** - * Converts a collection of configurations to a collection of objects of the type dstBottomType. - * - * @param src the collection of configs, may be nested, source - * @param dst the collection of objects, destination - * @param dstElementTypes the type of lists and objects in dst - */ - private void convertConfigsToObject(Collection src, Collection dst, List> dstElementTypes, int currentLevel) { - final Class currentType = dstElementTypes.get(currentLevel); - for (Object elem : src) { - if (elem == null) { - dst.add(null); - } else if (elem instanceof Collection) { - final Collection subSrc = (Collection) elem; - final Collection subDst; - - if (currentType == ArrayList.class - || currentType.isInterface() - || Modifier.isAbstract(currentType.getModifiers())) { - - subDst = new ArrayList<>(); - } else { - subDst = (Collection) createInstance(currentType); - } - convertConfigsToObject(subSrc, subDst, dstElementTypes, currentLevel + 1); - dst.add(subDst); - } else if (elem instanceof UnmodifiableConfig) { - Object elementObj = createInstance(currentType); - convertToObject((UnmodifiableConfig) elem, elementObj, currentType); - dst.add(elementObj); - } else { - String elemType = elem.getClass().toString(); - throw new InvalidValueException("Unexpected element of type " + elemType + " in collection of objects"); - } - } - } - /** * Converts a collection of objects of the type srcBottomType to a collection of configurations. * diff --git a/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt b/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt new file mode 100644 index 000000000..d809c2d33 --- /dev/null +++ b/module/basic/basic-configuration/src/test/kotlin/TestObjectConverterCollections.kt @@ -0,0 +1,81 @@ +import com.electronwill.nightconfig.core.Config +import com.electronwill.nightconfig.core.conversion.ObjectConverter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import java.util.EnumSet +import java.util.LinkedHashSet +import java.util.LinkedList +import java.util.Queue +import java.util.SortedMap +import java.util.SortedSet +import java.util.TreeMap +import java.util.TreeSet + +class TestObjectConverterCollections { + + @Test + fun `preserves declared collection types and converts nested elements`() { + val root = Config.inMemory() + root.set("names", listOf("alpha", "beta", "alpha")) + root.set("queue", listOf("first", "second")) + root.set("numbers", listOf(1L, 2L)) + root.set("sorted", listOf("beta", "alpha")) + root.set("modes", listOf("first", "SECOND")) + root.set("groups", listOf(listOf(itemConfig("one"), itemConfig("two")))) + + val indexed = Config.inMemory() + indexed.set("primary", itemConfig("indexed")) + root.set("indexed", indexed) + + val sortedIndex = Config.inMemory() + sortedIndex.set("second", 2) + sortedIndex.set("first", 1) + root.set("sortedIndex", sortedIndex) + + val result = CollectionHolder() + ObjectConverter().toObject(root, result) + + assertInstanceOf(LinkedHashSet::class.java, result.names) + assertEquals(linkedSetOf("alpha", "beta"), result.names) + assertInstanceOf(LinkedList::class.java, result.queue) + assertEquals(listOf("first", "second"), result.queue.toList()) + assertInstanceOf(LinkedList::class.java, result.numbers) + assertEquals(listOf(1, 2), result.numbers) + assertInstanceOf(TreeSet::class.java, result.sorted) + assertEquals(listOf("alpha", "beta"), result.sorted.toList()) + assertEquals(EnumSet.of(Mode.FIRST, Mode.SECOND), result.modes) + assertInstanceOf(LinkedHashSet::class.java, result.groups.single()) + assertEquals(listOf("one", "two"), result.groups.single().map { it.name }) + assertEquals("indexed", result.indexed.getValue("primary").name) + assertInstanceOf(TreeMap::class.java, result.sortedIndex) + assertEquals(listOf("first", "second"), result.sortedIndex.keys.toList()) + assertEquals(listOf(1L, 2L), result.sortedIndex.values.toList()) + } + + private fun itemConfig(name: String): Config { + return Config.inMemory().also { it.set("name", name) } + } + + class CollectionHolder { + + var names: Set = emptySet() + var queue: Queue = LinkedList() + var numbers: LinkedList = LinkedList() + var sorted: SortedSet = sortedSetOf() + var modes: EnumSet = EnumSet.noneOf(Mode::class.java) + var groups: List> = emptyList() + var indexed: Map = emptyMap() + var sortedIndex: SortedMap = sortedMapOf() + } + + class Item { + + var name: String = "" + } + + enum class Mode { + FIRST, + SECOND, + } +} diff --git a/module/database/build.gradle.kts b/module/database/build.gradle.kts index 5f7c7b7cb..fe73508dc 100644 --- a/module/database/build.gradle.kts +++ b/module/database/build.gradle.kts @@ -7,6 +7,7 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":common-util")) compileOnly(project(":module:basic:basic-configuration")) + testImplementation(project(":common-util")) testImplementation("com.zaxxer:HikariCP:4.0.3") testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") } diff --git a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt index 2ee77ab68..f4a41313f 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/ActionInsert.kt @@ -20,21 +20,32 @@ class ActionInsert(val table: String, val keys: Array) : Action { /** 重复时更新 */ private var duplicateUpdate = ArrayList() + /** 重复键方言 */ + private var duplicateKeyDialect = DuplicateKeyDialect.MYSQL + + /** 冲突目标 */ + private var conflictKeys: Array? = null + /** 语句 */ override val query: String - get() = Statement("INSERT INTO") - .addSegment(table.asFormattedColumnName()) - .addSegmentIfTrue(keys.isNotEmpty()) { - addKeys(keys) - } - .addSegmentIfTrue(values.isNotEmpty()) { - addSegment("VALUES") - addValues(values) + get() { + require(table.isNotBlank()) { "Insert table must not be blank" } + require(keys.none { it.isBlank() }) { "Insert keys must not contain blank names" } + require(values.isNotEmpty()) { "Insert values must not be empty" } + if (keys.isNotEmpty()) { + require(values.all { it.size == keys.size }) { "Insert value count must match key count" } } - .addSegmentIfTrue(duplicateUpdate.isNotEmpty()) { - addSegment("ON DUPLICATE KEY UPDATE") - addOperations(duplicateUpdate) - }.build() + return Statement("INSERT INTO") + .addSegment(table.asFormattedColumnName()) + .addSegmentIfTrue(keys.isNotEmpty()) { + addKeys(keys) + } + .addSegment("VALUES") + .addValues(values) + .addSegmentIfTrue(duplicateUpdate.isNotEmpty()) { + addDuplicateUpdate() + }.build() + } /** 元素 */ override val elements: List @@ -60,9 +71,28 @@ class ActionInsert(val table: String, val keys: Array) : Action { values.add(args.toTypedArray()) } - /** 重复时更新 */ + /** + * 重复时更新。 + * PostgreSQL 无法从插入字段可靠推断唯一约束,需使用带冲突字段的重载。 + */ fun onDuplicateKeyUpdate(func: DuplicateUpdateBehavior.() -> Unit) { - duplicateUpdate = DuplicateUpdateBehavior().also(func).updateOperations + setupDuplicateUpdate(null, func) + } + + /** + * 重复时更新,并显式指定 PostgreSQL/SQLite 的冲突字段。 + * MySQL 会忽略冲突字段并继续使用 ON DUPLICATE KEY UPDATE。 + */ + fun onDuplicateKeyUpdate(conflictKeys: Collection, func: DuplicateUpdateBehavior.() -> Unit) { + setupDuplicateUpdate(conflictKeys.toTypedArray(), func) + } + + internal fun setupDialect(host: Host<*>) { + duplicateKeyDialect = when (host) { + is HostPostgreSQL -> DuplicateKeyDialect.POSTGRESQL + is HostSQLite -> DuplicateKeyDialect.SQLITE + else -> DuplicateKeyDialect.MYSQL + } } override fun onFinally(onFinally: PreparedStatement.(Connection) -> Unit) { @@ -73,11 +103,53 @@ class ActionInsert(val table: String, val keys: Array) : Action { this.finallyCallback?.invoke(preparedStatement, connection) } + private fun setupDuplicateUpdate(conflictKeys: Array?, func: DuplicateUpdateBehavior.() -> Unit) { + val behavior = DuplicateUpdateBehavior().also(func) + duplicateUpdate = behavior.updateOperations + this.conflictKeys = conflictKeys + } + + private fun Statement.addDuplicateUpdate() { + when (duplicateKeyDialect) { + DuplicateKeyDialect.MYSQL -> { + addSegment("ON DUPLICATE KEY UPDATE") + addOperations(duplicateUpdate) + } + DuplicateKeyDialect.POSTGRESQL -> { + val targetKeys = conflictKeys + require(!targetKeys.isNullOrEmpty()) { + "PostgreSQL duplicate update requires explicit conflict keys" + } + require(targetKeys.none { it.isBlank() }) { + "PostgreSQL conflict keys must not contain blank names" + } + addSegment("ON CONFLICT") + addKeys(targetKeys) + addSegment("DO UPDATE SET") + addOperations(duplicateUpdate) + } + DuplicateKeyDialect.SQLITE -> { + addSegment("ON CONFLICT") + conflictKeys?.also { targetKeys -> + require(targetKeys.none { it.isBlank() }) { + "SQLite conflict keys must not contain blank names" + } + if (targetKeys.isNotEmpty()) { + addKeys(targetKeys) + } + } + addSegment("DO UPDATE SET") + addOperations(duplicateUpdate) + } + } + } + class DuplicateUpdateBehavior { val updateOperations = ArrayList() fun update(key: String, value: Any) { + require(key.isNotBlank()) { "Duplicate update key must not be blank" } updateOperations += if (value is PreValue) { UpdateOperation("${key.asFormattedColumnName()} = ${value.asFormattedColumnName()}") } else { @@ -85,4 +157,10 @@ class ActionInsert(val table: String, val keys: Array) : Action { } } } -} \ No newline at end of file + + private enum class DuplicateKeyDialect { + MYSQL, + POSTGRESQL, + SQLITE, + } +} diff --git a/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt b/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt index c2ce58d2a..c0244caaf 100644 --- a/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt +++ b/module/database/src/main/kotlin/taboolib/module/database/ExecutableSource.kt @@ -89,14 +89,20 @@ open class ExecutableSource(val table: Table<*, *>, var dataSource: DataSource, /** 插入数据 */ open fun insert(vararg keys: String, func: ActionInsert.() -> Unit = {}): ResultProcessor { setupQuoter() - val action = ActionInsert(table.name, arrayOf(*keys)).also(func) + val action = ActionInsert(table.name, arrayOf(*keys)).also { + it.setupDialect(table.host) + func(it) + } return executeUpdate(action.query, action) } /** 插入数据 */ open fun insert(keys: List, func: ActionInsert.() -> Unit = {}): ResultProcessor { setupQuoter() - val action = ActionInsert(table.name, keys.toTypedArray()).also(func) + val action = ActionInsert(table.name, keys.toTypedArray()).also { + it.setupDialect(table.host) + func(it) + } return executeUpdate(action.query, action) } @@ -289,9 +295,9 @@ open class ExecutableSource(val table: Table<*, *>, var dataSource: DataSource, .addSegmentIfTrue(index.checkExists) { addSegment("IF NOT EXISTS") } - .addSegment(index.name) + .addSegment(index.name.asFormattedColumnName()) .addSegment("ON") - .addSegment(table.name) + .addSegment(table.name.asFormattedColumnName()) .addSegment("(") .addSegment(index.columns.joinToString(",", transform = { it.asFormattedColumnName() })) .addSegment(")") diff --git a/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt new file mode 100644 index 000000000..0d7469ada --- /dev/null +++ b/module/database/src/test/kotlin/taboolib/module/database/ActionInsertDialectTest.kt @@ -0,0 +1,150 @@ +package taboolib.module.database + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import org.sqlite.SQLiteDataSource +import java.io.File + +class ActionInsertDialectTest { + + @AfterEach + fun resetIdentifierQuoter() { + currentQuoter.remove() + } + + @Test + fun `keeps mysql duplicate key syntax`() { + val host = HostSQL("localhost", "3306", "root", "", "test") + val action = insertAction(host, "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?", + action.query + ) + assertEquals(listOf("entry", 1, 2), action.elements) + } + + @Test + fun `uses postgresql conflict syntax and double quoted identifiers`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val action = insertAction(host, "public.order") { + onDuplicateKeyUpdate(listOf("key")) { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO \"public\".\"order\" (\"key\", \"value\") VALUES (?, ?) ON CONFLICT (\"key\") DO UPDATE SET \"value\" = ?", + action.query + ) + } + + @Test + fun `requires explicit postgresql conflict keys`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val action = insertAction(host, "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertThrows(IllegalArgumentException::class.java) { action.query } + } + + @Test + fun `uses sqlite conflict syntax without guessing a conflict target`() { + val action = insertAction(HostSQLite(File("database.db")), "order") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + + assertEquals( + "INSERT INTO `order` (`key`, `value`) VALUES (?, ?) ON CONFLICT DO UPDATE SET `value` = ?", + action.query + ) + } + + @Test + fun `keeps positional insert compatibility when keys are omitted`() { + setupQuoterForHost(HostSQLite(File("database.db"))) + val action = ActionInsert("order", emptyArray()).also { it.value("entry", 1) } + + assertEquals("INSERT INTO `order` VALUES (?, ?)", action.query) + assertEquals(listOf("entry", 1), action.elements) + } + + @Test + fun `rejects incomplete insert statements`() { + setupQuoterForHost(HostSQLite(File("database.db"))) + + val noValues = ActionInsert("order", arrayOf("key")) + assertThrows(IllegalArgumentException::class.java) { noValues.query } + + val blankKeys = ActionInsert("order", arrayOf(" ")).also { it.value("entry") } + assertThrows(IllegalArgumentException::class.java) { blankKeys.query } + + val mismatchedValues = ActionInsert("order", arrayOf("key", "value")).also { it.value("entry") } + assertThrows(IllegalArgumentException::class.java) { mismatchedValues.query } + } + + @Test + fun `executes generated sqlite upsert`() { + val action = insertAction(HostSQLite(File("database.db")), "entries") { + onDuplicateKeyUpdate { + update("value", 2) + } + } + val dataSource = SQLiteDataSource().also { it.url = "jdbc:sqlite::memory:" } + + dataSource.connection.use { connection -> + connection.createStatement().use { statement -> + statement.execute("CREATE TABLE entries (`key` TEXT PRIMARY KEY, `value` INTEGER)") + } + repeat(2) { + connection.prepareStatement(action.query).use { statement -> + action.elements.forEachIndexed { index, value -> statement.setObject(index + 1, value) } + statement.executeUpdate() + } + } + connection.createStatement().use { statement -> + statement.executeQuery("SELECT value FROM entries WHERE `key` = 'entry'").use { result -> + result.next() + assertEquals(2, result.getInt(1)) + } + } + } + } + + @Test + fun `quotes index names tables and columns for postgresql`() { + val host = HostPostgreSQL("localhost", "5432", "postgres", "", "test") + val table = Table("public.order", host) + val source = ExecutableSource(table, SQLiteDataSource(), false) + setupQuoterForHost(host) + + val query = with(source) { + table.generateCreateIndexQuery(Index("select", listOf("from", "value"), unique = true, checkExists = false)) + } + + assertEquals( + "CREATE UNIQUE INDEX \"select\" ON \"public\".\"order\" ( \"from\",\"value\" )", + query + ) + } + + private fun insertAction(host: Host<*>, table: String, configure: ActionInsert.() -> Unit): ActionInsert { + setupQuoterForHost(host) + return ActionInsert(table, arrayOf("key", "value")).also { + it.setupDialect(host) + it.value("entry", 1) + configure(it) + } + } +} diff --git a/module/script/script-jexl/build.gradle.kts b/module/script/script-jexl/build.gradle.kts index e51e45fd5..2dab3e43d 100644 --- a/module/script/script-jexl/build.gradle.kts +++ b/module/script/script-jexl/build.gradle.kts @@ -6,10 +6,11 @@ dependencies { compileOnly(project(":common-env")) // 表达式 compileOnly("org.apache.commons:commons-jexl3:3.2.1") + testImplementation("org.apache.commons:commons-jexl3:3.2.1") } tasks { withType { relocate("org.apache.commons.jexl3", "org.apache.commons.jexl3_3_2_1") } -} \ No newline at end of file +} diff --git a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt index 5ab5fdb92..7d8a9b3cd 100644 --- a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt +++ b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlCompiler.kt @@ -3,7 +3,6 @@ package taboolib.expansion import org.apache.commons.jexl3.JexlBuilder import org.apache.commons.jexl3.JexlEngine import org.apache.commons.jexl3.MapContext -import taboolib.common.util.unsafeLazy /** * TabooLib @@ -20,7 +19,18 @@ class JexlCompiler { .cacheThreshold(64) // 设置合适的缓存阈值 .collectMode(0) // 如果不需要变量收集,关闭它 - internal val jexlEngine: JexlEngine by unsafeLazy { jexlBuilder.create() } + private val engineLock = Any() + + @Volatile + private var currentEngine: JexlEngine? = null + + internal val jexlEngine: JexlEngine + get() { + currentEngine?.let { return it } + return synchronized(engineLock) { + currentEngine ?: jexlBuilder.create().also { currentEngine = it } + } + } /** * 是否启用 Ant 风格模式 @@ -44,68 +54,57 @@ class JexlCompiler { * 在高频调用场景:影响会更明显 */ fun antish(flag: Boolean): JexlCompiler { - jexlBuilder.antish(flag) - return this + return configure { antish(flag) } } /** 设置严格模式 */ fun strict(flag: Boolean): JexlCompiler { - jexlBuilder.strict(flag) - return this + return configure { strict(flag) } } /** 设置静默模式 */ fun silent(flag: Boolean): JexlCompiler { - jexlBuilder.silent(flag) - return this + return configure { silent(flag) } } /** 设置安全模式 */ fun safe(flag: Boolean): JexlCompiler { - jexlBuilder.safe(flag) - return this + return configure { safe(flag) } } /** 设置调试模式 */ fun debug(flag: Boolean): JexlCompiler { - jexlBuilder.debug(flag) - return this + return configure { debug(flag) } } /** 设置缓存大小 */ fun cache(size: Int): JexlCompiler { - jexlBuilder.cache(size) - return this + return configure { cache(size) } } /** 设置收集模式 */ fun collectMode(mode: Int): JexlCompiler { - jexlBuilder.collectMode(mode) - return this + return configure { collectMode(mode) } } /** 设置是否收集所有变量 */ fun collectAll(flag: Boolean): JexlCompiler { - jexlBuilder.collectAll(flag) - return this + return configure { collectAll(flag) } } /** 设置缓存阈值 */ fun cacheThreshold(size: Int): JexlCompiler { - jexlBuilder.cacheThreshold(size) - return this + return configure { cacheThreshold(size) } } /** 设置堆栈大小 */ fun stackOverflow(size: Int): JexlCompiler { - jexlBuilder.stackOverflow(size) - return this + return configure { stackOverflow(size) } } /** 设置命名空间 */ fun namespace(namespace: Map): JexlCompiler { - jexlBuilder.namespaces(namespace) - return this + return configure { namespaces(namespace) } } /** 编译为脚本 */ @@ -130,8 +129,16 @@ class JexlCompiler { } } + private fun configure(configureBuilder: JexlBuilder.() -> Unit): JexlCompiler { + synchronized(engineLock) { + jexlBuilder.configureBuilder() + currentEngine = null + } + return this + } + companion object { fun new() = JexlCompiler() } -} \ No newline at end of file +} diff --git a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt index 53e0bf0d4..d270fcdeb 100644 --- a/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt +++ b/module/script/script-jexl/src/main/kotlin/taboolib/expansion/JexlHelper.kt @@ -1,14 +1,26 @@ @file:Inject -@file:RuntimeDependency( - "!org.apache.commons:commons-jexl3:3.2.1", - test = "!org.apache.commons.jexl3_3_2_1.JexlEngine", - relocate = ["!org.apache.commons.jexl3", "!org.apache.commons.jexl3_3_2_1"], - transitive = false +@file:RuntimeDependencies( + RuntimeDependency( + "!org.apache.commons:commons-jexl3:3.2.1", + test = "!org.apache.commons.jexl3_3_2_1.JexlEngine", + relocate = [ + "!org.apache.commons.jexl3", "!org.apache.commons.jexl3_3_2_1", + "!org.apache.commons.logging", "!org.apache.commons.logging_1_2" + ], + transitive = false + ), + RuntimeDependency( + "!commons-logging:commons-logging:1.2", + test = "!org.apache.commons.logging_1_2.Log", + relocate = ["!org.apache.commons.logging", "!org.apache.commons.logging_1_2"], + transitive = false + ) ) package taboolib.expansion import taboolib.common.Inject +import taboolib.common.env.RuntimeDependencies import taboolib.common.env.RuntimeDependency import taboolib.common.util.unsafeLazy diff --git a/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt b/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt new file mode 100644 index 000000000..3c39ba8b9 --- /dev/null +++ b/module/script/script-jexl/src/test/kotlin/taboolib/expansion/JexlCompilerTest.kt @@ -0,0 +1,48 @@ +package taboolib.expansion + +import org.apache.commons.jexl3.JexlException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class JexlCompilerTest { + + @Test + fun `rebuilds the engine when strict mode changes after first compilation`() { + val compiler = JexlCompiler() + val initialEngine = compiler.jexlEngine + + assertNull(compiler.compileToExpression("missing").eval()) + + compiler.strict(true) + val strictEngine = compiler.jexlEngine + + assertNotSame(initialEngine, strictEngine) + assertSame(strictEngine, compiler.jexlEngine) + assertThrows(JexlException::class.java) { + compiler.compileToExpression("missing").eval() + } + } + + @Test + fun `applies namespace changes made after first compilation`() { + val compiler = JexlCompiler() + assertEquals(2, compiler.compileToExpression("1 + 1").eval()) + + compiler.namespace(mapOf("tools" to Tools(21))) + assertEquals(21, compiler.compileToExpression("tools:answer()").eval()) + + compiler.namespace(mapOf("tools" to Tools(42))) + assertEquals(42, compiler.compileToExpression("tools:answer()").eval()) + } + + class Tools(private val answer: Int) { + + fun answer(): Int { + return answer + } + } +}