From d24fcdc4003382fbe1c8722c0a18756d80903620 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Mon, 20 Oct 2025 20:00:14 +0100 Subject: [PATCH 1/5] Support ByteArray (SQLite BLOB) in sqllin-dsl --- .../driver/AndroidDatabaseConnection.kt | 7 +- .../ctrip/sqllin/driver/DatabaseConnection.kt | 4 +- .../driver/ConcurrentDatabaseConnection.kt | 2 +- .../sqllin/driver/JdbcDatabaseConnection.kt | 14 +- .../driver/ConcurrentDatabaseConnection.kt | 2 +- .../sqllin/driver/RealDatabaseConnection.kt | 11 +- sqllin-dsl/build.gradle.kts | 28 ++++ .../dsl/sql/clause/AndroidClauseBlob.kt | 100 +++++++++++ .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 125 ++++++++++++++ .../sqllin/dsl/sql/clause/ClauseBoolean.kt | 2 +- .../sqllin/dsl/sql/clause/ClauseElement.kt | 1 + .../sqllin/dsl/sql/clause/ClauseNumber.kt | 155 ++++++++++++------ .../sqllin/dsl/sql/clause/SelectCondition.kt | 5 +- .../ctrip/sqllin/dsl/sql/clause/SetClause.kt | 54 +++--- .../sqllin/dsl/sql/clause/WhereClause.kt | 27 ++- .../dsl/sql/compiler/AbstractValuesEncoder.kt | 128 --------------- .../dsl/sql/compiler/EncodeEntities2SQL.kt | 44 +++-- .../dsl/sql/compiler/InsertValuesEncoder.kt | 82 +++++++-- .../sqllin/dsl/sql/compiler/QueryDecoder.kt | 23 ++- .../ctrip/sqllin/dsl/sql/operation/Insert.kt | 4 +- .../dsl/sql/statement/OtherStatement.kt | 8 +- .../dsl/sql/statement/SelectStatement.kt | 31 +--- .../dsl/sql/statement/SingleStatement.kt | 7 +- .../statement/UnionSelectStatementGroup.kt | 2 +- .../sqllin/dsl/sql/clause/OtherClauseBlob.kt | 37 +++++ .../ctrip/sqllin/processor/ClauseProcessor.kt | 19 ++- 26 files changed, 617 insertions(+), 305 deletions(-) create mode 100644 sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt delete mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/AbstractValuesEncoder.kt create mode 100644 sqllin-dsl/src/jvmAndNativeMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OtherClauseBlob.kt diff --git a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt index 67cbcac..4fbe12d 100644 --- a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt +++ b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt @@ -35,7 +35,12 @@ internal class AndroidDatabaseConnection(private val database: SQLiteDatabase) : override fun executeUpdateDelete(sql: String, bindParams: Array?) = execSQL(sql, bindParams) - override fun query(sql: String, bindParams: Array?): CommonCursor = AndroidCursor(database.rawQuery(sql, bindParams)) + override fun query(sql: String, bindParams: Array?): CommonCursor { + val realParams = bindParams?.let { origin -> + Array(origin.size) { i -> origin[i]?.toString() } + } + return AndroidCursor(database.rawQuery(sql, realParams)) + } override fun beginTransaction() = database.beginTransaction() override fun endTransaction() = database.endTransaction() diff --git a/sqllin-driver/src/commonMain/kotlin/com/ctrip/sqllin/driver/DatabaseConnection.kt b/sqllin-driver/src/commonMain/kotlin/com/ctrip/sqllin/driver/DatabaseConnection.kt index 021886e..8d4abe8 100644 --- a/sqllin-driver/src/commonMain/kotlin/com/ctrip/sqllin/driver/DatabaseConnection.kt +++ b/sqllin-driver/src/commonMain/kotlin/com/ctrip/sqllin/driver/DatabaseConnection.kt @@ -54,10 +54,10 @@ public interface DatabaseConnection { * Executes a SELECT query and returns a cursor. * * @param sql The SELECT statement - * @param bindParams Optional string parameters to bind to the query + * @param bindParams Optional parameters to bind to the query * @return A cursor for iterating over query results */ - public fun query(sql: String, bindParams: Array? = null): CommonCursor + public fun query(sql: String, bindParams: Array? = null): CommonCursor /** * Begins a database transaction. diff --git a/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt b/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt index 6cef665..236886e 100644 --- a/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt +++ b/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt @@ -42,7 +42,7 @@ internal class ConcurrentDatabaseConnection(private val delegateConnection: Data delegateConnection.executeUpdateDelete(sql, bindParams) } - override fun query(sql: String, bindParams: Array?): CommonCursor = accessLock.withLock { + override fun query(sql: String, bindParams: Array?): CommonCursor = accessLock.withLock { delegateConnection.query(sql, bindParams) } diff --git a/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/JdbcDatabaseConnection.kt b/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/JdbcDatabaseConnection.kt index e5b7103..7eb8028 100644 --- a/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/JdbcDatabaseConnection.kt +++ b/sqllin-driver/src/jvmMain/kotlin/com/ctrip/sqllin/driver/JdbcDatabaseConnection.kt @@ -48,15 +48,11 @@ internal class JdbcDatabaseConnection(private val connection: Connection) : Abst it.executeUpdate() } - override fun query(sql: String, bindParams: Array?): CommonCursor { - val statement = connection.prepareStatement(sql) - bindParams?.forEachIndexed { index, str -> - str?.let { - statement.setString(index + 1, it) - } - } - return statement.executeQuery()?.let { JdbcCursor(it) } ?: throw IllegalStateException("The query result is null.") - } + override fun query(sql: String, bindParams: Array?): CommonCursor = + bindParamsToSQL(sql, bindParams) + .executeQuery() + ?.let { JdbcCursor(it) } + ?: throw IllegalStateException("The query result is null.") private val isTransactionSuccess = AtomicBoolean(false) diff --git a/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt b/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt index a91acda..7c0467e 100644 --- a/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt +++ b/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/ConcurrentDatabaseConnection.kt @@ -44,7 +44,7 @@ internal class ConcurrentDatabaseConnection( delegateConnection.executeUpdateDelete(sql, bindParams) } - override fun query(sql: String, bindParams: Array?): CommonCursor = accessLock.withLock { + override fun query(sql: String, bindParams: Array?): CommonCursor = accessLock.withLock { delegateConnection.query(sql, bindParams) } diff --git a/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/RealDatabaseConnection.kt b/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/RealDatabaseConnection.kt index a03c298..47a164a 100644 --- a/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/RealDatabaseConnection.kt +++ b/sqllin-driver/src/nativeMain/kotlin/com/ctrip/sqllin/driver/RealDatabaseConnection.kt @@ -69,15 +69,8 @@ internal class RealDatabaseConnection( } } - override fun query(sql: String, bindParams: Array?): CommonCursor { - val statement = createStatement(sql) - bindParams?.forEachIndexed { index, str -> - str?.let { - statement.bindString(index + 1, it) - } - } - return statement.query() - } + override fun query(sql: String, bindParams: Array?): CommonCursor = + bindParamsToSQL(sql, bindParams).query() override fun beginTransaction() = transactionLock.withLock { database.rawExecSql("BEGIN;") diff --git a/sqllin-dsl/build.gradle.kts b/sqllin-dsl/build.gradle.kts index 1c416a3..e3df36a 100644 --- a/sqllin-dsl/build.gradle.kts +++ b/sqllin-dsl/build.gradle.kts @@ -63,6 +63,34 @@ kotlin { implementation(libs.kotlinx.serialization) implementation(libs.kotlinx.coroutines.core) } + val jvmAndNativeMain by creating { + dependsOn(commonMain.get()) + } + + jvmMain { dependsOn(jvmAndNativeMain) } + + // Configure all native targets to depend on jvmAndNativeMain + iosX64Main { dependsOn(jvmAndNativeMain) } + iosArm64Main { dependsOn(jvmAndNativeMain) } + iosSimulatorArm64Main { dependsOn(jvmAndNativeMain) } + + macosX64Main { dependsOn(jvmAndNativeMain) } + macosArm64Main { dependsOn(jvmAndNativeMain) } + + watchosArm32Main { dependsOn(jvmAndNativeMain) } + watchosArm64Main { dependsOn(jvmAndNativeMain) } + watchosX64Main { dependsOn(jvmAndNativeMain) } + watchosSimulatorArm64Main { dependsOn(jvmAndNativeMain) } + watchosDeviceArm64Main { dependsOn(jvmAndNativeMain) } + + tvosArm64Main { dependsOn(jvmAndNativeMain) } + tvosX64Main { dependsOn(jvmAndNativeMain) } + tvosSimulatorArm64Main { dependsOn(jvmAndNativeMain) } + + linuxX64Main { dependsOn(jvmAndNativeMain) } + linuxArm64Main { dependsOn(jvmAndNativeMain) } + + mingwX64Main { dependsOn(jvmAndNativeMain) } } } diff --git a/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt b/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt new file mode 100644 index 0000000..28a2a20 --- /dev/null +++ b/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2025 Ctrip.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ctrip.sqllin.dsl.sql.clause + +import com.ctrip.sqllin.dsl.sql.Table + +/** + * Android-specific implementation of BLOB clause handling. + * + * On Android, SQLite BLOB literals use hexadecimal notation (X'...' format). + * This implementation converts ByteArray values to hex strings inline in the SQL, + * avoiding the need for parameterized binding for BLOB values on Android. + * + * For example, a ByteArray of [0x01, 0x02, 0xFF] becomes: X'0102FF' + * + * @author Yuang Qiao + */ +internal class AndroidClauseBlob( + valueName: String, + table: Table<*>, + isFunction: Boolean, +) : DefaultClauseBlob(valueName, table, isFunction) { + + /** + * Appends a BLOB value to the SQL condition using Android's hex literal format. + * + * Instead of using parameterized binding, this converts ByteArray to hex notation. + * Non-null values are encoded as X'hexstring' literals directly in the SQL. + * + * @param notNullSymbol The comparison operator (=, !=, etc.) + * @param nullSymbol The SQL clause to use when blob is null (IS NULL, IS NOT NULL) + * @param blob The ByteArray value to encode, or null + * @return SelectCondition with hex-encoded SQL and no parameters + */ + override fun appendBlob( + notNullSymbol: String, + nullSymbol: String, + blob: ByteArray? + ): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + if (blob == null) { + append(nullSymbol) + } else { + append(notNullSymbol) + append("X'") + blob toHexString this + append('\'') + } + } + return SelectCondition(sql, null) + } + + /** + * Converts ByteArray to uppercase hexadecimal string. + * + * Each byte is formatted as a two-digit hex value (00-FF). + * + * @param builder The StringBuilder to append hex characters to + */ + private infix fun ByteArray.toHexString(builder: StringBuilder) = joinTo( + buffer = builder, + separator = "", + transform = { "%02X".format(it) } + ) +} + +/** + * Platform-specific factory function for creating BLOB clause wrappers on Android. + * + * Returns an [AndroidClauseBlob] instance that uses hex literal encoding for BLOB values. + * + * @param valueName The column or function name + * @param table The table this clause belongs to + * @param isFunction True if this represents a SQL function result + * @return AndroidClauseBlob instance + */ +public actual fun ClauseBlob( + valueName: String, + table: Table<*>, + isFunction: Boolean, +): DefaultClauseBlob = AndroidClauseBlob(valueName, table, isFunction) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt new file mode 100644 index 0000000..f391d77 --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2025 Ctrip.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ctrip.sqllin.dsl.sql.clause + +import com.ctrip.sqllin.dsl.sql.Table + +/** + * Wrapper for BLOB (Binary Large Object) column/function references in SQL clauses. + * + * Provides comparison operators for BLOB values stored as ByteArray. Since SQLite stores + * BLOBs as byte sequences, this class enables type-safe operations on binary data: + * - Equality comparisons (NULL-safe) + * - Inequality comparisons (NULL-safe) + * + * BLOB columns are commonly used for storing: + * - Images, audio, video files + * - Serialized objects + * - Encrypted data + * - Binary protocols or formats + * + * @author Yuang Qiao + */ +public open class DefaultClauseBlob internal constructor( + valueName: String, + table: Table<*>, + isFunction: Boolean, +) : ClauseElement(valueName, table, isFunction) { + + /** + * Creates an equality comparison condition (=). + * + * Handles NULL values appropriately: + * - If `blob` is null, generates `IS NULL` condition + * - Otherwise, generates parameterized equality comparison + * + * @param blob The ByteArray value to compare against, or null + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun eq(blob: ByteArray?): SelectCondition = appendBlob("=", " IS NULL", blob) + + /** + * Creates an equality comparison condition against another BLOB column/function. + * + * @param clauseBlob The BLOB column/function to compare against + * @return Condition expression comparing two BLOB columns + */ + internal infix fun eq(clauseBlob: DefaultClauseBlob): SelectCondition = appendClauseBlob("=", clauseBlob) + + /** + * Creates an inequality comparison condition (!=). + * + * Handles NULL values appropriately: + * - If `blob` is null, generates `IS NOT NULL` condition + * - Otherwise, generates parameterized inequality comparison + * + * @param blob The ByteArray value to compare against, or null + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun neq(blob: ByteArray?): SelectCondition = appendBlob("!=", " IS NOT NULL", blob) + + /** + * Creates an inequality comparison condition against another BLOB column/function. + * + * @param clauseBlob The BLOB column/function to compare against + * @return Condition expression comparing two BLOB columns + */ + internal infix fun neq(clauseBlob: DefaultClauseBlob): SelectCondition = appendClauseBlob("!=", clauseBlob) + + protected open fun appendBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + if (blob == null) { + append(nullSymbol) + } else { + append(notNullSymbol) + append('?') + } + } + return SelectCondition(sql, if (blob == null) null else mutableListOf(blob)) + } + + private fun appendClauseBlob(symbol: String, clauseBlob: DefaultClauseBlob): SelectCondition { + val sql = buildString { + append(table.tableName) + append('.') + append(valueName) + append(' ') + append(symbol) + append(' ') + append(clauseBlob.table.tableName) + append('.') + append(clauseBlob.valueName) + } + return SelectCondition(sql, null) + } + + override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() + override fun equals(other: Any?): Boolean = (other as? DefaultClauseBlob)?.let { + it.valueName == valueName && it.table.tableName == table.tableName + } ?: false +} + +public expect fun ClauseBlob( + valueName: String, + table: Table<*>, + isFunction: Boolean, +): DefaultClauseBlob \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt index e4a602c..54bc665 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt @@ -51,7 +51,7 @@ public class ClauseBoolean( append('>') else append("<=") - append(0) + append('0') } return SelectCondition(sql, null) } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt index 064447c..7f728e2 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt @@ -28,6 +28,7 @@ import com.ctrip.sqllin.dsl.sql.Table * - [ClauseBoolean]: Boolean column/function references with comparison operators * - [ClauseNumber]: Numeric column/function references with arithmetic and comparison operators * - [ClauseString]: String column/function references with text comparison operators + * - [ClauseBlob]: BLOB (ByteArray) column/function references with comparison operators * * Used in: * - WHERE/HAVING conditions diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt index d2a3f13..62e6b9d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt @@ -22,17 +22,18 @@ import com.ctrip.sqllin.dsl.sql.Table * Wrapper for numeric column/function references in SQL clauses. * * Provides comparison and set operators for numeric values (Byte, Short, Int, Long, Float, Double). - * Supports comparisons against literal numbers or other numeric columns/functions. + * All value-based comparisons use parameterized binding (?) to prevent SQL injection and ensure + * proper type handling across platforms. * * Available operators: - * - `lt`: Less than (<) - * - `lte`: Less than or equal (<=) - * - `eq`: Equals (=) - handles null with IS NULL - * - `neq`: Not equals (!=) - handles null with IS NOT NULL - * - `gt`: Greater than (>) - * - `gte`: Greater than or equal (>=) - * - `inIterable`: IN (value1, value2, ...) - * - `between`: BETWEEN start AND end + * - `lt`: Less than (<) - parameterized + * - `lte`: Less than or equal (<=) - parameterized + * - `eq`: Equals (=) - parameterized or IS NULL + * - `neq`: Not equals (!=) - parameterized or IS NOT NULL + * - `gt`: Greater than (>) - parameterized + * - `gte`: Greater than or equal (>=) - parameterized + * - `inIterable`: IN (?, ?, ...) - all values parameterized + * - `between`: BETWEEN ? AND ? - both boundaries parameterized * * @author Yuang Qiao */ @@ -42,38 +43,80 @@ public class ClauseNumber( isFunction: Boolean, ) : ClauseElement(valueName, table, isFunction) { - /** Less than (<) */ - internal infix fun lt(number: Number): SelectCondition = appendNumber("<", number) + /** + * Less than (<) comparison using parameterized binding. + * + * Generates: `column < ?` + * + * @param number The value to compare against + * @return SelectCondition with placeholder and bound parameter + */ + internal infix fun lt(number: Number): SelectCondition = appendNumber(") */ - internal infix fun gt(number: Number): SelectCondition = appendNumber(">", number) + /** + * Greater than (>) comparison using parameterized binding. + * + * Generates: `column > ?` + * + * @param number The value to compare against + * @return SelectCondition with placeholder and bound parameter + */ + internal infix fun gt(number: Number): SelectCondition = appendNumber(">?", number) /** Greater than (>) - compare against another column/function */ internal infix fun gt(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">", clauseNumber) - /** Greater than or equal (>=) */ - internal infix fun gte(number: Number): SelectCondition = appendNumber(">=", number) + /** + * Greater than or equal (>=) comparison using parameterized binding. + * + * Generates: `column >= ?` + * + * @param number The value to compare against + * @return SelectCondition with placeholder and bound parameter + */ + internal infix fun gte(number: Number): SelectCondition = appendNumber(">=?", number) /** Greater than or equal (>=) - compare against another column/function */ internal infix fun gte(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">=", clauseNumber) @@ -81,11 +124,16 @@ public class ClauseNumber( /** * IN operator - checks if value is in the given set. * - * Generates: `column IN (1, 2, 3, ...)` + * Uses parameterized binding for all values to prevent SQL injection. + * Generates: `column IN (?, ?, ?, ...)` + * + * @param numbers Non-empty iterable of numbers to check against + * @return SelectCondition with placeholders and bound parameters + * @throws IllegalArgumentException if numbers is empty */ internal infix fun inIterable(numbers: Iterable): SelectCondition { - val iterator = numbers.iterator() - require(iterator.hasNext()) { "Param 'numbers' must not be empty!!!" } + val parameters = numbers.toMutableList() + require(parameters.isNotEmpty()) { "Param 'numbers' must not be empty!!!" } val sql = buildString { if (!isFunction) { append(table.tableName) @@ -93,20 +141,24 @@ public class ClauseNumber( } append(valueName) append(" IN (") - do { - append(iterator.next()) - val hasNext = iterator.hasNext() - val symbol = if (hasNext) ',' else ')' - append(symbol) - } while (hasNext) + + append('?') + repeat(parameters.size - 1) { + append(",?") + } + append(')') } - return SelectCondition(sql, null) + return SelectCondition(sql, parameters) } /** * BETWEEN operator - checks if value is within a range (inclusive). * - * Generates: `column BETWEEN start AND end` + * Uses parameterized binding for both range boundaries. + * Generates: `column BETWEEN ? AND ?` + * + * @param range The inclusive range to check (start..end) + * @return SelectCondition with placeholders and bound parameters */ internal infix fun between(range: LongRange): SelectCondition { val sql = buildString { @@ -115,12 +167,9 @@ public class ClauseNumber( append('.') } append(valueName) - append(" BETWEEN ") - append(range.first) - append(" AND ") - append(range.last) + append(" BETWEEN ? AND ?") } - return SelectCondition(sql, null) + return SelectCondition(sql, mutableListOf(range.first, range.last)) } private fun appendNumber(symbol: String, number: Number): SelectCondition { @@ -131,28 +180,26 @@ public class ClauseNumber( } append(valueName) append(symbol) - append(number) } - return SelectCondition(sql, null) + return SelectCondition(sql, mutableListOf(number)) } private fun appendNullableNumber(notNullSymbol: String, nullSymbol: String, number: Number?): SelectCondition { - val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } - append(valueName) - if (number == null){ - append(nullSymbol) - append(" NULL") - - } else { - append(notNullSymbol) - append(number) - } + val builder = StringBuilder() + if (!isFunction) { + builder.append(table.tableName) + builder.append('.') } - return SelectCondition(sql, null) + builder.append(valueName) + val parameters = if (number == null){ + builder.append(nullSymbol) + null + } else { + builder.append(notNullSymbol) + builder.append('?') + mutableListOf(number) + } + return SelectCondition(builder.toString(), parameters) } private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber): SelectCondition { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SelectCondition.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SelectCondition.kt index f01af77..1417f55 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SelectCondition.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SelectCondition.kt @@ -26,16 +26,17 @@ package com.ctrip.sqllin.dsl.sql.clause * ```kotlin * userTable.id EQ 42 // Creates: SelectCondition("id = ?", ["42"]) * userTable.age GT 18 // Creates: SelectCondition("age > ?", ["18"]) + * userTable.image EQ byteArray // Creates: SelectCondition("image = ?", [byteArray]) * ``` * * @property conditionSQL The SQL condition expression (may contain ? placeholders) - * @property parameters Parameterized query values (strings only), or null if none + * @property parameters Parameterized query values (String, ByteArray, etc.), or null if none * * @author Yuang Qiao */ public class SelectCondition internal constructor( internal val conditionSQL: String, - internal val parameters: MutableList?, + internal val parameters: MutableList?, ) { /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt index d0925d6..ca846a5 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt @@ -21,13 +21,18 @@ import com.ctrip.sqllin.dsl.annotation.StatementDslMaker /** * SET clause for UPDATE statements. * - * Builds column assignments in the format: `column1 = ?, column2 = ?, ...` + * Builds column assignments using parameterized binding for all values. + * Format: `column1 = ?, column2 = ?, ...` + * + * All values (including null) are passed as parameters to ensure type safety and + * prevent SQL injection across all platforms. * * Used in UPDATE operations to specify new values: * ```kotlin * UPDATE(user) SET { - * it.name = "John" - * it.age = 30 + * it.name = "John" // Generates: name = ? with parameter "John" + * it.age = 30 // Generates: age = ? with parameter 30 + * it.avatar = byteArray // Generates: avatar = ? with parameter byteArray * } WHERE (user.id EQ 42) * ``` * @@ -39,30 +44,37 @@ public class SetClause : Clause { private val clauseBuilder = StringBuilder() - internal var parameters: MutableList? = null + /** + * List of parameter values to bind to the SQL statement. + * + * Null until first property assignment. Contains values in order of appearance. + * Supports any type: String, Number, Boolean, ByteArray, null, etc. + */ + internal var parameters: MutableList? = null private set - public fun appendString(propertyName: String, propertyValue: String?) { + /** + * Appends a column assignment to the SET clause using parameterized binding. + * + * Generates: `propertyName = ?` and adds the value to parameters list. + * + * @param propertyName The column name to update + * @param propertyValue The new value (any type including null) + */ + public fun appendAny(propertyName: String, propertyValue: Any?) { clauseBuilder.append(propertyName) - if (propertyValue == null) - clauseBuilder.append("=NULL,") - else { - clauseBuilder.append("=?,") - val params = parameters ?: ArrayList().also { - parameters = it - } - params.add(propertyValue) + clauseBuilder.append("=?,") + val params = parameters ?: ArrayList().also { + parameters = it } + params.add(propertyValue) } - public fun appendAny(propertyName: String, propertyValue: Any?) { - clauseBuilder - .append(propertyName) - .append('=') - .append(propertyValue ?: "NULL") - .append(',') - } - + /** + * Finalizes the SET clause by removing trailing comma. + * + * @return The complete SET clause SQL string + */ internal fun finalize(): String = clauseBuilder.apply { if (this[lastIndex] == ',') deleteAt(lastIndex) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt index 8f2c6ec..c4e5426 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt @@ -57,12 +57,37 @@ public infix fun JoinSelectStatement.WHERE(condition: SelectCondition): W container changeLastStatement it } +/** + * Attaches a WHERE clause to an UPDATE statement and merges parameters. + * + * Combines parameters from both the SET clause and WHERE condition, preserving order: + * SET parameters first, then WHERE parameters. + * + * Example: + * ```kotlin + * UPDATE(user) SET { it.name = "John" } WHERE (user.id EQ 42) + * // Generates: UPDATE user SET name = ? WHERE id = ? + * // Parameters: ["John", 42] + * ``` + * + * @param condition The WHERE condition with its parameters + * @return The complete UPDATE statement SQL string + */ @StatementDslMaker public infix fun UpdateStatementWithoutWhereClause.WHERE(condition: SelectCondition): String { + val params = when { + parameters == null && condition.parameters != null -> condition.parameters + parameters != null && condition.parameters == null -> parameters + parameters == null && condition.parameters == null -> null + else -> { + parameters!!.addAll(condition.parameters!!) + parameters + } + } val statement = UpdateDeleteStatement(buildString { append(sqlStr) append(WhereClause(condition).clauseStr) - }, connection, condition.parameters) + }, connection, params) statementContainer changeLastStatement statement return statement.sqlStr } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/AbstractValuesEncoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/AbstractValuesEncoder.kt deleted file mode 100644 index 4c696e7..0000000 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/AbstractValuesEncoder.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (C) 2022 Ctrip.com. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.ctrip.sqllin.dsl.sql.compiler - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.AbstractEncoder -import kotlinx.serialization.modules.EmptySerializersModule -import kotlinx.serialization.modules.SerializersModule - -/** - * Base encoder for converting Kotlin objects to SQL VALUES clauses using kotlinx.serialization. - * - * This abstract class leverages kotlinx.serialization's encoder API to traverse entity objects - * and generate SQL parameter placeholders (for strings) or inline values (for numbers/booleans). - * String values are replaced with `?` placeholders and collected in [parameters] for safe - * parameterized queries. - * - * Subclasses must implement [appendTail] to control punctuation between values (e.g., commas, - * parentheses) depending on the SQL statement type. - * - * @author Yuang Qiao - */ -@OptIn(ExperimentalSerializationApi::class) -internal abstract class AbstractValuesEncoder : AbstractEncoder() { - - final override val serializersModule: SerializersModule = EmptySerializersModule() - - /** - * StringBuilder accumulating the SQL VALUES clause. - */ - protected abstract val sqlStrBuilder: StringBuilder - - /** - * List collecting string parameter values for parameterized queries. - */ - abstract val parameters: MutableList - - /** - * Appends appropriate punctuation after each encoded value. - * - * Implementations determine whether to append commas, closing parentheses, etc. - */ - protected abstract fun StringBuilder.appendTail(): StringBuilder - - protected var elementsIndex = 0 - protected var elementsCount = 0 - - /** - * The complete SQL VALUES clause generated by this encoder. - */ - val valuesSQL - get() = sqlStrBuilder.toString() - - override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { - elementsCount = descriptor.elementsCount - elementsIndex = index - return true - } - - /** - * Encodes Boolean as SQLite integer (1 for true, 0 for false). - */ - override fun encodeBoolean(value: Boolean) = encodeByte(if (value) 1 else 0) - - override fun encodeByte(value: Byte) { - sqlStrBuilder.append(value).appendTail() - } - - override fun encodeShort(value: Short) { - sqlStrBuilder.append(value).appendTail() - } - - override fun encodeInt(value: Int) { - sqlStrBuilder.append(value).appendTail() - } - - override fun encodeLong(value: Long) { - sqlStrBuilder.append(value).appendTail() - } - - /** - * Encodes Char as a string. - */ - override fun encodeChar(value: Char) = encodeString(value.toString()) - - /** - * Encodes String as a parameterized placeholder. - * - * Appends `?` to the SQL and adds the actual value to [parameters] - * for safe parameterized query execution. - */ - override fun encodeString(value: String) { - sqlStrBuilder.append('?').appendTail() - parameters.add(value) - } - - override fun encodeFloat(value: Float) { - sqlStrBuilder.append(value).appendTail() - } - - override fun encodeDouble(value: Double) { - sqlStrBuilder.append(value).appendTail() - } - - override fun encodeNull() { - sqlStrBuilder.append("NULL").appendTail() - } - - /** - * Encodes enum as its ordinal integer value. - */ - override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = encodeInt(index) -} \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt index 2543967..a508776 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt @@ -47,7 +47,7 @@ internal fun encodeEntities2InsertValues( table: Table, builder: StringBuilder, values: Iterable, - parameters: MutableList, + parameters: MutableList, isInsertWithId: Boolean, ) = with(builder) { val isInsertId = table.primaryKeyInfo?.run { @@ -55,15 +55,14 @@ internal fun encodeEntities2InsertValues( } ?: true val serializer = table.kSerializer() append('(') - val primaryKeyIndex = appendDBColumnName(serializer.descriptor, table.primaryKeyInfo?.primaryKeyName, isInsertId) - if (primaryKeyIndex >= 0) - parameters.removeAt(primaryKeyIndex) + val primaryKeyName = table.primaryKeyInfo?.primaryKeyName + appendDBColumnName(serializer.descriptor, primaryKeyName, isInsertId) append(')') append(" values ") val iterator = values.iterator() fun appendNext() { val value = iterator.next() - val encoder = InsertValuesEncoder(parameters) + val encoder = InsertValuesEncoder(parameters, primaryKeyName) encoder.encodeSerializableValue(serializer, value) append(encoder.valuesSQL) } @@ -90,27 +89,22 @@ internal fun StringBuilder.appendDBColumnName( descriptor: SerialDescriptor, primaryKeyName: String?, isInsertId: Boolean, -): Int = if (isInsertId) { - appendDBColumnName(descriptor) - -1 -} else { - var index = -1 - if (descriptor.elementsCount > 0) { - val elementName = descriptor.getElementName(0) - if (elementName != primaryKeyName) - append(elementName) - else - index = 0 - } - for (i in 1 ..< descriptor.elementsCount) { - append(',') - val elementName = descriptor.getElementName(i) - if (elementName != primaryKeyName) - append(elementName) - else - index = i +) { + if (isInsertId) { + appendDBColumnName(descriptor) + } else { + if (descriptor.elementsCount > 0) { + val elementName = descriptor.getElementName(0) + if (elementName != primaryKeyName) + append(elementName) + } + for (i in 1 ..< descriptor.elementsCount) { + append(',') + val elementName = descriptor.getElementName(i) + if (elementName != primaryKeyName) + append(elementName) + } } - index } /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt index 31357ba..3fcf4f9 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt @@ -16,32 +16,94 @@ package com.ctrip.sqllin.dsl.sql.compiler +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractEncoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + /** - * Encoder for generating VALUES clauses in INSERT statements. + * Encoder for converting Kotlin objects to SQL INSERT VALUES clauses using kotlinx.serialization. + * + * Leverages kotlinx.serialization's encoder API to traverse entity objects and generate + * parameterized VALUES clauses. All values (including null, numbers, strings, ByteArray, etc.) + * are converted to `?` placeholders and collected in [parameters] for safe execution. * - * Produces SQL in the format: `(value1, value2, ..., valueN)` + * Automatically skips the primary key field if [primaryKeyName] is provided, allowing + * database auto-increment to generate the value. * - * Each entity is encoded into a parenthesized tuple of values, with commas separating - * elements within the tuple. + * Example output: `(?, ?, ?)` with parameters: ["John", 30, byteArray] * - * Example output: `(123, 'Alice', 25)` or `(?, ?, ?)` with parameters `["Alice"]` + * @param parameters Mutable list to accumulate parameter values + * @param primaryKeyName Name of primary key field to skip, or null to include all fields * * @author Yuang Qiao */ +@OptIn(ExperimentalSerializationApi::class) internal class InsertValuesEncoder( - override val parameters: MutableList, -) : AbstractValuesEncoder() { + val parameters: MutableList, + val primaryKeyName: String?, +) : AbstractEncoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() - override val sqlStrBuilder = StringBuilder("(") + private var elementsIndex = 0 + private var elementsCount = 0 + + /** + * StringBuilder accumulating the SQL VALUES clause. + */ + private val sqlStrBuilder = StringBuilder("(") /** * Appends comma between values or closing parenthesis after the last value. + * + * Format: `?, ?, ?)` */ - override fun StringBuilder.appendTail(): StringBuilder { + private fun appendTail() { val symbol = if (elementsIndex < elementsCount - 1) ',' else ')' - return append(symbol) + sqlStrBuilder.append(symbol) + } + + /** + * Appends a parameter placeholder and records the value. + * + * @param value The parameter value (any type including null) + */ + private fun appendAny(value: Any?) { + sqlStrBuilder.append('?') + parameters.add(value) + appendTail() + } + + /** + * The complete SQL VALUES clause generated by this encoder. + */ + val valuesSQL + get() = sqlStrBuilder.toString() + + override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { + elementsCount = descriptor.elementsCount + elementsIndex = index + val elementName = descriptor.getElementName(index) + return elementName != primaryKeyName } + + /** + * Encodes any non-null value as a parameter placeholder. + */ + override fun encodeValue(value: Any) = appendAny(value) + + /** + * Encodes null as a parameter placeholder. + */ + override fun encodeNull() = appendAny(null) + + /** + * Encodes enum as its ordinal integer value parameter. + */ + override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = appendAny(index) } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/QueryDecoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/QueryDecoder.kt index 3b05694..faae3e5 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/QueryDecoder.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/QueryDecoder.kt @@ -17,8 +17,10 @@ package com.ctrip.sqllin.dsl.sql.compiler import com.ctrip.sqllin.driver.CommonCursor +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ByteArraySerializer import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.AbstractDecoder import kotlinx.serialization.encoding.CompositeDecoder @@ -42,7 +44,7 @@ import kotlinx.serialization.modules.SerializersModule */ @OptIn(ExperimentalSerializationApi::class) internal class QueryDecoder( - private val cursor: CommonCursor + private val cursor: CommonCursor, ) : AbstractDecoder() { private var elementIndex = 0 @@ -51,6 +53,10 @@ internal class QueryDecoder( override val serializersModule: SerializersModule = EmptySerializersModule() + private companion object { + val byteArrayDescriptor = ByteArraySerializer().descriptor + } + /** * Determines the next property to decode from the descriptor. * @@ -84,6 +90,21 @@ internal class QueryDecoder( if (it >= 0) block(it) else throw SerializationException("The Cursor doesn't have this column") } + /** + * Intercepts ByteArray deserialization to decode BLOBs directly from the cursor. + * + * By default, kotlinx.serialization treats ByteArray as a collection and would decode + * it element-by-element. This override enables efficient single-call BLOB retrieval. + */ + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { + return if (deserializer.descriptor == byteArrayDescriptor) { + @Suppress("UNCHECKED_CAST") + deserialize { cursor.getByteArray(it) ?: ByteArray(0) } as T + } else { + super.decodeSerializableValue(deserializer) + } + } + /** * Decodes SQLite integer (1/0) to Boolean (true/false). */ diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt index 2b94242..26852d3 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt @@ -50,13 +50,13 @@ internal object Insert : Operation { * @return INSERT statement ready for execution */ fun insert(table: Table, connection: DatabaseConnection, entities: Iterable, isInsertWithId: Boolean = false): SingleStatement { - val parameters = ArrayList() + val parameters = ArrayList() val sql = buildString { append(sqlStr) append(table.tableName) append(' ') encodeEntities2InsertValues(table, this,entities, parameters, isInsertWithId) } - return InsertStatement(sql, connection, parameters.takeIf { it.isNotEmpty() }) + return InsertStatement(sql, connection, parameters) } } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt index 2514585..735e119 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt @@ -39,7 +39,7 @@ public class UpdateStatementWithoutWhereClause internal constructor( preSQLStr: String, internal val statementContainer: StatementContainer, internal val connection: DatabaseConnection, - override val parameters: MutableList?, + override val parameters: MutableList?, ) : SingleStatement(preSQLStr) { public override fun execute(): Unit = connection.executeUpdateDelete(sqlStr, params) } @@ -55,7 +55,7 @@ public class UpdateStatementWithoutWhereClause internal constructor( public class UpdateDeleteStatement internal constructor( sqlStr: String, private val connection: DatabaseConnection, - override val parameters: MutableList?, + override val parameters: MutableList?, ) : SingleStatement(sqlStr) { public override fun execute(): Unit = connection.executeUpdateDelete(sqlStr, params) } @@ -71,7 +71,7 @@ public class UpdateDeleteStatement internal constructor( public class InsertStatement internal constructor( sqlStr: String, private val connection: DatabaseConnection, - override val parameters: MutableList?, + override val parameters: MutableList?, ) : SingleStatement(sqlStr) { public override fun execute(): Unit = connection.executeInsert(sqlStr, params) } @@ -89,5 +89,5 @@ public class CreateStatement internal constructor( private val connection: DatabaseConnection, ) : SingleStatement(sqlStr) { override fun execute(): Unit = connection.execSQL(sqlStr, params) - override val parameters: MutableList? = null + override val parameters: MutableList? = null } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt index 6d2439a..eea0d96 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt @@ -18,7 +18,6 @@ package com.ctrip.sqllin.dsl.sql.statement import com.ctrip.sqllin.driver.CommonCursor import com.ctrip.sqllin.driver.DatabaseConnection -import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.compiler.QueryDecoder import kotlinx.serialization.DeserializationStrategy @@ -38,7 +37,7 @@ import kotlin.concurrent.Volatile * @property deserializer kotlinx.serialization strategy for decoding cursor rows to entities * @property connection Database connection for executing the query * @property container Statement container for managing this statement in the DSL scope - * @property parameters Parameterized query values (strings only), or null if none + * @property parameters Parameterized query values, or null if none * * @author Yuang Qiao */ @@ -47,7 +46,7 @@ public sealed class SelectStatement( internal val deserializer: DeserializationStrategy, internal val connection: DatabaseConnection, internal val container: StatementContainer, - final override val parameters: MutableList?, + final override val parameters: MutableList?, ) : SingleStatement(sqlStr) { @Volatile @@ -82,18 +81,6 @@ public sealed class SelectStatement( append(sqlStr) append(clause.clauseStr) } - - internal fun crossJoin( - table: Table, - newDeserializer: DeserializationStrategy, - ): FinalSelectStatement { - val sql = buildString { - append(sqlStr) - append(" CROSS JOIN ") - append(table.tableName) - } - return FinalSelectStatement(sql, newDeserializer, connection, container, parameters) - } } /** @@ -111,7 +98,7 @@ public class WhereSelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = @@ -140,7 +127,7 @@ public class JoinSelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToWhere(clause: WhereClause): WhereSelectStatement { @@ -177,7 +164,7 @@ public class GroupBySelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = @@ -208,7 +195,7 @@ public class HavingSelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = @@ -231,7 +218,7 @@ public class OrderBySelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = @@ -251,7 +238,7 @@ public class LimitSelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { internal infix fun appendToFinal(clause: OffsetClause): FinalSelectStatement = @@ -271,5 +258,5 @@ public class FinalSelectStatement internal constructor( deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - parameters: MutableList?, + parameters: MutableList?, ) : SelectStatement(sqlStr, deserializer, connection, container, parameters) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SingleStatement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SingleStatement.kt index 813520f..8bec5ee 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SingleStatement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SingleStatement.kt @@ -34,16 +34,17 @@ public sealed class SingleStatement( ) : ExecutableStatement { /** - * Parameters for parameterized query placeholders (strings only). + * Parameters for parameterized query placeholders. * + * Supports multiple types (String, ByteArray, numeric types, etc.). * `null` if the statement has no parameters. */ - internal abstract val parameters: MutableList? + internal abstract val parameters: MutableList? /** * Parameters converted to array format for driver execution. */ - internal val params: Array? + internal val params: Array? get() = parameters?.toTypedArray() /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt index d7da0c8..73e6471 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt @@ -46,7 +46,7 @@ internal class UnionSelectStatementGroup : StatementContainer { */ internal fun unionStatements(isUnionAll: Boolean): FinalSelectStatement { require(statementList.isNotEmpty()) { "Please write at least two 'select' statements on 'UNION' scope" } - var parameters: MutableList? = null + var parameters: MutableList? = null val unionSqlStr = buildString { check(statementList.size > 1) { "Please write at least two 'select' statements on 'UNION' scope" } val unionKeyWord = if (isUnionAll) " UNION ALL " else " UNION " diff --git a/sqllin-dsl/src/jvmAndNativeMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OtherClauseBlob.kt b/sqllin-dsl/src/jvmAndNativeMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OtherClauseBlob.kt new file mode 100644 index 0000000..f92d915 --- /dev/null +++ b/sqllin-dsl/src/jvmAndNativeMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OtherClauseBlob.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 Ctrip.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.ctrip.sqllin.dsl.sql.clause + +import com.ctrip.sqllin.dsl.sql.Table + +/** + * Platform-specific factory function for creating BLOB clause wrappers on JVM and Native platforms. + * + * Returns the default [DefaultClauseBlob] implementation which uses parameterized binding + * for BLOB values. This approach passes ByteArray values as parameters rather than embedding + * them as literals in the SQL string. + * + * @param valueName The column or function name + * @param table The table this clause belongs to + * @param isFunction True if this represents a SQL function result + * @return DefaultClauseBlob instance using parameterized binding + */ +public actual fun ClauseBlob( + valueName: String, + table: Table<*>, + isFunction: Boolean, +): DefaultClauseBlob = DefaultClauseBlob(valueName, table, isFunction) \ No newline at end of file diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt index 566285a..4e68385 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt @@ -97,9 +97,11 @@ class ClauseProcessor( writer.write("package $packageName\n\n") writer.write("import com.ctrip.sqllin.dsl.annotation.ColumnNameDslMaker\n") + writer.write("import com.ctrip.sqllin.dsl.sql.clause.ClauseBlob\n") writer.write("import com.ctrip.sqllin.dsl.sql.clause.ClauseBoolean\n") writer.write("import com.ctrip.sqllin.dsl.sql.clause.ClauseNumber\n") writer.write("import com.ctrip.sqllin.dsl.sql.clause.ClauseString\n") + writer.write("import com.ctrip.sqllin.dsl.sql.clause.DefaultClauseBlob\n") writer.write("import com.ctrip.sqllin.dsl.sql.clause.SetClause\n") writer.write("import com.ctrip.sqllin.dsl.sql.PrimaryKeyInfo\n") writer.write("import com.ctrip.sqllin.dsl.sql.Table\n\n") @@ -199,7 +201,7 @@ class ClauseProcessor( /** * Maps a property's Kotlin type to the corresponding clause element type name. * - * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean), or null if unsupported + * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported */ private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? = when ( property.typeName @@ -220,6 +222,8 @@ class ClauseProcessor( Boolean::class.qualifiedName -> "ClauseBoolean" + ByteArray::class.qualifiedName -> "ClauseBlob" + else -> null } @@ -246,6 +250,8 @@ class ClauseProcessor( Char::class.qualifiedName -> "'0'" String::class.qualifiedName -> "\"\"" + ByteArray::class.qualifiedName -> "ByteArray(0)" + else -> null } @@ -267,12 +273,11 @@ class ClauseProcessor( UInt::class.qualifiedName, ULong::class.qualifiedName, UShort::class.qualifiedName, - UByte::class.qualifiedName, -> "appendAny($elementName, value)" - - Char::class.qualifiedName -> "appendString($elementName, value${if (isNotNull) "" else "?"}.toString())" - String::class.qualifiedName -> "appendString($elementName, value)" - - Boolean::class.qualifiedName -> "appendAny($elementName, value${if (isNotNull) "" else "?"}.let { if (it) 1 else 0 })" + UByte::class.qualifiedName, + Char::class.qualifiedName, + String::class.qualifiedName, + Boolean::class.qualifiedName, + ByteArray::class.qualifiedName -> "appendAny($elementName, value)" else -> null } From eca67ef73b81e52deba9ba71e4991f2b3f24258b Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Mon, 20 Oct 2025 20:22:44 +0100 Subject: [PATCH 2/5] Optimize SQL concatenation performance --- .../ctrip/sqllin/dsl/sql/clause/OrderByClause.kt | 12 +++++++----- .../sql/statement/JoinStatementWithoutCondition.kt | 13 ++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt index c66c263..08b6333 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt @@ -43,15 +43,17 @@ internal class CompleteOrderByClause(private val column2WayMap: Map internal constructor( require(iterator.hasNext()) { "Param 'clauseElements' must not be empty!!!" } val sql = buildString { append(sqlStr) - append(" USING (") - do { - append(iterator.next().valueName) - val hasNext = iterator.hasNext() - val symbol = if (hasNext) ',' else ')' - append(symbol) - } while (hasNext) + clauseElements.joinTo( + buffer = this, + separator = ",", + prefix = " USING (", + postfix = ")", + ) } val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, null) addSelectStatement(joinStatement) From 0a41e2eb907dae530b072ff9b09b3c50409168ce Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Tue, 21 Oct 2025 12:45:32 +0100 Subject: [PATCH 3/5] Write some unit tests and fix bugs --- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 21 ++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 34 +++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 197 +++++++++++++++++- .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 21 ++ .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 21 ++ .../sqllin/dsl/sql/clause/ClauseString.kt | 5 +- .../dsl/sql/compiler/EncodeEntities2SQL.kt | 18 +- .../dsl/sql/compiler/InsertValuesEncoder.kt | 4 +- .../ctrip/sqllin/dsl/sql/operation/Create.kt | 23 +- .../JoinStatementWithoutCondition.kt | 1 + .../ctrip/sqllin/processor/ClauseProcessor.kt | 4 +- 11 files changed, 322 insertions(+), 27 deletions(-) diff --git a/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index ad448a1..c0eedbc 100644 --- a/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -61,6 +61,27 @@ class AndroidTest { @Test fun testNullValue() = commonTest.testNullValue() + @Test + fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() + + @Test + fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() + + @Test + fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() + + @Test + fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + + @Test + fun testInsertWithId() = commonTest.testInsertWithId() + + @Test + fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() + + @Test + fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt index 3e8d24e..1f84bbe 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt @@ -16,7 +16,9 @@ package com.ctrip.sqllin.dsl.test +import com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey import kotlinx.serialization.Serializable /** @@ -63,4 +65,36 @@ data class NullTester( val paramInt: Int?, val paramString: String?, val paramDouble: Double?, +) + +@DBRow("person_with_id") +@Serializable +data class PersonWithId( + @PrimaryKey val id: Long?, + val name: String, + val age: Int, +) + +@DBRow("product") +@Serializable +data class Product( + @PrimaryKey val sku: String?, + val name: String, + val price: Double, +) + +@DBRow("student_with_autoincrement") +@Serializable +data class StudentWithAutoincrement( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val studentName: String, + val grade: Int, +) + +@DBRow("enrollment") +@Serializable +data class Enrollment( + @CompositePrimaryKey val studentId: Long, + @CompositePrimaryKey val courseId: Long, + val semester: String, ) \ No newline at end of file diff --git a/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index dfba999..5a31052 100644 --- a/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -16,6 +16,7 @@ package com.ctrip.sqllin.dsl.test +import com.ctrip.sqllin.driver.DatabaseConfiguration import com.ctrip.sqllin.driver.DatabasePath import com.ctrip.sqllin.dsl.DSLDBConfiguration import com.ctrip.sqllin.dsl.Database @@ -41,6 +42,8 @@ class CommonBasicTest(private val path: DatabasePath) { companion object { const val DATABASE_NAME = "BookStore.db" + const val SQL_CREATE_BOOK = "create table book (id integer primary key autoincrement, name text, author text, pages integer, price real)" + const val SQL_CREATE_CATEGORY = "create table category (id integer primary key autoincrement, name text, code integer)" } private inline fun Database.databaseAutoClose(block: (Database) -> Unit) = try { @@ -490,14 +493,204 @@ class CommonBasicTest(private val path: DatabasePath) { } } - private fun getDefaultDBConfig(): DSLDBConfiguration = - DSLDBConfiguration ( + fun testCreateTableWithLongPrimaryKey() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val person1 = PersonWithId(id = null, name = "Alice", age = 25) + val person2 = PersonWithId(id = null, name = "Bob", age = 30) + + lateinit var selectStatement: SelectStatement + database { + PersonWithIdTable { table -> + table INSERT listOf(person1, person2) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(2, results.size) + assertEquals("Alice", results[0].name) + assertEquals(25, results[0].age) + assertEquals("Bob", results[1].name) + assertEquals(30, results[1].age) + } + } + + fun testCreateTableWithStringPrimaryKey() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val product1 = Product(sku = null, name = "Widget", price = 19.99) + val product2 = Product(sku = null, name = "Gadget", price = 29.99) + + lateinit var selectStatement: SelectStatement + database { + ProductTable { table -> + table INSERT listOf(product1, product2) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(2, results.size) + assertEquals("Widget", results[0].name) + assertEquals(19.99, results[0].price) + assertEquals("Gadget", results[1].name) + assertEquals(29.99, results[1].price) + } + } + + fun testCreateTableWithAutoincrement() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val student1 = StudentWithAutoincrement(id = null, studentName = "Charlie", grade = 85) + val student2 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 92) + + lateinit var selectStatement: SelectStatement + database { + StudentWithAutoincrementTable { table -> + table INSERT listOf(student1, student2) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(2, results.size) + assertEquals("Charlie", results[0].studentName) + assertEquals(85, results[0].grade) + assertEquals("Diana", results[1].studentName) + assertEquals(92, results[1].grade) + } + } + + fun testCreateTableWithCompositePrimaryKey() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val enrollment1 = Enrollment(studentId = 1, courseId = 101, semester = "Fall 2025") + val enrollment2 = Enrollment(studentId = 1, courseId = 102, semester = "Fall 2025") + val enrollment3 = Enrollment(studentId = 2, courseId = 101, semester = "Fall 2025") + + lateinit var selectStatement: SelectStatement + database { + EnrollmentTable { table -> + table INSERT listOf(enrollment1, enrollment2, enrollment3) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(3, results.size) + assertEquals(true, results.any { it == enrollment1 }) + assertEquals(true, results.any { it == enrollment2 }) + assertEquals(true, results.any { it == enrollment3 }) + } + } + + @OptIn(com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI::class) + fun testInsertWithId() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val person1 = PersonWithId(id = 100, name = "Eve", age = 28) + val person2 = PersonWithId(id = 200, name = "Frank", age = 35) + + lateinit var selectStatement: SelectStatement + database { + PersonWithIdTable { table -> + table INSERT_WITH_ID listOf(person1, person2) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(2, results.size) + assertEquals(100L, results[0].id) + assertEquals("Eve", results[0].name) + assertEquals(28, results[0].age) + assertEquals(200L, results[1].id) + assertEquals("Frank", results[1].name) + assertEquals(35, results[1].age) + } + } + + fun testCreateInDatabaseScope() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val person = PersonWithId(id = null, name = "Grace", age = 40) + val product = Product(sku = null, name = "Thingamajig", price = 49.99) + + lateinit var personStatement: SelectStatement + lateinit var productStatement: SelectStatement + database { + PersonWithIdTable { table -> + table INSERT person + personStatement = table SELECT X + } + ProductTable { table -> + table INSERT product + productStatement = table SELECT X + } + } + + assertEquals(1, personStatement.getResults().size) + assertEquals("Grace", personStatement.getResults().first().name) + assertEquals(1, productStatement.getResults().size) + assertEquals("Thingamajig", productStatement.getResults().first().name) + assertEquals(49.99, productStatement.getResults().first().price) + } + } + + fun testUpdateAndDeleteWithPrimaryKey() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val person1 = PersonWithId(id = null, name = "Henry", age = 45) + val person2 = PersonWithId(id = null, name = "Iris", age = 50) + + database { + PersonWithIdTable { table -> + table INSERT listOf(person1, person2) + } + } + + lateinit var selectStatement: SelectStatement + database { + PersonWithIdTable { table -> + table UPDATE SET { age = 46 } WHERE (name EQ "Henry") + selectStatement = table SELECT WHERE (name EQ "Henry") + } + } + + val updatedPerson = selectStatement.getResults().first() + assertEquals("Henry", updatedPerson.name) + assertEquals(46, updatedPerson.age) + + database { + PersonWithIdTable { table -> + table DELETE WHERE (name EQ "Iris") + selectStatement = table SELECT X + } + } + + val remainingResults = selectStatement.getResults() + assertEquals(1, remainingResults.size) + assertEquals("Henry", remainingResults.first().name) + } + } + + private fun getDefaultDBConfig(): DatabaseConfiguration = + DatabaseConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + it.execSQL(SQL_CREATE_BOOK) + it.execSQL(SQL_CREATE_CATEGORY) + } + ) + + private fun getNewAPIDBConfig(): DSLDBConfiguration = + DSLDBConfiguration( name = DATABASE_NAME, path = path, version = 1, create = { CREATE(BookTable) CREATE(CategoryTable) + CREATE(PersonWithIdTable) + CREATE(ProductTable) + CREATE(StudentWithAutoincrementTable) + CREATE(EnrollmentTable) } ) } \ No newline at end of file diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 9527486..49c44d9 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -55,6 +55,27 @@ class JvmTest { @Test fun testNullValue() = commonTest.testNullValue() + @Test + fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() + + @Test + fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() + + @Test + fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() + + @Test + fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + + @Test + fun testInsertWithId() = commonTest.testInsertWithId() + + @Test + fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() + + @Test + fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) diff --git a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt index 8fadbb8..3cd58eb 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -71,6 +71,27 @@ class NativeTest { @Test fun testNullValue() = commonTest.testNullValue() + @Test + fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() + + @Test + fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() + + @Test + fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() + + @Test + fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + + @Test + fun testInsertWithId() = commonTest.testInsertWithId() + + @Test + fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() + + @Test + fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt index bd69901..896dcef 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt @@ -39,13 +39,13 @@ public class ClauseString( ) : ClauseElement(valueName, table, isFunction) { /** Equals (=), or IS NULL if value is null */ - internal infix fun eq(str: String?): SelectCondition = appendString("=", " IS", str) + internal infix fun eq(str: String?): SelectCondition = appendString("=", " IS NULL", str) /** Equals (=) - compare against another column/function */ internal infix fun eq(clauseString: ClauseString): SelectCondition = appendClauseString("=", clauseString) /** Not equals (!=), or IS NOT NULL if value is null */ - internal infix fun neq(str: String?): SelectCondition = appendString("!=", " IS NOT", str) + internal infix fun neq(str: String?): SelectCondition = appendString("!=", " IS NOT NULL", str) /** Not equals (!=) - compare against another column/function */ internal infix fun neq(clauseString: ClauseString): SelectCondition = appendClauseString("!=", clauseString) @@ -89,7 +89,6 @@ public class ClauseString( val isNull = str == null if (isNull) { append(nullSymbol) - append(" NULL") } else { append(notNullSymbol) append('?') diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt index a508776..229d5b5 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt @@ -62,7 +62,7 @@ internal fun encodeEntities2InsertValues( val iterator = values.iterator() fun appendNext() { val value = iterator.next() - val encoder = InsertValuesEncoder(parameters, primaryKeyName) + val encoder = InsertValuesEncoder(parameters, primaryKeyName, isInsertId) encoder.encodeSerializableValue(serializer, value) append(encoder.valuesSQL) } @@ -93,17 +93,17 @@ internal fun StringBuilder.appendDBColumnName( if (isInsertId) { appendDBColumnName(descriptor) } else { - if (descriptor.elementsCount > 0) { - val elementName = descriptor.getElementName(0) - if (elementName != primaryKeyName) - append(elementName) - } - for (i in 1 ..< descriptor.elementsCount) { - append(',') + val lastIndex = descriptor.elementsCount - 1 + for (i in 0 ..< lastIndex) { val elementName = descriptor.getElementName(i) - if (elementName != primaryKeyName) + if (elementName != primaryKeyName) { append(elementName) + append(',') + } } + val elementName = descriptor.getElementName(lastIndex) + if (elementName != primaryKeyName) + append(elementName) } } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt index 3fcf4f9..a6fdf30 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt @@ -43,6 +43,7 @@ import kotlinx.serialization.modules.SerializersModule internal class InsertValuesEncoder( val parameters: MutableList, val primaryKeyName: String?, + val isInsertId: Boolean, ) : AbstractEncoder() { override val serializersModule: SerializersModule = EmptySerializersModule() @@ -88,8 +89,7 @@ internal class InsertValuesEncoder( override fun encodeElement(descriptor: SerialDescriptor, index: Int): Boolean { elementsCount = descriptor.elementsCount elementsIndex = index - val elementName = descriptor.getElementName(index) - return elementName != primaryKeyName + return isInsertId || descriptor.getElementName(index) != primaryKeyName } /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt index 256bad7..e2efdf2 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt @@ -91,29 +91,34 @@ internal object Create : Operation { } } val isNullable = descriptor.isNullable + val isPrimaryKey = elementName == table.primaryKeyInfo?.primaryKeyName + append(elementName) append(type) - if (elementName == table.primaryKeyInfo?.primaryKeyName) { - if (table.primaryKeyInfo?.isAutomaticIncrement == true && type == FullNameCache.LONG) + + if (isPrimaryKey) { + if (table.primaryKeyInfo?.isAutomaticIncrement == true && type == " INTEGER") append(" PRIMARY KEY AUTOINCREMENT") else append(" PRIMARY KEY") - } else if (isNullable) { + // Add comma if not the last element + if (elementIndex < lastIndex) + append(',') + } else if (!isNullable) { + append(" NOT NULL") if (elementIndex < lastIndex) append(',') - } else { + // Nullable non-primary key columns if (elementIndex < lastIndex) - append(" NOT NULL,") - else - append(" NOT NULL") + append(',') } } table.primaryKeyInfo?.compositePrimaryKeys?.joinTo( buffer = this, separator = ",", - prefix = ", PRIMARY KEY ", - postfix = ")" + prefix = ", PRIMARY KEY (", + postfix = ")", ) append(')') } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt index 89dc312..54cef45 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt @@ -62,6 +62,7 @@ public class JoinStatementWithoutCondition internal constructor( separator = ",", prefix = " USING (", postfix = ")", + transform = { it.valueName } ) } val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, null) diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt index 4e68385..430f1bc 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt @@ -187,9 +187,9 @@ class ClauseProcessor( } else { writer.write(" compositePrimaryKeys = listOf(\n") compositePrimaryKeys.forEach { - writer.write(" $it,\n") + writer.write(" \"$it\",\n") } - writer.write(" )\n") + writer.write(" )\n") } writer.write(" )\n\n") writer.write("}\n") From 14f23dfd0414fb55094b4bb2473e3a4f8e7cbdb0 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Tue, 21 Oct 2025 17:22:05 +0100 Subject: [PATCH 4/5] Fix bugs for ByteArray support and write more unit tests for it --- .../driver/AndroidDatabaseConnection.kt | 48 ++++- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 15 ++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 34 ++- .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 195 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 15 ++ .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 15 ++ .../dsl/sql/clause/AndroidClauseBlob.kt | 15 +- .../dsl/sql/compiler/InsertValuesEncoder.kt | 13 ++ 8 files changed, 331 insertions(+), 19 deletions(-) diff --git a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt index 4fbe12d..54423da 100644 --- a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt +++ b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt @@ -16,7 +16,9 @@ package com.ctrip.sqllin.driver +import android.database.sqlite.SQLiteCursor import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteQuery /** * Android implementation of [DatabaseConnection] using Android's SQLiteDatabase. @@ -35,11 +37,49 @@ internal class AndroidDatabaseConnection(private val database: SQLiteDatabase) : override fun executeUpdateDelete(sql: String, bindParams: Array?) = execSQL(sql, bindParams) - override fun query(sql: String, bindParams: Array?): CommonCursor { - val realParams = bindParams?.let { origin -> - Array(origin.size) { i -> origin[i]?.toString() } + override fun query(sql: String, bindParams: Array?): CommonCursor = + if (bindParams == null) { + AndroidCursor(database.rawQuery(sql, null)) + } else { + // Use rawQueryWithFactory to bind parameters with proper types + // This allows us to bind parameters with their actual types (Int, Long, Double, etc.) + // instead of converting everything to String like rawQuery does + val cursorFactory = SQLiteDatabase.CursorFactory { _, masterQuery, editTable, query -> + bindTypedParameters(query, bindParams) + SQLiteCursor(masterQuery, editTable, query) + } + // Pass emptyArray() for selectionArgs since we bind parameters via the factory + // Use empty string for editTable since it's only needed for updateable cursors + AndroidCursor(database.rawQueryWithFactory(cursorFactory, sql, null, "")) + } + + /** + * Binds parameters to SQLiteQuery with proper type handling. + * + * Unlike rawQuery which only accepts String[], this method binds parameters + * with their actual types (Long, Double, ByteArray, etc.) to ensure correct + * SQLite type affinity and comparisons. + */ + private fun bindTypedParameters(query: SQLiteQuery, bindParams: Array) { + bindParams.forEachIndexed { index, param -> + val position = index + 1 // SQLite bind positions are 1-based + when (param) { + null -> query.bindNull(position) + is ByteArray -> query.bindBlob(position, param) + is Double -> query.bindDouble(position, param) + is Float -> query.bindDouble(position, param.toDouble()) + is Long -> query.bindLong(position, param) + is Int -> query.bindLong(position, param.toLong()) + is Short -> query.bindLong(position, param.toLong()) + is Byte -> query.bindLong(position, param.toLong()) + is Boolean -> query.bindLong(position, if (param) 1L else 0L) + is ULong -> query.bindLong(position, param.toLong()) + is UInt -> query.bindLong(position, param.toLong()) + is UShort -> query.bindLong(position, param.toLong()) + is UByte -> query.bindLong(position, param.toLong()) + else -> query.bindString(position, param.toString()) + } } - return AndroidCursor(database.rawQuery(sql, realParams)) } override fun beginTransaction() = database.beginTransaction() diff --git a/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index c0eedbc..7866a8b 100644 --- a/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidInstrumentedTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -82,6 +82,21 @@ class AndroidTest { @Test fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @Test + fun testByteArrayInsert() = commonTest.testByteArrayInsert() + + @Test + fun testByteArraySelect() = commonTest.testByteArraySelect() + + @Test + fun testByteArrayUpdate() = commonTest.testByteArrayUpdate() + + @Test + fun testByteArrayDelete() = commonTest.testByteArrayDelete() + + @Test + fun testByteArrayMultipleOperations() = commonTest.testByteArrayMultipleOperations() + @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt index 1f84bbe..5f9d35b 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt @@ -97,4 +97,36 @@ data class Enrollment( @CompositePrimaryKey val studentId: Long, @CompositePrimaryKey val courseId: Long, val semester: String, -) \ No newline at end of file +) + +@DBRow("file_data") +@Serializable +data class FileData( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val fileName: String, + val content: ByteArray, + val metadata: String, +) { + // ByteArray doesn't implement equals/hashCode properly for data class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as FileData + + if (id != other.id) return false + if (fileName != other.fileName) return false + if (!content.contentEquals(other.content)) return false + if (metadata != other.metadata) return false + + return true + } + + override fun hashCode(): Int { + var result = id?.hashCode() ?: 0 + result = 31 * result + fileName.hashCode() + result = 31 * result + content.contentHashCode() + result = 31 * result + metadata.hashCode() + return result + } +} \ No newline at end of file diff --git a/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index 5a31052..2abf8cf 100644 --- a/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonTest/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -668,6 +668,200 @@ class CommonBasicTest(private val path: DatabasePath) { } } + fun testByteArrayInsert() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file1 = FileData( + id = null, + fileName = "test.bin", + content = byteArrayOf(0x01, 0x02, 0x03, 0xFF.toByte()), + metadata = "Binary test file" + ) + val file2 = FileData( + id = null, + fileName = "empty.dat", + content = byteArrayOf(), + metadata = "Empty file" + ) + val file3 = FileData( + id = null, + fileName = "large.bin", + content = ByteArray(256) { it.toByte() }, + metadata = "Large file with all byte values" + ) + + lateinit var selectStatement: SelectStatement + database { + FileDataTable { table -> + table INSERT listOf(file1, file2, file3) + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(3, results.size) + + // Verify first file + assertEquals("test.bin", results[0].fileName) + assertEquals(true, results[0].content.contentEquals(byteArrayOf(0x01, 0x02, 0x03, 0xFF.toByte()))) + assertEquals("Binary test file", results[0].metadata) + + // Verify empty file + assertEquals("empty.dat", results[1].fileName) + assertEquals(true, results[1].content.contentEquals(byteArrayOf())) + assertEquals("Empty file", results[1].metadata) + + // Verify large file + assertEquals("large.bin", results[2].fileName) + assertEquals(256, results[2].content.size) + assertEquals(true, results[2].content.contentEquals(ByteArray(256) { it.toByte() })) + } + } + + fun testByteArraySelect() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file = FileData( + id = null, + fileName = "select_test.bin", + content = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()), + metadata = "SELECT test" + ) + + database { + FileDataTable { table -> + table INSERT file + } + } + + lateinit var selectStatement: SelectStatement + database { + FileDataTable { table -> + selectStatement = table SELECT WHERE (fileName EQ "select_test.bin") + } + } + + val results = selectStatement.getResults() + assertEquals(1, results.size) + assertEquals("select_test.bin", results.first().fileName) + assertEquals(true, results.first().content.contentEquals(byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()))) + } + } + + fun testByteArrayUpdate() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val originalFile = FileData( + id = null, + fileName = "update_test.bin", + content = byteArrayOf(0x00, 0x01, 0x02), + metadata = "Original" + ) + + database { + FileDataTable { table -> + table INSERT originalFile + } + } + + val newContent = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) + lateinit var selectStatement: SelectStatement + database { + FileDataTable { table -> + table UPDATE SET { + content = newContent + metadata = "Updated" + } WHERE (fileName EQ "update_test.bin") + selectStatement = table SELECT WHERE (fileName EQ "update_test.bin") + } + } + + val updatedFile = selectStatement.getResults().first() + assertEquals("update_test.bin", updatedFile.fileName) + assertEquals(true, updatedFile.content.contentEquals(newContent)) + assertEquals("Updated", updatedFile.metadata) + } + } + + fun testByteArrayDelete() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file1 = FileData( + id = null, + fileName = "delete_test1.bin", + content = byteArrayOf(0x01, 0x02), + metadata = "To delete" + ) + val file2 = FileData( + id = null, + fileName = "delete_test2.bin", + content = byteArrayOf(0x03, 0x04), + metadata = "To keep" + ) + + database { + FileDataTable { table -> + table INSERT listOf(file1, file2) + } + } + + lateinit var selectStatement: SelectStatement + database { + FileDataTable { table -> + table DELETE WHERE (fileName EQ "delete_test1.bin") + selectStatement = table SELECT X + } + } + + val results = selectStatement.getResults() + assertEquals(1, results.size) + assertEquals("delete_test2.bin", results.first().fileName) + } + } + + fun testByteArrayMultipleOperations() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test multiple INSERT, UPDATE, SELECT operations + val file1 = FileData( + id = null, + fileName = "multi1.bin", + content = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + metadata = "First" + ) + val file2 = FileData( + id = null, + fileName = "multi2.bin", + content = byteArrayOf(0xCC.toByte(), 0xDD.toByte()), + metadata = "Second" + ) + + // Insert + database { + FileDataTable { table -> + table INSERT listOf(file1, file2) + } + } + + // Update first file + val newContent = byteArrayOf(0x11, 0x22, 0x33) + database { + FileDataTable { table -> + table UPDATE SET { + content = newContent + } WHERE (fileName EQ "multi1.bin") + } + } + + // Select and verify + lateinit var selectStatement: SelectStatement + database { + FileDataTable { table -> + selectStatement = table SELECT WHERE (fileName EQ "multi1.bin") + } + } + + val updatedFile = selectStatement.getResults().first() + assertEquals(true, updatedFile.content.contentEquals(newContent)) + assertEquals("First", updatedFile.metadata) + } + } + private fun getDefaultDBConfig(): DatabaseConfiguration = DatabaseConfiguration( name = DATABASE_NAME, @@ -691,6 +885,7 @@ class CommonBasicTest(private val path: DatabasePath) { CREATE(ProductTable) CREATE(StudentWithAutoincrementTable) CREATE(EnrollmentTable) + CREATE(FileDataTable) } ) } \ No newline at end of file diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 49c44d9..3406dc6 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -76,6 +76,21 @@ class JvmTest { @Test fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @Test + fun testByteArrayInsert() = commonTest.testByteArrayInsert() + + @Test + fun testByteArraySelect() = commonTest.testByteArraySelect() + + @Test + fun testByteArrayUpdate() = commonTest.testByteArrayUpdate() + + @Test + fun testByteArrayDelete() = commonTest.testByteArrayDelete() + + @Test + fun testByteArrayMultipleOperations() = commonTest.testByteArrayMultipleOperations() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) diff --git a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt index 3cd58eb..0afdcbb 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -92,6 +92,21 @@ class NativeTest { @Test fun testUpdateAndDeleteWithPrimaryKey() = commonTest.testUpdateAndDeleteWithPrimaryKey() + @Test + fun testByteArrayInsert() = commonTest.testByteArrayInsert() + + @Test + fun testByteArraySelect() = commonTest.testByteArraySelect() + + @Test + fun testByteArrayUpdate() = commonTest.testByteArrayUpdate() + + @Test + fun testByteArrayDelete() = commonTest.testByteArrayDelete() + + @Test + fun testByteArrayMultipleOperations() = commonTest.testByteArrayMultipleOperations() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) diff --git a/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt b/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt index 28a2a20..1362543 100644 --- a/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt +++ b/sqllin-dsl/src/androidMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/AndroidClauseBlob.kt @@ -62,25 +62,12 @@ internal class AndroidClauseBlob( } else { append(notNullSymbol) append("X'") - blob toHexString this + append(blob.toHexString(HexFormat.UpperCase)) append('\'') } } return SelectCondition(sql, null) } - - /** - * Converts ByteArray to uppercase hexadecimal string. - * - * Each byte is formatted as a two-digit hex value (00-FF). - * - * @param builder The StringBuilder to append hex characters to - */ - private infix fun ByteArray.toHexString(builder: StringBuilder) = joinTo( - buffer = builder, - separator = "", - transform = { "%02X".format(it) } - ) } /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt index a6fdf30..8294a20 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt @@ -17,6 +17,7 @@ package com.ctrip.sqllin.dsl.sql.compiler import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationStrategy import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.AbstractEncoder import kotlinx.serialization.modules.EmptySerializersModule @@ -106,4 +107,16 @@ internal class InsertValuesEncoder( * Encodes enum as its ordinal integer value parameter. */ override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = appendAny(index) + + /** + * Handles inline values (including ByteArray). + * + * ByteArray is treated as an inline value in kotlinx.serialization, so we need to extract + * the actual ByteArray value and append it as a parameter. + */ + override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) = + if (value is ByteArray) + appendAny(value) + else + super.encodeSerializableValue(serializer, value) } \ No newline at end of file From 17c27378a9209046a64fb3a113cbee38553bc2e1 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Tue, 21 Oct 2025 19:09:46 +0100 Subject: [PATCH 5/5] Substitute `joinTo`s with manual loops. --- CHANGELOG.md | 1 + ROADMAP.md | 3 ++- .../sqllin/driver/AndroidDatabaseConnection.kt | 10 ++++++---- .../dsl/sql/compiler/InsertValuesEncoder.kt | 1 + .../ctrip/sqllin/dsl/sql/operation/Create.kt | 17 +++++++++++------ .../statement/JoinStatementWithoutCondition.kt | 14 +++++++------- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 037a5f9..792981e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * New experimental API: `DatabaseScope#CREATE` * New experimental API: `DatabaseScope#DROP` * New experimental API: `DatabaseSceop#ALERT` +* Support using ByteArray in DSL, that represents BLOB in SQLite ### sqllin-driver diff --git a/ROADMAP.md b/ROADMAP.md index be8ed76..8f9443e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,8 @@ * Support the key word REFERENCE * Support JOIN sub-query -* Fix the bug of storing ByteArray in DSL +* Support Enum type +* Support typealias for primitive types ## Medium Priority diff --git a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt index 54423da..cf3f3ee 100644 --- a/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt +++ b/sqllin-driver/src/androidMain/kotlin/com/ctrip/sqllin/driver/AndroidDatabaseConnection.kt @@ -37,9 +37,9 @@ internal class AndroidDatabaseConnection(private val database: SQLiteDatabase) : override fun executeUpdateDelete(sql: String, bindParams: Array?) = execSQL(sql, bindParams) - override fun query(sql: String, bindParams: Array?): CommonCursor = - if (bindParams == null) { - AndroidCursor(database.rawQuery(sql, null)) + override fun query(sql: String, bindParams: Array?): CommonCursor { + val cursor = if (bindParams == null) { + database.rawQuery(sql, null) } else { // Use rawQueryWithFactory to bind parameters with proper types // This allows us to bind parameters with their actual types (Int, Long, Double, etc.) @@ -50,8 +50,10 @@ internal class AndroidDatabaseConnection(private val database: SQLiteDatabase) : } // Pass emptyArray() for selectionArgs since we bind parameters via the factory // Use empty string for editTable since it's only needed for updateable cursors - AndroidCursor(database.rawQueryWithFactory(cursorFactory, sql, null, "")) + database.rawQueryWithFactory(cursorFactory, sql, null, "") } + return AndroidCursor(cursor) + } /** * Binds parameters to SQLiteQuery with proper type handling. diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt index 8294a20..1202d1f 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/InsertValuesEncoder.kt @@ -37,6 +37,7 @@ import kotlinx.serialization.modules.SerializersModule * * @param parameters Mutable list to accumulate parameter values * @param primaryKeyName Name of primary key field to skip, or null to include all fields + * @param isInsertId whether ignore encoding the special primary key that represents rowid in SQLite * * @author Yuang Qiao */ diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt index e2efdf2..0c7f4f6 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt @@ -114,12 +114,17 @@ internal object Create : Operation { append(',') } } - table.primaryKeyInfo?.compositePrimaryKeys?.joinTo( - buffer = this, - separator = ",", - prefix = ", PRIMARY KEY (", - postfix = ")", - ) + table.primaryKeyInfo?.compositePrimaryKeys?.let { + append(", PRIMARY KEY (") + if (it.isEmpty()) + return@let + append(it[0]) + for (i in 1 ..< it.size) { + append(',') + append(it[i]) + } + append(')') + } append(')') } } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt index 54cef45..2e46907 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt @@ -57,13 +57,13 @@ public class JoinStatementWithoutCondition internal constructor( require(iterator.hasNext()) { "Param 'clauseElements' must not be empty!!!" } val sql = buildString { append(sqlStr) - clauseElements.joinTo( - buffer = this, - separator = ",", - prefix = " USING (", - postfix = ")", - transform = { it.valueName } - ) + append(" USING (") + append(iterator.next().valueName) + while (iterator.hasNext()) { + append(',') + append(iterator.next().valueName) + } + append(')') } val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, null) addSelectStatement(joinStatement)