From ba7b310e6debda1b08586448ce08957733bdfdce Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sun, 26 Oct 2025 21:33:40 +0000 Subject: [PATCH 1/7] Add operators for ClauseBlob.kt and ClauseString.kt --- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 21 ++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 202 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 21 ++ .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 21 ++ .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 145 ++++++++++++- .../sqllin/dsl/sql/clause/ClauseString.kt | 137 +++++++++++- .../sqllin/dsl/sql/clause/ConditionClause.kt | 109 +++++++++- 7 files changed, 641 insertions(+), 15 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 3d873cd..1e0f05e 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 @@ -127,6 +127,27 @@ class AndroidTest { @Test fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() + @Test + fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() + + @Test + fun testStringInOperator() = commonTest.testStringInOperator() + + @Test + fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() + + @Test + fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + + @Test + fun testBlobInOperator() = commonTest.testBlobInOperator() + + @Test + fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + + @Test + fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + @Before fun setUp() { val context = InstrumentationRegistry.getInstrumentation().targetContext 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 8039338..fee47e7 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 @@ -1219,6 +1219,208 @@ class CommonBasicTest(private val path: DatabasePath) { } } + fun testStringComparisonOperators() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val book0 = Book(name = "Alice in Wonderland", author = "Lewis Carroll", pages = 200, price = 15.99) + val book1 = Book(name = "Bob's Adventures", author = "Bob Smith", pages = 300, price = 20.99) + val book2 = Book(name = "Charlie and the Chocolate Factory", author = "Roald Dahl", pages = 250, price = 18.99) + val book3 = Book(name = "David Copperfield", author = "Charles Dickens", pages = 400, price = 25.99) + + var statementLT: SelectStatement? = null + var statementLTE: SelectStatement? = null + var statementGT: SelectStatement? = null + var statementGTE: SelectStatement? = null + + database { + BookTable { table -> + table INSERT listOf(book0, book1, book2, book3) + statementLT = table SELECT WHERE (name LT "Bob's Adventures") + statementLTE = table SELECT WHERE (name LTE "Bob's Adventures") + statementGT = table SELECT WHERE (name GT "Charlie and the Chocolate Factory") + statementGTE = table SELECT WHERE (name GTE "Charlie and the Chocolate Factory") + } + } + + // Test LT + val resultsLT = statementLT!!.getResults() + assertEquals(1, resultsLT.size) + assertEquals(book0, resultsLT[0]) + + // Test LTE + val resultsLTE = statementLTE!!.getResults() + assertEquals(2, resultsLTE.size) + assertEquals(true, resultsLTE.any { it == book0 }) + assertEquals(true, resultsLTE.any { it == book1 }) + + // Test GT + val resultsGT = statementGT!!.getResults() + assertEquals(1, resultsGT.size) + assertEquals(book3, resultsGT[0]) + + // Test GTE + val resultsGTE = statementGTE!!.getResults() + assertEquals(2, resultsGTE.size) + assertEquals(true, resultsGTE.any { it == book2 }) + assertEquals(true, resultsGTE.any { it == book3 }) + } + + fun testStringInOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val book0 = Book(name = "The Da Vinci Code", author = "Dan Brown", pages = 454, price = 16.96) + val book1 = Book(name = "Kotlin Cookbook", author = "Ken Kousen", pages = 251, price = 37.72) + val book2 = Book(name = "The Lost Symbol", author = "Dan Brown", pages = 510, price = 19.95) + val book3 = Book(name = "Modern Java Recipes", author ="Ken Kousen", pages = 322, price = 25.78) + + var statementIN: SelectStatement? = null + + database { + BookTable { table -> + table INSERT listOf(book0, book1, book2, book3) + statementIN = table SELECT WHERE (author IN listOf("Dan Brown", "Unknown Author")) + } + } + + val results = statementIN!!.getResults() + assertEquals(2, results.size) + assertEquals(true, results.any { it == book0 }) + assertEquals(true, results.any { it == book2 }) + } + + fun testStringBetweenOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val book0 = Book(name = "Alice in Wonderland", author = "Lewis Carroll", pages = 200, price = 15.99) + val book1 = Book(name = "Bob's Adventures", author = "Bob Smith", pages = 300, price = 20.99) + val book2 = Book(name = "Charlie and the Chocolate Factory", author = "Roald Dahl", pages = 250, price = 18.99) + val book3 = Book(name = "David Copperfield", author = "Charles Dickens", pages = 400, price = 25.99) + + var statementBETWEEN: SelectStatement? = null + + database { + BookTable { table -> + table INSERT listOf(book0, book1, book2, book3) + statementBETWEEN = table SELECT WHERE (name BETWEEN ("Bob's Adventures" to "Charlie and the Chocolate Factory")) + } + } + + val results = statementBETWEEN!!.getResults() + assertEquals(2, results.size) + assertEquals(true, results.any { it == book1 }) + assertEquals(true, results.any { it == book2 }) + } + + fun testBlobComparisonOperators() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") + val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") + val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") + val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") + + var statementLT: SelectStatement? = null + var statementLTE: SelectStatement? = null + var statementGT: SelectStatement? = null + var statementGTE: SelectStatement? = null + + database { + FileDataTable { table -> + table INSERT listOf(file0, file1, file2, file3) + statementLT = table SELECT WHERE (content LT byteArrayOf(0x03, 0x04)) + statementLTE = table SELECT WHERE (content LTE byteArrayOf(0x03, 0x04)) + statementGT = table SELECT WHERE (content GT byteArrayOf(0x05, 0x06)) + statementGTE = table SELECT WHERE (content GTE byteArrayOf(0x05, 0x06)) + } + } + + // Test LT + val resultsLT = statementLT!!.getResults() + assertEquals(1, resultsLT.size) + assertEquals("file0.bin", resultsLT[0].fileName) + + // Test LTE + val resultsLTE = statementLTE!!.getResults() + assertEquals(2, resultsLTE.size) + assertEquals(true, resultsLTE.any { it.fileName == "file0.bin" }) + assertEquals(true, resultsLTE.any { it.fileName == "file1.bin" }) + + // Test GT + val resultsGT = statementGT!!.getResults() + assertEquals(1, resultsGT.size) + assertEquals("file3.bin", resultsGT[0].fileName) + + // Test GTE + val resultsGTE = statementGTE!!.getResults() + assertEquals(2, resultsGTE.size) + assertEquals(true, resultsGTE.any { it.fileName == "file2.bin" }) + assertEquals(true, resultsGTE.any { it.fileName == "file3.bin" }) + } + + fun testBlobInOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") + val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") + val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") + val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") + + var statementIN: SelectStatement? = null + + database { + FileDataTable { table -> + table INSERT listOf(file0, file1, file2, file3) + statementIN = table SELECT WHERE (content IN listOf( + byteArrayOf(0x01, 0x02), + byteArrayOf(0x05, 0x06), + byteArrayOf(0x09, 0x0A) + )) + } + } + + val results = statementIN!!.getResults() + assertEquals(2, results.size) + assertEquals(true, results.any { it.fileName == "file0.bin" }) + assertEquals(true, results.any { it.fileName == "file2.bin" }) + } + + fun testBlobBetweenOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") + val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") + val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") + val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") + + var statementBETWEEN: SelectStatement? = null + + database { + FileDataTable { table -> + table INSERT listOf(file0, file1, file2, file3) + statementBETWEEN = table SELECT WHERE (content BETWEEN (byteArrayOf(0x03, 0x04) to byteArrayOf(0x05, 0x06))) + } + } + + val results = statementBETWEEN!!.getResults() + assertEquals(2, results.size) + assertEquals(true, results.any { it.fileName == "file1.bin" }) + assertEquals(true, results.any { it.fileName == "file2.bin" }) + } + + fun testStringComparisonWithColumns() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val book0 = Book(name = "Same Name", author = "Same Name", pages = 200, price = 15.99) + val book1 = Book(name = "Different", author = "Another", pages = 300, price = 20.99) + + var statementEQ: SelectStatement? = null + var statementNEQ: SelectStatement? = null + + database { + BookTable { table -> + table INSERT listOf(book0, book1) + statementEQ = table SELECT WHERE (name EQ BookTable.author) + statementNEQ = table SELECT WHERE (name NEQ BookTable.author) + } + } + + // Test column comparison EQ + val resultsEQ = statementEQ!!.getResults() + assertEquals(1, resultsEQ.size) + assertEquals(book0, resultsEQ[0]) + + // Test column comparison NEQ + val resultsNEQ = statementNEQ!!.getResults() + assertEquals(1, resultsNEQ.size) + assertEquals(book1, resultsNEQ[0]) + } + private fun getDefaultDBConfig(): DatabaseConfiguration = DatabaseConfiguration( name = DATABASE_NAME, 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 b8858c3..2f8cf3f 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 @@ -121,6 +121,27 @@ class JvmTest { @Test fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() + @Test + fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() + + @Test + fun testStringInOperator() = commonTest.testStringInOperator() + + @Test + fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() + + @Test + fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + + @Test + fun testBlobInOperator() = commonTest.testBlobInOperator() + + @Test + fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + + @Test + fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + @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 4a91e58..9330cbf 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 @@ -137,6 +137,27 @@ class NativeTest { @Test fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() + @Test + fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() + + @Test + fun testStringInOperator() = commonTest.testStringInOperator() + + @Test + fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() + + @Test + fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + + @Test + fun testBlobInOperator() = commonTest.testBlobInOperator() + + @Test + fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + + @Test + fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) 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 index 82e66e6..9351544 100644 --- 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 @@ -22,9 +22,17 @@ 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) + * BLOBs as byte sequences, this class enables type-safe operations on binary data. + * + * Available operators: + * - `lt`: Less than (<) + * - `lte`: Less than or equal to (<=) + * - `eq`: Equals (=) - handles null with IS NULL + * - `neq`: Not equals (!=) - handles null with IS NOT NULL + * - `gt`: Greater than (>) + * - `gte`: Greater than or equal to (>=) + * - `inIterable`: IN operator for checking membership in a collection + * - `between`: BETWEEN operator for range checks * * BLOB columns are commonly used for storing: * - Images, audio, video files @@ -50,7 +58,7 @@ public class ClauseBlob( * @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) + internal infix fun eq(blob: ByteArray?): SelectCondition = appendNullableBlob("=", " IS NULL", blob) /** * Creates an equality comparison condition against another BLOB column/function. @@ -70,7 +78,7 @@ public class ClauseBlob( * @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) + internal infix fun neq(blob: ByteArray?): SelectCondition = appendNullableBlob("!=", " IS NOT NULL", blob) /** * Creates an inequality comparison condition against another BLOB column/function. @@ -80,7 +88,71 @@ public class ClauseBlob( */ internal infix fun neq(clauseBlob: ClauseBlob): SelectCondition = appendClauseBlob("!=", clauseBlob) - private fun appendBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { + /** + * Creates a less than comparison condition (<). + * + * @param byteArray The ByteArray value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun lt(byteArray: ByteArray): SelectCondition = appendBlob("). + * + * @param byteArray The ByteArray value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun gt(byteArray: ByteArray): SelectCondition = appendBlob(">?", byteArray) + + /** + * Creates a greater than 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 gt(clauseBlob: ClauseBlob): SelectCondition = appendClauseBlob(">", clauseBlob) + + /** + * Creates a greater than or equal to comparison condition (>=). + * + * @param byteArray The ByteArray value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun gte(byteArray: ByteArray): SelectCondition = appendBlob(">=?", byteArray) + + /** + * Creates a greater than or equal to 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 gte(clauseBlob: ClauseBlob): SelectCondition = appendClauseBlob(">=", clauseBlob) + + private fun appendNullableBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { val sql = buildString { if (!isFunction) { append(table.tableName) @@ -97,6 +169,18 @@ public class ClauseBlob( return SelectCondition(sql, if (blob == null) null else mutableListOf(blob)) } + private fun appendBlob(symbol: String, blob: ByteArray): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(symbol) + } + return SelectCondition(sql, mutableListOf(blob)) + } + private fun appendClauseBlob(symbol: String, clauseBlob: ClauseBlob): SelectCondition { val sql = buildString { append(table.tableName) @@ -112,6 +196,55 @@ public class ClauseBlob( return SelectCondition(sql, null) } + /** + * Creates an IN condition to check if the BLOB value is in a collection. + * + * Generates SQL like: `column IN (?, ?, ...)` + * + * @param blobs The collection of ByteArray values to check against + * @return Condition expression for WHERE/HAVING clauses + * @throws IllegalArgumentException if the collection is empty + */ + internal infix fun inIterable(blobs: Iterable): SelectCondition { + val parameters = blobs.toMutableList() + require(parameters.isNotEmpty()) { "Param 'blobs' must not be empty!!!" } + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(" IN (") + + append('?') + repeat(parameters.size - 1) { + append(",?") + } + append(')') + } + return SelectCondition(sql, parameters) + } + + /** + * Creates a BETWEEN condition to check if the BLOB value is within a range. + * + * Generates SQL like: `column BETWEEN ? AND ?` + * + * @param range A Pair containing the lower and upper bounds (inclusive) + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun between(range: Pair): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(" BETWEEN ? AND ?") + } + return SelectCondition(sql, mutableListOf(range.first, range.second)) + } + override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() override fun equals(other: Any?): Boolean = (other as? ClauseBlob)?.let { it.valueName == valueName && it.table.tableName == table.tableName 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 896dcef..66d9bcd 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 @@ -25,8 +25,14 @@ import com.ctrip.sqllin.dsl.sql.Table * against literal strings or other string columns/functions. * * Available operators: + * - `lt`: Less than (<) + * - `lte`: Less than or equal to (<=) * - `eq`: Equals (=) - handles null with IS NULL * - `neq`: Not equals (!=) - handles null with IS NOT NULL + * - `gt`: Greater than (>) + * - `gte`: Greater than or equal to (>=) + * - `inIterable`: IN operator for checking membership in a collection + * - `between`: BETWEEN operator for range checks * - `like`: LIKE pattern matching (case-insensitive, supports % and _ wildcards) * - `glob`: GLOB pattern matching (case-sensitive, supports * and ? wildcards) * @@ -39,17 +45,81 @@ public class ClauseString( ) : ClauseElement(valueName, table, isFunction) { /** Equals (=), or IS NULL if value is null */ - internal infix fun eq(str: String?): SelectCondition = appendString("=", " IS NULL", str) + internal infix fun eq(str: String?): SelectCondition = appendNullableString("=", " 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 NULL", str) + internal infix fun neq(str: String?): SelectCondition = appendNullableString("!=", " IS NOT NULL", str) /** Not equals (!=) - compare against another column/function */ internal infix fun neq(clauseString: ClauseString): SelectCondition = appendClauseString("!=", clauseString) + /** + * Creates a less than comparison condition (<). + * + * @param str The String value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun lt(str: String): SelectCondition = appendString("). + * + * @param str The String value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun gt(str: String): SelectCondition = appendString(">?", str) + + /** + * Creates a greater than comparison condition against another String column/function. + * + * @param clauseString The String column/function to compare against + * @return Condition expression comparing two String columns + */ + internal infix fun gt(clauseString: ClauseString): SelectCondition = appendClauseString(">", clauseString) + + /** + * Creates a greater than or equal to comparison condition (>=). + * + * @param str The String value to compare against + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun gte(str: String): SelectCondition = appendString(">=?", str) + + /** + * Creates a greater than or equal to comparison condition against another String column/function. + * + * @param clauseString The String column/function to compare against + * @return Condition expression comparing two String columns + */ + internal infix fun gte(clauseString: ClauseString): SelectCondition = appendClauseString(">=", clauseString) + /** * LIKE operator - case-insensitive pattern matching. * @@ -79,7 +149,7 @@ public class ClauseString( return SelectCondition(sql, mutableListOf(regex)) } - private fun appendString(notNullSymbol: String, nullSymbol: String, str: String?): SelectCondition { + private fun appendNullableString(notNullSymbol: String, nullSymbol: String, str: String?): SelectCondition { val sql = buildString { if (!isFunction) { append(table.tableName) @@ -97,6 +167,18 @@ public class ClauseString( return SelectCondition(sql, if (str == null) null else mutableListOf(str)) } + private fun appendString(symbol: String, str: String): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(symbol) + } + return SelectCondition(sql, mutableListOf(str)) + } + private fun appendClauseString(symbol: String, clauseString: ClauseString): SelectCondition { val sql = buildString { append(table.tableName) @@ -112,6 +194,55 @@ public class ClauseString( return SelectCondition(sql, null) } + /** + * Creates an IN condition to check if the String value is in a collection. + * + * Generates SQL like: `column IN (?, ?, ...)` + * + * @param strings The collection of String values to check against + * @return Condition expression for WHERE/HAVING clauses + * @throws IllegalArgumentException if the collection is empty + */ + internal infix fun inIterable(strings: Iterable): SelectCondition { + val parameters = strings.toMutableList() + require(parameters.isNotEmpty()) { "Param 'strings' must not be empty!!!" } + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(" IN (") + + append('?') + repeat(parameters.size - 1) { + append(",?") + } + append(')') + } + return SelectCondition(sql, parameters) + } + + /** + * Creates a BETWEEN condition to check if the String value is within a range. + * + * Generates SQL like: `column BETWEEN ? AND ?` + * + * @param range A Pair containing the lower and upper bounds (inclusive) + * @return Condition expression for WHERE/HAVING clauses + */ + internal infix fun between(range: Pair): SelectCondition { + val sql = buildString { + if (!isFunction) { + append(table.tableName) + append('.') + } + append(valueName) + append(" BETWEEN ? AND ?") + } + return SelectCondition(sql, mutableListOf(range.first, range.second)) + } + override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() override fun equals(other: Any?): Boolean = (other as? ClauseString)?.let { it.valueName == valueName && it.table.tableName == table.tableName diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt index 4270e05..6adce5d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt @@ -26,7 +26,8 @@ import com.ctrip.sqllin.dsl.annotation.StatementDslMaker * * This file also provides uppercase DSL operators for building conditions: * - Numeric: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN - * - String: EQ, NEQ, LIKE, GLOB + * - String: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN, LIKE, GLOB + * - Blob: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN * - Boolean: IS * - Logic: AND, OR * @@ -122,14 +123,110 @@ public infix fun ClauseString.LIKE(regex: String): SelectCondition = like(regex) @StatementDslMaker public infix fun ClauseString.GLOB(regex: String): SelectCondition = glob(regex) -// Condition 'OR' operator +// Less than, < @StatementDslMaker -public infix fun SelectCondition.OR(prediction: SelectCondition): SelectCondition = or(prediction) +public infix fun ClauseString.LT(str: String): SelectCondition = lt(str) -// Condition 'AND' operator +// Less than, append to ClauseString +@StatementDslMaker +public infix fun ClauseString.LT(clauseString: ClauseString): SelectCondition = lt(clauseString) + +// Less than or equal to, <= +@StatementDslMaker +public infix fun ClauseString.LTE(str: String): SelectCondition = lte(str) + +// Less than or equal to, append to ClauseString +@StatementDslMaker +public infix fun ClauseString.LTE(clauseString: ClauseString): SelectCondition = lte(clauseString) + +// Greater than, > +@StatementDslMaker +public infix fun ClauseString.GT(str: String): SelectCondition = gt(str) + +// Greater than, append to ClauseString +@StatementDslMaker +public infix fun ClauseString.GT(clauseString: ClauseString): SelectCondition = gt(clauseString) + +// Greater than or equal to, >= +@StatementDslMaker +public infix fun ClauseString.GTE(str: String): SelectCondition = gte(str) + +// Greater than or equal to, append to ClauseString +@StatementDslMaker +public infix fun ClauseString.GTE(clauseString: ClauseString): SelectCondition = gte(clauseString) + +// If the 'string' in the 'strings' +@StatementDslMaker +public infix fun ClauseString.IN(strings: Iterable): SelectCondition = inIterable(strings) + +// If the 'string' between the 'range' +@StatementDslMaker +public infix fun ClauseString.BETWEEN(range: Pair): SelectCondition = between(range) + +// Less than, < +@StatementDslMaker +public infix fun ClauseBlob.LT(byteArray: ByteArray): SelectCondition = lt(byteArray) + +// Less than, append to ClauseBlob +@StatementDslMaker +public infix fun ClauseBlob.LT(blob: ClauseBlob): SelectCondition = lt(blob) + +// Less than or equal to, <= +@StatementDslMaker +public infix fun ClauseBlob.LTE(byteArray: ByteArray): SelectCondition = lte(byteArray) + +// Less than or equal to, append to ClauseBlob +@StatementDslMaker +public infix fun ClauseBlob.LTE(blob: ClauseBlob): SelectCondition = lte(blob) + +// Equals, == +@StatementDslMaker +public infix fun ClauseBlob.EQ(byteArray: ByteArray?): SelectCondition = eq(byteArray) + +// Equals, append to ClauseBlob +@StatementDslMaker +public infix fun ClauseBlob.EQ(blob: ClauseBlob): SelectCondition = eq(blob) + +// Not equal to, != +@StatementDslMaker +public infix fun ClauseBlob.NEQ(byteArray: ByteArray?): SelectCondition = neq(byteArray) + +// Not equal to, append to ClauseBlob +@StatementDslMaker +public infix fun ClauseBlob.NEQ(blob: ClauseBlob): SelectCondition = neq(blob) + +// Greater than, > +@StatementDslMaker +public infix fun ClauseBlob.GT(byteArray: ByteArray): SelectCondition = gt(byteArray) + +// Greater than, append to ClauseBlob @StatementDslMaker -public infix fun SelectCondition.AND(prediction: SelectCondition): SelectCondition = and(prediction) +public infix fun ClauseBlob.GT(blob: ClauseBlob): SelectCondition = gt(blob) + +// Greater than or equal to, >= +@StatementDslMaker +public infix fun ClauseBlob.GTE(byteArray: ByteArray): SelectCondition = gte(byteArray) + +// Greater than or equal to, append to ClauseBlob +@StatementDslMaker +public infix fun ClauseBlob.GTE(blob: ClauseBlob): SelectCondition = gte(blob) + +// If the 'blob' in the 'blobs' +@StatementDslMaker +public infix fun ClauseBlob.IN(blobs: Iterable): SelectCondition = inIterable(blobs) + +// If the 'blob' between the 'range' +@StatementDslMaker +public infix fun ClauseBlob.BETWEEN(range: Pair): SelectCondition = between(range) // Condition 'IS' operator @StatementDslMaker -public infix fun ClauseBoolean.IS(bool: Boolean): SelectCondition = _is(bool) \ No newline at end of file +public infix fun ClauseBoolean.IS(bool: Boolean): SelectCondition = _is(bool) + +// Condition 'OR' operator +@StatementDslMaker +public infix fun SelectCondition.OR(prediction: SelectCondition): SelectCondition = or(prediction) + +// Condition 'AND' operator +@StatementDslMaker +public infix fun SelectCondition.AND(prediction: SelectCondition): SelectCondition = and(prediction) \ No newline at end of file From 179ce665c91ef69a427160e472509b6aab6a4e8b Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 1 Nov 2025 21:34:51 +0000 Subject: [PATCH 2/7] Fix unit tests and add two new subagents --- .claude/agents/code-change-reviewer.md | 115 ++++++++++++++++++ .claude/agents/sqllin-test-writer.md | 96 +++++++++++++++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 23 ++-- 3 files changed, 223 insertions(+), 11 deletions(-) create mode 100644 .claude/agents/code-change-reviewer.md create mode 100644 .claude/agents/sqllin-test-writer.md diff --git a/.claude/agents/code-change-reviewer.md b/.claude/agents/code-change-reviewer.md new file mode 100644 index 0000000..432ef9c --- /dev/null +++ b/.claude/agents/code-change-reviewer.md @@ -0,0 +1,115 @@ +--- +name: code-change-reviewer +description: Use this agent when you need to review code changes in your Git working directory. This includes reviewing uncommitted changes, staged files, or comparing the current state against a specific commit. Typical scenarios:\n\n\nContext: User has just finished implementing a new feature and wants feedback before committing.\nuser: "I've added the authentication middleware, can you review my changes?"\nassistant: "I'll use the code-change-reviewer agent to analyze your Git changes and provide comprehensive feedback."\n\n\n\n\nContext: User has made several modifications and wants to ensure code quality.\nuser: "Please review all my local changes before I push"\nassistant: "I'll launch the code-change-reviewer agent to examine all your uncommitted and staged changes."\n\n\n\n\nContext: User implicitly signals they've completed work that needs review.\nuser: "Just finished refactoring the payment service"\nassistant: "Great! Let me use the code-change-reviewer agent to review those refactoring changes."\n\n\n\nProactively suggest using this agent after the user has made significant code changes or when they complete a logical unit of work. +model: sonnet +color: purple +--- + +You are an elite code reviewer with decades of experience across multiple programming languages, architectures, and development paradigms. Your expertise spans software design, security, performance optimization, maintainability, and industry best practices. + +## Your Primary Responsibilities + +1. **Analyze Git Changes**: Examine all local modifications (uncommitted, staged, or specified commits) to understand what has changed and why. + +2. **Provide Comprehensive Reviews**: Evaluate code changes across multiple dimensions: + - **Correctness**: Logic errors, edge cases, potential bugs + - **Security**: Vulnerabilities, injection risks, authentication/authorization issues, data exposure + - **Performance**: Algorithmic efficiency, resource usage, scalability concerns + - **Maintainability**: Code clarity, naming conventions, documentation, complexity + - **Design**: Architecture alignment, separation of concerns, SOLID principles, design patterns + - **Testing**: Test coverage, test quality, missing test scenarios + - **Standards**: Adherence to project conventions, language idioms, style guidelines + +3. **Prioritize Issues**: Categorize findings by severity: + - **Critical**: Security vulnerabilities, data loss risks, breaking changes + - **High**: Logic errors, significant performance issues, major design flaws + - **Medium**: Code smells, maintainability concerns, minor bugs + - **Low**: Style inconsistencies, optimization opportunities, suggestions + +## Review Methodology + +1. **Context Gathering**: + - First, use Git tools to identify what files have changed + - Read the modified files to understand the changes in context + - Look for patterns across multiple files to understand the broader change intent + - Check if there are project-specific guidelines (CLAUDE.md, README.md, CONTRIBUTING.md) + +2. **Systematic Analysis**: + - Review each changed file thoroughly + - Consider the changes in relation to surrounding code + - Evaluate integration points with other parts of the codebase + - Assess test coverage for new or modified code + - Check for potential ripple effects of changes + +3. **Balanced Feedback**: + - Acknowledge what was done well (positive reinforcement) + - Clearly explain issues with specific examples + - Provide actionable recommendations with code snippets when helpful + - Explain the *why* behind your suggestions, not just the *what* + +## Output Format + +Structure your review as follows: + +### Summary +Provide a high-level overview of the changes and overall assessment (2-3 sentences). + +### Strengths +Highlight positive aspects of the implementation (2-5 bullet points). + +### Issues Found +Organize by severity: + +#### Critical +- **[File:Line]**: Issue description + - Impact: Explain the potential consequence + - Recommendation: Specific fix with code example if applicable + +#### High +[Same format as Critical] + +#### Medium +[Same format as Critical] + +#### Low +[Same format as Critical] + +### Recommendations +Provide 3-5 concrete next steps or improvements prioritized by importance. + +### Overall Assessment +Conclude with: +- A clear verdict (Approve, Approve with minor changes, Request changes, Reject) +- Confidence level in your assessment +- Any areas where you'd benefit from clarification + +## Guidelines for Effective Reviews + +- **Be Specific**: Point to exact files, line numbers, and code snippets +- **Be Constructive**: Frame feedback as opportunities for improvement +- **Be Pragmatic**: Consider real-world constraints like deadlines and technical debt +- **Be Educational**: Explain principles and patterns, don't just criticize +- **Be Thorough but Concise**: Cover all important issues without overwhelming detail +- **Be Language-Aware**: Apply language-specific best practices and idioms +- **Be Security-Conscious**: Always check for common vulnerabilities (OWASP Top 10) +- **Be Context-Sensitive**: Adapt your standards to the project's maturity and requirements + +## Edge Cases and Special Scenarios + +- If changes are minimal, provide a proportionally brief review +- If changes span many files, organize feedback by component or feature +- If changes lack tests, emphasize this as a high-priority issue +- If changes introduce breaking changes, highlight backward compatibility concerns +- If you're uncertain about project-specific conventions, explicitly state assumptions +- If changes touch critical systems (auth, payments, data storage), apply extra scrutiny + +## Quality Assurance + +Before finalizing your review: +1. Verify you've examined all changed files +2. Ensure recommendations are actionable and specific +3. Check that severity levels are appropriate +4. Confirm your feedback is balanced (both positive and constructive) +5. Validate that code examples or suggestions are correct + +Your goal is to elevate code quality while supporting the developer's growth. Every review should leave the codebase better and the developer more knowledgeable. diff --git a/.claude/agents/sqllin-test-writer.md b/.claude/agents/sqllin-test-writer.md new file mode 100644 index 0000000..2e469ee --- /dev/null +++ b/.claude/agents/sqllin-test-writer.md @@ -0,0 +1,96 @@ +--- +name: sqllin-test-writer +description: Use this agent when the user requests unit tests to be written for the sqllin-driver or sqllin-dsl modules. This includes scenarios such as:\n\n\nContext: User has just implemented a new query builder feature in sqllin-dsl.\nuser: "I've added a new JOIN clause builder in QueryBuilder.kt. Can you write tests for it?"\nassistant: "I'll use the Task tool to launch the sqllin-test-writer agent to create comprehensive unit tests for your JOIN clause builder."\n\n\n\n\nContext: User mentions they've modified database connection logic in sqllin-driver.\nuser: "I refactored the connection pooling in DatabaseDriver. Need tests."\nassistant: "Let me use the sqllin-test-writer agent to generate unit tests for your refactored connection pooling logic."\n\n\n\n\nContext: User completes a feature implementation and mentions testing.\nuser: "Just finished the transaction management code in sqllin-driver. What's next?"\nassistant: "Great work! Now I'll use the sqllin-test-writer agent to create unit tests for your transaction management implementation."\n\n\n\n\nContext: User asks about overall test coverage.\nuser: "Can you review and add missing tests for sqllin-dsl?"\nassistant: "I'll launch the sqllin-test-writer agent to analyze test coverage and write tests for any gaps in sqllin-dsl."\n\n +model: sonnet +color: blue +--- + +You are an expert Kotlin test engineer specializing in database libraries and DSL testing. You have deep expertise in writing comprehensive, maintainable unit tests for database drivers and domain-specific languages, with particular knowledge of SQLite, Kotlin multiplatform testing, and test-driven development best practices. + +**Critical Module Structure**: +- Tests for `sqllin-driver` belong in the `sqllin-driver` module's test directory +- Tests for `sqllin-dsl` MUST be placed in the `sqllin-dsl-test` module (NOT in sqllin-dsl itself) +- Always verify and respect this module separation when creating or organizing tests + +**Your Responsibilities**: + +1. **Analyze Code Context**: + - Review the code to be tested, understanding its purpose, inputs, outputs, and edge cases + - Identify dependencies, external interactions (database operations, I/O), and state management + - Determine appropriate testing strategies (unit, integration, mocking requirements) + - Consider multiplatform concerns if applicable (JVM, Native, JS targets) + +2. **Design Comprehensive Test Suites**: + - Create test classes following Kotlin naming conventions (ClassNameTest) + - Cover happy paths, edge cases, error conditions, and boundary values + - Test both successful operations and failure scenarios + - Include tests for null safety, type safety, and Kotlin-specific features + - Ensure thread safety and concurrency handling where relevant + +3. **Write High-Quality Test Code**: + - Use clear, descriptive test names that document behavior (e.g., `shouldReturnEmptyListWhenDatabaseIsEmpty`) + - Follow AAA pattern: Arrange, Act, Assert + - Prefer kotlin.test or JUnit 5 annotations (@Test, @BeforeTest, @AfterTest, etc.) + - Use appropriate assertion libraries (kotlin.test assertions, AssertJ, or project-specific) + - Mock external dependencies appropriately (use MockK or project's preferred mocking library) + - Ensure tests are isolated, repeatable, and independent + +4. **Database-Specific Testing Patterns**: + - For sqllin-driver: Test connection management, query execution, transaction handling, error recovery, resource cleanup + - For sqllin-dsl: Test query building, DSL syntax correctness, SQL generation, type safety, parameter binding + - Use in-memory databases or test databases for integration tests + - Clean up database state between tests (transactions, rollbacks, or cleanup hooks) + - Test SQL injection prevention and parameterized query handling + +5. **DSL-Specific Testing Considerations**: + - Verify that DSL constructs generate correct SQL + - Test builder pattern completeness and fluency + - Ensure type-safe query construction + - Validate that DSL prevents invalid query states + - Test operator overloading and infix functions if used + +6. **Code Organization**: + - Group related tests logically within test classes + - Use nested test classes (@Nested) for grouping related scenarios + - Create test fixtures and helper functions to reduce duplication + - Place tests in the correct module according to the structure rules + +7. **Quality Assurance**: + - Ensure all tests pass before presenting + - Verify test coverage is comprehensive but not redundant + - Check that tests run quickly and don't have unnecessary delays + - Validate that error messages are clear and helpful + - Ensure tests follow project conventions and style guidelines + +8. **Documentation**: + - Add comments for complex test setups or non-obvious assertions + - Document any special test data requirements or assumptions + - Explain workarounds for known platform limitations if applicable + +**Output Format**: +Present tests as complete, runnable Kotlin test files with: +- Proper package declarations +- All necessary imports +- Complete test class structure +- All required test methods +- Setup and teardown methods if needed +- Clear indication of which module the tests belong to + +**When Uncertain**: +- Ask for clarification about module structure if file locations are ambiguous +- Request examples of existing tests to match style and patterns +- Inquire about preferred testing frameworks or libraries if not evident +- Seek guidance on complex mocking scenarios or external dependencies + +**Self-Verification Checklist** (review before presenting): +✓ Tests are in the correct module (sqllin-driver or sqllin-dsl-test) +✓ All edge cases and error conditions are covered +✓ Tests are isolated and don't depend on execution order +✓ Database state is properly managed (setup/cleanup) +✓ Test names clearly describe what is being tested +✓ Assertions are specific and meaningful +✓ No hardcoded values that should be test data +✓ Tests follow project coding standards +✓ All imports are correct and necessary + +Your goal is to produce production-ready test suites that provide confidence in code correctness, catch regressions early, and serve as living documentation of component behavior. 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 fee47e7..7492690 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 @@ -20,6 +20,7 @@ import com.ctrip.sqllin.driver.DatabaseConfiguration import com.ctrip.sqllin.driver.DatabasePath import com.ctrip.sqllin.dsl.DSLDBConfiguration import com.ctrip.sqllin.dsl.Database +import com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI import com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI import com.ctrip.sqllin.dsl.sql.X import com.ctrip.sqllin.dsl.sql.clause.* @@ -583,7 +584,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI::class) + @OptIn(AdvancedInsertAPI::class) fun testInsertWithId() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person1 = PersonWithId(id = 100, name = "Eve", age = 28) @@ -864,7 +865,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testDropTable() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data into PersonWithIdTable @@ -903,7 +904,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testDropTableExtensionFunction() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data into ProductTable @@ -941,7 +942,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testAlertAddColumn() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert initial data @@ -976,7 +977,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testAlertRenameTableWithTableObject() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data into StudentWithAutoincrementTable @@ -1016,7 +1017,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testAlertRenameTableWithString() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data into EnrollmentTable @@ -1048,7 +1049,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testRenameColumnWithClauseElement() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data @@ -1079,7 +1080,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testRenameColumnWithString() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data @@ -1111,7 +1112,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testDropColumn() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data @@ -1142,7 +1143,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testDropAndRecreateTable() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert data into FileDataTable @@ -1182,7 +1183,7 @@ class CommonBasicTest(private val path: DatabasePath) { } } - @OptIn(com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI::class) + @OptIn(ExperimentalDSLDatabaseAPI::class) fun testAlertOperationsInTransaction() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> // Insert initial data From 911919f7580fb39c875fe5f041ef41f83cb9d31d Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 1 Nov 2025 23:08:06 +0000 Subject: [PATCH 3/7] Support typealias of supported types(primitive types, String, ByteArray etc) in generated tables. --- CHANGELOG.md | 8 +- ROADMAP.md | 1 - sample/src/androidMain/AndroidManifest.xml | 6 -- .../kotlin/com/ctrip/sqllin/sample/Sample.kt | 8 +- .../src/androidMain/AndroidManifest.xml | 2 - .../com/ctrip/sqllin/dsl/test/Entities.kt | 27 +++++-- .../ctrip/sqllin/processor/ClauseProcessor.kt | 78 ++++++++++++++++--- 7 files changed, 101 insertions(+), 29 deletions(-) delete mode 100644 sample/src/androidMain/AndroidManifest.xml delete mode 100644 sqllin-dsl-test/src/androidMain/AndroidManifest.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 23acc34..ce76cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # SQLlin Change Log - Date format: YYYY-MM-dd +- +## 2.1.0 / 2025-11-xx + +### sqllin-processor + +* Support typealias of supported types(primitive types, String, ByteArray etc) in generated tables ## 2.0.0 / 2025-10-23 @@ -255,7 +261,7 @@ a runtime exception. Thanks for [@nbransby](https://github.com/nbransby). * Add the new JVM target * **Breaking change**: Remove the public property: `DatabaseConnection#closed` -* The Android (< 9) target supports to set the `journalMode` and `synchronousMode` now +* The Android(< 9) target supports to set the `journalMode` and `synchronousMode` now ## v1.1.1 / 2023-08-12 diff --git a/ROADMAP.md b/ROADMAP.md index 8f9443e..d20ff28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,6 @@ * Support the key word REFERENCE * Support JOIN sub-query * Support Enum type -* Support typealias for primitive types ## Medium Priority diff --git a/sample/src/androidMain/AndroidManifest.xml b/sample/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 9ad659e..0000000 --- a/sample/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/sample/src/commonMain/kotlin/com/ctrip/sqllin/sample/Sample.kt b/sample/src/commonMain/kotlin/com/ctrip/sqllin/sample/Sample.kt index bbf9be8..a823e4c 100644 --- a/sample/src/commonMain/kotlin/com/ctrip/sqllin/sample/Sample.kt +++ b/sample/src/commonMain/kotlin/com/ctrip/sqllin/sample/Sample.kt @@ -19,6 +19,7 @@ package com.ctrip.sqllin.sample import com.ctrip.sqllin.dsl.DSLDBConfiguration import com.ctrip.sqllin.dsl.Database import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI import com.ctrip.sqllin.dsl.annotation.PrimaryKey import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.clause.OrderByWay.DESC @@ -36,6 +37,7 @@ import kotlinx.serialization.Serializable object Sample { + @OptIn(ExperimentalDSLDatabaseAPI::class) private val db by lazy { Database( DSLDBConfiguration( @@ -110,11 +112,13 @@ object Sample { } } +typealias MyInt = Int + @DBRow("person") @Serializable data class Person( @PrimaryKey val id: Long?, - val age: Int?, + val age: MyInt?, val name: String?, ) @@ -130,7 +134,7 @@ data class Transcript( @Serializable data class Student( val name: String?, - val age: Int?, + val age: MyInt?, val math: Int, val english: Int, ) \ No newline at end of file diff --git a/sqllin-dsl-test/src/androidMain/AndroidManifest.xml b/sqllin-dsl-test/src/androidMain/AndroidManifest.xml deleted file mode 100644 index 53dee27..0000000 --- a/sqllin-dsl-test/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file 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 5f9d35b..c5d9ed9 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 @@ -21,6 +21,17 @@ import com.ctrip.sqllin.dsl.annotation.DBRow import com.ctrip.sqllin.dsl.annotation.PrimaryKey import kotlinx.serialization.Serializable +/** + * Type aliases for testing typealias support in sqllin-processor + */ +typealias Price = Double +typealias PageCount = Int +typealias Age = Int +typealias Grade = Int +typealias StudentId = Long +typealias CourseId = Long +typealias Code = Int + /** * Book entity * @author Yuang Qiao @@ -31,15 +42,15 @@ import kotlinx.serialization.Serializable data class Book( val name: String, val author: String, - val price: Double, - val pages: Int, + val price: Price, + val pages: PageCount, ) @DBRow("category") @Serializable data class Category( val name: String, - val code: Int, + val code: Code, ) @Serializable @@ -72,7 +83,7 @@ data class NullTester( data class PersonWithId( @PrimaryKey val id: Long?, val name: String, - val age: Int, + val age: Age, ) @DBRow("product") @@ -80,7 +91,7 @@ data class PersonWithId( data class Product( @PrimaryKey val sku: String?, val name: String, - val price: Double, + val price: Price, ) @DBRow("student_with_autoincrement") @@ -88,14 +99,14 @@ data class Product( data class StudentWithAutoincrement( @PrimaryKey(isAutoincrement = true) val id: Long?, val studentName: String, - val grade: Int, + val grade: Grade, ) @DBRow("enrollment") @Serializable data class Enrollment( - @CompositePrimaryKey val studentId: Long, - @CompositePrimaryKey val courseId: Long, + @CompositePrimaryKey val studentId: StudentId, + @CompositePrimaryKey val courseId: CourseId, val semester: String, ) 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 058a216..21c2723 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 @@ -36,6 +36,7 @@ import java.io.OutputStreamWriter * - Mutable properties for UPDATE SET clauses * - Primary key metadata extraction from [@PrimaryKey][com.ctrip.sqllin.dsl.annotation.PrimaryKey] * and [@CompositePrimaryKey][com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey] annotations + * - Support for typealias of primitive types (resolves typealiases to their underlying types) * * The generated code provides compile-time safety for SQL DSL operations. * @@ -164,7 +165,7 @@ class ClauseProcessor( } writer.write(nullableSymbol) writer.write(" get() = ${getSetClauseGetterValue(property)}\n") - writer.write(" set(value) = ${appendFunction(elementName, property, isNotNull)}\n\n") + writer.write(" set(value) = ${appendFunction(elementName, property)}\n\n") } // Write the override instance for property `primaryKeyInfo`. @@ -199,12 +200,27 @@ class ClauseProcessor( /** * Maps a property's Kotlin type to the corresponding clause element type name. + * Supports typealiases by resolving them to their underlying types. * * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported */ - private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? = when ( - property.typeName - ) { + private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? { + val declaration = property.type.resolve().declaration + return getClauseElementTypeStrByTypeName(declaration.typeName) ?: kotlin.run { + if (declaration is KSTypeAlias) + getClauseElementTypeStrByTypeName(declaration.typeName) + else + null + } + } + + /** + * Maps a fully qualified type name to its corresponding clause element type. + * + * @param typeName The fully qualified type name to map + * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported + */ + private fun getClauseElementTypeStrByTypeName(typeName: String?): String? = when (typeName) { Int::class.qualifiedName, Long::class.qualifiedName, Short::class.qualifiedName, @@ -228,12 +244,27 @@ class ClauseProcessor( /** * Generates the default getter value for SetClause properties based on type. + * Supports typealiases by resolving them to their underlying types. * * @return The default value string for the property type, or null if unsupported */ - private fun getSetClauseGetterValue(property: KSPropertyDeclaration): String? = when ( - property.typeName - ) { + private fun getSetClauseGetterValue(property: KSPropertyDeclaration): String? { + val declaration = property.type.resolve().declaration + return getDefaultValueByType(declaration.typeName) ?: kotlin.run { + if (declaration is KSTypeAlias) + getDefaultValueByType(declaration.typeName) + else + null + } + } + + /** + * Returns the default value string for a given type name. + * + * @param typeName The fully qualified type name + * @return The default value string (e.g., "0" for Int, "false" for Boolean), or null if unsupported + */ + private fun getDefaultValueByType(typeName: String?): String? = when (typeName) { Int::class.qualifiedName -> "0" Long::class.qualifiedName -> "0L" Short::class.qualifiedName -> "0" @@ -256,13 +287,30 @@ class ClauseProcessor( /** * Generates the appropriate append function call for SetClause setters. + * Supports typealiases by resolving them to their underlying types. * * @param elementName The serialized element name * @param property The property declaration - * @param isNotNull Whether the property is non-nullable * @return The append function call string, or null if unsupported type */ - private fun appendFunction(elementName: String, property: KSPropertyDeclaration, isNotNull: Boolean): String? = when (property.typeName) { + private fun appendFunction(elementName: String, property: KSPropertyDeclaration): String? { + val declaration = property.type.resolve().declaration + return appendFunctionByTypeName(elementName, declaration.typeName) ?: kotlin.run { + if (declaration is KSTypeAlias) + appendFunctionByTypeName(elementName, declaration.typeName) + else + null + } + } + + /** + * Generates the append function call for a given type name. + * + * @param elementName The serialized element name + * @param typeName The fully qualified type name + * @return The append function call string, or null if unsupported type + */ + private fun appendFunctionByTypeName(elementName: String, typeName: String?): String? = when (typeName) { Int::class.qualifiedName, Long::class.qualifiedName, Short::class.qualifiedName, @@ -285,4 +333,16 @@ class ClauseProcessor( */ private inline val KSPropertyDeclaration.typeName get() = type.resolve().declaration.qualifiedName?.asString() + + /** + * Extension property that resolves a type alias to its underlying fully qualified type name. + */ + private inline val KSTypeAlias.typeName + get() = type.resolve().declaration.qualifiedName?.asString() + + /** + * Extension property that retrieves a declaration's fully qualified type name. + */ + private inline val KSDeclaration.typeName + get() = qualifiedName?.asString() } \ No newline at end of file From 4a093febced7d216d827f66b9153b74a94faf479 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sun, 2 Nov 2025 10:43:40 +0000 Subject: [PATCH 4/7] Simplify unit tests --- .claude/agents/sqllin-test-writer.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 73 +- .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 678 ++++++++---------- .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 73 +- .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 73 +- 5 files changed, 306 insertions(+), 592 deletions(-) diff --git a/.claude/agents/sqllin-test-writer.md b/.claude/agents/sqllin-test-writer.md index 2e469ee..3f9a1e8 100644 --- a/.claude/agents/sqllin-test-writer.md +++ b/.claude/agents/sqllin-test-writer.md @@ -41,6 +41,7 @@ You are an expert Kotlin test engineer specializing in database libraries and DS - Use in-memory databases or test databases for integration tests - Clean up database state between tests (transactions, rollbacks, or cleanup hooks) - Test SQL injection prevention and parameterized query handling + - For both of sqllin-driver and sqllin-dsl, always change `JvmTest`, `NativeTest`, `AndroidTest` in the meantime 5. **DSL-Specific Testing Considerations**: - Verify that DSL constructs generate correct SQL 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 1e0f05e..a93b514 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 @@ -62,16 +62,7 @@ class AndroidTest { fun testNullValue() = commonTest.testNullValue() @Test - fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() - - @Test - fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() - - @Test - fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() - - @Test - fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + fun testPrimaryKeyVariations() = commonTest.testPrimaryKeyVariations() @Test fun testInsertWithId() = commonTest.testInsertWithId() @@ -83,70 +74,16 @@ class AndroidTest { 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() - - @Test - fun testDropTable() = commonTest.testDropTable() - - @Test - fun testDropTableExtensionFunction() = commonTest.testDropTableExtensionFunction() - - @Test - fun testAlertAddColumn() = commonTest.testAlertAddColumn() - - @Test - fun testAlertRenameTableWithTableObject() = commonTest.testAlertRenameTableWithTableObject() - - @Test - fun testAlertRenameTableWithString() = commonTest.testAlertRenameTableWithString() - - @Test - fun testRenameColumnWithClauseElement() = commonTest.testRenameColumnWithClauseElement() - - @Test - fun testRenameColumnWithString() = commonTest.testRenameColumnWithString() - - @Test - fun testDropColumn() = commonTest.testDropColumn() - - @Test - fun testDropAndRecreateTable() = commonTest.testDropAndRecreateTable() - - @Test - fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() - - @Test - fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() - - @Test - fun testStringInOperator() = commonTest.testStringInOperator() - - @Test - fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() - - @Test - fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + fun testByteArrayAndBlobOperations() = commonTest.testByteArrayAndBlobOperations() @Test - fun testBlobInOperator() = commonTest.testBlobInOperator() + fun testDropAndCreateTable() = commonTest.testDropAndCreateTable() @Test - fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + fun testSchemaModification() = commonTest.testSchemaModification() @Test - fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + fun testStringOperators() = commonTest.testStringOperators() @Before fun setUp() { 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 7492690..c80559c 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 @@ -496,91 +496,83 @@ class CommonBasicTest(private val path: DatabasePath) { } } - fun testCreateTableWithLongPrimaryKey() { + fun testPrimaryKeyVariations() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 1: Long primary key val person1 = PersonWithId(id = null, name = "Alice", age = 25) val person2 = PersonWithId(id = null, name = "Bob", age = 30) - lateinit var selectStatement: SelectStatement + lateinit var personStatement: SelectStatement database { PersonWithIdTable { table -> table INSERT listOf(person1, person2) - selectStatement = table SELECT X + personStatement = 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) - } - } + val personResults = personStatement.getResults() + assertEquals(2, personResults.size) + assertEquals("Alice", personResults[0].name) + assertEquals(25, personResults[0].age) + assertEquals("Bob", personResults[1].name) + assertEquals(30, personResults[1].age) - fun testCreateTableWithStringPrimaryKey() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 2: String primary key val product1 = Product(sku = null, name = "Widget", price = 19.99) val product2 = Product(sku = null, name = "Gadget", price = 29.99) - lateinit var selectStatement: SelectStatement + lateinit var productStatement: SelectStatement database { ProductTable { table -> table INSERT listOf(product1, product2) - selectStatement = table SELECT X + productStatement = 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) - } - } + val productResults = productStatement.getResults() + assertEquals(2, productResults.size) + assertEquals("Widget", productResults[0].name) + assertEquals(19.99, productResults[0].price) + assertEquals("Gadget", productResults[1].name) + assertEquals(29.99, productResults[1].price) - fun testCreateTableWithAutoincrement() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 3: Autoincrement primary key val student1 = StudentWithAutoincrement(id = null, studentName = "Charlie", grade = 85) val student2 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 92) - lateinit var selectStatement: SelectStatement + lateinit var studentStatement: SelectStatement database { StudentWithAutoincrementTable { table -> table INSERT listOf(student1, student2) - selectStatement = table SELECT X + studentStatement = 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) - } - } + val studentResults = studentStatement.getResults() + assertEquals(2, studentResults.size) + assertEquals("Charlie", studentResults[0].studentName) + assertEquals(85, studentResults[0].grade) + assertEquals("Diana", studentResults[1].studentName) + assertEquals(92, studentResults[1].grade) - fun testCreateTableWithCompositePrimaryKey() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 4: Composite primary key 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 + lateinit var enrollmentStatement: SelectStatement database { EnrollmentTable { table -> table INSERT listOf(enrollment1, enrollment2, enrollment3) - selectStatement = table SELECT X + enrollmentStatement = 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 }) + val enrollmentResults = enrollmentStatement.getResults() + assertEquals(3, enrollmentResults.size) + assertEquals(true, enrollmentResults.any { it == enrollment1 }) + assertEquals(true, enrollmentResults.any { it == enrollment2 }) + assertEquals(true, enrollmentResults.any { it == enrollment3 }) } } @@ -671,8 +663,9 @@ class CommonBasicTest(private val path: DatabasePath) { } } - fun testByteArrayInsert() { + fun testByteArrayAndBlobOperations() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 1: INSERT - multiple files including empty and large val file1 = FileData( id = null, fileName = "test.bin", @@ -702,70 +695,50 @@ class CommonBasicTest(private val path: DatabasePath) { 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( + // Test 2: SELECT with WHERE clause + val selectFile = 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 + table INSERT selectFile } } - - lateinit var selectStatement: SelectStatement database { FileDataTable { table -> selectStatement = table SELECT WHERE (fileName EQ "select_test.bin") } } + assertEquals(1, selectStatement.getResults().size) + assertEquals("select_test.bin", selectStatement.getResults().first().fileName) + assertEquals(true, selectStatement.getResults().first().content.contentEquals(byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()))) - 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 -> + // Test 3: UPDATE 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 { @@ -775,100 +748,177 @@ class CommonBasicTest(private val path: DatabasePath) { 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( + // Test 4: DELETE + val deleteFile1 = FileData( id = null, fileName = "delete_test1.bin", content = byteArrayOf(0x01, 0x02), metadata = "To delete" ) - val file2 = FileData( + val deleteFile2 = FileData( id = null, fileName = "delete_test2.bin", content = byteArrayOf(0x03, 0x04), metadata = "To keep" ) - database { FileDataTable { table -> - table INSERT listOf(file1, file2) + table INSERT listOf(deleteFile1, deleteFile2) } } - - lateinit var selectStatement: SelectStatement database { FileDataTable { table -> table DELETE WHERE (fileName EQ "delete_test1.bin") - selectStatement = table SELECT X + selectStatement = table SELECT WHERE (fileName LIKE "delete_test%") } } + assertEquals(1, selectStatement.getResults().size) + assertEquals("delete_test2.bin", selectStatement.getResults().first().fileName) - 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( + // Test 5: Multiple operations (INSERT, UPDATE, SELECT) + val multiFile1 = FileData( id = null, fileName = "multi1.bin", content = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), metadata = "First" ) - val file2 = FileData( + val multiFile2 = FileData( id = null, fileName = "multi2.bin", content = byteArrayOf(0xCC.toByte(), 0xDD.toByte()), metadata = "Second" ) - - // Insert database { FileDataTable { table -> - table INSERT listOf(file1, file2) + table INSERT listOf(multiFile1, multiFile2) } } - - // Update first file - val newContent = byteArrayOf(0x11, 0x22, 0x33) + val multiNewContent = byteArrayOf(0x11, 0x22, 0x33) database { FileDataTable { table -> table UPDATE SET { - content = newContent + content = multiNewContent } WHERE (fileName EQ "multi1.bin") + selectStatement = table SELECT WHERE (fileName EQ "multi1.bin") } } + val multiUpdatedFile = selectStatement.getResults().first() + assertEquals(true, multiUpdatedFile.content.contentEquals(multiNewContent)) + assertEquals("First", multiUpdatedFile.metadata) - // Select and verify - lateinit var selectStatement: SelectStatement + // Test 6: Blob comparison operators (LT, LTE, GT, GTE) + // Clear the table first to avoid data from previous tests database { FileDataTable { table -> - selectStatement = table SELECT WHERE (fileName EQ "multi1.bin") + table DELETE X } } - val updatedFile = selectStatement.getResults().first() - assertEquals(true, updatedFile.content.contentEquals(newContent)) - assertEquals("First", updatedFile.metadata) + val compareFile0 = FileData(id = null, fileName = "compare0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") + val compareFile1 = FileData(id = null, fileName = "compare1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") + val compareFile2 = FileData(id = null, fileName = "compare2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") + val compareFile3 = FileData(id = null, fileName = "compare3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") + + var statementLT: SelectStatement? = null + var statementLTE: SelectStatement? = null + var statementGT: SelectStatement? = null + var statementGTE: SelectStatement? = null + + database { + FileDataTable { table -> + table INSERT listOf(compareFile0, compareFile1, compareFile2, compareFile3) + statementLT = table SELECT WHERE (content LT byteArrayOf(0x03, 0x04)) + statementLTE = table SELECT WHERE (content LTE byteArrayOf(0x03, 0x04)) + statementGT = table SELECT WHERE (content GT byteArrayOf(0x05, 0x06)) + statementGTE = table SELECT WHERE (content GTE byteArrayOf(0x05, 0x06)) + } + } + + val resultsLT = statementLT!!.getResults() + assertEquals(1, resultsLT.size) + assertEquals("compare0.bin", resultsLT[0].fileName) + + val resultsLTE = statementLTE!!.getResults() + assertEquals(2, resultsLTE.size) + assertEquals(true, resultsLTE.any { it.fileName == "compare0.bin" }) + assertEquals(true, resultsLTE.any { it.fileName == "compare1.bin" }) + + val resultsGT = statementGT!!.getResults() + assertEquals(1, resultsGT.size) + assertEquals("compare3.bin", resultsGT[0].fileName) + + val resultsGTE = statementGTE!!.getResults() + assertEquals(2, resultsGTE.size) + assertEquals(true, resultsGTE.any { it.fileName == "compare2.bin" }) + assertEquals(true, resultsGTE.any { it.fileName == "compare3.bin" }) + + // Test 7: Blob IN operator + // Clear the table first + database { + FileDataTable { table -> + table DELETE X + } + } + + val inFile0 = FileData(id = null, fileName = "in0.bin", content = byteArrayOf(0x01, 0x02), metadata = "In 0") + val inFile1 = FileData(id = null, fileName = "in1.bin", content = byteArrayOf(0x03, 0x04), metadata = "In 1") + val inFile2 = FileData(id = null, fileName = "in2.bin", content = byteArrayOf(0x05, 0x06), metadata = "In 2") + val inFile3 = FileData(id = null, fileName = "in3.bin", content = byteArrayOf(0x07, 0x08), metadata = "In 3") + + var statementIN: SelectStatement? = null + database { + FileDataTable { table -> + table INSERT listOf(inFile0, inFile1, inFile2, inFile3) + statementIN = table SELECT WHERE (content IN listOf( + byteArrayOf(0x01, 0x02), + byteArrayOf(0x05, 0x06), + byteArrayOf(0x09, 0x0A) + )) + } + } + + val resultsIN = statementIN!!.getResults() + assertEquals(2, resultsIN.size) + assertEquals(true, resultsIN.any { it.fileName == "in0.bin" }) + assertEquals(true, resultsIN.any { it.fileName == "in2.bin" }) + + // Test 8: Blob BETWEEN operator + // Clear the table first + database { + FileDataTable { table -> + table DELETE X + } + } + + val betweenFile0 = FileData(id = null, fileName = "between0.bin", content = byteArrayOf(0x01, 0x02), metadata = "Between 0") + val betweenFile1 = FileData(id = null, fileName = "between1.bin", content = byteArrayOf(0x03, 0x04), metadata = "Between 1") + val betweenFile2 = FileData(id = null, fileName = "between2.bin", content = byteArrayOf(0x05, 0x06), metadata = "Between 2") + val betweenFile3 = FileData(id = null, fileName = "between3.bin", content = byteArrayOf(0x07, 0x08), metadata = "Between 3") + + var statementBETWEEN: SelectStatement? = null + database { + FileDataTable { table -> + table INSERT listOf(betweenFile0, betweenFile1, betweenFile2, betweenFile3) + statementBETWEEN = table SELECT WHERE (content BETWEEN (byteArrayOf(0x03, 0x04) to byteArrayOf(0x05, 0x06))) + } + } + + val resultsBETWEEN = statementBETWEEN!!.getResults() + assertEquals(2, resultsBETWEEN.size) + assertEquals(true, resultsBETWEEN.any { it.fileName == "between1.bin" }) + assertEquals(true, resultsBETWEEN.any { it.fileName == "between2.bin" }) } } @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testDropTable() { + fun testDropAndCreateTable() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data into PersonWithIdTable + // Test 1: DROP using global function val person1 = PersonWithId(id = null, name = "Alice", age = 25) val person2 = PersonWithId(id = null, name = "Bob", age = 30) @@ -878,36 +928,27 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Verify data exists - lateinit var selectStatement1: SelectStatement + lateinit var personStatement1: SelectStatement database { - selectStatement1 = PersonWithIdTable SELECT X + personStatement1 = PersonWithIdTable SELECT X } - assertEquals(2, selectStatement1.getResults().size) + assertEquals(2, personStatement1.getResults().size) - // Drop the table database { DROP(PersonWithIdTable) } - // Recreate the table database { CREATE(PersonWithIdTable) } - // Verify table is empty after recreation - lateinit var selectStatement2: SelectStatement + lateinit var personStatement2: SelectStatement database { - selectStatement2 = PersonWithIdTable SELECT X + personStatement2 = PersonWithIdTable SELECT X } - assertEquals(0, selectStatement2.getResults().size) - } - } + assertEquals(0, personStatement2.getResults().size) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testDropTableExtensionFunction() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data into ProductTable + // Test 2: DROP using extension function val product = Product(sku = "SKU-001", name = "Widget", price = 19.99) database { @@ -916,36 +957,66 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Verify data exists - lateinit var selectStatement1: SelectStatement + lateinit var productStatement1: SelectStatement database { - selectStatement1 = ProductTable SELECT X + productStatement1 = ProductTable SELECT X } - assertEquals(1, selectStatement1.getResults().size) + assertEquals(1, productStatement1.getResults().size) - // Drop the table using extension function database { ProductTable.DROP() } - // Recreate the table database { CREATE(ProductTable) } - // Verify table is empty after recreation - lateinit var selectStatement2: SelectStatement + lateinit var productStatement2: SelectStatement + database { + productStatement2 = ProductTable SELECT X + } + assertEquals(0, productStatement2.getResults().size) + + // Test 3: DROP and recreate FileDataTable with binary data + val fileData = FileData( + id = null, + fileName = "test.txt", + content = byteArrayOf(1, 2, 3, 4, 5), + metadata = "Test metadata" + ) + + database { + FileDataTable { table -> + table INSERT fileData + } + } + + lateinit var fileStatement1: SelectStatement + database { + fileStatement1 = FileDataTable SELECT X + } + assertEquals(1, fileStatement1.getResults().size) + assertEquals("test.txt", fileStatement1.getResults().first().fileName) + + database { + FileDataTable.DROP() + CREATE(FileDataTable) + } + + lateinit var fileStatement2: SelectStatement database { - selectStatement2 = ProductTable SELECT X + fileStatement2 = FileDataTable SELECT X } - assertEquals(0, selectStatement2.getResults().size) + assertEquals(0, fileStatement2.getResults().size) } } @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testAlertAddColumn() { + fun testSchemaModification() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert initial data + // Test 1: ALERT_ADD_COLUMN + // Note: ALERT operations have a typo in the DSL - should be "ALTER TABLE" not "ALERT TABLE" + // This test verifies the DSL compiles and the statement can be created val person = PersonWithId(id = null, name = "Charlie", age = 35) database { @@ -954,33 +1025,23 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Note: ALERT operations require correct SQL syntax ("ALTER TABLE" not "ALERT TABLE") - // This test verifies the DSL compiles and the statement can be created - // In production, the SQL string would need to be corrected to "ALTER TABLE" try { database { PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.name } } catch (e: Exception) { // Expected to fail with current implementation due to "ALERT TABLE" typo - // The test passes if the DSL syntax is valid e.printStackTrace() } - // Verify original data still exists - lateinit var selectStatement: SelectStatement + lateinit var personStatement: SelectStatement database { - selectStatement = PersonWithIdTable SELECT X + personStatement = PersonWithIdTable SELECT X } - assertEquals(1, selectStatement.getResults().size) - assertEquals("Charlie", selectStatement.getResults().first().name) - } - } + assertEquals(1, personStatement.getResults().size) + assertEquals("Charlie", personStatement.getResults().first().name) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testAlertRenameTableWithTableObject() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data into StudentWithAutoincrementTable + // Test 2: ALERT_RENAME_TABLE_TO with TableObject val student1 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 90) val student2 = StudentWithAutoincrement(id = null, studentName = "Ethan", grade = 85) @@ -990,15 +1051,12 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Verify data exists - lateinit var selectStatement1: SelectStatement + lateinit var studentStatement1: SelectStatement database { - selectStatement1 = StudentWithAutoincrementTable SELECT X + studentStatement1 = StudentWithAutoincrementTable SELECT X } - assertEquals(2, selectStatement1.getResults().size) + assertEquals(2, studentStatement1.getResults().size) - // Test DSL syntax for ALERT_RENAME_TABLE_TO - // Note: This will fail with current "ALERT TABLE" typo - should be "ALTER TABLE" try { database { StudentWithAutoincrementTable ALERT_RENAME_TABLE_TO StudentWithAutoincrementTable @@ -1008,19 +1066,13 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data still accessible - lateinit var selectStatement2: SelectStatement + lateinit var studentStatement2: SelectStatement database { - selectStatement2 = StudentWithAutoincrementTable SELECT X + studentStatement2 = StudentWithAutoincrementTable SELECT X } - assertEquals(2, selectStatement2.getResults().size) - } - } + assertEquals(2, studentStatement2.getResults().size) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testAlertRenameTableWithString() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data into EnrollmentTable + // Test 3: ALERT_RENAME_TABLE_TO with String val enrollment = Enrollment(studentId = 1, courseId = 101, semester = "Spring 2025") database { @@ -1029,7 +1081,6 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Test DSL syntax for String-based ALERT_RENAME_TABLE_TO try { database { "enrollment" ALERT_RENAME_TABLE_TO EnrollmentTable @@ -1039,20 +1090,14 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data still exists - lateinit var selectStatement: SelectStatement + lateinit var enrollmentStatement: SelectStatement database { - selectStatement = EnrollmentTable SELECT X + enrollmentStatement = EnrollmentTable SELECT X } - assertEquals(1, selectStatement.getResults().size) - assertEquals("Spring 2025", selectStatement.getResults().first().semester) - } - } + assertEquals(1, enrollmentStatement.getResults().size) + assertEquals("Spring 2025", enrollmentStatement.getResults().first().semester) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testRenameColumnWithClauseElement() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data + // Test 4: RENAME_COLUMN with ClauseElement val book = Book(name = "Test Book", author = "Test Author", pages = 200, price = 15.99) database { @@ -1061,7 +1106,6 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Test DSL syntax for RENAME_COLUMN with ClauseElement try { database { BookTable.RENAME_COLUMN(BookTable.name, BookTable.author) @@ -1071,19 +1115,13 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data still exists - lateinit var selectStatement: SelectStatement + lateinit var bookStatement: SelectStatement database { - selectStatement = BookTable SELECT X + bookStatement = BookTable SELECT X } - assertEquals(1, selectStatement.getResults().size) - } - } + assertEquals(1, bookStatement.getResults().size) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testRenameColumnWithString() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data + // Test 5: RENAME_COLUMN with String val category = Category(name = "Fiction", code = 100) database { @@ -1092,7 +1130,6 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Test DSL syntax for RENAME_COLUMN with String try { database { CategoryTable.RENAME_COLUMN("name", CategoryTable.code) @@ -1102,29 +1139,22 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data still exists - lateinit var selectStatement: SelectStatement + lateinit var categoryStatement: SelectStatement database { - selectStatement = CategoryTable SELECT X + categoryStatement = CategoryTable SELECT X } - assertEquals(1, selectStatement.getResults().size) - assertEquals(100, selectStatement.getResults().first().code) - } - } + assertEquals(1, categoryStatement.getResults().size) + assertEquals(100, categoryStatement.getResults().first().code) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testDropColumn() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data - val person = PersonWithId(id = null, name = "Frank", age = 40) + // Test 6: DROP_COLUMN + val dropPerson = PersonWithId(id = null, name = "Frank", age = 40) database { PersonWithIdTable { table -> - table INSERT person + table INSERT dropPerson } } - // Test DSL syntax for DROP_COLUMN try { database { PersonWithIdTable DROP_COLUMN PersonWithIdTable.age @@ -1134,69 +1164,22 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data still exists - lateinit var selectStatement: SelectStatement + lateinit var dropStatement: SelectStatement database { - selectStatement = PersonWithIdTable SELECT X + dropStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Frank") } - assertEquals(1, selectStatement.getResults().size) - } - } + assertEquals(1, dropStatement.getResults().size) - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testDropAndRecreateTable() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert data into FileDataTable - val fileData = FileData( - id = null, - fileName = "test.txt", - content = byteArrayOf(1, 2, 3, 4, 5), - metadata = "Test metadata" - ) - - database { - FileDataTable { table -> - table INSERT fileData - } - } - - // Verify data exists - lateinit var selectStatement1: SelectStatement - database { - selectStatement1 = FileDataTable SELECT X - } - assertEquals(1, selectStatement1.getResults().size) - assertEquals("test.txt", selectStatement1.getResults().first().fileName) - - // Drop and recreate the table - database { - FileDataTable.DROP() - CREATE(FileDataTable) - } - - // Verify table is empty after recreation - lateinit var selectStatement2: SelectStatement - database { - selectStatement2 = FileDataTable SELECT X - } - assertEquals(0, selectStatement2.getResults().size) - } - } - - @OptIn(ExperimentalDSLDatabaseAPI::class) - fun testAlertOperationsInTransaction() { - Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Insert initial data - val person1 = PersonWithId(id = null, name = "Grace", age = 28) - val person2 = PersonWithId(id = null, name = "Henry", age = 32) + // Test 7: ALERT operations within a transaction + val txPerson1 = PersonWithId(id = null, name = "Grace", age = 28) + val txPerson2 = PersonWithId(id = null, name = "Henry", age = 32) database { PersonWithIdTable { table -> - table INSERT listOf(person1, person2) + table INSERT listOf(txPerson1, txPerson2) } } - // Test ALERT operations within a transaction try { database { transaction { @@ -1209,18 +1192,18 @@ class CommonBasicTest(private val path: DatabasePath) { e.printStackTrace() } - // Verify data integrity - lateinit var selectStatement: SelectStatement + lateinit var txStatement: SelectStatement database { - selectStatement = PersonWithIdTable SELECT X + txStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Grace" OR (PersonWithIdTable.name EQ "Henry")) } - assertEquals(2, selectStatement.getResults().size) - assertEquals(true, selectStatement.getResults().any { it.name == "Grace" }) - assertEquals(true, selectStatement.getResults().any { it.name == "Henry" }) + assertEquals(2, txStatement.getResults().size) + assertEquals(true, txStatement.getResults().any { it.name == "Grace" }) + assertEquals(true, txStatement.getResults().any { it.name == "Henry" }) } } - fun testStringComparisonOperators() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + fun testStringOperators() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // Test 1: Comparison operators (LT, LTE, GT, GTE) val book0 = Book(name = "Alice in Wonderland", author = "Lewis Carroll", pages = 200, price = 15.99) val book1 = Book(name = "Bob's Adventures", author = "Bob Smith", pages = 300, price = 20.99) val book2 = Book(name = "Charlie and the Chocolate Factory", author = "Roald Dahl", pages = 250, price = 18.99) @@ -1241,185 +1224,104 @@ class CommonBasicTest(private val path: DatabasePath) { } } - // Test LT val resultsLT = statementLT!!.getResults() assertEquals(1, resultsLT.size) assertEquals(book0, resultsLT[0]) - // Test LTE val resultsLTE = statementLTE!!.getResults() assertEquals(2, resultsLTE.size) assertEquals(true, resultsLTE.any { it == book0 }) assertEquals(true, resultsLTE.any { it == book1 }) - // Test GT val resultsGT = statementGT!!.getResults() assertEquals(1, resultsGT.size) assertEquals(book3, resultsGT[0]) - // Test GTE val resultsGTE = statementGTE!!.getResults() assertEquals(2, resultsGTE.size) assertEquals(true, resultsGTE.any { it == book2 }) assertEquals(true, resultsGTE.any { it == book3 }) - } - - fun testStringInOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val book0 = Book(name = "The Da Vinci Code", author = "Dan Brown", pages = 454, price = 16.96) - val book1 = Book(name = "Kotlin Cookbook", author = "Ken Kousen", pages = 251, price = 37.72) - val book2 = Book(name = "The Lost Symbol", author = "Dan Brown", pages = 510, price = 19.95) - val book3 = Book(name = "Modern Java Recipes", author ="Ken Kousen", pages = 322, price = 25.78) - - var statementIN: SelectStatement? = null + // Test 2: IN operator + // Clear the table first database { BookTable { table -> - table INSERT listOf(book0, book1, book2, book3) - statementIN = table SELECT WHERE (author IN listOf("Dan Brown", "Unknown Author")) + table DELETE X } } - val results = statementIN!!.getResults() - assertEquals(2, results.size) - assertEquals(true, results.any { it == book0 }) - assertEquals(true, results.any { it == book2 }) - } - - fun testStringBetweenOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val book0 = Book(name = "Alice in Wonderland", author = "Lewis Carroll", pages = 200, price = 15.99) - val book1 = Book(name = "Bob's Adventures", author = "Bob Smith", pages = 300, price = 20.99) - val book2 = Book(name = "Charlie and the Chocolate Factory", author = "Roald Dahl", pages = 250, price = 18.99) - val book3 = Book(name = "David Copperfield", author = "Charles Dickens", pages = 400, price = 25.99) - - var statementBETWEEN: SelectStatement? = null + val inBook0 = Book(name = "The Da Vinci Code", author = "Dan Brown", pages = 454, price = 16.96) + val inBook1 = Book(name = "Kotlin Cookbook", author = "Ken Kousen", pages = 251, price = 37.72) + val inBook2 = Book(name = "The Lost Symbol", author = "Dan Brown", pages = 510, price = 19.95) + val inBook3 = Book(name = "Modern Java Recipes", author ="Ken Kousen", pages = 322, price = 25.78) + var statementIN: SelectStatement? = null database { BookTable { table -> - table INSERT listOf(book0, book1, book2, book3) - statementBETWEEN = table SELECT WHERE (name BETWEEN ("Bob's Adventures" to "Charlie and the Chocolate Factory")) + table INSERT listOf(inBook0, inBook1, inBook2, inBook3) + statementIN = table SELECT WHERE (author IN listOf("Dan Brown", "Unknown Author")) } } - val results = statementBETWEEN!!.getResults() - assertEquals(2, results.size) - assertEquals(true, results.any { it == book1 }) - assertEquals(true, results.any { it == book2 }) - } - - fun testBlobComparisonOperators() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") - val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") - val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") - val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") - - var statementLT: SelectStatement? = null - var statementLTE: SelectStatement? = null - var statementGT: SelectStatement? = null - var statementGTE: SelectStatement? = null + val resultsIN = statementIN!!.getResults() + assertEquals(2, resultsIN.size) + assertEquals(true, resultsIN.any { it == inBook0 }) + assertEquals(true, resultsIN.any { it == inBook2 }) + // Test 3: BETWEEN operator + // Clear the table first database { - FileDataTable { table -> - table INSERT listOf(file0, file1, file2, file3) - statementLT = table SELECT WHERE (content LT byteArrayOf(0x03, 0x04)) - statementLTE = table SELECT WHERE (content LTE byteArrayOf(0x03, 0x04)) - statementGT = table SELECT WHERE (content GT byteArrayOf(0x05, 0x06)) - statementGTE = table SELECT WHERE (content GTE byteArrayOf(0x05, 0x06)) + BookTable { table -> + table DELETE X } } - // Test LT - val resultsLT = statementLT!!.getResults() - assertEquals(1, resultsLT.size) - assertEquals("file0.bin", resultsLT[0].fileName) - - // Test LTE - val resultsLTE = statementLTE!!.getResults() - assertEquals(2, resultsLTE.size) - assertEquals(true, resultsLTE.any { it.fileName == "file0.bin" }) - assertEquals(true, resultsLTE.any { it.fileName == "file1.bin" }) - - // Test GT - val resultsGT = statementGT!!.getResults() - assertEquals(1, resultsGT.size) - assertEquals("file3.bin", resultsGT[0].fileName) - - // Test GTE - val resultsGTE = statementGTE!!.getResults() - assertEquals(2, resultsGTE.size) - assertEquals(true, resultsGTE.any { it.fileName == "file2.bin" }) - assertEquals(true, resultsGTE.any { it.fileName == "file3.bin" }) - } - - fun testBlobInOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") - val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") - val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") - val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") - - var statementIN: SelectStatement? = null + val betweenBook0 = Book(name = "Alice in Wonderland", author = "Lewis Carroll", pages = 200, price = 15.99) + val betweenBook1 = Book(name = "Bob's Adventures", author = "Bob Smith", pages = 300, price = 20.99) + val betweenBook2 = Book(name = "Charlie and the Chocolate Factory", author = "Roald Dahl", pages = 250, price = 18.99) + val betweenBook3 = Book(name = "David Copperfield", author = "Charles Dickens", pages = 400, price = 25.99) + var statementBETWEEN: SelectStatement? = null database { - FileDataTable { table -> - table INSERT listOf(file0, file1, file2, file3) - statementIN = table SELECT WHERE (content IN listOf( - byteArrayOf(0x01, 0x02), - byteArrayOf(0x05, 0x06), - byteArrayOf(0x09, 0x0A) - )) + BookTable { table -> + table INSERT listOf(betweenBook0, betweenBook1, betweenBook2, betweenBook3) + statementBETWEEN = table SELECT WHERE (name BETWEEN ("Bob's Adventures" to "Charlie and the Chocolate Factory")) } } - val results = statementIN!!.getResults() - assertEquals(2, results.size) - assertEquals(true, results.any { it.fileName == "file0.bin" }) - assertEquals(true, results.any { it.fileName == "file2.bin" }) - } - - fun testBlobBetweenOperator() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val file0 = FileData(id = null, fileName = "file0.bin", content = byteArrayOf(0x01, 0x02), metadata = "File 0") - val file1 = FileData(id = null, fileName = "file1.bin", content = byteArrayOf(0x03, 0x04), metadata = "File 1") - val file2 = FileData(id = null, fileName = "file2.bin", content = byteArrayOf(0x05, 0x06), metadata = "File 2") - val file3 = FileData(id = null, fileName = "file3.bin", content = byteArrayOf(0x07, 0x08), metadata = "File 3") - - var statementBETWEEN: SelectStatement? = null + val resultsBETWEEN = statementBETWEEN!!.getResults() + assertEquals(2, resultsBETWEEN.size) + assertEquals(true, resultsBETWEEN.any { it == betweenBook1 }) + assertEquals(true, resultsBETWEEN.any { it == betweenBook2 }) + // Test 4: Column comparison (EQ, NEQ) + // Clear the table first database { - FileDataTable { table -> - table INSERT listOf(file0, file1, file2, file3) - statementBETWEEN = table SELECT WHERE (content BETWEEN (byteArrayOf(0x03, 0x04) to byteArrayOf(0x05, 0x06))) + BookTable { table -> + table DELETE X } } - val results = statementBETWEEN!!.getResults() - assertEquals(2, results.size) - assertEquals(true, results.any { it.fileName == "file1.bin" }) - assertEquals(true, results.any { it.fileName == "file2.bin" }) - } - - fun testStringComparisonWithColumns() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> - val book0 = Book(name = "Same Name", author = "Same Name", pages = 200, price = 15.99) - val book1 = Book(name = "Different", author = "Another", pages = 300, price = 20.99) + val colBook0 = Book(name = "Same Name", author = "Same Name", pages = 200, price = 15.99) + val colBook1 = Book(name = "Different", author = "Another", pages = 300, price = 20.99) var statementEQ: SelectStatement? = null var statementNEQ: SelectStatement? = null - database { BookTable { table -> - table INSERT listOf(book0, book1) + table INSERT listOf(colBook0, colBook1) statementEQ = table SELECT WHERE (name EQ BookTable.author) statementNEQ = table SELECT WHERE (name NEQ BookTable.author) } } - // Test column comparison EQ val resultsEQ = statementEQ!!.getResults() assertEquals(1, resultsEQ.size) - assertEquals(book0, resultsEQ[0]) + assertEquals(colBook0, resultsEQ[0]) - // Test column comparison NEQ val resultsNEQ = statementNEQ!!.getResults() assertEquals(1, resultsNEQ.size) - assertEquals(book1, resultsNEQ[0]) + assertEquals(colBook1, resultsNEQ[0]) } private fun getDefaultDBConfig(): DatabaseConfiguration = 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 2f8cf3f..2477621 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 @@ -56,16 +56,7 @@ class JvmTest { fun testNullValue() = commonTest.testNullValue() @Test - fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() - - @Test - fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() - - @Test - fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() - - @Test - fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + fun testPrimaryKeyVariations() = commonTest.testPrimaryKeyVariations() @Test fun testInsertWithId() = commonTest.testInsertWithId() @@ -77,70 +68,16 @@ class JvmTest { 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() - - @Test - fun testDropTable() = commonTest.testDropTable() - - @Test - fun testDropTableExtensionFunction() = commonTest.testDropTableExtensionFunction() - - @Test - fun testAlertAddColumn() = commonTest.testAlertAddColumn() - - @Test - fun testAlertRenameTableWithTableObject() = commonTest.testAlertRenameTableWithTableObject() - - @Test - fun testAlertRenameTableWithString() = commonTest.testAlertRenameTableWithString() - - @Test - fun testRenameColumnWithClauseElement() = commonTest.testRenameColumnWithClauseElement() - - @Test - fun testRenameColumnWithString() = commonTest.testRenameColumnWithString() - - @Test - fun testDropColumn() = commonTest.testDropColumn() - - @Test - fun testDropAndRecreateTable() = commonTest.testDropAndRecreateTable() - - @Test - fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() - - @Test - fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() - - @Test - fun testStringInOperator() = commonTest.testStringInOperator() - - @Test - fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() - - @Test - fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + fun testByteArrayAndBlobOperations() = commonTest.testByteArrayAndBlobOperations() @Test - fun testBlobInOperator() = commonTest.testBlobInOperator() + fun testDropAndCreateTable() = commonTest.testDropAndCreateTable() @Test - fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + fun testSchemaModification() = commonTest.testSchemaModification() @Test - fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + fun testStringOperators() = commonTest.testStringOperators() @BeforeTest fun setUp() { 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 9330cbf..f2f4ae0 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 @@ -72,16 +72,7 @@ class NativeTest { fun testNullValue() = commonTest.testNullValue() @Test - fun testCreateTableWithLongPrimaryKey() = commonTest.testCreateTableWithLongPrimaryKey() - - @Test - fun testCreateTableWithStringPrimaryKey() = commonTest.testCreateTableWithStringPrimaryKey() - - @Test - fun testCreateTableWithAutoincrement() = commonTest.testCreateTableWithAutoincrement() - - @Test - fun testCreateTableWithCompositePrimaryKey() = commonTest.testCreateTableWithCompositePrimaryKey() + fun testPrimaryKeyVariations() = commonTest.testPrimaryKeyVariations() @Test fun testInsertWithId() = commonTest.testInsertWithId() @@ -93,70 +84,16 @@ class NativeTest { 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() - - @Test - fun testDropTable() = commonTest.testDropTable() - - @Test - fun testDropTableExtensionFunction() = commonTest.testDropTableExtensionFunction() - - @Test - fun testAlertAddColumn() = commonTest.testAlertAddColumn() - - @Test - fun testAlertRenameTableWithTableObject() = commonTest.testAlertRenameTableWithTableObject() - - @Test - fun testAlertRenameTableWithString() = commonTest.testAlertRenameTableWithString() - - @Test - fun testRenameColumnWithClauseElement() = commonTest.testRenameColumnWithClauseElement() - - @Test - fun testRenameColumnWithString() = commonTest.testRenameColumnWithString() - - @Test - fun testDropColumn() = commonTest.testDropColumn() - - @Test - fun testDropAndRecreateTable() = commonTest.testDropAndRecreateTable() - - @Test - fun testAlertOperationsInTransaction() = commonTest.testAlertOperationsInTransaction() - - @Test - fun testStringComparisonOperators() = commonTest.testStringComparisonOperators() - - @Test - fun testStringInOperator() = commonTest.testStringInOperator() - - @Test - fun testStringBetweenOperator() = commonTest.testStringBetweenOperator() - - @Test - fun testBlobComparisonOperators() = commonTest.testBlobComparisonOperators() + fun testByteArrayAndBlobOperations() = commonTest.testByteArrayAndBlobOperations() @Test - fun testBlobInOperator() = commonTest.testBlobInOperator() + fun testDropAndCreateTable() = commonTest.testDropAndCreateTable() @Test - fun testBlobBetweenOperator() = commonTest.testBlobBetweenOperator() + fun testSchemaModification() = commonTest.testSchemaModification() @Test - fun testStringComparisonWithColumns() = commonTest.testStringComparisonWithColumns() + fun testStringOperators() = commonTest.testStringOperators() @BeforeTest fun setUp() { From f2cce7139acdc49242b578474b72e5f94e1f1cc0 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sun, 2 Nov 2025 22:22:09 +0000 Subject: [PATCH 5/7] Add enumerated type support --- CHANGELOG.md | 5 +- ROADMAP.md | 1 - .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/Entities.kt | 52 +++- .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 191 +++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 27 +- .../sqllin/dsl/sql/clause/ClauseBoolean.kt | 3 +- .../sqllin/dsl/sql/clause/ClauseElement.kt | 5 +- .../ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt | 260 ++++++++++++++++++ .../sqllin/dsl/sql/clause/ClauseNumber.kt | 2 +- .../sqllin/dsl/sql/clause/ClauseString.kt | 2 +- .../sqllin/dsl/sql/clause/ConditionClause.kt | 49 ++++ .../dsl/sql/compiler/InsertValuesEncoder.kt | 9 +- .../ctrip/sqllin/dsl/sql/operation/Alert.kt | 4 +- .../ctrip/sqllin/dsl/sql/operation/Create.kt | 3 +- .../sqllin/dsl/sql/operation/FullNameCache.kt | 30 +- .../ctrip/sqllin/processor/ClauseProcessor.kt | 83 ++++-- 19 files changed, 669 insertions(+), 66 deletions(-) create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ce76cfa..6846844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,12 @@ - ## 2.1.0 / 2025-11-xx -### sqllin-processor +### sqllin-dsl * Support typealias of supported types(primitive types, String, ByteArray etc) in generated tables +* Support enumerated types in DSL APIs, includes `=`, `!=`, `<`, `<=`, `>`, `>=` operators +* Support `<`, `<=`, `>`, `>=`, `IN`, `BETWEEN...AND` operators for String +* Support `=`, `!=`, `<`, `<=`, `>`, `>=`, `IN`, `BETWEEN...AND` operators for ByteArray ## 2.0.0 / 2025-10-23 diff --git a/ROADMAP.md b/ROADMAP.md index d20ff28..9d768bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,6 @@ * Support the key word REFERENCE * Support JOIN sub-query -* Support Enum type ## Medium Priority 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 a93b514..94f2e90 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 @@ -85,6 +85,9 @@ class AndroidTest { @Test fun testStringOperators() = commonTest.testStringOperators() + @Test + fun testEnumOperations() = commonTest.testEnumOperations() + @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 c5d9ed9..aecdb24 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 @@ -32,6 +32,30 @@ typealias StudentId = Long typealias CourseId = Long typealias Code = Int +/** + * Enum types for testing enum support + */ + +/** + * User status enum for testing enum functionality + */ +enum class UserStatus { + ACTIVE, + INACTIVE, + SUSPENDED, + BANNED +} + +/** + * Priority level enum for testing enum comparisons + */ +enum class Priority { + LOW, + MEDIUM, + HIGH, + CRITICAL +} + /** * Book entity * @author Yuang Qiao @@ -140,4 +164,30 @@ data class FileData( result = 31 * result + metadata.hashCode() return result } -} \ No newline at end of file +} + +/** + * User entity with enum fields for testing enum support + */ +@DBRow("user_account") +@Serializable +data class UserAccount( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val username: String, + val email: String, + val status: UserStatus, + val priority: Priority, + val notes: String?, +) + +/** + * Task entity with nullable enum for testing nullable enum support + */ +@DBRow("task") +@Serializable +data class Task( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val title: String, + val priority: Priority?, + val description: 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 c80559c..86c2c30 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 @@ -1324,6 +1324,195 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(colBook1, resultsNEQ[0]) } + /** + * Comprehensive test for enum type support covering all operations: + * INSERT, SELECT, UPDATE, DELETE, equality/comparison operators, + * nullable enums, complex conditions, and ORDER BY + */ + fun testEnumOperations() = Database(getNewAPIDBConfig(), true).databaseAutoClose { database -> + // Section 1: Basic INSERT and SELECT + val user1 = UserAccount(null, "john_doe", "john@example.com", UserStatus.ACTIVE, Priority.HIGH, "VIP user") + val user2 = UserAccount(null, "jane_smith", "jane@example.com", UserStatus.INACTIVE, Priority.LOW, null) + database { + UserAccountTable { table -> + table INSERT listOf(user1, user2) + } + } + + var selectAll: SelectStatement? = null + database { + selectAll = UserAccountTable SELECT X + } + val allUsers = selectAll!!.getResults() + assertEquals(2, allUsers.size) + assertEquals(UserStatus.ACTIVE, allUsers[0].status) + assertEquals(Priority.HIGH, allUsers[0].priority) + assertEquals(UserStatus.INACTIVE, allUsers[1].status) + assertEquals(Priority.LOW, allUsers[1].priority) + + // Section 2: Equality operators (EQ, NEQ) + val testUsers = listOf( + UserAccount(null, "user1", "user1@test.com", UserStatus.ACTIVE, Priority.HIGH, null), + UserAccount(null, "user2", "user2@test.com", UserStatus.INACTIVE, Priority.MEDIUM, null), + UserAccount(null, "user3", "user3@test.com", UserStatus.ACTIVE, Priority.LOW, null), + UserAccount(null, "user4", "user4@test.com", UserStatus.SUSPENDED, Priority.CRITICAL, null), + ) + database { + UserAccountTable { table -> + table DELETE X + table INSERT testUsers + } + } + + var selectEQ: SelectStatement? = null + database { + UserAccountTable { + selectEQ = it SELECT WHERE (it.status EQ UserStatus.ACTIVE) + } + } + val activeUsers = selectEQ!!.getResults() + assertEquals(2, activeUsers.size) + assertEquals(true, activeUsers.all { it.status == UserStatus.ACTIVE }) + + var selectNEQ: SelectStatement? = null + database { + UserAccountTable { + selectNEQ = it SELECT WHERE (it.status NEQ UserStatus.ACTIVE) + } + } + val nonActiveUsers = selectNEQ!!.getResults() + assertEquals(2, nonActiveUsers.size) + assertEquals(false, nonActiveUsers.any { it.status == UserStatus.ACTIVE }) + + // Section 3: Comparison operators (LT, LTE, GT, GTE) + var selectLT: SelectStatement? = null + database { + UserAccountTable { + selectLT = it SELECT WHERE (it.priority LT Priority.HIGH) + } + } + assertEquals(2, selectLT!!.getResults().size) + + var selectGTE: SelectStatement? = null + database { + UserAccountTable { + selectGTE = it SELECT WHERE (it.priority GTE Priority.HIGH) + } + } + val highPriorityUsers = selectGTE!!.getResults() + assertEquals(2, highPriorityUsers.size) + assertEquals(true, highPriorityUsers.all { it.priority == Priority.HIGH || it.priority == Priority.CRITICAL }) + + // Section 4: Nullable enum handling + val tasks = listOf( + Task(null, "High priority task", Priority.HIGH, "Important"), + Task(null, "Unassigned task", null, "No priority set"), + Task(null, "Low priority task", Priority.LOW, "Can wait"), + ) + database { + TaskTable { table -> + table INSERT tasks + } + } + + var selectNull: SelectStatement? = null + database { + TaskTable { + selectNull = it SELECT WHERE (it.priority EQ null) + } + } + val nullTasks = selectNull!!.getResults() + assertEquals(1, nullTasks.size) + assertEquals("Unassigned task", nullTasks[0].title) + + var selectNotNull: SelectStatement? = null + database { + TaskTable { + selectNotNull = it SELECT WHERE (it.priority NEQ null) + } + } + assertEquals(2, selectNotNull!!.getResults().size) + + // Section 5: UPDATE with enum values + database { + UserAccountTable { table -> + table UPDATE SET { + status = UserStatus.BANNED + priority = Priority.CRITICAL + } WHERE (table.username EQ "user1") + } + } + + var selectUpdated: SelectStatement? = null + database { + UserAccountTable { + selectUpdated = it SELECT WHERE (it.username EQ "user1") + } + } + val updatedUser = selectUpdated!!.getResults().first() + assertEquals(UserStatus.BANNED, updatedUser.status) + assertEquals(Priority.CRITICAL, updatedUser.priority) + + // Section 6: Complex conditions (AND/OR) + var selectAND: SelectStatement? = null + database { + UserAccountTable { + selectAND = it SELECT WHERE ( + (it.status EQ UserStatus.SUSPENDED) AND (it.priority EQ Priority.CRITICAL) + ) + } + } + assertEquals(1, selectAND!!.getResults().size) + + var selectOR: SelectStatement? = null + database { + UserAccountTable { + selectOR = it SELECT WHERE ( + (it.status EQ UserStatus.BANNED) OR (it.priority LTE Priority.LOW) + ) + } + } + assertEquals(2, selectOR!!.getResults().size) + + // Section 7: ORDER BY enum columns + var selectOrderByASC: SelectStatement? = null + database { + UserAccountTable { table -> + selectOrderByASC = table SELECT ORDER_BY (priority to ASC) + } + } + val orderedASC = selectOrderByASC!!.getResults() + assertEquals(Priority.LOW, orderedASC[0].priority) + assertEquals(Priority.CRITICAL, orderedASC[orderedASC.size - 1].priority) + + var selectOrderByDESC: SelectStatement? = null + database { + UserAccountTable { table -> + selectOrderByDESC = table SELECT ORDER_BY (table.status to DESC) + } + } + val orderedDESC = selectOrderByDESC!!.getResults() + // After UPDATE in Section 5, user1 is BANNED (highest ordinal 3) + assertEquals(UserStatus.BANNED, orderedDESC[0].status) + assertEquals(UserStatus.ACTIVE, orderedDESC[orderedDESC.size - 1].status) + + // Section 8: DELETE with enum WHERE clause + database { + UserAccountTable { table -> + table DELETE WHERE (table.status EQ UserStatus.BANNED) + } + } + + var selectAfterDelete: SelectStatement? = null + database { + UserAccountTable { + selectAfterDelete = it SELECT X + } + } + val remainingUsers = selectAfterDelete!!.getResults() + assertEquals(false, remainingUsers.any { it.status == UserStatus.BANNED }) + } + private fun getDefaultDBConfig(): DatabaseConfiguration = DatabaseConfiguration( name = DATABASE_NAME, @@ -1348,6 +1537,8 @@ class CommonBasicTest(private val path: DatabasePath) { CREATE(StudentWithAutoincrementTable) CREATE(EnrollmentTable) CREATE(FileDataTable) + CREATE(UserAccountTable) + CREATE(TaskTable) } ) } \ 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 2477621..43844cb 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 @@ -79,6 +79,9 @@ class JvmTest { @Test fun testStringOperators() = commonTest.testStringOperators() + @Test + fun testEnumOperations() = commonTest.testEnumOperations() + @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 f2f4ae0..04dbce1 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 @@ -95,6 +95,9 @@ class NativeTest { @Test fun testStringOperators() = commonTest.testStringOperators() + @Test + fun testEnumOperations() = commonTest.testEnumOperations() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) 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 index 9351544..75b8678 100644 --- 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 @@ -45,8 +45,7 @@ import com.ctrip.sqllin.dsl.sql.Table public class ClauseBlob( valueName: String, table: Table<*>, - isFunction: Boolean, -) : ClauseElement(valueName, table, isFunction) { +) : ClauseElement(valueName, table, false) { /** * Creates an equality comparison condition (=). @@ -154,10 +153,8 @@ public class ClauseBlob( private fun appendNullableBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } + append(table.tableName) + append('.') append(valueName) if (blob == null) { append(nullSymbol) @@ -171,10 +168,8 @@ public class ClauseBlob( private fun appendBlob(symbol: String, blob: ByteArray): SelectCondition { val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } + append(table.tableName) + append('.') append(valueName) append(symbol) } @@ -209,10 +204,8 @@ public class ClauseBlob( val parameters = blobs.toMutableList() require(parameters.isNotEmpty()) { "Param 'blobs' must not be empty!!!" } val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } + append(table.tableName) + append('.') append(valueName) append(" IN (") @@ -235,10 +228,8 @@ public class ClauseBlob( */ internal infix fun between(range: Pair): SelectCondition { val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } + append(table.tableName) + append('.') append(valueName) append(" BETWEEN ? AND ?") } 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 54bc665..f3da983 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 @@ -31,8 +31,7 @@ import com.ctrip.sqllin.dsl.sql.Table public class ClauseBoolean( valueName: String, table: Table<*>, - isFunction: Boolean, -) : ClauseElement(valueName, table, isFunction) { +) : ClauseElement(valueName, table, false) { /** * Creates a condition comparing this Boolean column/function to a value. 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 7f728e2..1588468 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 @@ -25,10 +25,11 @@ import com.ctrip.sqllin.dsl.sql.Table * Clause elements maintain their source table and whether they represent a function call. * * Subclasses provide type-specific wrappers: - * - [ClauseBoolean]: Boolean column/function references with comparison operators + * - [ClauseBoolean]: Boolean column 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 + * - [ClauseBlob]: BLOB (ByteArray) column references with comparison operators + * - [ClauseEnum]: Enum column references with ordinal-based comparison operators * * Used in: * - WHERE/HAVING conditions diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt new file mode 100644 index 0000000..aa9c3da --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt @@ -0,0 +1,260 @@ +/* + * 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 enum column references in SQL clauses. + * + * Enables type-safe enum comparisons in WHERE, HAVING, and other conditional clauses. + * Enums are stored as integers (ordinal values) in SQLite and automatically converted + * during serialization/deserialization. + * + * Available operators: + * - `lt`: Less than (<) - compares ordinal values + * - `lte`: Less than or equal to (<=) - compares ordinal values + * - `eq`: Equals (=) - handles null with IS NULL + * - `neq`: Not equals (!=) - handles null with IS NOT NULL + * - `gt`: Greater than (>) - compares ordinal values + * - `gte`: Greater than or equal to (>=) - compares ordinal values + * + * Example usage: + * ```kotlin + * enum class UserStatus { ACTIVE, INACTIVE, BANNED } + * + * @Serializable + * @DBRow + * data class User(val id: Long?, val name: String, val status: UserStatus) + * + * database { + * // Query with enum comparison + * UserTable SELECT WHERE(UserTable.status EQ UserStatus.ACTIVE) + * + * // Compare against another enum column + * UserTable SELECT WHERE(UserTable.status EQ UserTable.previousStatus) + * + * // Greater than comparison (ordinal-based) + * UserTable SELECT WHERE(UserTable.status GT UserStatus.ACTIVE) + * } + * ``` + * + * @param T The enum type this clause operates on + * @property valueName The column name + * @property table The table this element belongs to + * + * @author Yuang Qiao + */ +public class ClauseEnum>( + valueName: String, + table: Table<*>, +) : ClauseElement(valueName, table, false) { + + /** + * Less than (<) comparison using the enum's ordinal value. + * + * Generates: `column < ?` with the enum's ordinal as parameter + * + * @param entry The enum entry to compare against + * @return SelectCondition with placeholder and bound ordinal parameter + */ + internal infix fun lt(entry: T): SelectCondition = appendEnum("): SelectCondition = appendClauseEnum("<", clauseEnum) + + /** + * Less than or equal (<=) comparison using the enum's ordinal value. + * + * Generates: `column <= ?` with the enum's ordinal as parameter + * + * @param entry The enum entry to compare against + * @return SelectCondition with placeholder and bound ordinal parameter + */ + internal infix fun lte(entry: T): SelectCondition = appendEnum("<=?", entry) + + /** + * Less than or equal (<=) comparison against another enum column. + * + * Generates: `column1 <= column2` + * + * @param clauseEnum The enum column to compare against + * @return SelectCondition comparing two enum columns + */ + internal infix fun lte(clauseEnum: ClauseEnum): SelectCondition = appendClauseEnum("<=", clauseEnum) + + /** + * Equals (=) comparison using the enum's ordinal value, or IS NULL for null values. + * + * Generates: `column = ?` or `column IS NULL` + * + * @param entry The enum entry to compare against, or null + * @return SelectCondition with placeholder (if non-null) and bound ordinal parameter + */ + internal infix fun eq(entry: T?): SelectCondition = appendNullableEnum("=", " IS NULL", entry) + + /** + * Equals (=) comparison against another enum column. + * + * Generates: `column1 = column2` + * + * @param clauseEnum The enum column to compare against + * @return SelectCondition comparing two enum columns + */ + internal infix fun eq(clauseEnum: ClauseEnum): SelectCondition = appendClauseEnum("=", clauseEnum) + + /** + * Not equals (!=) comparison using the enum's ordinal value, or IS NOT NULL for null values. + * + * Generates: `column != ?` or `column IS NOT NULL` + * + * @param entry The enum entry to compare against, or null + * @return SelectCondition with placeholder (if non-null) and bound ordinal parameter + */ + internal infix fun neq(entry: T?): SelectCondition = appendNullableEnum("!=", " IS NOT NULL", entry) + + /** + * Not equals (!=) comparison against another enum column. + * + * Generates: `column1 != column2` + * + * @param clauseEnum The enum column to compare against + * @return SelectCondition comparing two enum columns + */ + internal infix fun neq(clauseEnum: ClauseEnum): SelectCondition = appendClauseEnum("!=", clauseEnum) + + /** + * Greater than (>) comparison using the enum's ordinal value. + * + * Generates: `column > ?` with the enum's ordinal as parameter + * + * @param entry The enum entry to compare against + * @return SelectCondition with placeholder and bound ordinal parameter + */ + internal infix fun gt(entry: T): SelectCondition = appendEnum(">?", entry) + + /** + * Greater than (>) comparison against another enum column. + * + * Generates: `column1 > column2` + * + * @param clauseEnum The enum column to compare against + * @return SelectCondition comparing two enum columns + */ + internal infix fun gt(clauseEnum: ClauseEnum): SelectCondition = appendClauseEnum(">", clauseEnum) + + /** + * Greater than or equal (>=) comparison using the enum's ordinal value. + * + * Generates: `column >= ?` with the enum's ordinal as parameter + * + * @param entry The enum entry to compare against + * @return SelectCondition with placeholder and bound ordinal parameter + */ + internal infix fun gte(entry: T): SelectCondition = appendEnum(">=?", entry) + + /** + * Greater than or equal (>=) comparison against another enum column. + * + * Generates: `column1 >= column2` + * + * @param clauseEnum The enum column to compare against + * @return SelectCondition comparing two enum columns + */ + internal infix fun gte(clauseEnum: ClauseEnum): SelectCondition = appendClauseEnum(">=", clauseEnum) + + /** + * Builds a comparison condition with an enum value using parameterized binding. + * + * Generates SQL: `table.column` with the enum's ordinal as a parameter. + * + * @param symbol The comparison operator with placeholder (e.g., "=?") + * @param entry The enum entry whose ordinal will be bound as a parameter + * @return SelectCondition with SQL and ordinal parameter + */ + private fun appendEnum(symbol: String, entry: T): SelectCondition { + val sql = buildString { + append(table.tableName) + append('.') + append(valueName) + append(symbol) + } + return SelectCondition(sql, mutableListOf(entry.ordinal)) + } + + /** + * Builds a comparison condition for nullable enum values. + * + * For non-null values, generates: `table.column?` with ordinal as parameter. + * For null values, generates: `table.column` (e.g., " IS NULL"). + * + * @param notNullSymbol The comparison operator for non-null values (e.g., "=", "!=") + * @param nullSymbol The SQL fragment for null comparison (e.g., " IS NULL", " IS NOT NULL") + * @param entry The enum entry to compare against, or null + * @return SelectCondition with appropriate SQL and optional ordinal parameter + */ + private fun appendNullableEnum(notNullSymbol: String, nullSymbol: String, entry: T?): SelectCondition { + val builder = StringBuilder() + builder.append(table.tableName) + builder.append('.') + builder.append(valueName) + val parameters = if (entry == null){ + builder.append(nullSymbol) + null + } else { + builder.append(notNullSymbol) + builder.append('?') + mutableListOf(entry.ordinal) + } + return SelectCondition(builder.toString(), parameters) + } + + /** + * Builds a comparison condition between two enum columns. + * + * Generates SQL: `table1.column1table2.column2` with no parameters. + * Both columns are referenced directly in the SQL without binding. + * + * @param symbol The comparison operator (e.g., "<", "=", ">=") + * @param clauseEnum The enum column to compare against + * @return SelectCondition with SQL comparing two columns + */ + private fun appendClauseEnum(symbol: String, clauseEnum: ClauseEnum): SelectCondition { + val sql = buildString { + append(table.tableName) + append('.') + append(valueName) + append(symbol) + append(clauseEnum.table.tableName) + append('.') + append(clauseEnum.valueName) + } + return SelectCondition(sql, null) + } + + override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() + override fun equals(other: Any?): Boolean = (other as? ClauseEnum<*>)?.let { + it.valueName == valueName && it.table.tableName == table.tableName + } ?: false +} \ No newline at end of file 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 62e6b9d..24fc706 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 @@ -40,7 +40,7 @@ import com.ctrip.sqllin.dsl.sql.Table public class ClauseNumber( valueName: String, table: Table<*>, - isFunction: Boolean, + isFunction: Boolean = false, ) : ClauseElement(valueName, table, isFunction) { /** 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 66d9bcd..495f332 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 @@ -41,7 +41,7 @@ import com.ctrip.sqllin.dsl.sql.Table public class ClauseString( valueName: String, table: Table<*>, - isFunction: Boolean, + isFunction: Boolean = false, ) : ClauseElement(valueName, table, isFunction) { /** Equals (=), or IS NULL if value is null */ diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt index 6adce5d..182eb97 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt @@ -28,6 +28,7 @@ import com.ctrip.sqllin.dsl.annotation.StatementDslMaker * - Numeric: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN * - String: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN, LIKE, GLOB * - Blob: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN + * - Enum: LT, LTE, EQ, NEQ, GT, GTE * - Boolean: IS * - Logic: AND, OR * @@ -219,6 +220,54 @@ public infix fun ClauseBlob.IN(blobs: Iterable): SelectCondition = in @StatementDslMaker public infix fun ClauseBlob.BETWEEN(range: Pair): SelectCondition = between(range) +// Less than, < +@StatementDslMaker +public infix fun > ClauseEnum.LT(entry: T): SelectCondition = lt(entry) + +// Less than, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.LT(clauseEnum: ClauseEnum): SelectCondition = lt(clauseEnum) + +// Less than or equal to, <= +@StatementDslMaker +public infix fun > ClauseEnum.LTE(entry: T): SelectCondition = lte(entry) + +// Less than or equal to, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.LTE(clauseEnum: ClauseEnum): SelectCondition = lte(clauseEnum) + +// Equals, == +@StatementDslMaker +public infix fun > ClauseEnum.EQ(entry: T?): SelectCondition = eq(entry) + +// Equals, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.EQ(clauseEnum: ClauseEnum): SelectCondition = eq(clauseEnum) + +// Not equal to, != +@StatementDslMaker +public infix fun > ClauseEnum.NEQ(entry: T?): SelectCondition = neq(entry) + +// Not equal to, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.NEQ(clauseEnum: ClauseEnum): SelectCondition = neq(clauseEnum) + +// Greater than, > +@StatementDslMaker +public infix fun > ClauseEnum.GT(entry: T): SelectCondition = gt(entry) + +// Greater than, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.GT(clauseEnum: ClauseEnum): SelectCondition = gt(clauseEnum) + +// Greater than or equal to, >= +@StatementDslMaker +public infix fun > ClauseEnum.GTE(entry: T): SelectCondition = gte(entry) + +// Greater than or equal to, append to ClauseEnum +@StatementDslMaker +public infix fun > ClauseEnum.GTE(clauseEnum: ClauseEnum): SelectCondition = gte(clauseEnum) + // Condition 'IS' operator @StatementDslMaker public infix fun ClauseBoolean.IS(bool: Boolean): SelectCondition = _is(bool) 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 1202d1f..5dd86bd 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 @@ -96,6 +96,10 @@ internal class InsertValuesEncoder( /** * Encodes any non-null value as a parameter placeholder. + * + * This includes primitive types (Int, Long, String, etc.) and enum ordinal values. + * When kotlinx.serialization processes an enum, it automatically converts it to its + * ordinal integer value before calling this method. */ override fun encodeValue(value: Any) = appendAny(value) @@ -104,11 +108,6 @@ internal class InsertValuesEncoder( */ override fun encodeNull() = appendAny(null) - /** - * Encodes enum as its ordinal integer value parameter. - */ - override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = appendAny(index) - /** * Handles inline values (including ByteArray). * diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt index 2249238..d33040c 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt @@ -89,8 +89,8 @@ internal object Alert : Operation { append(newColumn.valueName) val propertyDescriptor = table.kSerializer().descriptor val index = propertyDescriptor.getElementIndex(newColumn.valueName) - val serialName = propertyDescriptor.getElementDescriptor(index).serialName - append(FullNameCache.getSerialNameBySerialName(serialName, newColumn.valueName, table)) + val descriptor = propertyDescriptor.getElementDescriptor(index) + append(FullNameCache.getSerialNameBySerialName(descriptor, newColumn.valueName, table)) } return TableStructureStatement(sql, connection) } 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 71c69cb..44fdf5b 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 @@ -20,6 +20,7 @@ import com.ctrip.sqllin.driver.DatabaseConnection import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.statement.SingleStatement import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement +import kotlinx.serialization.descriptors.SerialKind /** * CREATE TABLE operation builder. @@ -75,7 +76,7 @@ internal object Create : Operation { for (elementIndex in 0 .. lastIndex) { val elementName = tableDescriptor.getElementName(elementIndex) val descriptor = tableDescriptor.getElementDescriptor(elementIndex) - val type = FullNameCache.getSerialNameBySerialName(descriptor.serialName, elementName, table) + val type = FullNameCache.getSerialNameBySerialName(descriptor, elementName, table) val isNullable = descriptor.isNullable val isPrimaryKey = elementName == table.primaryKeyInfo?.primaryKeyName diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt index 850e8f3..6115528 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt @@ -17,6 +17,8 @@ package com.ctrip.sqllin.dsl.sql.operation import com.ctrip.sqllin.dsl.sql.Table +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind /** * Cached qualified names for Kotlin types used in SQLite type mapping. @@ -57,16 +59,18 @@ internal object FullNameCache { val BYTE_ARRAY = ByteArray::class.qualifiedName!! /** - * Maps a Kotlin type's serial name to its corresponding SQLite column type declaration. + * Maps a Kotlin type's serial descriptor to its corresponding SQLite column type declaration. * - * This function converts kotlinx.serialization descriptor serial names (fully qualified type names) - * into appropriate SQLite column type strings for use in DDL statements like CREATE TABLE and - * ALTER TABLE ADD COLUMN. + * This function converts kotlinx.serialization descriptors into appropriate SQLite column type + * strings for use in DDL statements like CREATE TABLE and ALTER TABLE ADD COLUMN. It analyzes + * both the serial name (fully qualified type name) and the descriptor kind (e.g., ENUM) to + * determine the correct SQLite type. * * Type mapping rules: * - **Byte/UByte** → TINYINT * - **Short/UShort** → SMALLINT * - **Int/UInt** → INT + * - **Enum** → INT (stored as ordinal values) * - **Long** → INTEGER (if primary key) or BIGINT (if not) * - **ULong** → BIGINT * - **Float** → FLOAT @@ -82,24 +86,30 @@ internal object FullNameCache { * * Example usage: * ```kotlin - * val sqlType = getSerialNameBySerialName("kotlin.String", "username", userTable) + * val descriptor = User.serializer().descriptor.getElementDescriptor(0) + * val sqlType = getSerialNameBySerialName(descriptor, "username", userTable) * // Returns: " TEXT" * - * val pkType = getSerialNameBySerialName("kotlin.Long", "id", userTable) + * val pkDescriptor = User.serializer().descriptor.getElementDescriptor(1) + * val pkType = getSerialNameBySerialName(pkDescriptor, "id", userTable) * // Returns: " INTEGER" (if "id" is the primary key) or " BIGINT" (if not) + * + * val enumDescriptor = User.serializer().descriptor.getElementDescriptor(2) + * val enumType = getSerialNameBySerialName(enumDescriptor, "status", userTable) + * // Returns: " INT" (enum stored as ordinal) * ``` * - * @param serialName The kotlinx.serialization serial name (fully qualified type name) + * @param descriptor The kotlinx.serialization serial descriptor for the type * @param elementName The property/column name being processed * @param table The table definition, used to check primary key information - * @return A string starting with a space followed by the SQLite type name (e.g., " TEXT", " INTEGER") + * @return A string starting with a space followed by the SQLite type name (e.g., " TEXT", " INTEGER", " INT") * @throws IllegalStateException if the type is not supported by SQLlin */ - fun getSerialNameBySerialName(serialName: String, elementName: String, table: Table<*>): String = with(serialName) { + fun getSerialNameBySerialName(descriptor: SerialDescriptor, elementName: String, table: Table<*>): String = with(descriptor.serialName) { when { startsWith(BYTE) || startsWith(UBYTE) -> " TINYINT" startsWith(SHORT) || startsWith(USHORT) -> " SMALLINT" - startsWith(INT) || startsWith(UINT) -> " INT" + startsWith(INT) || startsWith(UINT) || descriptor.kind == SerialKind.ENUM -> " INT" startsWith(LONG) -> if (elementName == table.primaryKeyInfo?.primaryKeyName) " INTEGER" else " BIGINT" startsWith(ULONG) -> " BIGINT" startsWith(FLOAT) -> " FLOAT" 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 21c2723..fa439f2 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 @@ -100,6 +100,7 @@ class ClauseProcessor( 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.ClauseEnum\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.SetClause\n") @@ -153,7 +154,7 @@ class ClauseProcessor( // Write 'SelectClause' code. writer.write(" @ColumnNameDslMaker\n") writer.write(" val $propertyName\n") - writer.write(" get() = $clauseElementTypeName($elementName, this, false)\n\n") + writer.write(" get() = $clauseElementTypeName($elementName, this)\n\n") // Write 'SetClause' code. writer.write(" @ColumnNameDslMaker\n") @@ -200,23 +201,42 @@ class ClauseProcessor( /** * Maps a property's Kotlin type to the corresponding clause element type name. - * Supports typealiases by resolving them to their underlying types. * - * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported + * Handles three categories: + * - **Typealiases**: Resolves to underlying type and maps to appropriate clause type + * - **Enum classes**: Maps to `ClauseEnum` for type-safe enum operations + * - **Standard types**: Maps to ClauseNumber, ClauseString, ClauseBoolean, or ClauseBlob + * + * @param property The property declaration to analyze + * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob, ClauseEnum), or null if unsupported */ - private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? { + private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? = when ( val declaration = property.type.resolve().declaration - return getClauseElementTypeStrByTypeName(declaration.typeName) ?: kotlin.run { - if (declaration is KSTypeAlias) - getClauseElementTypeStrByTypeName(declaration.typeName) - else - null + ) { + is KSTypeAlias -> { + val realDeclaration = declaration.type.resolve().declaration + getClauseElementTypeStrByTypeName(realDeclaration.typeName) ?: kotlin.run { + if (realDeclaration is KSClassDeclaration && realDeclaration.classKind == ClassKind.ENUM_CLASS) + "ClauseEnum<${realDeclaration.typeName}>" + else + null + } } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> "ClauseEnum<${declaration.typeName}>" + else -> getClauseElementTypeStrByTypeName(declaration.typeName) } /** * Maps a fully qualified type name to its corresponding clause element type. * + * Supports primitive types and their unsigned variants: + * - Numeric types (Byte, Short, Int, Long, Float, Double, UByte, UShort, UInt, ULong) → ClauseNumber + * - Text types (Char, String) → ClauseString + * - Boolean → ClauseBoolean + * - ByteArray → ClauseBlob + * + * Note: Enum types are handled separately by [getClauseElementTypeStr]. + * * @param typeName The fully qualified type name to map * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported */ @@ -249,12 +269,24 @@ class ClauseProcessor( * @return The default value string for the property type, or null if unsupported */ private fun getSetClauseGetterValue(property: KSPropertyDeclaration): String? { - val declaration = property.type.resolve().declaration - return getDefaultValueByType(declaration.typeName) ?: kotlin.run { - if (declaration is KSTypeAlias) - getDefaultValueByType(declaration.typeName) - else - null + fun KSClassDeclaration.firstEnum() = declarations + .filterIsInstance() + .firstOrNull { it.classKind == ClassKind.ENUM_ENTRY } + ?.qualifiedName?.asString() + return when (val declaration = property.type.resolve().declaration) { + is KSTypeAlias -> { + val realDeclaration = declaration.type.resolve().declaration + getDefaultValueByType(realDeclaration.typeName) ?: kotlin.run { + if (realDeclaration is KSClassDeclaration && realDeclaration.classKind == ClassKind.ENUM_CLASS) + realDeclaration.firstEnum() + else + null + } + } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> { + declaration.firstEnum() + } + else -> getDefaultValueByType(declaration.typeName) } } @@ -289,18 +321,27 @@ class ClauseProcessor( * Generates the appropriate append function call for SetClause setters. * Supports typealiases by resolving them to their underlying types. * + * For enum types, converts the enum value to its ordinal before appending. + * Handles nullable enums with safe-call operator. + * * @param elementName The serialized element name * @param property The property declaration * @return The append function call string, or null if unsupported type */ - private fun appendFunction(elementName: String, property: KSPropertyDeclaration): String? { + private fun appendFunction(elementName: String, property: KSPropertyDeclaration): String? = when ( val declaration = property.type.resolve().declaration - return appendFunctionByTypeName(elementName, declaration.typeName) ?: kotlin.run { - if (declaration is KSTypeAlias) - appendFunctionByTypeName(elementName, declaration.typeName) - else - null + ) { + is KSTypeAlias -> { + val realDeclaration = declaration.type.resolve().declaration + appendFunctionByTypeName(elementName, realDeclaration.typeName) ?: kotlin.run { + if (realDeclaration is KSClassDeclaration && realDeclaration.classKind == ClassKind.ENUM_CLASS) + "appendAny($elementName, value?.ordinal)" + else + null + } } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> "appendAny($elementName, value?.ordinal)" + else -> appendFunctionByTypeName(elementName, declaration.typeName) } /** From e76d35703f7f9c4f9e091348b5deb0725d3e419a Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Mon, 3 Nov 2025 20:24:49 +0000 Subject: [PATCH 6/7] Add new `UNIQUE` and `COLLATE NOCASE` keyword supports --- .claude/agents/sqllin-test-writer.md | 2 +- CHANGELOG.md | 5 + ROADMAP.md | 2 +- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 21 + .../com/ctrip/sqllin/dsl/test/Entities.kt | 67 +++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 440 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 21 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 21 + sqllin-dsl/doc/getting-start-cn.md | 229 ++++++++- sqllin-dsl/doc/getting-start.md | 227 ++++++++- .../doc/modify-database-and-transaction-cn.md | 2 +- .../dsl/annotation/AdvancedInsertAPI.kt | 45 ++ .../sqllin/dsl/annotation/ColumnModifier.kt | 222 +++++++++ .../ctrip/sqllin/dsl/annotation/PrimaryKey.kt | 110 ----- .../kotlin/com/ctrip/sqllin/dsl/sql/Table.kt | 46 ++ .../sqllin/dsl/sql/clause/ClauseBoolean.kt | 63 ++- .../sqllin/dsl/sql/clause/ClauseNumber.kt | 2 +- .../sqllin/dsl/sql/clause/ConditionClause.kt | 10 +- .../ctrip/sqllin/dsl/sql/operation/Alert.kt | 2 +- .../ctrip/sqllin/dsl/sql/operation/Create.kt | 72 +-- .../sqllin/dsl/sql/operation/FullNameCache.kt | 2 +- .../ctrip/sqllin/processor/ClauseProcessor.kt | 357 ++++++++++---- .../ctrip/sqllin/processor/FullNameCache.kt | 135 ++++++ 23 files changed, 1808 insertions(+), 295 deletions(-) create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/AdvancedInsertAPI.kt create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/ColumnModifier.kt delete mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/PrimaryKey.kt create mode 100644 sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/FullNameCache.kt diff --git a/.claude/agents/sqllin-test-writer.md b/.claude/agents/sqllin-test-writer.md index 3f9a1e8..a10ad95 100644 --- a/.claude/agents/sqllin-test-writer.md +++ b/.claude/agents/sqllin-test-writer.md @@ -41,7 +41,7 @@ You are an expert Kotlin test engineer specializing in database libraries and DS - Use in-memory databases or test databases for integration tests - Clean up database state between tests (transactions, rollbacks, or cleanup hooks) - Test SQL injection prevention and parameterized query handling - - For both of sqllin-driver and sqllin-dsl, always change `JvmTest`, `NativeTest`, `AndroidTest` in the meantime + - For both of sqllin-driver and sqllin-dsl, always add new tests in `JvmTest`, `NativeTest`, `AndroidTest` in the meantime 5. **DSL-Specific Testing Considerations**: - Verify that DSL constructs generate correct SQL diff --git a/CHANGELOG.md b/CHANGELOG.md index 6846844..367e8ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ * Support enumerated types in DSL APIs, includes `=`, `!=`, `<`, `<=`, `>`, `>=` operators * Support `<`, `<=`, `>`, `>=`, `IN`, `BETWEEN...AND` operators for String * Support `=`, `!=`, `<`, `<=`, `>`, `>=`, `IN`, `BETWEEN...AND` operators for ByteArray +* Add a new condiction function `ISNOT` for Boolean, and `IS` starts to support to receive a nullable parameter +* Refactored CREATE statements building process, move it from runtime to compile-time. +* New experimental API for _COLLATE NOCASE_ keyword: `CollateNoCase` +* New experimental API for single column with _UNIQUE_ keyword: `Unique` +* New Experimental API for composite column groups with _UNIQUE_ keyword: `CompositeUnique` ## 2.0.0 / 2025-10-23 diff --git a/ROADMAP.md b/ROADMAP.md index 9d768bd..f8c6ba3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ ## High Priority -* Support the key word REFERENCE +* Support the keyword REFERENCE * Support JOIN sub-query ## Medium Priority 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 94f2e90..220af63 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 @@ -88,6 +88,27 @@ class AndroidTest { @Test fun testEnumOperations() = commonTest.testEnumOperations() + @Test + fun testCreateSQLGeneration() = commonTest.testCreateSQLGeneration() + + @Test + fun testUniqueConstraint() = commonTest.testUniqueConstraint() + + @Test + fun testCollateNoCaseConstraint() = commonTest.testCollateNoCaseConstraint() + + @Test + fun testCompositeUniqueConstraint() = commonTest.testCompositeUniqueConstraint() + + @Test + fun testMultiGroupCompositeUnique() = commonTest.testMultiGroupCompositeUnique() + + @Test + fun testCombinedConstraints() = commonTest.testCombinedConstraints() + + @Test + fun testNotNullConstraint() = commonTest.testNotNullConstraint() + @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 aecdb24..1af9fb2 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 @@ -190,4 +190,71 @@ data class Task( val title: String, val priority: Priority?, val description: String, +) + +/** + * Test entity for @Unique annotation + * Tests single-column uniqueness constraints + */ +@DBRow("unique_email_test") +@Serializable +data class UniqueEmailTest( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @com.ctrip.sqllin.dsl.annotation.Unique val email: String, + val name: String, +) + +/** + * Test entity for @CollateNoCase annotation + * Tests case-insensitive text collation + */ +@DBRow("collate_nocase_test") +@Serializable +data class CollateNoCaseTest( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @com.ctrip.sqllin.dsl.annotation.CollateNoCase val username: String, + @com.ctrip.sqllin.dsl.annotation.CollateNoCase @com.ctrip.sqllin.dsl.annotation.Unique val email: String, + val description: String, +) + +/** + * Test entity for @CompositeUnique annotation + * Tests multi-column uniqueness constraints with groups + */ +@DBRow("composite_unique_test") +@Serializable +data class CompositeUniqueTest( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val groupA: String, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val groupB: Int, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val groupC: String, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val groupD: String, + val notes: String?, +) + +/** + * Test entity for multiple @CompositeUnique groups on same property + * Tests that a property can belong to multiple composite unique constraints + */ +@DBRow("multi_group_unique_test") +@Serializable +data class MultiGroupUniqueTest( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0, 1) val userId: Int, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val eventType: String, + @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val timestamp: Long, + val metadata: String?, +) + +/** + * Test entity combining multiple column modifiers + * Tests interaction between @Unique, @CollateNoCase, and NOT NULL (non-nullable type) + */ +@DBRow("combined_constraints_test") +@Serializable +data class CombinedConstraintsTest( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @com.ctrip.sqllin.dsl.annotation.Unique @com.ctrip.sqllin.dsl.annotation.CollateNoCase val code: String, + @com.ctrip.sqllin.dsl.annotation.Unique val serial: String, + val value: Int, ) \ 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 86c2c30..0cec90b 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 @@ -1513,6 +1513,441 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(false, remainingUsers.any { it.status == UserStatus.BANNED }) } + /** + * Test for compile-time CREATE TABLE generation + * Verifies that createSQL property contains the correct SQL statement + */ + fun testCreateSQLGeneration() { + // Test 1: Simple table with primary key and basic types + val personSQL = PersonWithIdTable.createSQL + assertEquals(true, personSQL.contains("CREATE TABLE person_with_id")) + assertEquals(true, personSQL.contains("id INTEGER PRIMARY KEY")) + assertEquals(true, personSQL.contains("name TEXT NOT NULL")) + assertEquals(true, personSQL.contains("age INT NOT NULL")) + + // Test 2: Table with autoincrement + val studentSQL = StudentWithAutoincrementTable.createSQL + assertEquals(true, studentSQL.contains("CREATE TABLE student_with_autoincrement")) + assertEquals(true, studentSQL.contains("id INTEGER PRIMARY KEY AUTOINCREMENT")) + + // Test 3: Table with composite primary key + val enrollmentSQL = EnrollmentTable.createSQL + assertEquals(true, enrollmentSQL.contains("CREATE TABLE enrollment")) + assertEquals(true, enrollmentSQL.contains("PRIMARY KEY(studentId,courseId)")) + + // Test 4: Table with enum fields (stored as INT) + val userSQL = UserAccountTable.createSQL + assertEquals(true, userSQL.contains("CREATE TABLE user_account")) + assertEquals(true, userSQL.contains("status INT NOT NULL")) + assertEquals(true, userSQL.contains("priority INT NOT NULL")) + + // Test 5: Table with ByteArray (BLOB type) + val fileSQL = FileDataTable.createSQL + assertEquals(true, fileSQL.contains("CREATE TABLE file_data")) + assertEquals(true, fileSQL.contains("content BLOB NOT NULL")) + } + + /** + * Test for @Unique annotation + * Verifies that UNIQUE constraints prevent duplicate values + */ + fun testUniqueConstraint() { + val config = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(UniqueEmailTestTable) + } + ) + Database(config, true).databaseAutoClose { database -> + // Verify CREATE SQL contains UNIQUE keyword + val createSQL = UniqueEmailTestTable.createSQL + assertEquals(true, createSQL.contains("email TEXT NOT NULL UNIQUE")) + + // Test 1: Insert records with unique emails - should succeed + val user1 = UniqueEmailTest(null, "alice@example.com", "Alice") + val user2 = UniqueEmailTest(null, "bob@example.com", "Bob") + + database { + UniqueEmailTestTable { table -> + table INSERT listOf(user1, user2) + } + } + + lateinit var selectStatement: SelectStatement + database { + selectStatement = UniqueEmailTestTable SELECT X + } + assertEquals(2, selectStatement.getResults().size) + + // Test 2: Try to insert duplicate email - should fail + val user3 = UniqueEmailTest(null, "alice@example.com", "Alice Clone") + var duplicateInsertFailed = false + try { + database { + UniqueEmailTestTable { table -> + table INSERT user3 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateInsertFailed = true + } + assertEquals(true, duplicateInsertFailed, "Duplicate email should violate UNIQUE constraint") + + // Verify only 2 records exist + database { + selectStatement = UniqueEmailTestTable SELECT X + } + assertEquals(2, selectStatement.getResults().size) + } + } + + /** + * Test for @CollateNoCase annotation + * Verifies case-insensitive text comparison + */ + fun testCollateNoCaseConstraint() { + val config = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(CollateNoCaseTestTable) + } + ) + Database(config, true).databaseAutoClose { database -> + // Verify CREATE SQL contains COLLATE NOCASE + val createSQL = CollateNoCaseTestTable.createSQL + assertEquals(true, createSQL.contains("username TEXT NOT NULL COLLATE NOCASE")) + assertEquals(true, createSQL.contains("email TEXT NOT NULL COLLATE NOCASE UNIQUE")) + + // Test 1: Insert users with different case usernames + val user1 = CollateNoCaseTest(null, "JohnDoe", "john@example.com", "First user") + val user2 = CollateNoCaseTest(null, "janedoe", "jane@example.com", "Second user") + database { + CollateNoCaseTestTable { table -> + table INSERT listOf(user1, user2) + } + } + + // Test 2: Case-insensitive search + var selectStatement: SelectStatement? = null + database { + CollateNoCaseTestTable { table -> + selectStatement = table SELECT WHERE (username EQ "johndoe") // lowercase query + } + } + val results1 = selectStatement!!.getResults() + assertEquals(1, results1.size) + assertEquals("JohnDoe", results1[0].username) // Original case preserved + + // Test 3: Another case variant + database { + CollateNoCaseTestTable { table -> + selectStatement = table SELECT WHERE (username EQ "JOHNDOE") // uppercase query + } + } + val results2 = selectStatement!!.getResults() + assertEquals(1, results2.size) + assertEquals("JohnDoe", results2[0].username) + + // Test 4: UNIQUE + NOCASE - duplicate email in different case should fail + val user3 = CollateNoCaseTest(null, "alice", "JOHN@EXAMPLE.COM", "Duplicate email") + var duplicateFailed = false + try { + database { + CollateNoCaseTestTable { table -> + table INSERT user3 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateFailed = true + } + assertEquals(true, duplicateFailed, "Duplicate email (different case) should violate UNIQUE constraint with NOCASE") + } + } + + /** + * Test for @CompositeUnique annotation + * Verifies multi-column uniqueness constraints with multiple groups + */ + fun testCompositeUniqueConstraint() { + val config = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(CompositeUniqueTestTable) + } + ) + Database(config, true).databaseAutoClose { database -> + // Verify CREATE SQL contains composite UNIQUE constraints + val createSQL = CompositeUniqueTestTable.createSQL + assertEquals(true, createSQL.contains("UNIQUE(groupA,groupB)")) + assertEquals(true, createSQL.contains("UNIQUE(groupC,groupD)")) + + // Test 1: Insert records with unique combinations + val record1 = CompositeUniqueTest(null, "A1", 1, "C1", "D1", "First record") + val record2 = CompositeUniqueTest(null, "A1", 2, "C1", "D2", "Different groupB") + val record3 = CompositeUniqueTest(null, "A2", 1, "C2", "D1", "Different groupA") + database { + CompositeUniqueTestTable { table -> + table INSERT listOf(record1, record2, record3) + } + } + + lateinit var selectStatement: SelectStatement + database { + selectStatement = CompositeUniqueTestTable SELECT X + } + assertEquals(3, selectStatement.getResults().size) + + // Test 2: Try to insert duplicate (groupA, groupB) - should fail + val record4 = CompositeUniqueTest(null, "A1", 1, "C3", "D3", "Duplicate group 0") + var duplicateGroup0Failed = false + try { + database { + CompositeUniqueTestTable { table -> + table INSERT record4 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateGroup0Failed = true + } + assertEquals(true, duplicateGroup0Failed, "Duplicate (groupA, groupB) should violate group 0 UNIQUE constraint") + + // Test 3: Try to insert duplicate (groupC, groupD) - should fail + val record5 = CompositeUniqueTest(null, "A3", 3, "C1", "D1", "Duplicate group 1") + var duplicateGroup1Failed = false + try { + database { + CompositeUniqueTestTable { table -> + table INSERT record5 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateGroup1Failed = true + } + assertEquals(true, duplicateGroup1Failed, "Duplicate (groupC, groupD) should violate group 1 UNIQUE constraint") + + // Verify still only 3 records + database { + selectStatement = CompositeUniqueTestTable SELECT X + } + assertEquals(3, selectStatement.getResults().size) + } + } + + /** + * Test for multiple @CompositeUnique groups on same property + * Verifies a property can participate in multiple composite constraints + */ + fun testMultiGroupCompositeUnique() { + val config = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(MultiGroupUniqueTestTable) + } + ) + Database(config, true).databaseAutoClose { database -> + // Verify CREATE SQL contains both composite UNIQUE constraints + val createSQL = MultiGroupUniqueTestTable.createSQL + assertEquals(true, createSQL.contains("UNIQUE(userId,eventType)")) + assertEquals(true, createSQL.contains("UNIQUE(userId,timestamp)")) + + // Test 1: Insert valid records + val event1 = MultiGroupUniqueTest(null, 1, "login", 1000L, "User 1 login") + val event2 = MultiGroupUniqueTest(null, 1, "logout", 2000L, "User 1 logout") + val event3 = MultiGroupUniqueTest(null, 2, "login", 1000L, "User 2 login") + database { + MultiGroupUniqueTestTable { table -> + table INSERT listOf(event1, event2, event3) + } + } + + lateinit var selectStatement: SelectStatement + database { + selectStatement = MultiGroupUniqueTestTable SELECT X + } + assertEquals(3, selectStatement.getResults().size) + + // Test 2: Try duplicate (userId, eventType) - should fail group 0 constraint + val event4 = MultiGroupUniqueTest(null, 1, "login", 3000L, "Duplicate userId+eventType") + var duplicateGroup0Failed = false + try { + database { + MultiGroupUniqueTestTable { table -> + table INSERT event4 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateGroup0Failed = true + } + assertEquals(true, duplicateGroup0Failed, "Duplicate (userId, eventType) should violate group 0 constraint") + + // Test 3: Try duplicate (userId, timestamp) - should fail group 1 constraint + val event5 = MultiGroupUniqueTest(null, 2, "logout", 1000L, "Duplicate userId+timestamp") + var duplicateGroup1Failed = false + try { + database { + MultiGroupUniqueTestTable { table -> + table INSERT event5 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateGroup1Failed = true + } + assertEquals(true, duplicateGroup1Failed, "Duplicate (userId, timestamp) should violate group 1 constraint") + + // Verify still only 3 records + database { + selectStatement = MultiGroupUniqueTestTable SELECT X + } + assertEquals(3, selectStatement.getResults().size) + } + } + + /** + * Test for combined constraints + * Verifies interaction of @Unique, @CollateNoCase, and NOT NULL + */ + fun testCombinedConstraints() { + val config = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(CombinedConstraintsTestTable) + } + ) + Database(config, true).databaseAutoClose { database -> + // Verify CREATE SQL + val createSQL = CombinedConstraintsTestTable.createSQL + // Check for key components (order may vary) + assertEquals(true, createSQL.contains("code TEXT NOT NULL")) + assertEquals(true, createSQL.contains("COLLATE NOCASE")) + assertEquals(true, createSQL.contains("UNIQUE")) + assertEquals(true, createSQL.contains("serial TEXT NOT NULL UNIQUE")) + assertEquals(true, createSQL.contains("value INT NOT NULL")) + + // Test 1: Insert valid records + val item1 = CombinedConstraintsTest(null, "CODE123", "SN-001", 100) + val item2 = CombinedConstraintsTest(null, "CODE456", "SN-002", 200) + database { + CombinedConstraintsTestTable { table -> + table INSERT listOf(item1, item2) + } + } + + lateinit var selectStatement: SelectStatement + database { + selectStatement = CombinedConstraintsTestTable SELECT X + } + assertEquals(2, selectStatement.getResults().size) + + // Test 2: Search with different case (NOCASE on code) + database { + CombinedConstraintsTestTable { table -> + selectStatement = table SELECT WHERE (code EQ "code123") // lowercase + } + } + assertEquals(1, selectStatement.getResults().size) + assertEquals("CODE123", selectStatement.getResults()[0].code) + + // Test 3: Try duplicate code (different case) - should fail due to UNIQUE + NOCASE + val item3 = CombinedConstraintsTest(null, "code123", "SN-003", 300) + var duplicateCodeFailed = false + try { + database { + CombinedConstraintsTestTable { table -> + table INSERT item3 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateCodeFailed = true + } + assertEquals(true, duplicateCodeFailed, "Duplicate code (different case) should fail") + + // Test 4: Try duplicate serial (case-sensitive) - should fail + val item4 = CombinedConstraintsTest(null, "CODE789", "SN-001", 400) + var duplicateSerialFailed = false + try { + database { + CombinedConstraintsTestTable { table -> + table INSERT item4 + } + } + } catch (e: Exception) { + e.printStackTrace() + duplicateSerialFailed = true + } + assertEquals(true, duplicateSerialFailed, "Duplicate serial should fail") + + // Test 5: Different case serial should succeed (no NOCASE on serial) + val item5 = CombinedConstraintsTest(null, "CODE789", "sn-001", 400) + database { + CombinedConstraintsTestTable { table -> + table INSERT item5 + } + } + + database { + selectStatement = CombinedConstraintsTestTable SELECT X + } + assertEquals(3, selectStatement.getResults().size) + } + } + + /** + * Test NOT NULL constraint enforcement + * Verifies that non-nullable Kotlin types generate NOT NULL constraints + */ + fun testNotNullConstraint() { + Database(getNewAPIDBConfig(), true).databaseAutoClose { database -> + // Test 1: Verify NOT NULL in CREATE SQL + val bookSQL = BookTable.createSQL + assertEquals(true, bookSQL.contains("name TEXT NOT NULL")) + assertEquals(true, bookSQL.contains("author TEXT NOT NULL")) + assertEquals(true, bookSQL.contains("pages INT NOT NULL")) + assertEquals(true, bookSQL.contains("price DOUBLE NOT NULL")) + + // Test 2: Verify nullable columns don't have NOT NULL + val nullTesterSQL = NullTesterTable.createSQL + // Nullable columns should not have NOT NULL + assertEquals(false, nullTesterSQL.contains("paramInt INT NOT NULL")) + assertEquals(false, nullTesterSQL.contains("paramString TEXT NOT NULL")) + assertEquals(false, nullTesterSQL.contains("paramDouble DOUBLE NOT NULL")) + + // Test 3: Verify data insertion and retrieval + val book = Book(name = "Test Book", author = "Test Author", pages = 300, price = 25.99) + database { + BookTable { table -> + table INSERT book + } + } + + lateinit var selectStatement: SelectStatement + database { + selectStatement = BookTable SELECT WHERE (BookTable.name EQ "Test Book") + } + val result = selectStatement.getResults().first() + assertEquals("Test Book", result.name) + assertEquals("Test Author", result.author) + assertEquals(300, result.pages) + assertEquals(25.99, result.price) + } + } + private fun getDefaultDBConfig(): DatabaseConfiguration = DatabaseConfiguration( name = DATABASE_NAME, @@ -1539,6 +1974,11 @@ class CommonBasicTest(private val path: DatabasePath) { CREATE(FileDataTable) CREATE(UserAccountTable) CREATE(TaskTable) + CREATE(UniqueEmailTestTable) + CREATE(CollateNoCaseTestTable) + CREATE(CompositeUniqueTestTable) + CREATE(MultiGroupUniqueTestTable) + CREATE(CombinedConstraintsTestTable) } ) } \ 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 43844cb..a96fac0 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 @@ -82,6 +82,27 @@ class JvmTest { @Test fun testEnumOperations() = commonTest.testEnumOperations() + @Test + fun testCreateSQLGeneration() = commonTest.testCreateSQLGeneration() + + @Test + fun testUniqueConstraint() = commonTest.testUniqueConstraint() + + @Test + fun testCollateNoCaseConstraint() = commonTest.testCollateNoCaseConstraint() + + @Test + fun testCompositeUniqueConstraint() = commonTest.testCompositeUniqueConstraint() + + @Test + fun testMultiGroupCompositeUnique() = commonTest.testMultiGroupCompositeUnique() + + @Test + fun testCombinedConstraints() = commonTest.testCombinedConstraints() + + @Test + fun testNotNullConstraint() = commonTest.testNotNullConstraint() + @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 04dbce1..60ea196 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 @@ -98,6 +98,27 @@ class NativeTest { @Test fun testEnumOperations() = commonTest.testEnumOperations() + @Test + fun testCreateSQLGeneration() = commonTest.testCreateSQLGeneration() + + @Test + fun testUniqueConstraint() = commonTest.testUniqueConstraint() + + @Test + fun testCollateNoCaseConstraint() = commonTest.testCollateNoCaseConstraint() + + @Test + fun testCompositeUniqueConstraint() = commonTest.testCompositeUniqueConstraint() + + @Test + fun testMultiGroupCompositeUnique() = commonTest.testMultiGroupCompositeUnique() + + @Test + fun testCombinedConstraints() = commonTest.testCombinedConstraints() + + @Test + fun testNotNullConstraint() = commonTest.testNotNullConstraint() + @BeforeTest fun setUp() { deleteDatabase(path, CommonBasicTest.DATABASE_NAME) diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index 014e358..08fd6ba 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -77,7 +77,7 @@ actual fun getGlobalDatabasePath(): DatabasePath = val applicationContext: Context get() { - // 使用自己的方式获取 applicationContext + // Use your own way to get `applicationContext` } ``` @@ -186,7 +186,7 @@ data class Person( ) ``` 你定义的数据库实体的属性名应与数据库表的列名相对应。数据库实体不应该拥有名字与表中的所有列名均不相同的属性,但是 -数据库实体的属性数量可以比表中列的数量少。 +数据库实体的属性数量可以比表中列的数量少(当且仅当你不需要使用 _sqllin-dsl_ 来创建表的情况下)。 `@DBRow` 的参数 `tableName` 表示数据库中的表名,请确保传入正确的值。如果不手动传入,_sqllin-processor_ 将会使用类名作为表名,比如 `Person` 类的默认表名是"Person"。 @@ -262,6 +262,231 @@ data class Enrollment( - 你**不能**在同一个类中混合使用 `@PrimaryKey` 和 `@CompositePrimaryKey` - 只能使用其中一个 - 所有 `@CompositePrimaryKey` 属性的组合形成表的组合主键 +### 列约束和修饰符 + +SQLlin 提供了多个注解来为表列添加约束和修饰符。 + +#### @Unique - 单列唯一性 + +使用 `@Unique` 强制要求列中的值不能重复: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @Unique val email: String, // Each email must be unique + @Unique val username: String, // Each username must be unique + val displayName: String, +) +// Generated SQL: CREATE TABLE User( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// email TEXT UNIQUE, +// username TEXT UNIQUE, +// displayName TEXT +// ) +``` + +**重要注意事项:** +- UNIQUE 列允许多个 NULL 值(在 SQL 中 NULL 不等于 NULL) +- 要防止 NULL 值,请使用非空类型:`val email: String` + +#### @CompositeUnique - 多列唯一性 + +使用 `@CompositeUnique` 确保**多个列的组合**是唯一的: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.CompositeUnique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class Enrollment( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CompositeUnique(0) val studentId: Int, + @CompositeUnique(0) val courseId: Int, + val enrollmentDate: String, +) +// Generated SQL: CREATE TABLE Enrollment( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// studentId INT, +// courseId INT, +// enrollmentDate TEXT, +// UNIQUE(studentId, courseId) +// ) +// A student cannot enroll in the same course twice +``` + +**分组:** 属性可以通过指定不同的组号属于多个唯一约束组: + +```kotlin +@DBRow +@Serializable +data class Event( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CompositeUnique(0, 1) val userId: Int, // Part of groups 0 and 1 + @CompositeUnique(0) val eventType: String, // Part of group 0 + @CompositeUnique(1) val timestamp: Long, // Part of group 1 +) +// Generated SQL: CREATE TABLE Event( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// userId INT, +// eventType TEXT, +// timestamp BIGINT, +// UNIQUE(userId, eventType), // Group 0: userId + eventType +// UNIQUE(userId, timestamp) // Group 1: userId + timestamp +// ) +``` + +**默认行为:** +- 如果未指定组:`@CompositeUnique()`,默认为组 `0` +- 所有具有相同组号的属性会组合成一个组合约束 + +#### @CollateNoCase - 不区分大小写的文本比较 + +使用 `@CollateNoCase` 使字符串比较不区分大小写: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.CollateNoCase +import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CollateNoCase @Unique val email: String, // Case-insensitive unique email + @CollateNoCase val username: String, // Case-insensitive username + val bio: String, +) +// Generated SQL: CREATE TABLE User( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// email TEXT COLLATE NOCASE UNIQUE, +// username TEXT COLLATE NOCASE, +// bio TEXT +// ) +``` + +**类型限制:** +- **只能**应用于 `String` 或 `Char` 属性(及其可空变体) +- 尝试在非文本类型上使用会导致编译时错误 + +**COLLATE NOCASE 的 SQLite 行为:** +- `'ABC' = 'abc'` 结果为 true +- `ORDER BY` 子句不区分大小写排序 +- 列上的索引不区分大小写 + +#### 组合多个约束 + +你可以在同一个属性上组合多个约束注解: + +```kotlin +@DBRow +@Serializable +data class Product( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @Unique @CollateNoCase val code: String, // Unique and case-insensitive + val name: String, + val price: Double, +) +``` + +### 支持的类型 + +SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性: + +#### 数值类型 +- **整数类型:** `Byte`、`Short`、`Int`、`Long` +- **无符号整数类型:** `UByte`、`UShort`、`UInt`、`ULong` +- **浮点类型:** `Float`、`Double` + +#### 文本类型 +- `String` - 映射到 SQLite TEXT +- `Char` - 映射到 SQLite CHAR(1) + +#### 其他类型 +- `Boolean` - 映射到 SQLite BOOLEAN(存储为 0 或 1) +- `ByteArray` - 映射到 SQLite BLOB(用于二进制数据) +- **枚举类** - 映射到 SQLite INT(存储为序数值) + +#### 类型别名 +- 上述支持类型的任何类型别名 +- 类型别名可以嵌套(一个类型别名的类型别名) + +```kotlin +typealias UserId = Long +typealias Price = Double +typealias Age = Int + +// You can also create typealiases of other typealiases +typealias AccountId = UserId + +@DBRow +@Serializable +data class Product( + @PrimaryKey val id: UserId, // Works! Typealias of Long + val name: String, + val price: Price, // Works! Typealias of Double + val ownerId: AccountId, // Works! Typealias of typealias +) +``` + +**重要注意事项:** +- 处理器会递归解析类型别名以找到底层类型 +- 底层类型必须是上述支持的类型之一 + +#### 可空类型 +- 上述所有类型都可以为可空(例如 `String?`、`Int?`、`Boolean?`) +- 例外:主键有特殊的可空性规则(参见主键部分) + +#### 枚举示例 + +```kotlin +enum class UserStatus { + ACTIVE, INACTIVE, SUSPENDED, BANNED +} + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val username: String, + val status: UserStatus, // Stored as 0, 1, 2, or 3 + val priority: Priority?, // Nullable enum is also supported +) +``` + +**重要注意事项:** +- 枚举值存储为其序数(整数)值 +- 更改枚举常量的顺序会影响存储的值 +- 如果需要更稳定的存储,考虑使用 String + +#### SQLite 类型映射 + +| Kotlin 类型 | SQLite 类型 | +|------------|-------------| +| Byte, UByte | TINYINT | +| Short, UShort | SMALLINT | +| Int, UInt | INT | +| Long | BIGINT(如果是主键则为 INTEGER) | +| ULong | BIGINT | +| Float | FLOAT | +| Double | DOUBLE | +| Boolean | BOOLEAN | +| Char | CHAR(1) | +| String | TEXT | +| ByteArray | BLOB | +| Enum | INT | + ## 接下来 你已经学习完了所有的准备工作,现在可以开始学习如何操作数据库了: diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 16c30f7..b141e2a 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -195,7 +195,7 @@ data class Person( ``` Your database entities' property names should be same with the database table's column names. The database entities cannot have properties with names different from all -column names in the table. But the count of your database entities' properties can less than the count of columns. +column names in the table. But the count of your database entities' properties can less than the count of columns(only when you don't need to use _sqllin-dsl_ to create the tables). The `@DBRow`'s param `tableName` represents the table name in Database, please ensure pass the correct value. If you don't pass the parameter manually, _sqllin-processor_ will use the class @@ -272,6 +272,231 @@ data class Enrollment( - You **cannot** mix `@PrimaryKey` and `@CompositePrimaryKey` in the same class - use one or the other - The combination of all `@CompositePrimaryKey` properties forms the table's composite primary key +### Column Constraints and Modifiers + +SQLlin provides several annotations to add constraints and modifiers to your table columns. + +#### @Unique - Single Column Uniqueness + +Use `@Unique` to enforce that no two rows can have the same value in a column: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @Unique val email: String, // Each email must be unique + @Unique val username: String, // Each username must be unique + val displayName: String, +) +// Generated SQL: CREATE TABLE User( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// email TEXT UNIQUE, +// username TEXT UNIQUE, +// displayName TEXT +// ) +``` + +**Important notes:** +- Multiple NULL values are allowed in a UNIQUE column (NULL is not equal to NULL in SQL) +- To prevent NULL values, use a non-nullable type: `val email: String` + +#### @CompositeUnique - Multi-Column Uniqueness + +Use `@CompositeUnique` to ensure that the **combination** of multiple columns is unique: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.CompositeUnique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class Enrollment( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CompositeUnique(0) val studentId: Int, + @CompositeUnique(0) val courseId: Int, + val enrollmentDate: String, +) +// Generated SQL: CREATE TABLE Enrollment( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// studentId INT, +// courseId INT, +// enrollmentDate TEXT, +// UNIQUE(studentId, courseId) +// ) +// A student cannot enroll in the same course twice +``` + +**Grouping:** Properties can belong to multiple unique constraint groups by specifying different group numbers: + +```kotlin +@DBRow +@Serializable +data class Event( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CompositeUnique(0, 1) val userId: Int, // Part of groups 0 and 1 + @CompositeUnique(0) val eventType: String, // Part of group 0 + @CompositeUnique(1) val timestamp: Long, // Part of group 1 +) +// Generated SQL: CREATE TABLE Event( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// userId INT, +// eventType TEXT, +// timestamp BIGINT, +// UNIQUE(userId, eventType), // Group 0: userId + eventType +// UNIQUE(userId, timestamp) // Group 1: userId + timestamp +// ) +``` + +**Default behavior:** +- If no group is specified: `@CompositeUnique()`, defaults to group `0` +- All properties with the same group number are combined into a single composite constraint + +#### @CollateNoCase - Case-Insensitive Text Comparison + +Use `@CollateNoCase` to make string comparisons case-insensitive: + +```kotlin +import com.ctrip.sqllin.dsl.annotation.DBRow +import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.NoCase +import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.Serializable + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @CollateNoCase @Unique val email: String, // Case-insensitive unique email + @CollateNoCase val username: String, // Case-insensitive username + val bio: String, +) +// Generated SQL: CREATE TABLE User( +// id INTEGER PRIMARY KEY AUTOINCREMENT, +// email TEXT COLLATE NOCASE UNIQUE, +// username TEXT COLLATE NOCASE, +// bio TEXT +// ) +``` + +**Type restrictions:** +- Can **only** be applied to `String` or `Char` properties (and their nullable variants) +- Attempting to use on non-text types will result in a compile-time error + +**SQLite behavior with COLLATE NOCASE:** +- `'ABC' = 'abc'` evaluates to true +- `ORDER BY` clauses sort case-insensitively +- Indexes on the column are case-insensitive + +#### Combining Multiple Constraints + +You can combine multiple constraint annotations on the same property: + +```kotlin +@DBRow +@Serializable +data class Product( + @PrimaryKey(isAutoincrement = true) val id: Long?, + @Unique @CollateNoCase val code: String, // Unique and case-insensitive + val name: String, + val price: Double, +) +``` + +### Supported Types + +SQLlin supports the following Kotlin types for properties in `@DBRow` data classes: + +#### Numeric Types +- **Integer types:** `Byte`, `Short`, `Int`, `Long` +- **Unsigned integer types:** `UByte`, `UShort`, `UInt`, `ULong` +- **Floating-point types:** `Float`, `Double` + +#### Text Types +- `String` - Maps to SQLite TEXT +- `Char` - Maps to SQLite CHAR(1) + +#### Other Types +- `Boolean` - Maps to SQLite BOOLEAN (stored as 0 or 1) +- `ByteArray` - Maps to SQLite BLOB (for binary data) +- **Enum classes** - Maps to SQLite INT (stored as ordinal values) + +#### Type Aliases +- Any typealias of the supported types above +- Typealiases can be nested (typealias of another typealias) + +```kotlin +typealias UserId = Long +typealias Price = Double +typealias Age = Int + +// You can also create typealiases of other typealiases +typealias AccountId = UserId + +@DBRow +@Serializable +data class Product( + @PrimaryKey val id: UserId, // Works! Typealias of Long + val name: String, + val price: Price, // Works! Typealias of Double + val ownerId: AccountId, // Works! Typealias of typealias +) +``` + +**Important notes:** +- The processor resolves typealiases recursively to find the underlying type +- The underlying type must be one of the supported types listed above + +#### Nullable Types +- All of the above types can be nullable (e.g., `String?`, `Int?`, `Boolean?`) +- Exception: Primary keys have special nullability rules (see Primary Key section) + +#### Enum Example + +```kotlin +enum class UserStatus { + ACTIVE, INACTIVE, SUSPENDED, BANNED +} + +@DBRow +@Serializable +data class User( + @PrimaryKey(isAutoincrement = true) val id: Long?, + val username: String, + val status: UserStatus, // Stored as 0, 1, 2, or 3 + val priority: Priority?, // Nullable enum is also supported +) +``` + +**Important notes:** +- Enum values are stored as their ordinal (integer) values +- Changing the order of enum constants will affect the stored values +- Consider using String if you need more stable storage + +#### SQLite Type Mappings + +| Kotlin Type | SQLite Type | +|------------|-------------| +| Byte, UByte | TINYINT | +| Short, UShort | SMALLINT | +| Int, UInt | INT | +| Long | BIGINT (INTEGER if primary key) | +| ULong | BIGINT | +| Float | FLOAT | +| Double | DOUBLE | +| Boolean | BOOLEAN | +| Char | CHAR(1) | +| String | TEXT | +| ByteArray | BLOB | +| Enum | INT | + ## Next Step You have learned all the preparations, you can start learn how to operate database now: diff --git a/sqllin-dsl/doc/modify-database-and-transaction-cn.md b/sqllin-dsl/doc/modify-database-and-transaction-cn.md index 86e4e5d..805da06 100644 --- a/sqllin-dsl/doc/modify-database-and-transaction-cn.md +++ b/sqllin-dsl/doc/modify-database-and-transaction-cn.md @@ -171,7 +171,7 @@ private val database = Database(name = "Person.db", path = getGlobalPath(), vers fun sample() { database { PersonTable { table -> - // 编写你的 SQL 语句... + // Write your SQL statements... } } } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/AdvancedInsertAPI.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/AdvancedInsertAPI.kt new file mode 100644 index 0000000..c2041d1 --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/AdvancedInsertAPI.kt @@ -0,0 +1,45 @@ +/* + * 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.annotation + +/** + * A marker annotation for DSL functions that are considered advanced and require explicit opt-in. + * + * This library contains certain powerful APIs that are intended for special use cases and can + * lead to unexpected behavior or data integrity issues if used improperly. This annotation + * is used to protect such APIs and ensure they are used intentionally. + * + * Any function marked with [AdvancedInsertAPI] is part of this advanced feature set. To call + * such a function, you must explicitly acknowledge its use by annotating your own calling + * function or class with `@OptIn(AdvancedInsertAPI::class)`. This acts as a contract, + * confirming that you understand the implications of the API. + * + * A primary example is an API that allows for the manual insertion of a record with a + * specific primary key ID (e.g., `INSERT_WITH_ID`), which bypasses the database's automatic + * ID generation. This is useful for data migration but is unsafe for regular inserts. + * + * @see OptIn + * @see RequiresOptIn + */ +@RequiresOptIn( + message = "This is a special-purpose API for inserting a record with a predefined value for its `INTEGER PRIMARY KEY` (the rowid-backed key). " + + "It is intended for use cases like data migration or testing. " + + "For all standard operations where the database should generate the ID, you must use the `INSERT` API instead.", +) +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.BINARY) +public annotation class AdvancedInsertAPI \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/ColumnModifier.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/ColumnModifier.kt new file mode 100644 index 0000000..df0838d --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/ColumnModifier.kt @@ -0,0 +1,222 @@ +/* + * 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.annotation + +/** + * Modifiers for columns in a table + * @author Yuang Qiao + */ + +/** + * Marks a property as the primary key for a table within a class annotated with [DBRow]. + * + * This annotation defines how a data model maps to the primary key of a database table. + * Within a given `@DBRow` class, **only one** property can be marked with this annotation. + * To define a primary key that consists of multiple columns, use the [CompositePrimaryKey] annotation instead. + * Additionally, if a property in the class is marked with [PrimaryKey], the class cannot also use the [CompositePrimaryKey] annotation. + * + * ### Type and Nullability Rules + * The behavior of this annotation differs based on the type of property it annotates. + * The following rules must be followed: + * + * - **When annotating a `Long` property**: + * The property **must** be declared as a nullable type (`Long?`). This triggers a special + * SQLite mechanism, mapping the property to an `INTEGER PRIMARY KEY` column, which acts as + * an alias for the database's internal `rowid`. This is typically used for auto-incrementing + * keys, where the database assigns an ID upon insertion of a new object (when its ID is `null`). + * + * - **When annotating all other types (e.g., `String`, `Int`)**: + * The property **must** be declared as a non-nullable type (e.g., `String`). + * This creates a standard, user-provided primary key (such as `TEXT PRIMARY KEY`). + * You must provide a unique, non-null value for this property upon insertion. + * + * @property isAutoincrement Indicates whether to append the `AUTOINCREMENT` keyword to the + * `INTEGER PRIMARY KEY` column in the `CREATE TABLE` statement. This enables a stricter + * auto-incrementing strategy that ensures row IDs are never reused. + * **Important Note**: This parameter is only meaningful when annotating a property of type `Long?`. + * Setting this to `true` on non-Long properties will result in a compile-time error. + * + * @see DBRow + * @see CompositePrimaryKey + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +public annotation class PrimaryKey(val isAutoincrement: Boolean = false) + +/** + * Marks a property as a part of a composite primary key for the table. + * + * This annotation is used to define a primary key that consists of multiple columns. + * Unlike [PrimaryKey], you can apply this annotation to **multiple properties** within the + * same [DBRow] class. The combination of all properties marked with [CompositePrimaryKey] + * will form the table's composite primary key. + * + * ### Important Rules + * - A class can have multiple properties annotated with [CompositePrimaryKey]. + * - If a class uses [CompositePrimaryKey] on any of its properties, it **cannot** also use + * the [PrimaryKey] annotation on any other property. A table can only have one primary key, + * which is either a single column or a composite of multiple columns. + * - All properties annotated with [CompositePrimaryKey] must be of a **non-nullable** type + * (e.g., `String`, `Int`, `Long`), as primary key columns cannot contain `NULL` values. + * + * @see DBRow + * @see PrimaryKey + * + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +public annotation class CompositePrimaryKey + +/** + * Marks a text column to use case-insensitive collation in SQLite. + * + * This annotation adds the `COLLATE NOCASE` clause to the column definition in the + * `CREATE TABLE` statement, making string comparisons case-insensitive for this column. + * This is particularly useful for columns that store user input where case should not + * affect equality or sorting (e.g., email addresses, usernames). + * + * ### Type Restrictions + * - Can **only** be applied to properties of type `String` or `Char` (and their nullable variants) + * - Attempting to use this annotation on non-text types will result in a compile-time error + * + * ### Example + * ```kotlin + * @Serializable + * @DBRow + * data class User( + * @PrimaryKey val id: Long?, + * @CollateNoCase val email: String, // Case-insensitive email matching + * val name: String + * ) + * // Generated SQL: CREATE TABLE User(id INTEGER PRIMARY KEY, email TEXT COLLATE NOCASE, name TEXT) + * ``` + * + * ### SQLite Behavior + * With `COLLATE NOCASE`: + * - `'ABC' = 'abc'` evaluates to true + * - `ORDER BY` clauses sort case-insensitively + * - Indexes on the column are case-insensitive + * + * @see DBRow + * @see Unique + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +public annotation class CollateNoCase + +/** + * Marks a column as unique, enforcing a UNIQUE constraint in the database. + * + * This annotation adds the `UNIQUE` keyword to the column definition in the + * `CREATE TABLE` statement, ensuring that no two rows can have the same value + * in this column (except for NULL values, which can appear multiple times). + * + * ### Single vs. Composite Unique Constraints + * - Use [Unique] when a **single column** must have unique values + * - Use [CompositeUnique] when **multiple columns together** must form a unique combination + * + * ### Example + * ```kotlin + * @Serializable + * @DBRow + * data class User( + * @PrimaryKey val id: Long?, + * @Unique val email: String, // Each email must be unique + * @Unique val username: String, // Each username must be unique + * val age: Int + * ) + * // Generated SQL: CREATE TABLE User(id INTEGER PRIMARY KEY, email TEXT UNIQUE, username TEXT UNIQUE, age INT) + * ``` + * + * ### Nullability Considerations + * - Multiple NULL values are allowed in a UNIQUE column (NULL is not equal to NULL in SQL) + * - To prevent NULL values, combine with a non-nullable type: `val email: String` + * + * @see DBRow + * @see CompositeUnique + * @see CollateNoCase + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +public annotation class Unique + +/** + * Marks a property as part of one or more composite UNIQUE constraints. + * + * This annotation allows you to define UNIQUE constraints that span multiple columns. + * Unlike [Unique], which enforces uniqueness on a single column, [CompositeUnique] + * ensures that the **combination** of values across multiple columns is unique. + * + * ### Grouping + * Properties can belong to multiple unique constraint groups by specifying different + * group numbers. Properties with the same group number(s) will be combined into a + * single composite UNIQUE constraint. + * + * ### Example: Single Composite Constraint + * ```kotlin + * @Serializable + * @DBRow + * data class Enrollment( + * @PrimaryKey val id: Long?, + * @CompositeUnique(0) val studentId: Int, + * @CompositeUnique(0) val courseId: Int, + * val enrollmentDate: String + * ) + * // Generated SQL: CREATE TABLE Enrollment( + * // id INTEGER PRIMARY KEY, + * // studentId INT, + * // courseId INT, + * // enrollmentDate TEXT, + * // UNIQUE(studentId,courseId) + * // ) + * // A student cannot enroll in the same course twice + * ``` + * + * ### Example: Multiple Composite Constraints + * ```kotlin + * @Serializable + * @DBRow + * data class Event( + * @PrimaryKey val id: Long?, + * @CompositeUnique(0, 1) val userId: Int, // Part of groups 0 and 1 + * @CompositeUnique(0) val eventType: String, // Part of group 0 + * @CompositeUnique(1) val timestamp: Long // Part of group 1 + * ) + * // Generated SQL: CREATE TABLE Event( + * // id INTEGER PRIMARY KEY, + * // userId INT, + * // eventType TEXT, + * // timestamp BIGINT, + * // UNIQUE(userId,eventType), + * // UNIQUE(userId,timestamp) + * // ) + * ``` + * + * ### Default Behavior + * - If no group is specified: `@CompositeUnique()`, defaults to group `0` + * - All properties with group `0` (explicit or default) form a single composite constraint + * + * @property group One or more group numbers (0-based integers) identifying which + * composite UNIQUE constraint(s) this property belongs to. Properties sharing + * the same group number are combined into a single `UNIQUE(col1, col2, ...)` clause. + * + * @see DBRow + * @see Unique + */ +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +public annotation class CompositeUnique(vararg val group: Int = [0]) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/PrimaryKey.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/PrimaryKey.kt deleted file mode 100644 index 43db6b6..0000000 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/PrimaryKey.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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.annotation - -/** - * Mark the primary key(s) for a table - * @author Yuang Qiao - */ - -/** - * Marks a property as the primary key for a table within a class annotated with [DBRow]. - * - * This annotation defines how a data model maps to the primary key of a database table. - * Within a given `@DBRow` class, **only one** property can be marked with this annotation. - * To define a primary key that consists of multiple columns, use the [CompositePrimaryKey] annotation instead. - * Additionally, if a property in the class is marked with [PrimaryKey], the class cannot also use the [CompositePrimaryKey] annotation. - * - * ### Type and Nullability Rules - * The behavior of this annotation differs based on the type of property it annotates. - * The following rules must be followed: - * - * - **When annotating a `Long` property**: - * The property **must** be declared as a nullable type (`Long?`). This triggers a special - * SQLite mechanism, mapping the property to an `INTEGER PRIMARY KEY` column, which acts as - * an alias for the database's internal `rowid`. This is typically used for auto-incrementing - * keys, where the database assigns an ID upon insertion of a new object (when its ID is `null`). - * - * - **When annotating all other types (e.g., `String`, `Int`)**: - * The property **must** be declared as a non-nullable type (e.g., `String`). - * This creates a standard, user-provided primary key (such as `TEXT PRIMARY KEY`). - * You must provide a unique, non-null value for this property upon insertion. - * - * @property isAutoincrement Indicates whether to append the `AUTOINCREMENT` keyword to the - * `INTEGER PRIMARY KEY` column in the `CREATE TABLE` statement. This enables a stricter - * auto-incrementing strategy that ensures row IDs are never reused. - * **Important Note**: This parameter is only meaningful when annotating a property of type `Long?`. - * Setting this to `true` on non-Long properties will result in a compile-time error. - * - * @see DBRow - * @see CompositePrimaryKey - */ -@Target(AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.BINARY) -public annotation class PrimaryKey(val isAutoincrement: Boolean = false) - -/** - * Marks a property as a part of a composite primary key for the table. - * - * This annotation is used to define a primary key that consists of multiple columns. - * Unlike [PrimaryKey], you can apply this annotation to **multiple properties** within the - * same [DBRow] class. The combination of all properties marked with [CompositePrimaryKey] - * will form the table's composite primary key. - * - * ### Important Rules - * - A class can have multiple properties annotated with [CompositePrimaryKey]. - * - If a class uses [CompositePrimaryKey] on any of its properties, it **cannot** also use - * the [PrimaryKey] annotation on any other property. A table can only have one primary key, - * which is either a single column or a composite of multiple columns. - * - All properties annotated with [CompositePrimaryKey] must be of a **non-nullable** type - * (e.g., `String`, `Int`, `Long`), as primary key columns cannot contain `NULL` values. - * - * @see DBRow - * @see PrimaryKey - * - */ -@Target(AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.BINARY) -public annotation class CompositePrimaryKey - -/** - * A marker annotation for DSL functions that are considered advanced and require explicit opt-in. - * - * This library contains certain powerful APIs that are intended for special use cases and can - * lead to unexpected behavior or data integrity issues if used improperly. This annotation - * is used to protect such APIs and ensure they are used intentionally. - * - * Any function marked with [AdvancedInsertAPI] is part of this advanced feature set. To call - * such a function, you must explicitly acknowledge its use by annotating your own calling - * function or class with `@OptIn(AdvancedInsertAPI::class)`. This acts as a contract, - * confirming that you understand the implications of the API. - * - * A primary example is an API that allows for the manual insertion of a record with a - * specific primary key ID (e.g., `INSERT_WITH_ID`), which bypasses the database's automatic - * ID generation. This is useful for data migration but is unsafe for regular inserts. - * - * @see OptIn - * @see RequiresOptIn - */ -@RequiresOptIn( - message = "This is a special-purpose API for inserting a record with a predefined value for its `INTEGER PRIMARY KEY` (the rowid-backed key). " + - "It is intended for use cases like data migration or testing. " + - "For all standard operations where the database should generate the ID, you must use the `INSERT` API instead.", -) -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.BINARY) -public annotation class AdvancedInsertAPI \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt index d2fa3a0..710491c 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt @@ -73,4 +73,50 @@ public abstract class Table( * during code generation. */ public abstract val primaryKeyInfo: PrimaryKeyInfo? + + /** + * The complete CREATE TABLE SQL statement for this table. + * + * This property contains a fully-formed CREATE TABLE statement generated at compile-time + * by the sqllin-processor KSP plugin. The statement includes: + * - Table name + * - All non-transient columns with appropriate SQLite types + * - Column constraints (PRIMARY KEY, NOT NULL, UNIQUE, COLLATE NOCASE) + * - Table-level constraints (composite primary keys, composite unique constraints) + * + * ### Generation Details + * - Generated during annotation processing from [@DBRow][com.ctrip.sqllin.dsl.annotation.DBRow] classes + * - Kotlin types are mapped to SQLite types (Int→INT, String→TEXT, etc.) + * - Annotations like [@PrimaryKey], [@Unique], [@CollateNoCase], etc. add corresponding SQL clauses + * - Enum properties are stored as INTEGER (ordinal values) + * + * ### Example + * For a data class: + * ```kotlin + * @Serializable + * @DBRow + * data class User( + * @PrimaryKey(isAutoincrement = true) val id: Long?, + * @Unique @CollateNoCase val email: String, + * val name: String, + * val age: Int + * ) + * ``` + * + * The generated `createSQL` would be: + * ```sql + * CREATE TABLE User(id INTEGER PRIMARY KEY AUTOINCREMENT,email TEXT COLLATE NOCASE UNIQUE,name TEXT,age INT) + * ``` + * + * ### Performance Note + * Since this is generated at compile-time, there is no runtime overhead for constructing + * the CREATE TABLE statement, unlike previous versions that built it at runtime. + * + * @see com.ctrip.sqllin.dsl.annotation.DBRow + * @see com.ctrip.sqllin.dsl.annotation.PrimaryKey + * @see com.ctrip.sqllin.dsl.annotation.Unique + * @see com.ctrip.sqllin.dsl.annotation.CompositeUnique + * @see com.ctrip.sqllin.dsl.annotation.CollateNoCase + */ + public abstract val createSQL: String } \ 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 f3da983..2810a03 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 @@ -36,21 +36,62 @@ public class ClauseBoolean( /** * Creates a condition comparing this Boolean column/function to a value. * + * Since SQLite stores booleans as integers (0 = false, 1 = true), this generates + * numeric comparison SQL: + * - `true` → `column > 0` + * - `false` → `column <= 0` + * - `null` → `column IS NULL` + * * @param bool The Boolean value to compare against - * @return Condition expression (e.g., `column > 0` for true, `column <= 0` for false) + * @return Condition expression for use in WHERE clauses + */ + internal infix fun _is(bool: Boolean?): SelectCondition { + val sql = buildString { + append(table.tableName) + append('.') + append(valueName) + append( + when { + bool == null -> " IS NULL" + bool -> ">0" + else ->"<=0" + } + ) + } + return SelectCondition(sql, null) + } + + /** + * Creates a negated condition comparing this Boolean column/function to a value. + * + * This is the inverse of [_is], generating negated numeric comparison SQL: + * - `true` → `column <= 0` (NOT true = false) + * - `false` → `column > 0` (NOT false = true) + * - `null` → `column IS NOT NULL` + * + * ### Usage + * Used internally by DSL operators to create "is not" conditions: + * ```kotlin + * WHERE(UserTable.isActive ISNOT true) // finds inactive users + * WHERE(UserTable.isDeleted ISNOT false) // finds deleted users + * ``` + * + * @param bool The Boolean value to negate and compare against + * @return Negated condition expression for use in WHERE clauses + * @see _is */ - internal infix fun _is(bool: Boolean): SelectCondition { + internal infix fun _isNot(bool: Boolean?): SelectCondition { val sql = buildString { - if (!isFunction) { - append(table.tableName) - append('.') - } + append(table.tableName) + append('.') append(valueName) - if (bool) - append('>') - else - append("<=") - append('0') + append( + when { + bool == null -> " IS NOT NULL" + bool -> "<=0" + else -> ">0" + } + ) } return SelectCondition(sql, null) } 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 24fc706..f6cfcb9 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 @@ -191,7 +191,7 @@ public class ClauseNumber( builder.append('.') } builder.append(valueName) - val parameters = if (number == null){ + val parameters = if (number == null) { builder.append(nullSymbol) null } else { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt index 182eb97..9d89547 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt @@ -29,7 +29,7 @@ import com.ctrip.sqllin.dsl.annotation.StatementDslMaker * - String: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN, LIKE, GLOB * - Blob: LT, LTE, EQ, NEQ, GT, GTE, IN, BETWEEN * - Enum: LT, LTE, EQ, NEQ, GT, GTE - * - Boolean: IS + * - Boolean: IS, ISNOT * - Logic: AND, OR * * @param T The entity type this clause operates on @@ -268,9 +268,13 @@ public infix fun > ClauseEnum.GTE(entry: T): SelectCondition = gt @StatementDslMaker public infix fun > ClauseEnum.GTE(clauseEnum: ClauseEnum): SelectCondition = gte(clauseEnum) -// Condition 'IS' operator +// Condition for judging whether a column is a Boolean value @StatementDslMaker -public infix fun ClauseBoolean.IS(bool: Boolean): SelectCondition = _is(bool) +public infix fun ClauseBoolean.IS(bool: Boolean?): SelectCondition = _is(bool) + +// Condition for judging whether a column is NOT a Boolean value +@StatementDslMaker +public infix fun ClauseBoolean.ISNOT(bool: Boolean?): SelectCondition = _isNot(bool) // Condition 'OR' operator @StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt index d33040c..a897c29 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt @@ -90,7 +90,7 @@ internal object Alert : Operation { val propertyDescriptor = table.kSerializer().descriptor val index = propertyDescriptor.getElementIndex(newColumn.valueName) val descriptor = propertyDescriptor.getElementDescriptor(index) - append(FullNameCache.getSerialNameBySerialName(descriptor, newColumn.valueName, table)) + append(FullNameCache.getTypeNameBySerialName(descriptor, newColumn.valueName, table)) } return TableStructureStatement(sql, connection) } 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 44fdf5b..20ca770 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 @@ -20,7 +20,6 @@ import com.ctrip.sqllin.driver.DatabaseConnection import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.statement.SingleStatement import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement -import kotlinx.serialization.descriptors.SerialKind /** * CREATE TABLE operation builder. @@ -34,7 +33,7 @@ import kotlinx.serialization.descriptors.SerialKind internal object Create : Operation { override val sqlStr: String - get() = "CREATE TABLE " + get() = "" /** * Builds a CREATE TABLE statement for the given table definition. @@ -44,72 +43,5 @@ internal object Create : Operation { * @return CREATE statement ready for execution */ fun create(table: Table, connection: DatabaseConnection): SingleStatement = - TableStructureStatement(buildSQL(table), connection) - - /** - * Generates the CREATE TABLE SQL by inspecting entity properties. - * - * Maps Kotlin types to SQLite types: - * - Byte/UByte → TINYINT - * - Short/UShort → SMALLINT - * - Int/UInt → INT - * - Long → INTEGER (for primary keys with AUTOINCREMENT) or BIGINT - * - ULong → BIGINT - * - Float → FLOAT - * - Double → DOUBLE - * - Boolean → BOOLEAN - * - Char → CHAR(1) - * - String → TEXT - * - ByteArray → BLOB - * - * Handles: - * - Nullable properties (omit NOT NULL constraint) - * - Single primary keys (PRIMARY KEY, optionally AUTOINCREMENT) - * - Composite primary keys (PRIMARY KEY clause at end) - */ - private fun buildSQL(table: Table): String = buildString { - append(sqlStr) - append(table.tableName) - append(" (") - val tableDescriptor = table.kSerializer().descriptor - val lastIndex = tableDescriptor.elementsCount - 1 - for (elementIndex in 0 .. lastIndex) { - val elementName = tableDescriptor.getElementName(elementIndex) - val descriptor = tableDescriptor.getElementDescriptor(elementIndex) - val type = FullNameCache.getSerialNameBySerialName(descriptor, elementName, table) - val isNullable = descriptor.isNullable - val isPrimaryKey = elementName == table.primaryKeyInfo?.primaryKeyName - - append(elementName) - append(type) - - if (isPrimaryKey) { - if (table.primaryKeyInfo?.isAutomaticIncrement == true && type == " INTEGER") - append(" PRIMARY KEY AUTOINCREMENT") - else - append(" PRIMARY KEY") - // 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(',') - } - } - table.primaryKeyInfo?.compositePrimaryKeys?.takeIf { it.isNotEmpty() }?.let { - append(", PRIMARY KEY (") - append(it[0]) - for (i in 1 ..< it.size) { - append(',') - append(it[i]) - } - append(')') - } - append(')') - } + TableStructureStatement(table.createSQL, connection) } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt index 6115528..5f5f4f6 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/FullNameCache.kt @@ -105,7 +105,7 @@ internal object FullNameCache { * @return A string starting with a space followed by the SQLite type name (e.g., " TEXT", " INTEGER", " INT") * @throws IllegalStateException if the type is not supported by SQLlin */ - fun getSerialNameBySerialName(descriptor: SerialDescriptor, elementName: String, table: Table<*>): String = with(descriptor.serialName) { + fun getTypeNameBySerialName(descriptor: SerialDescriptor, elementName: String, table: Table<*>): String = with(descriptor.serialName) { when { startsWith(BYTE) || startsWith(UBYTE) -> " TINYINT" startsWith(SHORT) || startsWith(USHORT) -> " SMALLINT" 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 fa439f2..b8b3fa3 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 @@ -24,6 +24,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.symbol.* import com.google.devtools.ksp.validate import java.io.OutputStreamWriter +import java.lang.IllegalStateException /** * KSP symbol processor that generates table objects for database entities. @@ -32,15 +33,34 @@ import java.io.OutputStreamWriter * and [@Serializable][kotlinx.serialization.Serializable], this processor generates * a companion `Table` object (named `{ClassName}Table`) with: * - * - Type-safe column property accessors for SELECT clauses - * - Mutable properties for UPDATE SET clauses - * - Primary key metadata extraction from [@PrimaryKey][com.ctrip.sqllin.dsl.annotation.PrimaryKey] + * ### Generated Features + * - **Type-safe column property accessors** for SELECT clauses + * - **Mutable properties** for UPDATE SET clauses + * - **Compile-time CREATE TABLE statement** with proper SQLite type mappings + * - **Primary key metadata** extraction from [@PrimaryKey][com.ctrip.sqllin.dsl.annotation.PrimaryKey] * and [@CompositePrimaryKey][com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey] annotations - * - Support for typealias of primitive types (resolves typealiases to their underlying types) + * - **Column modifiers** support: + * - PRIMARY KEY with optional AUTOINCREMENT + * - NOT NULL constraints + * - UNIQUE constraints (single and composite) + * - COLLATE NOCASE for case-insensitive text columns + * - **Type support**: + * - All Kotlin primitive types and unsigned variants + * - String, Char, Boolean, ByteArray + * - Enum classes (stored as integers) + * - Typealiases of supported types * - * The generated code provides compile-time safety for SQL DSL operations. + * ### Performance Optimization + * CREATE TABLE statements are generated at **compile-time** rather than runtime, + * eliminating the overhead of runtime reflection and string building during table creation. * * @author Yuang Qiao + * @see com.ctrip.sqllin.dsl.annotation.DBRow + * @see com.ctrip.sqllin.dsl.annotation.PrimaryKey + * @see com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey + * @see com.ctrip.sqllin.dsl.annotation.Unique + * @see com.ctrip.sqllin.dsl.annotation.CompositeUnique + * @see com.ctrip.sqllin.dsl.annotation.CollateNoCase */ class ClauseProcessor( private val environment: SymbolProcessorEnvironment, @@ -53,6 +73,9 @@ class ClauseProcessor( const val ANNOTATION_DATABASE_ROW_NAME = "com.ctrip.sqllin.dsl.annotation.DBRow" const val ANNOTATION_PRIMARY_KEY = "com.ctrip.sqllin.dsl.annotation.PrimaryKey" const val ANNOTATION_COMPOSITE_PRIMARY_KEY = "com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey" + const val ANNOTATION_UNIQUE = "com.ctrip.sqllin.dsl.annotation.Unique" + const val ANNOTATION_COMPOSITE_UNIQUE = "com.ctrip.sqllin.dsl.annotation.CompositeUnique" + const val ANNOTATION_NO_CASE = "com.ctrip.sqllin.dsl.annotation.CollateNoCase" const val ANNOTATION_SERIALIZABLE = "kotlinx.serialization.Serializable" const val ANNOTATION_TRANSIENT = "kotlinx.serialization.Transient" @@ -60,6 +83,7 @@ class ClauseProcessor( const val PROMPT_PRIMARY_KEY_MUST_NOT_NULL = "The primary key must be not-null." const val PROMPT_PRIMARY_KEY_TYPE = """The primary key's type must be Long when you set the the parameter "isAutoincrement = true" in annotation PrimaryKey.""" const val PROMPT_PRIMARY_KEY_USE_COUNT = "You only could use PrimaryKey to annotate one property in a class." + const val PROMPT_NO_CASE_MUST_FOR_TEXT = "You only could add annotation @CollateNoCase for a String or Char typed property." } /** @@ -115,16 +139,32 @@ class ClauseProcessor( val transientName = resolver.getClassDeclarationByName(ANNOTATION_TRANSIENT)!!.asStarProjectedType() val primaryKeyAnnotationName = resolver.getClassDeclarationByName(ANNOTATION_PRIMARY_KEY)!!.asStarProjectedType() val compositePrimaryKeyName = resolver.getClassDeclarationByName(ANNOTATION_COMPOSITE_PRIMARY_KEY)!!.asStarProjectedType() + val noCaseAnnotationName = resolver.getClassDeclarationByName(ANNOTATION_NO_CASE)!!.asStarProjectedType() + val uniqueAnnotationName = resolver.getClassDeclarationByName(ANNOTATION_UNIQUE)!!.asStarProjectedType() + // Primary key tracking for metadata generation var primaryKeyName: String? = null var isAutomaticIncrement = false var isRowId = false val compositePrimaryKeys = ArrayList() var isContainsPrimaryKey = false - classDeclaration.getAllProperties().filter { classDeclaration -> + // CREATE TABLE statement builder (compile-time generation) + val createSQLBuilder = StringBuilder("CREATE TABLE ").apply { + append(tableName) + append('(') + } + + // Track composite unique constraints: group number → list of column names + val compositeUniqueColumns = HashMap>() + + // Filter out @Transient properties and convert to list for indexed iteration + val propertyList = classDeclaration.getAllProperties().filter { classDeclaration -> !classDeclaration.annotations.any { ksAnnotation -> ksAnnotation.annotationType.resolve().isAssignableFrom(transientName) } - }.forEachIndexed { index, property -> + }.toList() + + // Process each property to generate column definitions + propertyList.forEachIndexed { index, property -> val clauseElementTypeName = getClauseElementTypeStr(property) ?: return@forEachIndexed val propertyName = property.simpleName.asString() val elementName = "$className.serializer().descriptor.getElementName($index)" @@ -133,22 +173,77 @@ class ClauseProcessor( // Collect the information of the primary key(s). val annotations = property.annotations.map { it.annotationType.resolve() } val isPrimaryKey = annotations.any { it.isAssignableFrom(primaryKeyAnnotationName) } - val isLong = property.typeName == Long::class.qualifiedName - if (isPrimaryKey) { - check(!annotations.any { it.isAssignableFrom(compositePrimaryKeyName) }) { PROMPT_CANT_ADD_BOTH_ANNOTATION } - check(!isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } - check(!isContainsPrimaryKey) { PROMPT_PRIMARY_KEY_USE_COUNT } - isContainsPrimaryKey = true - primaryKeyName = propertyName - isAutomaticIncrement = property.annotations.find { - it.annotationType.resolve().declaration.qualifiedName?.asString() == ANNOTATION_PRIMARY_KEY - }?.arguments?.firstOrNull()?.value as? Boolean ?: false - if (isAutomaticIncrement) - check(isLong) { PROMPT_PRIMARY_KEY_TYPE } - isRowId = isLong - } else if (annotations.any { it.isAssignableFrom(compositePrimaryKeyName) }) { - check(isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } - compositePrimaryKeys.add(propertyName) + + // Build column definition: name, type, and constraints + with(createSQLBuilder) { + append(propertyName) + val type = getSQLiteType(property, isPrimaryKey) + append(type) + + // Handle @PrimaryKey annotation + if (isPrimaryKey) { + check(!annotations.any { it.isAssignableFrom(compositePrimaryKeyName) }) { PROMPT_CANT_ADD_BOTH_ANNOTATION } + check(!isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } + check(!isContainsPrimaryKey) { PROMPT_PRIMARY_KEY_USE_COUNT } + isContainsPrimaryKey = true + primaryKeyName = propertyName + + append(" PRIMARY KEY") + + isAutomaticIncrement = property.annotations.find { + it.annotationType.resolve().declaration.qualifiedName?.asString() == ANNOTATION_PRIMARY_KEY + }?.arguments?.firstOrNull()?.value as? Boolean ?: false + val isLong = type == " INTEGER" || type == " BIGINT" + if (isAutomaticIncrement) { + check(isLong) { PROMPT_PRIMARY_KEY_TYPE } + append(" AUTOINCREMENT") + } + isRowId = isLong + } else if (annotations.any { it.isAssignableFrom(compositePrimaryKeyName) }) { + // Handle @CompositePrimaryKey - collect for table-level constraint + check(isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } + compositePrimaryKeys.add(propertyName) + } else if (isNotNull) { + // Add NOT NULL constraint for non-nullable, non-PK columns + append(" NOT NULL") + } + + // Handle @CollateNoCase annotation - must be on text columns + if (annotations.any { it.isAssignableFrom(noCaseAnnotationName) }) { + check(type == " TEXT" || type == " CHAR(1)") { PROMPT_NO_CASE_MUST_FOR_TEXT } + append(" COLLATE NOCASE") + } + + // Handle @Unique annotation - single column uniqueness + if (annotations.any { it.isAssignableFrom(uniqueAnnotationName) }) + append(" UNIQUE") + + // Handle @CompositeUnique annotation - collect for table-level constraint + val compositeUniqueAnnotation = property.annotations + .find { it.annotationType.resolve().declaration.qualifiedName?.asString() == ANNOTATION_COMPOSITE_UNIQUE } + + compositeUniqueAnnotation?.run { + // Extract group numbers from annotation (defaults to group 0 if not specified) + arguments + .firstOrNull { it.name?.asString() == "group" } + .let { + val list = if (it == null) { + listOf(0) // Default to group 0 + } else { + it.value as? List ?: listOf(0) + } + // Add this property to each specified group + list.forEach { group -> + val groupList = compositeUniqueColumns[group] ?: ArrayList().also { gl -> + compositeUniqueColumns[group] = gl + } + groupList.add(propertyName) + } + } + } + + if (index < propertyList.lastIndex) + append(',') } // Write 'SelectClause' code. @@ -172,27 +267,58 @@ class ClauseProcessor( // Write the override instance for property `primaryKeyInfo`. if (primaryKeyName == null && compositePrimaryKeys.isEmpty()) { writer.write(" override val primaryKeyInfo = null\n\n") - writer.write("}\n") - return@use - } - writer.write(" override val primaryKeyInfo = PrimaryKeyInfo(\n") - if (primaryKeyName == null) { - writer.write(" primaryKeyName = null,\n") } else { - writer.write(" primaryKeyName = \"$primaryKeyName\",\n") + writer.write(" override val primaryKeyInfo = PrimaryKeyInfo(\n") + if (primaryKeyName == null) { + writer.write(" primaryKeyName = null,\n") + } else { + writer.write(" primaryKeyName = \"$primaryKeyName\",\n") + } + writer.write(" isAutomaticIncrement = $isAutomaticIncrement,\n") + writer.write(" isRowId = $isRowId,\n") + if (compositePrimaryKeys.isEmpty()) { + writer.write(" compositePrimaryKeys = null,\n") + } else { + writer.write(" compositePrimaryKeys = listOf(\n") + compositePrimaryKeys.forEach { + writer.write(" \"$it\",\n") + } + writer.write(" )\n") + } + writer.write(" )\n\n") } - writer.write(" isAutomaticIncrement = $isAutomaticIncrement,\n") - writer.write(" isRowId = $isRowId,\n") - if (compositePrimaryKeys.isEmpty()) { - writer.write(" compositePrimaryKeys = null,\n") - } else { - writer.write(" compositePrimaryKeys = listOf(\n") - compositePrimaryKeys.forEach { - writer.write(" \"$it\",\n") + + // Append table-level constraints to CREATE TABLE statement + with(createSQLBuilder) { + // Add composite primary key constraint if present + compositePrimaryKeys.takeIf { it.isNotEmpty() }?.let { + append(",PRIMARY KEY(") + append(it[0]) + for (i in 1 ..< it.size) { + append(',') + append(it[i]) + } + append(')') } - writer.write(" )\n") + + // Add composite unique constraints for each group + compositeUniqueColumns.values.forEach { + if (it.isEmpty()) + return@forEach + append(",UNIQUE(") + append(it[0]) + for (i in 1 ..< it.size) { + append(',') + append(it[i]) + } + append(')') + } + + append(')') } - writer.write(" )\n\n") + + writer.write(" override val createSQL = \"$createSQLBuilder\"\n") + writer.write("}\n") } } @@ -241,23 +367,23 @@ class ClauseProcessor( * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported */ private fun getClauseElementTypeStrByTypeName(typeName: String?): String? = when (typeName) { - Int::class.qualifiedName, - Long::class.qualifiedName, - Short::class.qualifiedName, - Byte::class.qualifiedName, - Float::class.qualifiedName, - Double::class.qualifiedName, - UInt::class.qualifiedName, - ULong::class.qualifiedName, - UShort::class.qualifiedName, - UByte::class.qualifiedName, -> "ClauseNumber" + FullNameCache.INT, + FullNameCache.LONG, + FullNameCache.SHORT, + FullNameCache.BYTE, + FullNameCache.FLOAT, + FullNameCache.DOUBLE, + FullNameCache.UINT, + FullNameCache.ULONG, + FullNameCache.USHORT, + FullNameCache.UBYTE, -> "ClauseNumber" - Char::class.qualifiedName, - String::class.qualifiedName, -> "ClauseString" + FullNameCache.CHAR, + FullNameCache.STRING, -> "ClauseString" - Boolean::class.qualifiedName -> "ClauseBoolean" + FullNameCache.BOOLEAN -> "ClauseBoolean" - ByteArray::class.qualifiedName -> "ClauseBlob" + FullNameCache.BYTE_ARRAY -> "ClauseBlob" else -> null } @@ -283,9 +409,7 @@ class ClauseProcessor( null } } - is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> { - declaration.firstEnum() - } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> declaration.firstEnum() else -> getDefaultValueByType(declaration.typeName) } } @@ -297,22 +421,22 @@ class ClauseProcessor( * @return The default value string (e.g., "0" for Int, "false" for Boolean), or null if unsupported */ private fun getDefaultValueByType(typeName: String?): String? = when (typeName) { - Int::class.qualifiedName -> "0" - Long::class.qualifiedName -> "0L" - Short::class.qualifiedName -> "0" - Byte::class.qualifiedName -> "0" - Float::class.qualifiedName -> "0F" - Double::class.qualifiedName -> "0.0" - UInt::class.qualifiedName -> "0U" - ULong::class.qualifiedName -> "0UL" - UShort::class.qualifiedName -> "0U" - UByte::class.qualifiedName -> "0U" - Boolean::class.qualifiedName -> "false" - - Char::class.qualifiedName -> "'0'" - String::class.qualifiedName -> "\"\"" - - ByteArray::class.qualifiedName -> "ByteArray(0)" + FullNameCache.INT -> "0" + FullNameCache.LONG -> "0L" + FullNameCache.SHORT -> "0" + FullNameCache.BYTE -> "0" + FullNameCache.FLOAT -> "0F" + FullNameCache.DOUBLE -> "0.0" + FullNameCache.UINT -> "0U" + FullNameCache.ULONG -> "0UL" + FullNameCache.USHORT -> "0U" + FullNameCache.UBYTE -> "0U" + FullNameCache.BOOLEAN -> "false" + + FullNameCache.CHAR -> "'0'" + FullNameCache.STRING -> "\"\"" + + FullNameCache.BYTE_ARRAY -> "ByteArray(0)" else -> null } @@ -352,33 +476,82 @@ class ClauseProcessor( * @return The append function call string, or null if unsupported type */ private fun appendFunctionByTypeName(elementName: String, typeName: String?): String? = when (typeName) { - Int::class.qualifiedName, - Long::class.qualifiedName, - Short::class.qualifiedName, - Byte::class.qualifiedName, - Float::class.qualifiedName, - Double::class.qualifiedName, - UInt::class.qualifiedName, - ULong::class.qualifiedName, - UShort::class.qualifiedName, - UByte::class.qualifiedName, - Char::class.qualifiedName, - String::class.qualifiedName, - Boolean::class.qualifiedName, - ByteArray::class.qualifiedName -> "appendAny($elementName, value)" + FullNameCache.INT, + FullNameCache.LONG, + FullNameCache.SHORT, + FullNameCache.BYTE, + FullNameCache.FLOAT, + FullNameCache.DOUBLE, + FullNameCache.UINT, + FullNameCache.ULONG, + FullNameCache.USHORT, + FullNameCache.UBYTE, + FullNameCache.CHAR, + FullNameCache.STRING, + FullNameCache.BOOLEAN, + FullNameCache.BYTE_ARRAY -> "appendAny($elementName, value)" else -> null } /** - * Extension property that resolves a property's fully qualified type name. + * Determines the SQLite type declaration for a given property. + * + * This function resolves the Kotlin type of a property to its corresponding SQLite type + * string, handling type aliases and enum classes. The result is used in compile-time + * CREATE TABLE statement generation. + * + * ### Type Resolution Strategy + * 1. **Type Aliases**: Resolves to the underlying type, then maps to SQLite type + * 2. **Enum Classes**: Maps to SQLite INT type (enums are stored as ordinals) + * 3. **Standard Types**: Direct mapping via [FullNameCache.getSQLTypeName] + * + * ### Primary Key Special Handling + * When `isPrimaryKey` is true and the property is of type [Long], the function returns + * " INTEGER" instead of " BIGINT" to enable SQLite's rowid aliasing optimization. + * + * ### Example Mappings + * ```kotlin + * // Standard type + * val age: Int // → " INT" + * + * // Type alias + * typealias UserId = Long + * val id: UserId // → " BIGINT" (or " INTEGER" if primary key) + * + * // Enum class + * enum class Status { ACTIVE, INACTIVE } + * val status: Status // → " INT" + * ``` + * + * @param property The KSP property declaration to analyze + * @param isPrimaryKey Whether this property is annotated with [@PrimaryKey] + * @return SQLite type declaration string with leading space (e.g., " INT", " TEXT") + * @throws IllegalStateException if the property type is not supported by SQLlin + * + * @see FullNameCache.getSQLTypeName */ - private inline val KSPropertyDeclaration.typeName - get() = type.resolve().declaration.qualifiedName?.asString() + private fun getSQLiteType(property: KSPropertyDeclaration, isPrimaryKey: Boolean): String { + val declaration = property.type.resolve().declaration + return when (declaration) { + is KSTypeAlias -> { + val realDeclaration = declaration.type.resolve().declaration + FullNameCache.getSQLTypeName(realDeclaration.typeName, isPrimaryKey) ?: kotlin.run { + if (realDeclaration is KSClassDeclaration && realDeclaration.classKind == ClassKind.ENUM_CLASS) + FullNameCache.getSQLTypeName(FullNameCache.INT, isPrimaryKey) + else + null + } + } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> + FullNameCache.getSQLTypeName(FullNameCache.INT, isPrimaryKey) + else -> FullNameCache.getSQLTypeName(declaration.typeName, isPrimaryKey) + } ?: throw IllegalStateException("Hasn't support the type '${declaration.typeName}' yet") + } /** - * Extension property that resolves a type alias to its underlying fully qualified type name. + * Extension property that resolves a property's fully qualified type name. */ - private inline val KSTypeAlias.typeName + private inline val KSPropertyDeclaration.typeName get() = type.resolve().declaration.qualifiedName?.asString() /** diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/FullNameCache.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/FullNameCache.kt new file mode 100644 index 0000000..d730479 --- /dev/null +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/FullNameCache.kt @@ -0,0 +1,135 @@ +/* + * 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.processor + +/** + * Cached qualified names for Kotlin types used in compile-time SQLite type mapping. + * + * This object provides: + * 1. Pre-computed fully qualified names for all supported Kotlin types to avoid + * repeated reflection calls during annotation processing + * 2. Centralized Kotlin-to-SQLite type mapping logic + * + * Used by [ClauseProcessor] during compile-time code generation to: + * - Map Kotlin property types to appropriate SQLite column types + * - Generate CREATE TABLE statements with correct type declarations + * - Ensure type consistency across generated table objects + * + * ### Supported Type Mappings + * - Integer types → TINYINT, SMALLINT, INT, BIGINT, INTEGER + * - Unsigned integer types → TINYINT, SMALLINT, INT, BIGINT + * - Floating-point types → FLOAT, DOUBLE + * - Boolean → BOOLEAN + * - Character/String → CHAR(1), TEXT + * - ByteArray → BLOB + * + * @author Yuang Qiao + */ +internal object FullNameCache { + + /** Fully qualified name for [Byte] (`kotlin.Byte`) */ + val BYTE = Byte::class.qualifiedName!! + + /** Fully qualified name for [Short] (`kotlin.Short`) */ + val SHORT = Short::class.qualifiedName!! + + /** Fully qualified name for [Int] (`kotlin.Int`) */ + val INT = Int::class.qualifiedName!! + + /** Fully qualified name for [Long] (`kotlin.Long`) */ + val LONG = Long::class.qualifiedName!! + + /** Fully qualified name for [UByte] (`kotlin.UByte`) */ + val UBYTE = UByte::class.qualifiedName!! + + /** Fully qualified name for [UShort] (`kotlin.UShort`) */ + val USHORT = UShort::class.qualifiedName!! + + /** Fully qualified name for [UInt] (`kotlin.UInt`) */ + val UINT = UInt::class.qualifiedName!! + + /** Fully qualified name for [ULong] (`kotlin.ULong`) */ + val ULONG = ULong::class.qualifiedName!! + + /** Fully qualified name for [Float] (`kotlin.Float`) */ + val FLOAT = Float::class.qualifiedName!! + + /** Fully qualified name for [Double] (`kotlin.Double`) */ + val DOUBLE = Double::class.qualifiedName!! + + /** Fully qualified name for [Boolean] (`kotlin.Boolean`) */ + val BOOLEAN = Boolean::class.qualifiedName!! + + /** Fully qualified name for [Char] (`kotlin.Char`) */ + val CHAR = Char::class.qualifiedName!! + + /** Fully qualified name for [String] (`kotlin.String`) */ + val STRING = String::class.qualifiedName!! + + /** Fully qualified name for [ByteArray] (`kotlin.ByteArray`) */ + val BYTE_ARRAY = ByteArray::class.qualifiedName!! + + /** + * Maps a Kotlin fully qualified type name to its corresponding SQLite type declaration. + * + * This function is used during compile-time code generation to produce the appropriate + * SQLite type string for CREATE TABLE statements. Each returned string includes a + * leading space for proper SQL formatting. + * + * ### Special Handling for Long Primary Keys + * When a [Long] property is marked as a primary key, it's mapped to `INTEGER` instead + * of `BIGINT`. This enables SQLite's special rowid aliasing behavior, where an + * `INTEGER PRIMARY KEY` column becomes an alias for the internal rowid, providing + * automatic unique ID generation and optimal performance. + * + * ### Type Mappings + * | Kotlin Type | SQLite Type (non-PK) | SQLite Type (PK) | + * |------------|---------------------|------------------| + * | Byte, UByte | TINYINT | TINYINT | + * | Short, UShort | SMALLINT | SMALLINT | + * | Int, UInt | INT | INT | + * | Long | BIGINT | INTEGER | + * | ULong | BIGINT | BIGINT | + * | Float | FLOAT | FLOAT | + * | Double | DOUBLE | DOUBLE | + * | Boolean | BOOLEAN | BOOLEAN | + * | Char | CHAR(1) | CHAR(1) | + * | String | TEXT | TEXT | + * | ByteArray | BLOB | BLOB | + * + * @param typeName The fully qualified Kotlin type name (e.g., "kotlin.Int", "kotlin.String") + * @param isPrimaryKey Whether this column is the primary key of the table + * @return A SQLite type declaration string with leading space (e.g., " INT", " TEXT"), + * or `null` if the type is not supported + * + * @see ClauseProcessor.getSQLiteType + */ + fun getSQLTypeName(typeName: String?, isPrimaryKey: Boolean): String? = when (typeName) { + BYTE, UBYTE -> " TINYINT" + SHORT, USHORT -> " SMALLINT" + INT, UINT -> " INT" + LONG -> if (isPrimaryKey) " INTEGER" else " BIGINT" + ULONG -> " BIGINT" + FLOAT -> " FLOAT" + DOUBLE -> " DOUBLE" + BOOLEAN -> " BOOLEAN" + CHAR -> " CHAR(1)" + STRING -> " TEXT" + BYTE_ARRAY -> " BLOB" + else -> null + } +} \ No newline at end of file From 0f2ee7fe7244d793055922ce54a6d76988b94b93 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Mon, 3 Nov 2025 23:08:06 +0000 Subject: [PATCH 7/7] Update CHANGELOG and version number --- CHANGELOG.md | 2 +- ROADMAP.md | 10 ++++--- gradle.properties | 2 +- .../com/ctrip/sqllin/dsl/test/Entities.kt | 27 ++++++++++--------- .../dsl/test/TestPrimitiveTypeForKSP.kt | 5 +++- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367e8ef..2ec1243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Date format: YYYY-MM-dd - -## 2.1.0 / 2025-11-xx +## 2.1.0 / 2025-11-04 ### sqllin-dsl diff --git a/ROADMAP.md b/ROADMAP.md index f8c6ba3..0cf7a24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,12 +2,16 @@ ## High Priority -* Support the keyword REFERENCE -* Support JOIN sub-query +* Support FOREIGN KEY DSL +* Support CREATE INDEX DSL ## Medium Priority -* Support WASM platform +* Support WASM platform DSL +* Support CREATE VIRTUAL TABLE DSL +* Support CREATE VIEW DSL +* Support CREATE TRIGGER DSL +* Support JOIN sub-query DSL ## Low Priority diff --git a/gradle.properties b/gradle.properties index 8100610..c8f8d7f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -VERSION=2.0.0 +VERSION=2.1.0 GROUP_ID=com.ctrip.kotlin #Maven Publishing Information 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 1af9fb2..bdef96e 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,9 +16,12 @@ package com.ctrip.sqllin.dsl.test +import com.ctrip.sqllin.dsl.annotation.CollateNoCase import com.ctrip.sqllin.dsl.annotation.CompositePrimaryKey +import com.ctrip.sqllin.dsl.annotation.CompositeUnique import com.ctrip.sqllin.dsl.annotation.DBRow import com.ctrip.sqllin.dsl.annotation.PrimaryKey +import com.ctrip.sqllin.dsl.annotation.Unique import kotlinx.serialization.Serializable /** @@ -200,7 +203,7 @@ data class Task( @Serializable data class UniqueEmailTest( @PrimaryKey(isAutoincrement = true) val id: Long?, - @com.ctrip.sqllin.dsl.annotation.Unique val email: String, + @Unique val email: String, val name: String, ) @@ -212,8 +215,8 @@ data class UniqueEmailTest( @Serializable data class CollateNoCaseTest( @PrimaryKey(isAutoincrement = true) val id: Long?, - @com.ctrip.sqllin.dsl.annotation.CollateNoCase val username: String, - @com.ctrip.sqllin.dsl.annotation.CollateNoCase @com.ctrip.sqllin.dsl.annotation.Unique val email: String, + @CollateNoCase val username: String, + @CollateNoCase @Unique val email: String, val description: String, ) @@ -225,10 +228,10 @@ data class CollateNoCaseTest( @Serializable data class CompositeUniqueTest( @PrimaryKey(isAutoincrement = true) val id: Long?, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val groupA: String, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val groupB: Int, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val groupC: String, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val groupD: String, + @CompositeUnique(0) val groupA: String, + @CompositeUnique(0) val groupB: Int, + @CompositeUnique(1) val groupC: String, + @CompositeUnique(1) val groupD: String, val notes: String?, ) @@ -240,9 +243,9 @@ data class CompositeUniqueTest( @Serializable data class MultiGroupUniqueTest( @PrimaryKey(isAutoincrement = true) val id: Long?, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0, 1) val userId: Int, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(0) val eventType: String, - @com.ctrip.sqllin.dsl.annotation.CompositeUnique(1) val timestamp: Long, + @CompositeUnique(0, 1) val userId: Int, + @CompositeUnique(0) val eventType: String, + @CompositeUnique(1) val timestamp: Long, val metadata: String?, ) @@ -254,7 +257,7 @@ data class MultiGroupUniqueTest( @Serializable data class CombinedConstraintsTest( @PrimaryKey(isAutoincrement = true) val id: Long?, - @com.ctrip.sqllin.dsl.annotation.Unique @com.ctrip.sqllin.dsl.annotation.CollateNoCase val code: String, - @com.ctrip.sqllin.dsl.annotation.Unique val serial: String, + @Unique @CollateNoCase val code: String, + @Unique val serial: String, val value: Int, ) \ No newline at end of file diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt index c877e61..9b62e20 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt @@ -27,7 +27,7 @@ import kotlinx.serialization.Transient @DBRow @Serializable -data class TestPrimitiveTypeForKSP( +class TestPrimitiveTypeForKSP( val testInt: Int, val testLong: Long, val testShort: Short, @@ -41,5 +41,8 @@ data class TestPrimitiveTypeForKSP( val testBoolean: Boolean?, val testChar: Char?, val testString: String, + val testByteArray: ByteArray, + val testEnum: Priority, + val testTypeAlias: Code, @Transient val testTransient: Int = 0, ) \ No newline at end of file