From 06bcbb7d31ca7645f78c3c1db1734cb1692f9cf8 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sun, 27 Sep 2026 21:07:59 +0100 Subject: [PATCH 01/18] Rename @PrimaryKey's parameter from `isAutoincrement` to `autoIncrement` The annotation's parameter was named `isAutoincrement` while parts of the documentation referred to it as `autoIncrement`, so code copied from the docs failed to compile with "Cannot find a parameter with this name". `autoIncrement` is the better of the two names: Kotlin's `is` prefix convention applies to properties rather than annotation parameters, none of the other annotations (`@CompositeUnique`, `@ForeignKey`, `@References`, `@Default`) carry such a prefix, and `isAutoincrement` was itself inconsistent in its casing. This is a source-incompatible rename, so call sites passing the argument by name have to be updated. The processor reads the argument positionally, so the generated DDL is unchanged. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 38 ++++++++--------- sqllin-dsl/doc/getting-start-cn.md | 42 +++++++++---------- sqllin-dsl/doc/getting-start.md | 42 +++++++++---------- .../annotation/CreateStatementModifiers.kt | 4 +- .../ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt | 2 +- .../kotlin/com/ctrip/sqllin/dsl/sql/Table.kt | 2 +- .../processor/ColumnConstraintParser.kt | 4 +- 8 files changed, 71 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b6e19b..71517bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ * Migrate the Android instrumented tests to `Robolectric`, they now run on the JVM as host unit tests against `API 26` and `API 37`, and no longer need an emulator * Move the `sqllin-driver` tests back into the `sqllin-driver` module's `commonTest`, and remove the `sqllin-driver-test` module +### sqllin-dsl + +* **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected + ### sqllin-driver * Update `sqlite-jdbc`'s version to `3.53.4.0` 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 da6cd9a6..03f69455 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 @@ -124,7 +124,7 @@ data class Product( @DBRow("student_with_autoincrement") @Serializable data class StudentWithAutoincrement( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val studentName: String, val grade: Grade, ) @@ -140,7 +140,7 @@ data class Enrollment( @DBRow("file_data") @Serializable data class FileData( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val fileName: String, val content: ByteArray, val metadata: String, @@ -175,7 +175,7 @@ data class FileData( @DBRow("user_account") @Serializable data class UserAccount( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val username: String, val email: String, val status: UserStatus, @@ -189,7 +189,7 @@ data class UserAccount( @DBRow("task") @Serializable data class Task( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val title: String, val priority: Priority?, val description: String, @@ -202,7 +202,7 @@ data class Task( @DBRow("unique_email_test") @Serializable data class UniqueEmailTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -214,7 +214,7 @@ data class UniqueEmailTest( @DBRow("collate_nocase_test") @Serializable data class CollateNoCaseTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CollateNoCase val username: String, @CollateNoCase @Unique val email: String, val description: String, @@ -227,7 +227,7 @@ data class CollateNoCaseTest( @DBRow("composite_unique_test") @Serializable data class CompositeUniqueTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CompositeUnique(0) val groupA: String, @CompositeUnique(0) val groupB: Int, @CompositeUnique(1) val groupC: String, @@ -242,7 +242,7 @@ data class CompositeUniqueTest( @DBRow("multi_group_unique_test") @Serializable data class MultiGroupUniqueTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CompositeUnique(0, 1) val userId: Int, @CompositeUnique(0) val eventType: String, @CompositeUnique(1) val timestamp: Long, @@ -256,7 +256,7 @@ data class MultiGroupUniqueTest( @DBRow("combined_constraints_test") @Serializable data class CombinedConstraintsTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique @CollateNoCase val code: String, @Unique val serial: String, val value: Int, @@ -272,7 +272,7 @@ data class CombinedConstraintsTest( @DBRow("fk_user") @Serializable data class FKUser( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -283,7 +283,7 @@ data class FKUser( @DBRow("fk_order") @Serializable data class FKOrder( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -300,7 +300,7 @@ data class FKOrder( @DBRow("fk_post") @Serializable data class FKPost( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -317,7 +317,7 @@ data class FKPost( @DBRow("fk_profile") @Serializable data class FKProfile( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -351,7 +351,7 @@ data class FKProduct( trigger = com.ctrip.sqllin.dsl.annotation.Trigger.ON_DELETE_CASCADE ) data class FKOrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.ForeignKey(group = 0, reference = "categoryId") val productCategory: Int, @com.ctrip.sqllin.dsl.annotation.ForeignKey(group = 0, reference = "productCode") @@ -366,7 +366,7 @@ data class FKOrderItem( @DBRow("fk_comment") @Serializable data class FKComment( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -394,7 +394,7 @@ data class FKComment( @DBRow("default_values_test") @Serializable data class DefaultValuesTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, @com.ctrip.sqllin.dsl.annotation.Default("'active'") val status: String, @com.ctrip.sqllin.dsl.annotation.Default("0") val loginCount: Int, @@ -409,7 +409,7 @@ data class DefaultValuesTest( @DBRow("default_nullable_test") @Serializable data class DefaultNullableTest( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, @com.ctrip.sqllin.dsl.annotation.Default("'In Stock'") val availability: String?, @com.ctrip.sqllin.dsl.annotation.Default("100") val quantity: Int?, @@ -422,7 +422,7 @@ data class DefaultNullableTest( @DBRow("default_fk_parent") @Serializable data class DefaultFKParent( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, ) @@ -438,7 +438,7 @@ data class DefaultFKParent( trigger = com.ctrip.sqllin.dsl.annotation.Trigger.ON_DELETE_SET_DEFAULT ) data class DefaultFKChild( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.ForeignKey(group = 0, reference = "id") @com.ctrip.sqllin.dsl.annotation.Default("0") val parentId: Long, diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index 8b70fd08..e5d35d47 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -279,7 +279,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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, @@ -309,7 +309,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class Enrollment( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CompositeUnique(0) val studentId: Int, @CompositeUnique(0) val courseId: Int, val enrollmentDate: String, @@ -330,7 +330,7 @@ data class Enrollment( @DBRow @Serializable data class Event( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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 @@ -363,7 +363,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CollateNoCase @Unique val email: String, // Case-insensitive unique email @CollateNoCase val username: String, // Case-insensitive username val bio: String, @@ -393,7 +393,7 @@ data class User( @DBRow @Serializable data class Product( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique @CollateNoCase val code: String, // Unique and case-insensitive val name: String, val price: Double, @@ -413,7 +413,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, @Default("'active'") val status: String, // String default @Default("0") val loginCount: Int, // Numeric default @@ -445,7 +445,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -534,7 +534,7 @@ enum class UserStatus { @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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 @@ -605,7 +605,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, val email: String, ) @@ -613,7 +613,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -662,7 +662,7 @@ data class Product( constraintName = "fk_product" ) data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @ForeignKey(group = 0, reference = "categoryId") val productCategory: Int, @ForeignKey(group = 0, reference = "productCode") @@ -690,7 +690,7 @@ data class OrderItem( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -703,7 +703,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Must be nullable! val content: String, @@ -716,7 +716,7 @@ data class Post( @DBRow @Serializable data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "Order", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) val orderId: Long, val productId: Long, @@ -729,7 +729,7 @@ data class OrderItem( @DBRow @Serializable data class Comment( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_DEFAULT) val userId: Long = 0L, // Default to 0 (anonymous user) val content: String, @@ -764,7 +764,7 @@ UPDATE 操作也有相同的操作: @ForeignKeyGroup(group = 0, tableName = "User", trigger = Trigger.ON_DELETE_CASCADE) @ForeignKeyGroup(group = 1, tableName = "Product", trigger = Trigger.ON_DELETE_RESTRICT) data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @ForeignKey(group = 0, reference = "id") val userId: Long, @ForeignKey(group = 1, reference = "id") val productId: Long, val quantity: Int, @@ -784,7 +784,7 @@ data class OrderItem( @DBRow @Serializable data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, @References(tableName = "Product", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) @@ -801,7 +801,7 @@ data class OrderItem( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -836,7 +836,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -845,7 +845,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -856,7 +856,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Nullable - posts can exist without author val title: String, diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 8b721c8d..92eacaf7 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -289,7 +289,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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, @@ -319,7 +319,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class Enrollment( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CompositeUnique(0) val studentId: Int, @CompositeUnique(0) val courseId: Int, val enrollmentDate: String, @@ -340,7 +340,7 @@ data class Enrollment( @DBRow @Serializable data class Event( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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 @@ -373,7 +373,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @CollateNoCase @Unique val email: String, // Case-insensitive unique email @CollateNoCase val username: String, // Case-insensitive username val bio: String, @@ -403,7 +403,7 @@ You can combine multiple constraint annotations on the same property: @DBRow @Serializable data class Product( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique @CollateNoCase val code: String, // Unique and case-insensitive val name: String, val price: Double, @@ -423,7 +423,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, @Default("'active'") val status: String, // String default @Default("0") val loginCount: Int, // Numeric default @@ -455,7 +455,7 @@ Default values are **required** when using `ON_DELETE_SET_DEFAULT` or `ON_UPDATE @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -544,7 +544,7 @@ enum class UserStatus { @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = 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 @@ -615,7 +615,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, val email: String, ) @@ -623,7 +623,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -672,7 +672,7 @@ data class Product( constraintName = "fk_product" ) data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @ForeignKey(group = 0, reference = "categoryId") val productCategory: Int, @ForeignKey(group = 0, reference = "productCode") @@ -700,7 +700,7 @@ Triggers define what happens when a referenced row is deleted or updated. SQLlin @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -713,7 +713,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Must be nullable! val content: String, @@ -726,7 +726,7 @@ data class Post( @DBRow @Serializable data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "Order", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) val orderId: Long, val productId: Long, @@ -739,7 +739,7 @@ data class OrderItem( @DBRow @Serializable data class Comment( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_DEFAULT) val userId: Long = 0L, // Default to 0 (anonymous user) val content: String, @@ -774,7 +774,7 @@ A table can have multiple foreign key constraints to different parent tables: @ForeignKeyGroup(group = 0, tableName = "User", trigger = Trigger.ON_DELETE_CASCADE) @ForeignKeyGroup(group = 1, tableName = "Product", trigger = Trigger.ON_DELETE_RESTRICT) data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @ForeignKey(group = 0, reference = "id") val userId: Long, @ForeignKey(group = 1, reference = "id") val productId: Long, val quantity: Int, @@ -794,7 +794,7 @@ Or using `@References`: @DBRow @Serializable data class OrderItem( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, @References(tableName = "Product", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) @@ -811,7 +811,7 @@ You can optionally name your foreign key constraints for better error messages a @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -846,7 +846,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -855,7 +855,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -866,7 +866,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(isAutoincrement = true) val id: Long?, + @PrimaryKey(autoIncrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Nullable - posts can exist without author val title: String, diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt index 6f7b6555..3246b077 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt @@ -44,7 +44,7 @@ package com.ctrip.sqllin.dsl.annotation * 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 + * @property autoIncrement 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?`. @@ -55,7 +55,7 @@ package com.ctrip.sqllin.dsl.annotation */ @Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.BINARY) -public annotation class PrimaryKey(val isAutoincrement: Boolean = false) +public annotation class PrimaryKey(val autoIncrement: Boolean = false) /** * Marks a property as a part of a composite primary key for the table. diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt index 07be95d1..5d693648 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt @@ -29,7 +29,7 @@ package com.ctrip.sqllin.dsl.sql * - [primaryKeyName] contains the column name * - [compositePrimaryKeys] is `null` * - [isRowId] is `true` if the key is a `Long?` type (maps to SQLite's INTEGER PRIMARY KEY/rowid) - * - [isAutomaticIncrement] is `true` if `@PrimaryKey(isAutoincrement = true)` was specified + * - [isAutomaticIncrement] is `true` if `@PrimaryKey(autoIncrement = true)` was specified * * **Composite Primary Key:** * When a table has multiple primary key columns (marked with `@CompositePrimaryKey`): 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 710491cb..fa7e8b0d 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 @@ -96,7 +96,7 @@ public abstract class Table( * @Serializable * @DBRow * data class User( - * @PrimaryKey(isAutoincrement = true) val id: Long?, + * @PrimaryKey(autoIncrement = true) val id: Long?, * @Unique @CollateNoCase val email: String, * val name: String, * val age: Int diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt index 854afadc..3971e6fb 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt @@ -92,7 +92,7 @@ class ColumnConstraintParser(resolver: Resolver) { const val PROMPT_CANT_ADD_BOTH_ANNOTATION = "You can't add both @PrimaryKey and @CompositePrimaryKey to the same property." 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_TYPE = """The primary key's type must be Long when you set the the parameter "autoIncrement = 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." } @@ -130,7 +130,7 @@ class ColumnConstraintParser(resolver: Resolver) { * * #### Primary Key * ```kotlin - * @PrimaryKey(isAutoincrement = true) + * @PrimaryKey(autoIncrement = true) * val id: Long? * // Generated: id INTEGER PRIMARY KEY AUTOINCREMENT * ``` From 4231b08114cedcc04872711dd09cafbd33932a64 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 16:51:38 +0100 Subject: [PATCH 02/18] Propagate the @DBRow entity's visibility to the generated table object (B3) The processor always emitted a `public` table object, so an `internal` @DBRow class failed to compile with EXPOSED_SUPER_CLASS, EXPOSED_FUNCTION_RETURN_TYPE and EXPOSED_RECEIVER_TYPE. Keeping a data layer internal therefore forced the entities to be public, which on iOS also pushes them into the generated ObjC header. Since every generated member lives inside that object, narrowing the object alone narrows all of them; the `override`s cannot be narrowed individually anyway, as Kotlin forbids reducing an override's visibility. A @DBRow class that is neither public nor internal is now reported through KSPLogger instead of producing code that cannot compile, because the generated object lives in a different file and cannot reference a private entity. Covered by a new `internal` test entity: without the fix, the test module no longer compiles. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ .../kotlin/com/ctrip/sqllin/dsl/test/Entities.kt | 14 +++++++++++++- .../ctrip/sqllin/processor/ClauseProcessor.kt | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71517bd1..464931c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ * Update `sqlite-jdbc`'s version to `3.53.4.0` +### sqllin-processor + +* Fix: the visibility of the class annotated with `@DBRow` is now propagated to the generated table object. An `internal` `@DBRow` class used to produce a `public` object, which failed to compile with `EXPOSED_SUPER_CLASS`, `EXPOSED_FUNCTION_RETURN_TYPE` and `EXPOSED_RECEIVER_TYPE`. A `@DBRow` class that is neither `public` nor `internal` is now reported as an error + ## 2.3.0 / 2026-08-20 ### All 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 03f69455..ec27378b 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 @@ -443,4 +443,16 @@ data class DefaultFKChild( @com.ctrip.sqllin.dsl.annotation.Default("0") val parentId: Long, val description: String, -) \ No newline at end of file +) +/** + * An `internal` entity, used to verify that the processor propagates the entity's visibility + * to the generated table object. If it doesn't, the generated `public` object triggers + * EXPOSED_SUPER_CLASS, EXPOSED_FUNCTION_RETURN_TYPE and EXPOSED_RECEIVER_TYPE errors, and + * this module fails to compile. + */ +@DBRow("internal_visibility") +@Serializable +internal data class InternalVisibility( + @PrimaryKey(autoIncrement = true) val id: Long?, + val name: 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 d2906bac..d0935d0c 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 @@ -17,6 +17,7 @@ package com.ctrip.sqllin.processor import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.getVisibility import com.google.devtools.ksp.processing.Dependencies import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor @@ -93,6 +94,19 @@ class ClauseProcessor( if (classDeclaration.annotations.all { !it.annotationType.resolve().isAssignableFrom(serializableType) }) continue // Don't handle the classes that didn't be annotated 'Serializable' + // The generated table object must not be more visible than the entity it is built for, + // otherwise an 'internal' @DBRow class produces a 'public' object that exposes it. + val visibility = classDeclaration.getVisibility() + if (visibility != Visibility.PUBLIC && visibility != Visibility.INTERNAL) { + environment.logger.error( + "The class annotated with '@DBRow' must be 'public' or 'internal', but " + + "'${classDeclaration.simpleName.asString()}' is '${visibility.name.lowercase()}'.", + classDeclaration, + ) + continue + } + val visibilityModifier = if (visibility == Visibility.INTERNAL) "internal " else "" + val foreignKeyParser = ForeignKeyParser() foreignKeyParser.parseGroups(classDeclaration.annotations) @@ -122,7 +136,7 @@ class ClauseProcessor( writer.write("import com.ctrip.sqllin.dsl.sql.PrimaryKeyInfo\n") writer.write("import com.ctrip.sqllin.dsl.sql.Table\n\n") - writer.write("object $objectName : Table<$className>(\"$tableName\") {\n\n") + writer.write("${visibilityModifier}object $objectName : Table<$className>(\"$tableName\") {\n\n") writer.write(" override fun kSerializer() = $className.serializer()\n\n") From 1e066a781759442ee61bdfc22cc2a0ee36e9dfde Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 16:52:08 +0100 Subject: [PATCH 03/18] Document that DatabaseScope defers execution to scope exit (B5) The class KDoc said statements are executed in batch when the scope exits, but its example then read a query's results inside the scope: val adults = PersonTable SELECT WHERE(age GTE 18) LIMIT 10 `adults` is a statement, not a list, and calling `getResults()` on it there throws IllegalStateException. The example now keeps the statement in a variable declared outside the scope and reads it afterwards, and the KDoc states the rule explicitly, including its consequence that a read-modify-write cannot be expressed in a single scope. The example also used bare column names outside the table object's scope, where they do not resolve, so it would not have compiled as written. It is now wrapped in `PersonTable { table -> ... }`; the whole example was transcribed into the test module and compiled to confirm it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 32 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 464931c4..a5117619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### sqllin-dsl * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected +* Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws ### sqllin-driver diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 07f58cd9..2e8fa2c0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -59,21 +59,37 @@ import kotlin.jvm.JvmName * - Use [transaction] to execute multiple statements atomically * - Transactions can be nested and are automatically committed or rolled back * + * **Execution is deferred**: no statement runs until the scope exits. A [SelectStatement] built + * inside the scope therefore holds no results while the scope is still open, and calling + * `getResults()` on it there throws [IllegalStateException]. Hold the statement in a variable + * declared outside the scope and read its results after the scope has exited, as shown below. + * For the same reason a query's result cannot inform a write in the same scope: a read-modify-write + * has to be split into two scopes. + * * Example: * ```kotlin + * // Create and modify table structure * database { - * // Create and modify table structure * CREATE(PersonTable) - * PersonTable ALERT_ADD_COLUMN email + * PersonTable ALERT_ADD_COLUMN PersonTable.email + * } * - * // Data manipulation - * transaction { - * PersonTable INSERT person - * PersonTable UPDATE SET { name = "Alice" } WHERE (age GTE 18) + * // Modify data, and build a query whose results are read once the scope has exited + * lateinit var adults: SelectStatement + * database { + * PersonTable { table -> + * transaction { + * table INSERT person + * table UPDATE SET { name = "Alice" } WHERE (age GTE 18) + * } + * adults = table SELECT WHERE(age GTE 18) LIMIT 10 * } - * val adults = PersonTable SELECT WHERE(age GTE 18) LIMIT 10 + * } + * // Every statement above ran when the scope exited, so the results are available only here + * val results = adults.getResults() * - * // Cleanup + * // Cleanup + * database { * PersonTable.DROP() * } * ``` From e7747e9e57ec5864902fc8f7458d0af1c9de831f Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 16:52:23 +0100 Subject: [PATCH 04/18] Fix the index examples referencing a non-existent `KClass.table` (B8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` KDoc examples were written as User::class.table.CREATE_INDEX("idx_user_email", User::email) but no `KClass.table` extension exists anywhere in the library, and the columns are not Kotlin property references either — they are accessors on the generated table object. Both examples now use the form the tests already exercise: UserTable.CREATE_INDEX("idx_user_email", UserTable.email) Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5117619..216cd816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws +* Fix documentation: the `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` examples referenced a `KClass.table` extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object ### sqllin-driver diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 2e8fa2c0..1a8b60b4 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -643,8 +643,8 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * User::class.table.CREATE_INDEX("idx_user_email", User::email) - * User::class.table.CREATE_INDEX("idx_user_name_age", User::name, User::age) + * UserTable.CREATE_INDEX("idx_user_email", UserTable.email) + * UserTable.CREATE_INDEX("idx_user_name_age", UserTable.name, UserTable.age) * } * ``` * @@ -668,8 +668,8 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * User::class.table.CREATE_UNIQUE_INDEX("idx_unique_email", User::email) - * Product::class.table.CREATE_UNIQUE_INDEX("idx_unique_sku", Product::sku) + * UserTable.CREATE_UNIQUE_INDEX("idx_unique_email", UserTable.email) + * ProductTable.CREATE_UNIQUE_INDEX("idx_unique_sku", ProductTable.sku) * } * ``` * From 26cee52b77119e0bff04937af1539874e8efe539 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 16:53:08 +0100 Subject: [PATCH 05/18] Rename the ALERT_* DSL APIs to ALTER_* (B9) `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` misspelled the SQL keyword `ALTER`. They are renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, and the internal `Alert` operation object to `Alter`, along with every reference in the documentation, the KDoc and the tests. This is a source-incompatible rename of public API. The 2.2.0 entry in the change log still says `ALERT`, which is what that version actually shipped, so it is left as it is. Note that the operations still emit the invalid keyword "ALERT TABLE" and therefore still fail at runtime; that is a separate defect, fixed in the next commit. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 21 ++++++------ sqllin-dsl/doc/getting-start-cn.md | 2 +- sqllin-dsl/doc/getting-start.md | 2 +- .../doc/modify-database-and-transaction-cn.md | 12 +++---- .../doc/modify-database-and-transaction.md | 12 +++---- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 32 +++++++++---------- .../dsl/sql/operation/{Alert.kt => Alter.kt} | 17 ++++------ .../dsl/sql/statement/OtherStatement.kt | 2 +- 9 files changed, 50 insertions(+), 51 deletions(-) rename sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/{Alert.kt => Alter.kt} (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 216cd816..f1f011c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### sqllin-dsl * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected +* **Breaking change**: The DSL APIs `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, correcting a misspelling of the SQL keyword `ALTER`. The internal `Alert` operation object is renamed to `Alter` accordingly * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws * Fix documentation: the `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` examples referenced a `KClass.table` extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index d4743474..e24a99f1 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -1098,8 +1098,9 @@ class CommonBasicTest(private val path: DatabasePath) { @OptIn(ExperimentalDSLDatabaseAPI::class) fun testSchemaModification() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Test 1: ALERT_ADD_COLUMN - // Note: ALERT operations have a typo in the DSL - should be "ALTER TABLE" not "ALERT TABLE" + // Test 1: ALTER_ADD_COLUMN + // Note: the ALTER operations still emit the invalid keyword "ALERT TABLE" instead of + // "ALTER TABLE", so they fail at runtime. See the Alter object's sqlStr. // This test verifies the DSL compiles and the statement can be created val person = PersonWithId(id = null, name = "Charlie", age = 35) @@ -1111,10 +1112,10 @@ class CommonBasicTest(private val path: DatabasePath) { try { database { - PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.name + PersonWithIdTable ALTER_ADD_COLUMN PersonWithIdTable.name } } catch (e: Exception) { - // Expected to fail with current implementation due to "ALERT TABLE" typo + // Expected to fail while the generated keyword is still "ALERT TABLE" e.printStackTrace() } @@ -1125,7 +1126,7 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(1, personStatement.getResults().size) assertEquals("Charlie", personStatement.getResults().first().name) - // Test 2: ALERT_RENAME_TABLE_TO with TableObject + // Test 2: ALTER_RENAME_TABLE_TO with TableObject val student1 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 90) val student2 = StudentWithAutoincrement(id = null, studentName = "Ethan", grade = 85) @@ -1143,7 +1144,7 @@ class CommonBasicTest(private val path: DatabasePath) { try { database { - StudentWithAutoincrementTable ALERT_RENAME_TABLE_TO StudentWithAutoincrementTable + StudentWithAutoincrementTable ALTER_RENAME_TABLE_TO StudentWithAutoincrementTable } } catch (e: Exception) { // Expected to fail with current implementation @@ -1156,7 +1157,7 @@ class CommonBasicTest(private val path: DatabasePath) { } assertEquals(2, studentStatement2.getResults().size) - // Test 3: ALERT_RENAME_TABLE_TO with String + // Test 3: ALTER_RENAME_TABLE_TO with String val enrollment = Enrollment(studentId = 1, courseId = 101, semester = "Spring 2025") database { @@ -1167,7 +1168,7 @@ class CommonBasicTest(private val path: DatabasePath) { try { database { - "enrollment" ALERT_RENAME_TABLE_TO EnrollmentTable + "enrollment" ALTER_RENAME_TABLE_TO EnrollmentTable } } catch (e: Exception) { // Expected to fail with current implementation @@ -1254,7 +1255,7 @@ class CommonBasicTest(private val path: DatabasePath) { } assertEquals(1, dropStatement.getResults().size) - // Test 7: ALERT operations within a transaction + // Test 7: ALTER operations within a transaction val txPerson1 = PersonWithId(id = null, name = "Grace", age = 28) val txPerson2 = PersonWithId(id = null, name = "Henry", age = 32) @@ -1267,7 +1268,7 @@ class CommonBasicTest(private val path: DatabasePath) { try { database { transaction { - PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.age + PersonWithIdTable ALTER_ADD_COLUMN PersonWithIdTable.age PersonWithIdTable.RENAME_COLUMN("name", PersonWithIdTable.name) } } diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index e5d35d47..a1b03238 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -150,7 +150,7 @@ val database = Database( when (oldVersion) { 1 -> { // Example: Add a new column in version 2 - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email } } } diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 92eacaf7..20caf62c 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -158,7 +158,7 @@ val database = Database( when (oldVersion) { 1 -> { // Example: Add a new column in version 2 - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email } } } diff --git a/sqllin-dsl/doc/modify-database-and-transaction-cn.md b/sqllin-dsl/doc/modify-database-and-transaction-cn.md index 805da063..e2962a58 100644 --- a/sqllin-dsl/doc/modify-database-and-transaction-cn.md +++ b/sqllin-dsl/doc/modify-database-and-transaction-cn.md @@ -4,7 +4,7 @@ ## 表结构操作 -SQLlin 提供了用于管理表结构的类型安全 DSL 操作:CREATE、DROP 和 ALTER(在 API 中称为 ALERT)。 +SQLlin 提供了用于管理表结构的类型安全 DSL 操作:CREATE、DROP 和 ALTER。 ### CREATE - 创建表 @@ -61,7 +61,7 @@ fun sample() { ### ALTER - 修改表结构 -SQLlin 提供了多种 ALTER(ALERT)操作来修改现有的表结构: +SQLlin 提供了多种 ALTER 操作来修改现有的表结构: #### 添加列 @@ -78,7 +78,7 @@ data class Person( fun sample() { database { - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email } } ``` @@ -91,10 +91,10 @@ fun sample() { fun sample() { database { // Rename using Table object - PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + PersonTable ALTER_RENAME_TABLE_TO NewPersonTable // Or rename using old table name as String - "old_person" ALERT_RENAME_TABLE_TO NewPersonTable + "old_person" ALTER_RENAME_TABLE_TO NewPersonTable } } ``` @@ -149,7 +149,7 @@ val database = Database( when (oldVersion) { 1 -> { // Upgrade from version 1 to 2 - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email CREATE(AddressTable) } } diff --git a/sqllin-dsl/doc/modify-database-and-transaction.md b/sqllin-dsl/doc/modify-database-and-transaction.md index 2f480b94..fde5524c 100644 --- a/sqllin-dsl/doc/modify-database-and-transaction.md +++ b/sqllin-dsl/doc/modify-database-and-transaction.md @@ -7,7 +7,7 @@ we start to learn how to write SQL statements with SQLlin. ## Table Structure Operations -SQLlin provides type-safe DSL operations for managing table structures: CREATE, DROP, and ALTER (referred to as ALERT in the API). +SQLlin provides type-safe DSL operations for managing table structures: CREATE, DROP, and ALTER. ### CREATE - Creating Tables @@ -64,7 +64,7 @@ fun sample() { ### ALTER - Modifying Table Structure -SQLlin provides several ALTER (ALERT) operations for modifying existing table structures: +SQLlin provides several ALTER operations for modifying existing table structures: #### Add Column @@ -81,7 +81,7 @@ data class Person( fun sample() { database { - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email } } ``` @@ -94,10 +94,10 @@ Rename an existing table to a new name: fun sample() { database { // Rename using Table object - PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + PersonTable ALTER_RENAME_TABLE_TO NewPersonTable // Or rename using old table name as String - "old_person" ALERT_RENAME_TABLE_TO NewPersonTable + "old_person" ALTER_RENAME_TABLE_TO NewPersonTable } } ``` @@ -152,7 +152,7 @@ val database = Database( when (oldVersion) { 1 -> { // Upgrade from version 1 to 2 - PersonTable ALERT_ADD_COLUMN PersonTable.email + PersonTable ALTER_ADD_COLUMN PersonTable.email CREATE(AddressTable) } } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 1a8b60b4..75dbc334 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -23,7 +23,7 @@ import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.X import com.ctrip.sqllin.dsl.sql.clause.* -import com.ctrip.sqllin.dsl.sql.operation.Alert +import com.ctrip.sqllin.dsl.sql.operation.Alter import com.ctrip.sqllin.dsl.sql.operation.Create import com.ctrip.sqllin.dsl.sql.operation.Delete import com.ctrip.sqllin.dsl.sql.operation.Drop @@ -53,7 +53,7 @@ import kotlin.jvm.JvmName * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION * - **CREATE**: Create tables from data class definitions * - **DROP**: Remove tables from the database - * - **ALERT (ALTER)**: Modify table structures (add columns, rename tables/columns, drop columns) + * - **ALTER**: Modify table structures (add columns, rename tables/columns, drop columns) * * Transaction support: * - Use [transaction] to execute multiple statements atomically @@ -71,7 +71,7 @@ import kotlin.jvm.JvmName * // Create and modify table structure * database { * CREATE(PersonTable) - * PersonTable ALERT_ADD_COLUMN PersonTable.email + * PersonTable ALTER_ADD_COLUMN PersonTable.email * } * * // Modify data, and build a query whose results are read once the scope has exited @@ -728,7 +728,7 @@ public class DatabaseScope internal constructor( @JvmName("drop") public fun Table.DROP(): Unit = DROP(this) - // ========== ALERT (ALTER) Operations ========== + // ========== ALTER Operations ========== /** * Adds a new column to an existing table. @@ -740,7 +740,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_ADD_COLUMN email + * PersonTable ALTER_ADD_COLUMN email * } * ``` * @@ -748,8 +748,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALERT_ADD_COLUMN(column: ClauseElement) { - val statement = Alert.addColumn(this, column, databaseConnection) + public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement) { + val statement = Alter.addColumn(this, column, databaseConnection) addStatement(statement) } @@ -759,7 +759,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -767,8 +767,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALERT_RENAME_TABLE_TO(newTable: Table<*>) { - val statement = Alert.renameTable(tableName, newTable, databaseConnection) + public infix fun Table.ALTER_RENAME_TABLE_TO(newTable: Table<*>) { + val statement = Alter.renameTable(tableName, newTable, databaseConnection) addStatement(statement) } @@ -780,7 +780,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * "old_person" ALERT_RENAME_TABLE_TO NewPersonTable + * "old_person" ALTER_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -789,8 +789,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun String.ALERT_RENAME_TABLE_TO(newTable: Table<*>) { - val statement = Alert.renameTable(this, newTable, databaseConnection) + public infix fun String.ALTER_RENAME_TABLE_TO(newTable: Table<*>) { + val statement = Alter.renameTable(this, newTable, databaseConnection) addStatement(statement) } @@ -813,7 +813,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public fun Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { - val statement = Alert.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) + val statement = Alter.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) addStatement(statement) } @@ -836,7 +836,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement) { - val statement = Alert.renameColumn(this, oldColumnName, newColumn, databaseConnection) + val statement = Alter.renameColumn(this, oldColumnName, newColumn, databaseConnection) addStatement(statement) } @@ -859,7 +859,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public infix fun Table.DROP_COLUMN(column: ClauseElement) { - val statement = Alert.dropColumn(this, column, databaseConnection) + val statement = Alter.dropColumn(this, column, databaseConnection) addStatement(statement) } 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/Alter.kt similarity index 91% rename from sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt rename to sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt index a897c29c..15526d6a 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/Alter.kt @@ -23,10 +23,7 @@ import com.ctrip.sqllin.dsl.sql.statement.SingleStatement import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement /** - * ALERT (ALTER) operation for modifying database table structures. - * - * Note: This is named "Alert" but generates SQL ALTER TABLE statements. The naming follows - * the existing codebase convention. + * ALTER operation for modifying database table structures. * * Supports common table modification operations: * - **ADD COLUMN**: Add a new column to an existing table @@ -38,12 +35,12 @@ import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement * ```kotlin * database { * // Add a new column - * PersonTable ALERT_ADD_COLUMN email + * PersonTable ALTER_ADD_COLUMN email * * // Rename table - * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable * // or from old name - * "old_person" ALERT_RENAME_TABLE_TO NewPersonTable + * "old_person" ALTER_RENAME_TABLE_TO NewPersonTable * * // Rename column * PersonTable.RENAME_COLUMN(oldName, newName) @@ -55,13 +52,13 @@ import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement * } * ``` * - * @see com.ctrip.sqllin.dsl.DatabaseScope.ALERT_ADD_COLUMN - * @see com.ctrip.sqllin.dsl.DatabaseScope.ALERT_RENAME_TABLE_TO + * @see com.ctrip.sqllin.dsl.DatabaseScope.ALTER_ADD_COLUMN + * @see com.ctrip.sqllin.dsl.DatabaseScope.ALTER_RENAME_TABLE_TO * @see com.ctrip.sqllin.dsl.DatabaseScope.RENAME_COLUMN * @see com.ctrip.sqllin.dsl.DatabaseScope.DROP_COLUMN * @author Yuang Qiao */ -internal object Alert : Operation { +internal object Alter : Operation { override val sqlStr: String get() = "ALERT TABLE " diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt index 3c2bdc99..4822c357 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/OtherStatement.kt @@ -77,7 +77,7 @@ public class InsertStatement internal constructor( } /** - * CREATE, DROP, ALERT statement (final form). + * CREATE, DROP, ALTER statement (final form). * * Represents a complete CREATE TABLE operation. Does not support parameterized queries * since DDL statements use direct SQL execution. From 9582b25ef96f413011d2a4994323b823aaf52305 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 16:45:40 +0100 Subject: [PATCH 06/18] Fix the ALTER operations emitting "ALERT TABLE", and rewrite their tests (B10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Alter.sqlStr` produced the invalid keyword `ALERT TABLE`, so every ALTER operation — `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO` (both overloads), `RENAME_COLUMN` (both overloads) and `DROP_COLUMN` — failed at runtime and had never worked. The existing tests hid this. Each of their seven cases wrapped the operation in try/catch, swallowed the exception, and then asserted only that the rows were still present, so none of them asserted anything about the operation itself. Every case was also built on a statement that was invalid to begin with: adding a column that already existed, renaming a table to its own name, or renaming a column onto an existing column's name. They could not have passed even with the correct keyword. They are replaced by a single migration test that drives 'alter_target' from the shape of `AlterBefore` to the shape of `AlterAfter`, reading the table back through the entity that matches the shape it should have at each point, so a step that does not run fails the test instead of passing quietly. `AlterWithLegacy` serves as a probe for whether the dropped column is really gone. Two platform details shape the test. DROP COLUMN requires SQLite 3.35, which the Android framework bundles only from API 34 on, so it runs last and its effect is asserted only where the statement actually executes. The helper reads the query results rather than merely executing the statement, because the Android driver's `rawQuery` is lazy: a missing table or column surfaces only once the cursor is read. Verified on jvmTest, testAndroidHostTest (Robolectric API 26 and 37) and macosArm64Test. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 230 ++++++------------ .../com/ctrip/sqllin/dsl/test/Entities.kt | 51 ++++ .../ctrip/sqllin/dsl/sql/operation/Alter.kt | 2 +- 4 files changed, 134 insertions(+), 150 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f011c0..da35693d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * **Breaking change**: The DSL APIs `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, correcting a misspelling of the SQL keyword `ALTER`. The internal `Alert` operation object is renamed to `Alter` accordingly +* Fix: the ALTER operations emitted the invalid keyword `ALERT TABLE` instead of `ALTER TABLE`, so `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO`, `RENAME_COLUMN` and `DROP_COLUMN` all failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticed * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws * Fix documentation: the `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` examples referenced a `KClass.table` extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index e24a99f1..a1e2bc63 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/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.DatabaseScope import com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI import com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI import com.ctrip.sqllin.dsl.sql.X @@ -1095,198 +1096,129 @@ class CommonBasicTest(private val path: DatabasePath) { } } + /** + * Exercises every ALTER operation as one realistic schema migration. + * + * 'alter_target' is created in the shape of [AlterBefore] (`id`, `name`, `legacy`) and migrated + * to the shape of [AlterAfter] (`id`, `fullName`, `nickname`) by ADD COLUMN, RENAME COLUMN and + * DROP COLUMN, then renamed to 'alter_renamed' and back. Every step is verified by reading the + * table through the entity matching the shape it should have at that point, so a step that does + * not actually run makes the test fail instead of passing quietly. + */ @OptIn(ExperimentalDSLDatabaseAPI::class) fun testSchemaModification() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> - // Test 1: ALTER_ADD_COLUMN - // Note: the ALTER operations still emit the invalid keyword "ALERT TABLE" instead of - // "ALTER TABLE", so they fail at runtime. See the Alter object's sqlStr. - // This test verifies the DSL compiles and the statement can be created - val person = PersonWithId(id = null, name = "Charlie", age = 35) - - database { - PersonWithIdTable { table -> - table INSERT person - } - } - - try { - database { - PersonWithIdTable ALTER_ADD_COLUMN PersonWithIdTable.name - } - } catch (e: Exception) { - // Expected to fail while the generated keyword is still "ALERT TABLE" - e.printStackTrace() - } - - lateinit var personStatement: SelectStatement - database { - personStatement = PersonWithIdTable SELECT X - } - assertEquals(1, personStatement.getResults().size) - assertEquals("Charlie", personStatement.getResults().first().name) - - // Test 2: ALTER_RENAME_TABLE_TO with TableObject - val student1 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 90) - val student2 = StudentWithAutoincrement(id = null, studentName = "Ethan", grade = 85) - database { - StudentWithAutoincrementTable { table -> - table INSERT listOf(student1, student2) + CREATE(AlterBeforeTable) + AlterBeforeTable { table -> + table INSERT AlterBefore(id = null, name = "Charlie", legacy = 7) } } - lateinit var studentStatement1: SelectStatement - database { - studentStatement1 = StudentWithAutoincrementTable SELECT X - } - assertEquals(2, studentStatement1.getResults().size) - - try { - database { - StudentWithAutoincrementTable ALTER_RENAME_TABLE_TO StudentWithAutoincrementTable - } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() - } - - lateinit var studentStatement2: SelectStatement - database { - studentStatement2 = StudentWithAutoincrementTable SELECT X - } - assertEquals(2, studentStatement2.getResults().size) - - // Test 3: ALTER_RENAME_TABLE_TO with String - val enrollment = Enrollment(studentId = 1, courseId = 101, semester = "Spring 2025") + // Reading the migrated shape must fail first: 'nickname' does not exist yet. + assertEquals( + true, + database.selectFails { AlterAfterTable SELECT X }, + "'nickname' should not exist before ADD COLUMN", + ) + // ADD COLUMN. The receiver must be the table whose serializer declares the new column, + // because the column's SQL type is resolved from that descriptor. database { - EnrollmentTable { table -> - table INSERT enrollment - } - } - - try { - database { - "enrollment" ALTER_RENAME_TABLE_TO EnrollmentTable - } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() + AlterAfterTable ALTER_ADD_COLUMN AlterAfterTable.nickname } - lateinit var enrollmentStatement: SelectStatement + // RENAME COLUMN, naming the old column by string. Both entities map to 'alter_target'. database { - enrollmentStatement = EnrollmentTable SELECT X + AlterAfterTable.RENAME_COLUMN("name", AlterAfterTable.fullName) } - assertEquals(1, enrollmentStatement.getResults().size) - assertEquals("Spring 2025", enrollmentStatement.getResults().first().semester) - - // Test 4: RENAME_COLUMN with ClauseElement - val book = Book(name = "Test Book", author = "Test Author", pages = 200, price = 15.99) + // Both steps landed: the table now has 'fullName' and 'nickname', and still 'legacy'. + lateinit var withLegacy: SelectStatement database { - BookTable { table -> - table INSERT book - } - } - - try { - database { - BookTable.RENAME_COLUMN(BookTable.name, BookTable.author) - } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() + withLegacy = AlterWithLegacyTable SELECT X } + assertEquals(1, withLegacy.getResults().size) + assertEquals("Charlie", withLegacy.getResults().first().fullName) + assertEquals(null, withLegacy.getResults().first().nickname) + assertEquals(7, withLegacy.getResults().first().legacy) - lateinit var bookStatement: SelectStatement + lateinit var migrated: SelectStatement database { - bookStatement = BookTable SELECT X + migrated = AlterAfterTable SELECT X } - assertEquals(1, bookStatement.getResults().size) - - // Test 5: RENAME_COLUMN with String - val category = Category(name = "Fiction", code = 100) + assertEquals(1, migrated.getResults().size) + assertEquals("Charlie", migrated.getResults().first().fullName) + assertEquals(null, migrated.getResults().first().nickname) + // RENAME TO, with a Table receiver, inside a transaction. database { - CategoryTable { table -> - table INSERT category - } - } - - try { - database { - CategoryTable.RENAME_COLUMN("name", CategoryTable.code) + transaction { + AlterAfterTable ALTER_RENAME_TABLE_TO AlterRenamedTable } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() } - lateinit var categoryStatement: SelectStatement + lateinit var renamed: SelectStatement database { - categoryStatement = CategoryTable SELECT X + renamed = AlterRenamedTable SELECT X } - assertEquals(1, categoryStatement.getResults().size) - assertEquals(100, categoryStatement.getResults().first().code) - - // Test 6: DROP_COLUMN - val dropPerson = PersonWithId(id = null, name = "Frank", age = 40) + assertEquals(1, renamed.getResults().size) + assertEquals("Charlie", renamed.getResults().first().fullName) - database { - PersonWithIdTable { table -> - table INSERT dropPerson - } - } - - try { - database { - PersonWithIdTable DROP_COLUMN PersonWithIdTable.age - } - } catch (e: Exception) { - // Expected to fail with current implementation or SQLite version - e.printStackTrace() - } + assertEquals( + true, + database.selectFails { AlterAfterTable SELECT X }, + "'alter_target' should not exist after RENAME TO", + ) - lateinit var dropStatement: SelectStatement + // RENAME TO again, this time through the String receiver overload, renaming it back. database { - dropStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Frank") + "alter_renamed" ALTER_RENAME_TABLE_TO AlterAfterTable } - assertEquals(1, dropStatement.getResults().size) - - // Test 7: ALTER operations within a transaction - val txPerson1 = PersonWithId(id = null, name = "Grace", age = 28) - val txPerson2 = PersonWithId(id = null, name = "Henry", age = 32) + lateinit var renamedBack: SelectStatement database { - PersonWithIdTable { table -> - table INSERT listOf(txPerson1, txPerson2) - } + renamedBack = AlterAfterTable SELECT X } + assertEquals(1, renamedBack.getResults().size) + assertEquals("Charlie", renamedBack.getResults().first().fullName) + // DROP COLUMN last, because it needs SQLite 3.35+ (2021) and the Android framework only + // bundles that from API 34 on. Keeping it last means its failure on older SQLite cannot + // disturb the steps above. Where it does run, its effect is asserted. + var legacyDropped = true try { database { - transaction { - PersonWithIdTable ALTER_ADD_COLUMN PersonWithIdTable.age - PersonWithIdTable.RENAME_COLUMN("name", PersonWithIdTable.name) - } + AlterBeforeTable DROP_COLUMN AlterBeforeTable.legacy } } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() + legacyDropped = false } - - lateinit var txStatement: SelectStatement - database { - txStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Grace" OR (PersonWithIdTable.name EQ "Henry")) + if (legacyDropped) { + assertEquals( + true, + database.selectFails { AlterWithLegacyTable SELECT X }, + "'legacy' should be gone after DROP COLUMN", + ) } - assertEquals(2, txStatement.getResults().size) - assertEquals(true, txStatement.getResults().any { it.name == "Grace" }) - assertEquals(true, txStatement.getResults().any { it.name == "Henry" }) } } + /** + * Runs [block] in its own database scope and reports whether the query failed. The results are + * read as well as executed, because the Android driver's `rawQuery` is lazy: a missing table or + * column surfaces only once the cursor is actually read, not when the statement runs. + */ + private fun Database.selectFails(block: DatabaseScope.() -> SelectStatement<*>): Boolean = + try { + var statement: SelectStatement<*>? = null + this.invoke { statement = block() } + statement!!.getResults() + false + } catch (e: Exception) { + true + } + 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) 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 ec27378b..ca7d55db 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 @@ -456,3 +456,54 @@ internal data class InternalVisibility( @PrimaryKey(autoIncrement = true) val id: Long?, val name: String, ) + +/** + * The 'alter_target' table in its shape *before* the migration exercised by + * `testSchemaModification`: it has `name` and `legacy`, and no `nickname`. + */ +@DBRow("alter_target") +@Serializable +data class AlterBefore( + @PrimaryKey(autoIncrement = true) val id: Long?, + val name: String, + val legacy: Int, +) + +/** + * The same 'alter_target' table in its shape *after* the migration: `nickname` has been added, + * `name` has been renamed to `fullName`, and `legacy` has been dropped. Mapping two entities onto + * one table name is what lets the test read the table back through whichever shape it should + * currently have, so a migration step that silently does nothing fails the test. + */ +@DBRow("alter_target") +@Serializable +data class AlterAfter( + @PrimaryKey(autoIncrement = true) val id: Long?, + val fullName: String, + val nickname: String?, +) + +/** + * The migrated shape plus the `legacy` column, used purely as a probe: selecting it succeeds while + * `legacy` is still present and fails once DROP COLUMN has removed it. + */ +@DBRow("alter_target") +@Serializable +data class AlterWithLegacy( + @PrimaryKey(autoIncrement = true) val id: Long?, + val fullName: String, + val nickname: String?, + val legacy: Int, +) + +/** + * Supplies the destination table name for the `ALTER_RENAME_TABLE_TO` step; same shape as + * [AlterAfter]. + */ +@DBRow("alter_renamed") +@Serializable +data class AlterRenamed( + @PrimaryKey(autoIncrement = true) val id: Long?, + val fullName: String, + val nickname: String?, +) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt index 15526d6a..b3dd96cf 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt @@ -61,7 +61,7 @@ import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement internal object Alter : Operation { override val sqlStr: String - get() = "ALERT TABLE " + get() = "ALTER TABLE " private const val ADD_COLUMN = " ADD COLUMN " private const val RENAME_TABLE = " RENAME TO " From a2780160282a7f52ad934067c51e814a4aefb388 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 18:03:46 +0100 Subject: [PATCH 07/18] Generate each SetClause property with its own column's nullability (B12) The processor decided whether a generated `SetClause` property is nullable by reading `ColumnConstraintParser.isRowId`, a parser-level flag that is set once the `@PrimaryKey` column has been parsed and never reset. Every column declared after a `Long?` primary key was therefore generated as nullable, whatever the entity declared. For data class PersonWithId(@PrimaryKey val id: Long?, val name: String, val age: Age) `name` and `age` were generated as `String?` and `Int?`, so `UPDATE SET { name = null }` compiled against a NOT NULL column and failed only at runtime. The behaviour also depended on the order the properties were declared in. The flag was redundant even for the key itself, whose `Long?` type already makes it nullable, so the branch is removed and each property takes the nullability of its own column. A compile-time check in the test module assigns the generated properties to non-null variables; it fails to compile without this fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/test/CommonBasicTest.kt | 13 +++++++++++++ .../com/ctrip/sqllin/processor/ClauseProcessor.kt | 7 +------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da35693d..cced8c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ ### sqllin-processor * Fix: the visibility of the class annotated with `@DBRow` is now propagated to the generated table object. An `internal` `@DBRow` class used to produce a `public` object, which failed to compile with `EXPOSED_SUPER_CLASS`, `EXPOSED_FUNCTION_RETURN_TYPE` and `EXPOSED_RECEIVER_TYPE`. A `@DBRow` class that is neither `public` nor `internal` is now reported as an error +* Fix: every `SetClause` property generated for a column declared after a nullable `Long` `@PrimaryKey` was typed nullable regardless of the column's own declaration, so `UPDATE ... SET { column = null }` compiled against `NOT NULL` columns and failed only at runtime. Each property now takes the nullability its own column declares ## 2.3.0 / 2026-08-20 diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index a1e2bc63..f0500b68 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -1219,6 +1219,19 @@ class CommonBasicTest(private val path: DatabasePath) { true } + /** + * Compile-time check, never called: the generated `SetClause` properties must carry the + * nullability the entity declares. [PersonWithId] declares a `Long?` primary key *followed by* + * non-null columns, which is the order that used to leak the key's nullability into every later + * column. These assignments only compile while `name` and `age` are generated as non-null. + */ + @Suppress("unused", "UNUSED_VARIABLE") + private fun checkSetClauseNullability(clause: SetClause): Unit = with(PersonWithIdTable) { + val id: Long? = clause.id + val name: String = clause.name + val age: Age = clause.age + } + 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) 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 d0935d0c..53ed6f17 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 @@ -182,12 +182,7 @@ class ClauseProcessor( writer.write(" get() = $clauseElementTypeName($elementName, this)\n\n") writer.write(" @ColumnNameDslMaker\n") writer.write(" var SetClause<$className>.$propertyName: ${property.typeName}") - val nullableSymbol = when { - columnConstraintParser.isRowId -> "?\n" - isNotNull -> "\n" - else -> "?\n" - } - writer.write(nullableSymbol) + writer.write(if (isNotNull) "\n" else "?\n") writer.write(" get() = ${getSetClauseGetterValue(property)}\n") writer.write(" set(value) = ${appendFunction(elementName, property)}\n\n") } From e599cb25de775cff4f4269f6c48acac8489a7634 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 18:03:58 +0100 Subject: [PATCH 08/18] Let a @PrimaryKey's nullability decide who supplies its value (B2) Every `@PrimaryKey` property was required to be nullable, unconditionally. That contradicted the annotation's own KDoc, which says a key of any type other than Long must be non-null, and the error message for it, "The primary key must be not-null.", said the opposite of what the check enforced. The test suite had followed the check rather than the documentation (`@PrimaryKey val sku: String?`). The cost was more than an inconvenience. A forced-nullable String key generated `sku TEXT PRIMARY KEY`, and on a rowid table SQLite does not let PRIMARY KEY imply NOT NULL for anything but an INTEGER PRIMARY KEY, so such a key accepted NULL in any number of rows. Several tests inserted products with a NULL SKU. In standard SQL a primary key is NOT NULL whoever supplies it; what differs is only whether an INSERT may leave it out for the database to assign, and only a rowid alias can be assigned. The Kotlin `?` therefore expresses "not assigned yet", not "may be NULL", and that is what it now means: - `Long?`: an INTEGER PRIMARY KEY the database assigns; a plain INSERT omits it. - `Long`: still an INTEGER PRIMARY KEY, and still a rowid alias, but supplied by the caller and written by every INSERT. This is new, and replaces the single-column @CompositePrimaryKey that a caller-supplied numeric key used to need, which produced `BIGINT ... PRIMARY KEY(id)`, not a rowid alias. - any other type: supplied by the caller, must be non-null, and is declared `PRIMARY KEY NOT NULL`. `Long` and `Long?` keys produce the same DDL, so switching between them needs no migration. `autoIncrement = true` now requires a `Long?` key. A nullable key of any other type is rejected, which also covers `ULong?`: it maps to BIGINT, was treated as a rowid because the check accepted BIGINT, and so was left out of INSERT although nothing assigned it, storing NULL. `PrimaryKeyInfo.isRowId` is renamed to `isGeneratedByDatabase`, since a non-null Long key is a rowid alias that the database does not generate. Its KDoc had always described this meaning. The rejection paths were verified by compiling entities that declare a `String?` key, a `ULong?` key and an `autoIncrement` non-null `Long` key. This is a source-incompatible change: drop the `?` from any non-Long @PrimaryKey. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 69 +++++++++++++++++-- .../com/ctrip/sqllin/dsl/test/Entities.kt | 13 +++- .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + sqllin-dsl/doc/getting-start-cn.md | 20 ++++-- sqllin-dsl/doc/getting-start.md | 20 ++++-- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 3 + .../annotation/CreateStatementModifiers.kt | 30 ++++---- .../ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt | 11 +-- .../dsl/sql/compiler/EncodeEntities2SQL.kt | 10 +-- .../dsl/sql/compiler/InsertValuesEncoder.kt | 6 +- .../processor/ColumnConstraintParser.kt | 51 +++++++++----- 14 files changed, 187 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cced8c6e..205402c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### sqllin-dsl * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected +* **Breaking change**: The nullability of a `@PrimaryKey` property now decides who supplies its value. A `Long?` key is assigned by the database, as before. A non-null `Long` key is now allowed: it remains an `INTEGER PRIMARY KEY`, a rowid alias, but is supplied by the caller and written by every `INSERT`. A key of any other type must be non-null and is declared `NOT NULL`. Previously every `@PrimaryKey` was forced to be nullable, against the annotation's own documentation, and because SQLite does not let `PRIMARY KEY` imply `NOT NULL` on such a column, a `String` key could hold `NULL` in any number of rows. `autoIncrement = true` now requires a `Long?` key, and a `ULong?` key, which used to be stored as `NULL` because it was left out of `INSERT` without being a rowid alias, is now rejected. To migrate, drop the `?` from any non-`Long` `@PrimaryKey`; a single-column `@CompositePrimaryKey` that only existed to hold a caller-supplied `Long` key can become `@PrimaryKey val id: Long` * **Breaking change**: The DSL APIs `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, correcting a misspelling of the SQL keyword `ALTER`. The internal `Alert` operation object is renamed to `Alter` accordingly * Fix: the ALTER operations emitted the invalid keyword `ALERT TABLE` instead of `ALTER TABLE`, so `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO`, `RENAME_COLUMN` and `DROP_COLUMN` all failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticed * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws diff --git a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index f25d179c..08252927 100644 --- a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -87,6 +87,9 @@ class AndroidTest { @Test fun testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index f0500b68..35bbfc3e 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -551,8 +551,8 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(30, personResults[1].age) // 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) + val product1 = Product(sku = "SKU-WIDGET", name = "Widget", price = 19.99) + val product2 = Product(sku = "SKU-GADGET", name = "Gadget", price = 29.99) lateinit var productStatement: SelectStatement database { @@ -564,8 +564,10 @@ class CommonBasicTest(private val path: DatabasePath) { val productResults = productStatement.getResults() assertEquals(2, productResults.size) + assertEquals("SKU-WIDGET", productResults[0].sku) assertEquals("Widget", productResults[0].name) assertEquals(19.99, productResults[0].price) + assertEquals("SKU-GADGET", productResults[1].sku) assertEquals("Gadget", productResults[1].name) assertEquals(29.99, productResults[1].price) @@ -689,7 +691,7 @@ class CommonBasicTest(private val path: DatabasePath) { fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) - val product = Product(sku = null, name = "Thingamajig", price = 49.99) + val product = Product(sku = "SKU-THING", name = "Thingamajig", price = 49.99) lateinit var personStatement: SelectStatement lateinit var productStatement: SelectStatement @@ -1232,6 +1234,60 @@ class CommonBasicTest(private val path: DatabasePath) { val age: Age = clause.age } + /** + * Covers how a single `@PrimaryKey`'s nullability decides who supplies its value: a `Long?` key is + * assigned by the database; a non-null `Long` key is supplied by the caller yet stays a rowid alias; + * and a key of any other type is supplied by the caller and declared `NOT NULL`, which SQLite would + * otherwise not imply for it. + */ + @OptIn(ExperimentalDSLDatabaseAPI::class) + fun testPrimaryKeyNullability() { + // Both Long keys are rowid aliases; only the non-Long key needs NOT NULL spelled out. + assertEquals(true, PersonWithIdTable.createSQL.contains("id INTEGER PRIMARY KEY,")) + assertEquals(true, RemoteMovieTable.createSQL.contains("id INTEGER PRIMARY KEY,")) + assertEquals(true, ProductTable.createSQL.contains("sku TEXT PRIMARY KEY NOT NULL,")) + + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + database { + CREATE(RemoteMovieTable) + } + + // A caller-supplied Long key is written by a plain INSERT rather than left for the database. + lateinit var movies: SelectStatement + database { + RemoteMovieTable { table -> + table INSERT listOf( + RemoteMovie(id = 603, title = "The Matrix"), + RemoteMovie(id = 27205, title = "Inception"), + ) + movies = table SELECT ORDER_BY(id to ASC) + } + } + assertEquals(listOf(603L, 27205L), movies.getResults().map { it.id }) + + // ...and it is a real primary key: inserting the same ID again is rejected. + var duplicateFailed = false + try { + database { + RemoteMovieTable INSERT RemoteMovie(id = 603, title = "The Matrix Reloaded") + } + } catch (e: Exception) { + duplicateFailed = true + } + assertEquals(true, duplicateFailed, "A duplicate caller-supplied key should be rejected") + + // A Long? key is still assigned by the database. + lateinit var people: SelectStatement + database { + PersonWithIdTable { table -> + table INSERT PersonWithId(id = null, name = "Ivy", age = 21) + people = table SELECT X + } + } + assertNotEquals(null, people.getResults().first().id) + } + } + 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) @@ -1633,15 +1689,16 @@ class CommonBasicTest(private val path: DatabasePath) { ProductTable.CREATE_UNIQUE_INDEX("idx_unique_product_name", ProductTable.name) } - val product1 = Product(sku = null, name = "Widget", price = 19.99) + val product1 = Product(sku = "SKU-WIDGET-1", name = "Widget", price = 19.99) database { ProductTable { table -> table INSERT product1 } } - // Try to insert duplicate - should fail - val product2 = Product(sku = null, name = "Widget", price = 29.99) + // Try to insert duplicate - should fail. The SKU differs on purpose, so the only constraint the + // second product can violate is the unique index on 'name'. + val product2 = Product(sku = "SKU-WIDGET-2", name = "Widget", price = 29.99) var duplicateFailed = false try { database { 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 ca7d55db..98a0b89d 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 @@ -116,7 +116,7 @@ data class PersonWithId( @DBRow("product") @Serializable data class Product( - @PrimaryKey val sku: String?, + @PrimaryKey val sku: String, val name: String, val price: Price, ) @@ -507,3 +507,14 @@ data class AlterRenamed( val fullName: String, val nickname: String?, ) + +/** + * A non-null `Long` primary key: supplied by the caller, as an ID assigned by a remote service would + * be, yet still an `INTEGER PRIMARY KEY` and so still an alias for SQLite's rowid. + */ +@DBRow("remote_movie") +@Serializable +data class RemoteMovie( + @PrimaryKey val id: Long, + val title: String, +) 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 e44a5bf5..08a65ba2 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 testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() 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 ef1f1367..12695beb 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 testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index a1b03238..dca4b602 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -217,11 +217,23 @@ data class Person( ) ``` -**重要的类型和可空性规则:** +**重要的类型和可空性规则:** 属性的可空性决定了主键的值由谁提供。 -- **对于自增的 `Long` 主键**:属性**必须**声明为可空类型(`Long?`)。这会映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 +- **`Long?`,由数据库分配**:映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 -- **对于其他类型(String、Int 等)**:属性**必须**是非空的。插入时必须提供唯一值: +- **`Long`,由你提供**:同样映射到 `INTEGER PRIMARY KEY`,因此仍然是 `rowid` 的别名,但每次插入都会写入你提供的值。适用于来自外部的数字主键,例如远端服务分配的 ID: + +```kotlin +@DBRow +@Serializable +data class Movie( + @PrimaryKey + val id: Long, // Non-nullable, user-provided, still a rowid alias + val title: String, +) +``` + +- **其他类型(String、Int 等),由你提供**:属性**必须**是非空的,映射为 `TEXT PRIMARY KEY NOT NULL` 这样的列。除 `Long` 以外任何类型的可空主键都会导致编译错误。插入时必须提供唯一值: ```kotlin @DBRow @@ -233,7 +245,7 @@ data class User( ) ``` -`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。这仅对 `Long?` 属性有意义。 +`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。它要求属性为 `Long?`,这是唯一一种由数据库分配值的主键。 #### 使用 @CompositePrimaryKey 定义组合主键 diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 20caf62c..1a479bc0 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -227,11 +227,23 @@ data class Person( ) ``` -**Important type and nullability rules:** +**Important type and nullability rules:** the nullability of the property decides who supplies the key's value. -- **For `Long` primary keys with auto-increment**: The property **must** be declared as nullable (`Long?`). This maps to SQLite's `INTEGER PRIMARY KEY` which acts as an alias for the internal `rowid`. When inserting a new record with `id = null`, SQLite automatically generates the ID. +- **`Long?`, assigned by the database**: This maps to SQLite's `INTEGER PRIMARY KEY`, which acts as an alias for the internal `rowid`. When inserting a new record with `id = null`, SQLite automatically generates the ID. -- **For other types (String, Int, etc.)**: The property **must** be non-nullable. You must provide a unique value when inserting: +- **`Long`, supplied by you**: This also maps to `INTEGER PRIMARY KEY`, so it is still a `rowid` alias, but every insert writes the value you provide. Use it for numeric keys that come from elsewhere, such as IDs assigned by a remote service: + +```kotlin +@DBRow +@Serializable +data class Movie( + @PrimaryKey + val id: Long, // Non-nullable, user-provided, still a rowid alias + val title: String, +) +``` + +- **Other types (String, Int, etc.), supplied by you**: The property **must** be non-nullable, and maps to a column such as `TEXT PRIMARY KEY NOT NULL`. A nullable primary key of any type other than `Long` is a compile-time error. You must provide a unique value when inserting: ```kotlin @DBRow @@ -243,7 +255,7 @@ data class User( ) ``` -The `autoIncrement` parameter enables stricter auto-incrementing behavior (using `AUTOINCREMENT` keyword), ensuring row IDs are never reused. This is only meaningful for `Long?` properties. +The `autoIncrement` parameter enables stricter auto-incrementing behavior (using `AUTOINCREMENT` keyword), ensuring row IDs are never reused. It requires a `Long?` property, the only kind of key the database assigns. #### Composite Primary Key with @CompositePrimaryKey diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 75dbc334..ae174398 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -220,6 +220,9 @@ public class DatabaseScope internal constructor( * the database auto-generate it. For normal inserts where the database should generate IDs * automatically, use [INSERT] instead. * + * This only matters for a `Long?` primary key. If the key is always supplied by the caller, + * declare it as a non-null `Long` instead, and a plain [INSERT] writes it. + * * This function is particularly useful for: * - Data migration from another database where you need to preserve existing IDs * - Testing scenarios where you need predictable, specific ID values diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt index 3246b077..a80dad9a 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt @@ -30,25 +30,29 @@ package com.ctrip.sqllin.dsl.annotation * 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: + * The nullability of the property decides who supplies the key's value: * - * - **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`). + * - **`Long?`: assigned by the database.** + * The property maps to an `INTEGER PRIMARY KEY` column, an alias for SQLite's internal `rowid`. + * Insert an object whose key is `null` and the database assigns the next ID; a plain `INSERT` + * leaves the column out for that reason. * - * - **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. + * - **`Long`: supplied by the caller.** + * The property still maps to an `INTEGER PRIMARY KEY` column, so it is still an alias for `rowid`, + * but every `INSERT` writes the value you provide. Use this for a numeric key that comes from + * elsewhere, such as an ID assigned by a remote service. + * + * - **Any other type (e.g. `String`, `Int`): supplied by the caller, and must be non-null.** + * The property maps to a column such as `TEXT PRIMARY KEY NOT NULL`. A nullable key of any type + * other than `Long` is a compile-time error, since nothing would ever assign its value. The + * `NOT NULL` is spelled out because SQLite, unlike standard SQL, does not let `PRIMARY KEY` imply it + * on such a column. * * @property autoIncrement 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. + * **Important Note**: This parameter requires a property of type `Long?`, the only kind of key the + * database assigns. Setting it to `true` on any other property is a compile-time error. * * @see DBRow * @see CompositePrimaryKey diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt index 5d693648..8841e5ff 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt @@ -28,14 +28,16 @@ package com.ctrip.sqllin.dsl.sql * When a table has a single primary key column (marked with `@PrimaryKey`): * - [primaryKeyName] contains the column name * - [compositePrimaryKeys] is `null` - * - [isRowId] is `true` if the key is a `Long?` type (maps to SQLite's INTEGER PRIMARY KEY/rowid) + * - [isGeneratedByDatabase] is `true` if the key is a `Long?`, whose value the database assigns. A + * non-null `Long` key is also an `INTEGER PRIMARY KEY` (a rowid alias) but is supplied by the caller, + * so it is `false` there, as it is for a key of any other type * - [isAutomaticIncrement] is `true` if `@PrimaryKey(autoIncrement = true)` was specified * * **Composite Primary Key:** * When a table has multiple primary key columns (marked with `@CompositePrimaryKey`): * - [primaryKeyName] is `null` * - [compositePrimaryKeys] contains the list of column names forming the composite key - * - [isRowId] is `false` (composite keys cannot use rowid alias) + * - [isGeneratedByDatabase] is `false` (the caller supplies every column of a composite key) * - [isAutomaticIncrement] is `false` (composite keys cannot auto-increment) * * **No Primary Key:** @@ -43,7 +45,8 @@ package com.ctrip.sqllin.dsl.sql * * @property primaryKeyName The name of the single primary key column, or `null` for composite keys * @property isAutomaticIncrement Whether the primary key uses SQLite's AUTOINCREMENT keyword - * @property isRowId Whether the primary key is a `Long?` type that maps to SQLite's rowid + * @property isGeneratedByDatabase Whether the database assigns the primary key's value, in which case + * a plain INSERT leaves the column out * @property compositePrimaryKeys List of column names forming a composite primary key, or `null` for single keys * * @author Yuang Qiao @@ -51,6 +54,6 @@ package com.ctrip.sqllin.dsl.sql public class PrimaryKeyInfo( internal val primaryKeyName: String?, internal val isAutomaticIncrement: Boolean, - internal val isRowId: Boolean, + internal val isGeneratedByDatabase: Boolean, internal val compositePrimaryKeys: List?, ) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt index 229d5b50..227f6e88 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt @@ -34,14 +34,16 @@ import kotlinx.serialization.descriptors.SerialDescriptor * ``` * * Handles primary key logic: - * - For auto-increment `Long?` primary keys, omits the ID column unless [isInsertWithId] is true - * - For user-provided primary keys or composite keys, includes all columns + * - For a `Long?` primary key, whose value the database assigns, omits the ID column unless + * [isInsertWithId] is true + * - For a primary key the caller supplies (a non-null `Long`, any other type, or a composite key), + * includes all columns * * @param table The table definition containing serialization and primary key metadata * @param builder StringBuilder to append the SQL to * @param values The entities to insert * @param parameters Mutable list to collect parameterized query values - * @param isInsertWithId Whether to include the primary key column for rowid-backed keys + * @param isInsertWithId Whether to include the primary key column even when the database would assign it */ internal fun encodeEntities2InsertValues( table: Table, @@ -51,7 +53,7 @@ internal fun encodeEntities2InsertValues( isInsertWithId: Boolean, ) = with(builder) { val isInsertId = table.primaryKeyInfo?.run { - !isRowId || isInsertWithId + !isGeneratedByDatabase || isInsertWithId } ?: true val serializer = table.kSerializer() append('(') 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 5dd86bde..1774afd8 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 @@ -30,14 +30,14 @@ import kotlinx.serialization.modules.SerializersModule * parameterized VALUES clauses. All values (including null, numbers, strings, ByteArray, etc.) * are converted to `?` placeholders and collected in [parameters] for safe execution. * - * Automatically skips the primary key field if [primaryKeyName] is provided, allowing - * database auto-increment to generate the value. + * Leaves out the primary key field named [primaryKeyName] when [isInsertId] is `false`, so that the + * database assigns its value. * * Example output: `(?, ?, ?)` with parameters: ["John", 30, byteArray] * * @param parameters Mutable list to accumulate parameter values * @param primaryKeyName Name of primary key field to skip, or null to include all fields - * @param isInsertId whether ignore encoding the special primary key that represents rowid in SQLite + * @param isInsertId Whether to encode the primary key field; `false` leaves it for the database to assign * * @author Yuang Qiao */ diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt index 3971e6fb..b282b501 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt @@ -65,9 +65,10 @@ import java.io.Writer * * ### Validation Rules * - Cannot use both [@PrimaryKey] and [@CompositePrimaryKey] on the same property - * - Primary key properties must be nullable (SQLite rowid aliasing requirement) + * - A [@PrimaryKey] may be nullable only when it is a `Long`: a `Long?` key is left for the database + * to assign, while a non-null `Long` or a key of any other type is supplied by the caller * - Only one [@PrimaryKey] annotation allowed per table - * - AUTOINCREMENT requires Long type (mapped to INTEGER in SQLite) + * - AUTOINCREMENT requires a `Long?` key, the only kind of key the database assigns * - [@CollateNoCase] can only be applied to String or Char properties * - [@CompositePrimaryKey] properties must be non-nullable * @@ -92,7 +93,8 @@ class ColumnConstraintParser(resolver: Resolver) { const val PROMPT_CANT_ADD_BOTH_ANNOTATION = "You can't add both @PrimaryKey and @CompositePrimaryKey to the same property." 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 "autoIncrement = true" in annotation PrimaryKey.""" + const val PROMPT_NULLABLE_PRIMARY_KEY_MUST_BE_LONG = "Only a primary key of type Long can be nullable, which leaves its value for the database to assign. A primary key of any other type is supplied by the caller and must be not-null." + const val PROMPT_AUTO_INCREMENT_REQUIRES_NULLABLE_LONG = """The parameter "autoIncrement = true" in annotation PrimaryKey requires the primary key to be a nullable Long (Long?), the only kind of key whose value the database assigns.""" 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." } @@ -105,8 +107,7 @@ class ColumnConstraintParser(resolver: Resolver) { // Primary key tracking for metadata generation private var primaryKeyName: String? = null private var isAutomaticIncrement = false - var isRowId = false - private set + private var isGeneratedByDatabase = false private val compositePrimaryKeys = ArrayList() private var isContainsPrimaryKey = false @@ -131,8 +132,16 @@ class ColumnConstraintParser(resolver: Resolver) { * #### Primary Key * ```kotlin * @PrimaryKey(autoIncrement = true) - * val id: Long? + * val id: Long? // assigned by the database * // Generated: id INTEGER PRIMARY KEY AUTOINCREMENT + * + * @PrimaryKey + * val id: Long // supplied by the caller, still a rowid alias + * // Generated: id INTEGER PRIMARY KEY + * + * @PrimaryKey + * val sku: String // supplied by the caller + * // Generated: sku TEXT PRIMARY KEY NOT NULL * ``` * * #### Composite Primary Key @@ -161,9 +170,9 @@ class ColumnConstraintParser(resolver: Resolver) { * * ### Processing Order * 1. Determine SQLite type via [getSQLiteType] - * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present + * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present, plus NOT NULL unless it is a rowid alias * 3. Collect [@CompositePrimaryKey] columns for table-level constraint - * 4. Apply NOT NULL for non-nullable, non-PK columns + * 4. Apply NOT NULL for other non-nullable, non-PK columns * 5. Apply COLLATE NOCASE if [@CollateNoCase] present * 6. Apply UNIQUE if [@Unique] present * 7. Collect [@CompositeUnique] groups for table-level constraints @@ -173,7 +182,7 @@ class ColumnConstraintParser(resolver: Resolver) { * - Sets [primaryKeyName] for single-column primary keys * - Adds to [compositePrimaryKeys] for composite primary keys * - Populates [compositeUniqueColumns] for composite unique constraints - * - Updates [isAutomaticIncrement] and [isRowId] flags + * - Updates [isAutomaticIncrement] and [isGeneratedByDatabase] flags * * @param createSQLBuilder StringBuilder to append column definition and constraints to * @param property The property declaration to process @@ -203,22 +212,30 @@ class ColumnConstraintParser(resolver: Resolver) { // Handle @PrimaryKey annotation if (isPrimaryKey) { check(!annotationKSType.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 + // Only a Long key becomes `INTEGER PRIMARY KEY`, an alias of SQLite's rowid, and only a rowid + // alias gets its value assigned by the database. Declaring that key nullable is what asks the + // database to assign it; any other key is supplied by the caller, so it can't be nullable. + val isRowIdAlias = type == " INTEGER" + check(isNotNull || isRowIdAlias) { PROMPT_NULLABLE_PRIMARY_KEY_MUST_BE_LONG } + isGeneratedByDatabase = isRowIdAlias && !isNotNull + 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 } + check(isGeneratedByDatabase) { PROMPT_AUTO_INCREMENT_REQUIRES_NULLABLE_LONG } append(" AUTOINCREMENT") } - isRowId = isLong + + // On a rowid table SQLite doesn't let PRIMARY KEY imply NOT NULL, except for a rowid alias + if (!isRowIdAlias) + append(" NOT NULL") } else if (annotationKSType.any { it.isAssignableFrom(compositePrimaryKeyName) }) { // Handle @CompositePrimaryKey - collect for table-level constraint check(isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } @@ -286,7 +303,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = "id", * isAutomaticIncrement = true, - * isRowId = true, + * isGeneratedByDatabase = true, * compositePrimaryKeys = null, * ) * ``` @@ -296,7 +313,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = null, * isAutomaticIncrement = false, - * isRowId = false, + * isGeneratedByDatabase = false, * compositePrimaryKeys = listOf( * "userId", * "productId", @@ -321,7 +338,7 @@ class ColumnConstraintParser(resolver: Resolver) { * This method reads state accumulated by [parseProperty]: * - [primaryKeyName]: Name of single-column primary key (if any) * - [isAutomaticIncrement]: Whether AUTOINCREMENT is enabled - * - [isRowId]: Whether the primary key can serve as SQLite rowid alias + * - [isGeneratedByDatabase]: Whether the database assigns the primary key's value * - [compositePrimaryKeys]: List of columns in composite primary key * - [compositeUniqueColumns]: Map of group number to columns for UNIQUE constraints * @@ -344,7 +361,7 @@ class ColumnConstraintParser(resolver: Resolver) { write(" primaryKeyName = \"$primaryKeyName\",\n") } write(" isAutomaticIncrement = $isAutomaticIncrement,\n") - write(" isRowId = $isRowId,\n") + write(" isGeneratedByDatabase = $isGeneratedByDatabase,\n") if (compositePrimaryKeys.isEmpty()) { write(" compositePrimaryKeys = null,\n") } else { From 096cafbd57d30fe26dfea8b8d2b8ae0a36454335 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 19:22:52 +0100 Subject: [PATCH 09/18] Reject a @CompositePrimaryKey on a single property (B14) Standard SQL accepts a one-column table constraint, `PRIMARY KEY(col)`, and it means the same as declaring `PRIMARY KEY` on the column itself, so this is not a question of SQL validity. It is one of API shape: `@CompositePrimaryKey` is documented as a key that "consists of multiple columns", and a single-column key already has `@PrimaryKey`. Allowing both was not harmless. Whether SQLite makes a single-column key a rowid alias depends only on its declared type being exactly INTEGER, and the two paths disagreed on that for a Long: `@PrimaryKey` maps it to INTEGER, a rowid alias, while `@CompositePrimaryKey` maps it to BIGINT, which is not. The same intent, a numeric key the caller supplies, therefore produced two different storage layouts depending on which annotation was picked. That was the only way to express such a key before `@PrimaryKey val id: Long` became possible. The processor now rejects a `@CompositePrimaryKey` that ends up with exactly one column, pointing to `@PrimaryKey`. The count is only known once every property has been parsed, so the check runs when the primary key metadata is generated. This is a source-incompatible change: replace a lone `@CompositePrimaryKey` with `@PrimaryKey`. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + sqllin-dsl/doc/getting-start-cn.md | 2 +- sqllin-dsl/doc/getting-start.md | 2 +- .../sqllin/dsl/annotation/CreateStatementModifiers.kt | 3 ++- .../com/ctrip/sqllin/processor/ColumnConstraintParser.kt | 7 +++++++ 5 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 205402c6..494cae41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * **Breaking change**: The nullability of a `@PrimaryKey` property now decides who supplies its value. A `Long?` key is assigned by the database, as before. A non-null `Long` key is now allowed: it remains an `INTEGER PRIMARY KEY`, a rowid alias, but is supplied by the caller and written by every `INSERT`. A key of any other type must be non-null and is declared `NOT NULL`. Previously every `@PrimaryKey` was forced to be nullable, against the annotation's own documentation, and because SQLite does not let `PRIMARY KEY` imply `NOT NULL` on such a column, a `String` key could hold `NULL` in any number of rows. `autoIncrement = true` now requires a `Long?` key, and a `ULong?` key, which used to be stored as `NULL` because it was left out of `INSERT` without being a rowid alias, is now rejected. To migrate, drop the `?` from any non-`Long` `@PrimaryKey`; a single-column `@CompositePrimaryKey` that only existed to hold a caller-supplied `Long` key can become `@PrimaryKey val id: Long` +* **Breaking change**: `@CompositePrimaryKey` now requires at least two properties. A single-column primary key is declared with `@PrimaryKey`, which for a `Long` key maps to `INTEGER`, a rowid alias, where a single-column `@CompositePrimaryKey` mapped it to `BIGINT`. To migrate, replace a lone `@CompositePrimaryKey` with `@PrimaryKey` * **Breaking change**: The DSL APIs `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, correcting a misspelling of the SQL keyword `ALTER`. The internal `Alert` operation object is renamed to `Alter` accordingly * Fix: the ALTER operations emitted the invalid keyword `ALERT TABLE` instead of `ALTER TABLE`, so `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO`, `RENAME_COLUMN` and `DROP_COLUMN` all failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticed * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index dca4b602..74d96d1d 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -269,7 +269,7 @@ data class Enrollment( **重要规则:** -- 你可以在同一个类中对**多个属性**应用 `@CompositePrimaryKey` +- 必须在同一个类中对**至少两个属性**应用 `@CompositePrimaryKey`;只标注一个会导致编译错误,单列主键应使用 `@PrimaryKey` - 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的** - 你**不能**在同一个类中混合使用 `@PrimaryKey` 和 `@CompositePrimaryKey` - 只能使用其中一个 - 所有 `@CompositePrimaryKey` 属性的组合形成表的组合主键 diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 1a479bc0..d496e7de 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -279,7 +279,7 @@ data class Enrollment( **Important rules:** -- You can apply `@CompositePrimaryKey` to **multiple properties** in the same class +- Apply `@CompositePrimaryKey` to **at least two properties** in the same class; annotating only one is a compile-time error, since a single-column primary key is declared with `@PrimaryKey` - All properties with `@CompositePrimaryKey` **must be non-nullable** - 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 diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt index a80dad9a..8b41a6b3 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt @@ -70,7 +70,8 @@ public annotation class PrimaryKey(val autoIncrement: Boolean = false) * will form the table's composite primary key. * * ### Important Rules - * - A class can have multiple properties annotated with [CompositePrimaryKey]. + * - At least two properties must be annotated with [CompositePrimaryKey]; annotating only one is a + * compile-time error. A single-column primary key is declared with [PrimaryKey]. * - 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. diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt index b282b501..223da095 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt @@ -71,6 +71,7 @@ import java.io.Writer * - AUTOINCREMENT requires a `Long?` key, the only kind of key the database assigns * - [@CollateNoCase] can only be applied to String or Char properties * - [@CompositePrimaryKey] properties must be non-nullable + * - [@CompositePrimaryKey] needs at least two properties; a single-column key uses [@PrimaryKey] * * @param resolver KSP resolver for looking up annotation types * @@ -95,6 +96,7 @@ class ColumnConstraintParser(resolver: Resolver) { const val PROMPT_PRIMARY_KEY_MUST_NOT_NULL = "The primary key must be not-null." const val PROMPT_NULLABLE_PRIMARY_KEY_MUST_BE_LONG = "Only a primary key of type Long can be nullable, which leaves its value for the database to assign. A primary key of any other type is supplied by the caller and must be not-null." const val PROMPT_AUTO_INCREMENT_REQUIRES_NULLABLE_LONG = """The parameter "autoIncrement = true" in annotation PrimaryKey requires the primary key to be a nullable Long (Long?), the only kind of key whose value the database assigns.""" + const val PROMPT_COMPOSITE_PRIMARY_KEY_SINGLE_COLUMN = "A composite primary key needs at least two columns. Use @PrimaryKey for a single-column primary key, such as `@PrimaryKey val id: Long` for a numeric key you supply yourself." 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." } @@ -349,6 +351,11 @@ class ColumnConstraintParser(resolver: Resolver) { * @see com.ctrip.sqllin.dsl.sql.PrimaryKeyInfo */ fun generateCodeForPrimaryKey(writer: Writer, createSQLBuilder: StringBuilder) { + // Standard SQL accepts a one-column `PRIMARY KEY(col)`, but @PrimaryKey already declares that key, and does it + // better for a Long: it maps to INTEGER, a rowid alias, where this path maps a Long to BIGINT. Only known here, + // once every property has been parsed. + check(compositePrimaryKeys.size != 1) { PROMPT_COMPOSITE_PRIMARY_KEY_SINGLE_COLUMN } + // Write the override instance for property `primaryKeyInfo`. with(writer) { if (primaryKeyName == null && compositePrimaryKeys.isEmpty()) { From ed000c40bd1353cc8bf2f658fa57db803090a681 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 19:25:16 +0100 Subject: [PATCH 10/18] Declare the columns of a composite primary key NOT NULL (B4) A `@CompositePrimaryKey` produced, for example, enrollment(studentId BIGINT,courseId BIGINT,...,PRIMARY KEY(studentId,courseId)) On a rowid table SQLite, unlike standard SQL, does not let a table-level PRIMARY KEY imply NOT NULL, so these columns accepted NULL, and with it the key stopped identifying rows: two rows with the key (NULL, 101) are both accepted, because a unique index treats every NULL as distinct. SQLlin itself cannot write those NULLs. The key columns are non-null Kotlin types, the generated SetClause properties are non-null since the previous fix, and the processor already refuses an ON DELETE SET NULL foreign key on a non-null column. Anything else writing to the database can, though, and SQLlin then reads such a row back without complaint, as 0 or an empty string, so two rows keyed (NULL, 101) surface as two entities keyed (0, 101). NOT NULL used to be appended in two places: inside the @PrimaryKey branch, and in a final `else if` that composite key columns never reached. It is now one rule after the branch: every non-null column is declared NOT NULL except a rowid alias, which is the only column SQLite itself keeps from being NULL. Across all 33 tables generated by the test module, the only DDL that changes is that of the two composite-key tables. This only affects tables created from now on. An existing table keeps its schema, since SQLite cannot add NOT NULL to an existing column. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 5 ++++ sqllin-dsl/doc/getting-start-cn.md | 2 +- sqllin-dsl/doc/getting-start.md | 2 +- .../annotation/CreateStatementModifiers.kt | 4 ++- .../processor/ColumnConstraintParser.kt | 28 +++++++++---------- 6 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 494cae41..63ada05b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ * Fix: the visibility of the class annotated with `@DBRow` is now propagated to the generated table object. An `internal` `@DBRow` class used to produce a `public` object, which failed to compile with `EXPOSED_SUPER_CLASS`, `EXPOSED_FUNCTION_RETURN_TYPE` and `EXPOSED_RECEIVER_TYPE`. A `@DBRow` class that is neither `public` nor `internal` is now reported as an error * Fix: every `SetClause` property generated for a column declared after a nullable `Long` `@PrimaryKey` was typed nullable regardless of the column's own declaration, so `UPDATE ... SET { column = null }` compiled against `NOT NULL` columns and failed only at runtime. Each property now takes the nullability its own column declares +* Fix: the columns of a `@CompositePrimaryKey` are now declared `NOT NULL`. SQLite, unlike standard SQL, does not let a table-level `PRIMARY KEY` imply it on a rowid table, so such a key used to accept `NULL`, and any number of rows sharing the same key once a `NULL` was part of it. SQLlin itself could not write those `NULL`s, but anything else writing to the database could, and SQLlin then read them back as `0` or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add `NOT NULL` to an existing column ## 2.3.0 / 2026-08-20 diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index 35bbfc3e..d6eae6c6 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -1791,6 +1791,11 @@ class CommonBasicTest(private val path: DatabasePath) { val enrollmentSQL = EnrollmentTable.createSQL assertEquals(true, enrollmentSQL.contains("CREATE TABLE enrollment")) assertEquals(true, enrollmentSQL.contains("PRIMARY KEY(studentId,courseId)")) + // SQLite doesn't let a table-level PRIMARY KEY imply NOT NULL, so each key column must declare it + assertEquals(true, enrollmentSQL.contains("studentId BIGINT NOT NULL,")) + assertEquals(true, enrollmentSQL.contains("courseId BIGINT NOT NULL,")) + assertEquals(true, FKProductTable.createSQL.contains("categoryId INT NOT NULL,")) + assertEquals(true, FKProductTable.createSQL.contains("productCode TEXT NOT NULL,")) // Test 4: Table with enum fields (stored as INT) val userSQL = UserAccountTable.createSQL diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index 74d96d1d..b138cffd 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -270,7 +270,7 @@ data class Enrollment( **重要规则:** - 必须在同一个类中对**至少两个属性**应用 `@CompositePrimaryKey`;只标注一个会导致编译错误,单列主键应使用 `@PrimaryKey` -- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的** +- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的**,并在生成的表中声明为 `NOT NULL` - 你**不能**在同一个类中混合使用 `@PrimaryKey` 和 `@CompositePrimaryKey` - 只能使用其中一个 - 所有 `@CompositePrimaryKey` 属性的组合形成表的组合主键 diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index d496e7de..f454a5de 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -280,7 +280,7 @@ data class Enrollment( **Important rules:** - Apply `@CompositePrimaryKey` to **at least two properties** in the same class; annotating only one is a compile-time error, since a single-column primary key is declared with `@PrimaryKey` -- All properties with `@CompositePrimaryKey` **must be non-nullable** +- All properties with `@CompositePrimaryKey` **must be non-nullable**, and are declared `NOT NULL` in the generated table - 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 diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt index 8b41a6b3..f53966f2 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt @@ -76,7 +76,9 @@ public annotation class PrimaryKey(val autoIncrement: Boolean = false) * 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. + * (e.g., `String`, `Int`, `Long`), as primary key columns cannot contain `NULL` values. They are + * declared `NOT NULL` in the generated table, because SQLite, unlike standard SQL, does not let + * `PRIMARY KEY` imply it. * * @see DBRow * @see PrimaryKey diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt index 223da095..db37295c 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ColumnConstraintParser.kt @@ -150,7 +150,7 @@ class ColumnConstraintParser(resolver: Resolver) { * ```kotlin * @CompositePrimaryKey * val userId: Long - * // Column: userId BIGINT + * // Column: userId BIGINT NOT NULL * // Later appended: ,PRIMARY KEY(userId,productId) * ``` * @@ -172,9 +172,9 @@ class ColumnConstraintParser(resolver: Resolver) { * * ### Processing Order * 1. Determine SQLite type via [getSQLiteType] - * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present, plus NOT NULL unless it is a rowid alias + * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present * 3. Collect [@CompositePrimaryKey] columns for table-level constraint - * 4. Apply NOT NULL for other non-nullable, non-PK columns + * 4. Apply NOT NULL to every non-nullable column except a rowid alias, primary key columns included * 5. Apply COLLATE NOCASE if [@CollateNoCase] present * 6. Apply UNIQUE if [@Unique] present * 7. Collect [@CompositeUnique] groups for table-level constraints @@ -211,6 +211,9 @@ class ColumnConstraintParser(resolver: Resolver) { val type = getSQLiteType(property, isPrimaryKey) append(type) + // Only a Long @PrimaryKey becomes `INTEGER PRIMARY KEY`, an alias of SQLite's rowid + val isRowIdAlias = isPrimaryKey && type == " INTEGER" + // Handle @PrimaryKey annotation if (isPrimaryKey) { check(!annotationKSType.any { it.isAssignableFrom(compositePrimaryKeyName) }) { PROMPT_CANT_ADD_BOTH_ANNOTATION } @@ -218,10 +221,8 @@ class ColumnConstraintParser(resolver: Resolver) { isContainsPrimaryKey = true primaryKeyName = propertyName - // Only a Long key becomes `INTEGER PRIMARY KEY`, an alias of SQLite's rowid, and only a rowid - // alias gets its value assigned by the database. Declaring that key nullable is what asks the - // database to assign it; any other key is supplied by the caller, so it can't be nullable. - val isRowIdAlias = type == " INTEGER" + // Only a rowid alias gets its value assigned by the database. Declaring that key nullable is + // what asks the database to assign it; any other key is supplied by the caller, so it can't be nullable. check(isNotNull || isRowIdAlias) { PROMPT_NULLABLE_PRIMARY_KEY_MUST_BE_LONG } isGeneratedByDatabase = isRowIdAlias && !isNotNull @@ -234,19 +235,18 @@ class ColumnConstraintParser(resolver: Resolver) { check(isGeneratedByDatabase) { PROMPT_AUTO_INCREMENT_REQUIRES_NULLABLE_LONG } append(" AUTOINCREMENT") } - - // On a rowid table SQLite doesn't let PRIMARY KEY imply NOT NULL, except for a rowid alias - if (!isRowIdAlias) - append(" NOT NULL") } else if (annotationKSType.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") } + // A rowid alias is the only column SQLite itself keeps from being NULL. On a rowid table, PRIMARY KEY + // doesn't imply NOT NULL for any other column, a single key or a part of a composite one alike, so every + // other non-null column spells it out. + if (isNotNull && !isRowIdAlias) + append(" NOT NULL") + // Handle @CollateNoCase annotation - must be on text columns if (annotationKSType.any { it.isAssignableFrom(noCaseAnnotationName) }) { check(type == " TEXT" || type == " CHAR(1)") { PROMPT_NO_CASE_MUST_FOR_TEXT } From b15d96356ce8c9686f93768459046392869d0143 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 19:37:47 +0100 Subject: [PATCH 11/18] Require @Default for an ON ... SET DEFAULT foreign key (B13) ON DELETE / ON UPDATE SET DEFAULT writes the column's default value, which is NULL when the column declares none. The documentation, in both user guides and in the KDoc of @Default, has always said that a default is required for these triggers, but the processor enforced something else on each of its two paths. On @References the check was inverted. It read check(isNotNull || hasDefaultValue) { "The column must be nullable or have a default value ..." } so it accepted a non-null column without a default, whose parent row then could not be deleted (NOT NULL constraint failed), and rejected a nullable one, while its own message described the opposite rule. On a @ForeignKeyGroup there was no check at all. Both paths now require @Default. A nullable column without one is rejected too: setting it to its default would only set it to NULL, which is what ON_DELETE_SET_NULL already says, so it is most likely a forgotten @Default. The group path cannot check where it reads @ForeignKey, as its SET NULL check does, because @Default may come later among the property's annotations, as it does in the test entity DefaultFKChild. The check is made once every annotation of the property has been read, so it holds whichever order they are written in. Verified by compiling entities that cover both paths, nullable and non-null columns, and @Default before and after the foreign key annotation. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ctrip/sqllin/processor/ForeignKeyParser.kt | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ada05b..572bcf20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ * Fix: the visibility of the class annotated with `@DBRow` is now propagated to the generated table object. An `internal` `@DBRow` class used to produce a `public` object, which failed to compile with `EXPOSED_SUPER_CLASS`, `EXPOSED_FUNCTION_RETURN_TYPE` and `EXPOSED_RECEIVER_TYPE`. A `@DBRow` class that is neither `public` nor `internal` is now reported as an error * Fix: every `SetClause` property generated for a column declared after a nullable `Long` `@PrimaryKey` was typed nullable regardless of the column's own declaration, so `UPDATE ... SET { column = null }` compiled against `NOT NULL` columns and failed only at runtime. Each property now takes the nullability its own column declares * Fix: the columns of a `@CompositePrimaryKey` are now declared `NOT NULL`. SQLite, unlike standard SQL, does not let a table-level `PRIMARY KEY` imply it on a rowid table, so such a key used to accept `NULL`, and any number of rows sharing the same key once a `NULL` was part of it. SQLlin itself could not write those `NULL`s, but anything else writing to the database could, and SQLlin then read them back as `0` or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add `NOT NULL` to an existing column +* Fix: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all. Both are now a compile-time error, whichever order `@Default` and the foreign key annotation are written in ## 2.3.0 / 2026-08-20 diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ForeignKeyParser.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ForeignKeyParser.kt index 586a1977..abf8614f 100644 --- a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ForeignKeyParser.kt +++ b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ForeignKeyParser.kt @@ -64,6 +64,7 @@ import com.google.devtools.ksp.symbol.KSClassDeclaration * - [@ForeignKeyGroup] groups must have unique group numbers * - [@ForeignKey] annotations must reference a declared [@ForeignKeyGroup] * - Properties with `ON_DELETE_SET_NULL` or `ON_UPDATE_SET_NULL` must be nullable + * - Properties with `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` must declare [@Default] * - [@References] foreignKeys array cannot be empty * - Foreign key groups must have at least one [@ForeignKey] property * @@ -186,6 +187,8 @@ class ForeignKeyParser { * - Ensures `tableName` is not blank * - Validates that `foreignKeys` array is not empty * - Checks that properties with SET_NULL triggers are nullable + * - Checks that properties with SET_DEFAULT triggers declare a default value, wherever the + * [@Default] annotation appears relative to the foreign key one * - Verifies that referenced [@ForeignKeyGroup] exists * * @param createSQLBuilder StringBuilder to append SQL fragments to (for @References only) @@ -202,6 +205,7 @@ class ForeignKeyParser { isNotNull: Boolean, ) { val columnReferenceEntities = ArrayList() + val setDefaultGroups = ArrayList() var defaultValue = "" annotations.forEach { annotation -> when (annotation.annotationType.resolve().declaration.qualifiedName?.asString()) { @@ -249,6 +253,9 @@ class ForeignKeyParser { if ((triggerEnumName == "ON_DELETE_SET_NULL" || triggerEnumName == "ON_UPDATE_SET_NULL") && isNotNull) { throw IllegalArgumentException("Can't use trigger `ON_DELETE_SET_NULL` or `ON_UPDATE_SET_NULL` on a non-null property in foreign key group `$group`.") } + // Checked once all annotations are read: @Default may come after @ForeignKey + if (triggerEnumName == "ON_DELETE_SET_DEFAULT" || triggerEnumName == "ON_UPDATE_SET_DEFAULT") + setDefaultGroups.add(group) columns.add(propertyName) references.add(reference) } @@ -262,8 +269,15 @@ class ForeignKeyParser { } } + // ON ... SET DEFAULT writes the column's default, which is NULL without @Default. That fails on a non-null + // column, and on a nullable one it is only ON ... SET NULL spelled differently, so a default is required. + val hasDefaultValue = defaultValue.isNotEmpty() + setDefaultGroups.forEach { group -> + if (!hasDefaultValue) + throw IllegalArgumentException("Can't use trigger `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` on a property without @Default in foreign key group `$group`. Without one the column's default is NULL, which fails on a non-null property and is only `ON_DELETE_SET_NULL` or `ON_UPDATE_SET_NULL` on a nullable one.") + } + with(createSQLBuilder) { - val hasDefaultValue = defaultValue.isNotEmpty() if (hasDefaultValue) { append(" DEFAULT ") append(defaultValue) @@ -287,7 +301,7 @@ class ForeignKeyParser { "ON DELETE SET NULL", "ON UPDATE SET NULL" -> check(!isNotNull) { "Can't use trigger `ON_DELETE_SET_NULL` or `ON_UPDATE_SET_NULL` on a non-null property." } "ON DELETE SET DEFAULT", "ON UPDATE SET DEFAULT" -> - check(isNotNull || hasDefaultValue) { "The column must be nullable or have a default value when using trigger 'ON DELETE SET DEFAULT' or 'ON UPDATE SET DEFAULT'" } + check(hasDefaultValue) { "Can't use trigger `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` on a property without @Default. Without one the column's default is NULL, which fails on a non-null property and is only `ON_DELETE_SET_NULL` or `ON_UPDATE_SET_NULL` on a nullable one." } } append(' ') append(it.triggerSQL) From 30b52a6deab80590ab5e749be5301c3098e7a0ac Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 23:46:36 +0100 Subject: [PATCH 12/18] Leave computed properties of a @DBRow class out of the table (B16) The processor collected a class's properties with getAllProperties(), which includes computed properties that have no backing field, such as val title: String get() = "$name by $author" kotlinx.serialization doesn't serialize those, yet each one was given a column, NOT NULL when its type was non-null, and an accessor that looked the column up by its index in the serializer's descriptor. INSERT writes only the serialized properties, so it never filled that column and every insert failed with "NOT NULL constraint failed", and the accessor's index ran past the end of the descriptor. The property list now keeps only properties backed by a field, besides leaving out @Transient ones, so it matches exactly what the serializer writes, in the same order. A body property with an initializer has a backing field, is serialized, and still gets its column. Book now declares a computed `title`. Without this fix, four tests fail with "NOT NULL constraint failed: book.title". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt | 3 +++ .../kotlin/com/ctrip/sqllin/dsl/test/Entities.kt | 6 +++++- .../kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt | 9 ++++++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 572bcf20..e31275e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ * Fix: every `SetClause` property generated for a column declared after a nullable `Long` `@PrimaryKey` was typed nullable regardless of the column's own declaration, so `UPDATE ... SET { column = null }` compiled against `NOT NULL` columns and failed only at runtime. Each property now takes the nullability its own column declares * Fix: the columns of a `@CompositePrimaryKey` are now declared `NOT NULL`. SQLite, unlike standard SQL, does not let a table-level `PRIMARY KEY` imply it on a rowid table, so such a key used to accept `NULL`, and any number of rows sharing the same key once a `NULL` was part of it. SQLlin itself could not write those `NULL`s, but anything else writing to the database could, and SQLlin then read them back as `0` or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add `NOT NULL` to an existing column * Fix: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all. Both are now a compile-time error, whichever order `@Default` and the foreign key annotation are written in +* Fix: a computed property of a `@DBRow` class, one without a backing field such as `val title: String get() = ...`, no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a `NOT NULL` column that `INSERT` never wrote, so every insert failed with `NOT NULL constraint failed`, and an accessor that looked its column up past the end of the serializer's descriptor ## 2.3.0 / 2026-08-20 diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index d6eae6c6..fe35213f 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -1787,6 +1787,9 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(true, studentSQL.contains("CREATE TABLE student_with_autoincrement")) assertEquals(true, studentSQL.contains("id INTEGER PRIMARY KEY AUTOINCREMENT")) + // A computed property isn't serialized, so it must not become a column: Book declares `title` that way + assertEquals(false, BookTable.createSQL.contains("title")) + // Test 3: Table with composite primary key val enrollmentSQL = EnrollmentTable.createSQL assertEquals(true, enrollmentSQL.contains("CREATE TABLE enrollment")) 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 98a0b89d..de7610c3 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 @@ -71,7 +71,11 @@ data class Book( val author: String, val price: Price, val pages: PageCount, -) +) { + // Computed, so kotlinx.serialization doesn't serialize it: it must not become a column, or every INSERT, + // which writes only the serialized properties, would leave that column empty + val title: String get() = "$name by $author" +} @DBRow("category") @Serializable 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 53ed6f17..75e962fa 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 @@ -151,9 +151,12 @@ class ClauseProcessor( append('(') } - // 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) } + // Keep exactly the properties the serializer writes, in its order, as the generated accessors look a column + // up by its index in the serializer's descriptor. That leaves out @Transient properties and, because + // kotlinx.serialization only serializes properties backed by a field, computed ones like `val x get() = ...` + val propertyList = classDeclaration.getAllProperties().filter { property -> + property.hasBackingField && + !property.annotations.any { ksAnnotation -> ksAnnotation.annotationType.resolve().isAssignableFrom(transientName) } }.toList() // Process each property to generate column definitions From 5e6a202d87eee8aff9ae72cad5854defd62f8f9b Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 23:46:36 +0100 Subject: [PATCH 13/18] Reject a @DBRow property whose type no column can hold (B15) The processor skipped a property whose type it couldn't map to a column, such as a List, without saying anything. The property was left out of CREATE TABLE, but its serializer still wrote and read it, so INSERT failed at runtime with "has no column named" and SELECT with "no such column". When it was the last property, the comma already written after the previous column stayed in place, so CREATE TABLE itself failed with a syntax error. Such a property is now a compile-time error that names it, gives its type with its nullability, lists the supported types, and suggests @Transient to keep it out of the table. Every unsupported property of a class is reported at once, with its location, and no table file is generated for the class. The check relies on the previous fix: a computed property isn't serialized, so it isn't checked, whatever its type. TestPrimitiveTypeForKSP now has a @Transient List and a computed List, so the test module stops compiling if either exclusion breaks. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../dsl/test/TestPrimitiveTypeForKSP.kt | 7 +++- sqllin-dsl/doc/getting-start-cn.md | 2 +- sqllin-dsl/doc/getting-start.md | 2 +- .../ctrip/sqllin/processor/ClauseProcessor.kt | 36 +++++++++++++------ 5 files changed, 35 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e31275e0..f0f84fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ * Fix: the columns of a `@CompositePrimaryKey` are now declared `NOT NULL`. SQLite, unlike standard SQL, does not let a table-level `PRIMARY KEY` imply it on a rowid table, so such a key used to accept `NULL`, and any number of rows sharing the same key once a `NULL` was part of it. SQLlin itself could not write those `NULL`s, but anything else writing to the database could, and SQLlin then read them back as `0` or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add `NOT NULL` to an existing column * Fix: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all. Both are now a compile-time error, whichever order `@Default` and the foreign key annotation are written in * Fix: a computed property of a `@DBRow` class, one without a backing field such as `val title: String get() = ...`, no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a `NOT NULL` column that `INSERT` never wrote, so every insert failed with `NOT NULL constraint failed`, and an accessor that looked its column up past the end of the serializer's descriptor +* Fix: a `@DBRow` property of a type no column can hold, such as a `List`, is now a compile-time error naming the property. It used to be skipped silently: left out of `CREATE TABLE` while its serializer still wrote and read it, so `INSERT` and `SELECT` failed at runtime with "no column named", and as the last property it left a trailing comma that made `CREATE TABLE` itself fail. Annotate such a property with `kotlinx.serialization.Transient` to keep it out of the table ## 2.3.0 / 2026-08-20 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 9b62e20f..9f0a29cf 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 @@ -45,4 +45,9 @@ class TestPrimitiveTypeForKSP( val testEnum: Priority, val testTypeAlias: Code, @Transient val testTransient: Int = 0, -) \ No newline at end of file + // No column can hold a List, so this only compiles while @Transient keeps it out of the table + @Transient val testTransientUnsupported: List = emptyList(), +) { + // Nor this, which compiles because a computed property isn't serialized and so isn't a column at all + val testComputedUnsupported: List get() = emptyList() +} \ No newline at end of file diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index b138cffd..6f694dd9 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -490,7 +490,7 @@ val status: String ### 支持的类型 -SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性: +SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性。其他任何类型的属性都会导致编译错误;如果想让这样的属性不进入表中,请为它加上 `kotlinx.serialization.Transient` 注解: #### 数值类型 - **整数类型:** `Byte`、`Short`、`Int`、`Long` diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index f454a5de..72537dd3 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -500,7 +500,7 @@ val status: String ### Supported Types -SQLlin supports the following Kotlin types for properties in `@DBRow` data classes: +SQLlin supports the following Kotlin types for properties in `@DBRow` data classes. A property of any other type is a compile-time error; to keep such a property out of the table, annotate it with `kotlinx.serialization.Transient`: #### Numeric Types - **Integer types:** `Byte`, `Short`, `Int`, `Long` 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 75e962fa..9ac72741 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 @@ -117,6 +117,31 @@ class ClauseProcessor( it.annotationType.resolve().declaration.qualifiedName?.asString() == ANNOTATION_DATABASE_ROW_NAME }?.arguments?.firstOrNull()?.value?.takeIf { (it as? String)?.isNotBlank() == true } ?: className + // Keep exactly the properties the serializer writes, in its order, as the generated accessors look a column + // up by its index in the serializer's descriptor. That leaves out @Transient properties and, because + // kotlinx.serialization only serializes properties backed by a field, computed ones like `val x get() = ...` + val transientName = resolver.getClassDeclarationByName(ANNOTATION_TRANSIENT)!!.asStarProjectedType() + val propertyList = classDeclaration.getAllProperties().filter { property -> + property.hasBackingField && + !property.annotations.any { ksAnnotation -> ksAnnotation.annotationType.resolve().isAssignableFrom(transientName) } + }.toList() + + // Every stored property needs a column. A property of a type no column can hold used to be skipped silently: + // left out of CREATE TABLE while its serializer still wrote and read it, so it only failed at runtime, and as + // the last property it left a trailing comma that made CREATE TABLE itself invalid. Report all of them here. + val unsupportedProperties = propertyList.filter { getClauseElementTypeStr(it) == null } + unsupportedProperties.forEach { property -> + environment.logger.error( + "The property '${property.simpleName.asString()}' of '@DBRow' class '$className' has the type " + + "'${property.type.resolve()}', which no column can hold. Supported types are Byte, Short, Int, Long, " + + "Float, Double and their unsigned variants, Boolean, Char, String, ByteArray, enum classes, and type " + + "aliases of these. To keep the property out of the table, annotate it with @kotlinx.serialization.Transient.", + property, + ) + } + if (unsupportedProperties.isNotEmpty()) + continue + val outputStream = environment.codeGenerator.createNewFile( dependencies = classDeclaration.containingFile?.let { Dependencies(true, it) } ?: Dependencies(true), packageName = packageName, @@ -141,7 +166,6 @@ class ClauseProcessor( writer.write(" override fun kSerializer() = $className.serializer()\n\n") writer.write(" inline operator fun invoke(block: $objectName.(table: $objectName) -> R): R = this.block(this)\n\n") - val transientName = resolver.getClassDeclarationByName(ANNOTATION_TRANSIENT)!!.asStarProjectedType() val columnConstraintParser = ColumnConstraintParser(resolver) @@ -151,17 +175,9 @@ class ClauseProcessor( append('(') } - // Keep exactly the properties the serializer writes, in its order, as the generated accessors look a column - // up by its index in the serializer's descriptor. That leaves out @Transient properties and, because - // kotlinx.serialization only serializes properties backed by a field, computed ones like `val x get() = ...` - val propertyList = classDeclaration.getAllProperties().filter { property -> - property.hasBackingField && - !property.annotations.any { ksAnnotation -> ksAnnotation.annotationType.resolve().isAssignableFrom(transientName) } - }.toList() - // Process each property to generate column definitions propertyList.forEachIndexed { index, property -> - val clauseElementTypeName = getClauseElementTypeStr(property) ?: return@forEachIndexed + val clauseElementTypeName = checkNotNull(getClauseElementTypeStr(property)) // Rejected above val propertyName = property.simpleName.asString() val elementName = "$className.serializer().descriptor.getElementName($index)" val isNotNull = property.type.resolve().nullability == Nullability.NOT_NULL From 751427089fb0608e65d4e0975e20af9d443a1caf Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 23:46:36 +0100 Subject: [PATCH 14/18] Suppress DSL_MARKER_APPLIED_TO_WRONG_TARGET where SQLlin applies its DSL markers (B11) SQLlin's four @DslMarker annotations are applied to functions, properties and enum entries. That is where IntelliJ IDEA looks for them when it gives DSL calls one of its highlighting styles, which is what they are for. It is not where the compiler's DSL scope control applies, which needs them on types, and since Kotlin 2.3.20 the compiler warns about this use (KT-81567): 157 warnings in sqllin-dsl, and two per column in every generated table, which land in the build of each module that uses SQLlin. The markers stay, since they do their job. The warning is suppressed: - on each generated table object, as generated code is compiled in the user's module; - in sqllin-dsl, at the narrowest scope that doesn't repeat itself: on a file or class where more than half of the declarations carry a marker, and on each such declaration otherwise. The KDoc of the markers said they prevent implicit receiver nesting, which they never did. It now describes what they are for. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 2 +- .../ctrip/sqllin/dsl/annotation/DslMakers.kt | 23 +++++++++++-------- .../sqllin/dsl/sql/clause/BaseJoinClause.kt | 3 +++ .../sqllin/dsl/sql/clause/ConditionClause.kt | 2 ++ .../sqllin/dsl/sql/clause/CrossJoinClause.kt | 1 + .../ctrip/sqllin/dsl/sql/clause/Function.kt | 2 ++ .../sqllin/dsl/sql/clause/GroupByClause.kt | 2 ++ .../sqllin/dsl/sql/clause/HavingClause.kt | 1 + .../sqllin/dsl/sql/clause/InnerJoinClause.kt | 2 ++ .../dsl/sql/clause/LeftOuterJoinClause.kt | 2 ++ .../sqllin/dsl/sql/clause/LimitClause.kt | 2 ++ .../sqllin/dsl/sql/clause/OrderByClause.kt | 2 ++ .../ctrip/sqllin/dsl/sql/clause/SetClause.kt | 1 + .../sqllin/dsl/sql/clause/WhereClause.kt | 2 ++ .../ctrip/sqllin/processor/ClauseProcessor.kt | 3 +++ 16 files changed, 40 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f84fbf..d364e1ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ * Fix: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all. Both are now a compile-time error, whichever order `@Default` and the foreign key annotation are written in * Fix: a computed property of a `@DBRow` class, one without a backing field such as `val title: String get() = ...`, no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a `NOT NULL` column that `INSERT` never wrote, so every insert failed with `NOT NULL constraint failed`, and an accessor that looked its column up past the end of the serializer's descriptor * Fix: a `@DBRow` property of a type no column can hold, such as a `List`, is now a compile-time error naming the property. It used to be skipped silently: left out of `CREATE TABLE` while its serializer still wrote and read it, so `INSERT` and `SELECT` failed at runtime with "no column named", and as the last property it left a trailing comma that made `CREATE TABLE` itself fail. Annotate such a property with `kotlinx.serialization.Transient` to keep it out of the table +* Fix: the generated table objects no longer produce a `DSL_MARKER_APPLIED_TO_WRONG_TARGET` warning for every column, which Kotlin 2.3.20 and later report in the module that compiles them. Their `@ColumnNameDslMaker` is there for IntelliJ IDEA's DSL highlighting rather than for the compiler's DSL scope control, so the warning is now suppressed on each generated object ## 2.3.0 / 2026-08-20 diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index ae174398..7b995311 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -96,7 +96,7 @@ import kotlin.jvm.JvmName * * @author Yuang Qiao */ -@Suppress("UNCHECKED_CAST") +@Suppress("UNCHECKED_CAST", "DSL_MARKER_APPLIED_TO_WRONG_TARGET") public class DatabaseScope internal constructor( private val databaseConnection: DatabaseConnection, private val enableSimpleSQLLog: Boolean, diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt index faea3ce2..d2377b9d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt @@ -16,10 +16,17 @@ package com.ctrip.sqllin.dsl.annotation +/* + * These DSL markers exist for IntelliJ IDEA, which gives a call to any function or property annotated with a + * @DslMarker annotation one of its four DSL highlighting styles. They are applied to functions and properties + * for that reason, and so don't provide the compiler's DSL scope control, which @DslMarker only gives when + * applied to types. The compiler reports DSL_MARKER_APPLIED_TO_WRONG_TARGET for that use, so it's suppressed + * wherever these annotations are applied. + */ + /** - * DSL marker for SQL statement functions to prevent implicit receiver nesting. - * - * Applied to top-level SQL statement functions (SELECT, INSERT, UPDATE, DELETE). + * DSL marker that highlights calls to SQL statement functions (SELECT, INSERT, UPDATE, DELETE, WHERE, ...) in + * IntelliJ IDEA. * * @author Yuang Qiao */ @@ -29,9 +36,7 @@ package com.ctrip.sqllin.dsl.annotation internal annotation class StatementDslMaker /** - * DSL marker for SQL keyword classes and properties to prevent implicit receiver nesting. - * - * Applied to SQL keyword constructs (WHERE, ORDER BY, etc.) and their properties. + * DSL marker that highlights SQL keywords, such as `X` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. * * @author Yuang Qiao */ @@ -41,9 +46,7 @@ internal annotation class StatementDslMaker internal annotation class KeyWordDslMaker /** - * DSL marker for SQL function builders to prevent implicit receiver nesting. - * - * Applied to SQL function builder functions (aggregate functions, etc.). + * DSL marker that highlights calls to SQL functions (aggregate, numeric and string functions) in IntelliJ IDEA. * * @author Yuang Qiao */ @@ -53,7 +56,7 @@ internal annotation class KeyWordDslMaker internal annotation class FunctionDslMaker /** - * DSL marker for generated column name properties. + * DSL marker that highlights the generated column properties in IntelliJ IDEA. * * This annotation is applied by sqllin-processor to generated table column properties. * **Do not use this annotation manually** - it is intended for code generation only. diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt index 6cb79ad0..f852622c 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt @@ -65,14 +65,17 @@ public sealed class NaturalJoinClause(vararg tables: Table<*>) : BaseJoinClau */ public sealed class JoinClause(vararg tables: Table<*>) : BaseJoinClause(*tables) +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public infix fun JoinStatementWithoutCondition.ON(condition: SelectCondition): JoinSelectStatement = convertToJoinSelectStatement(condition) +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public inline infix fun JoinStatementWithoutCondition.USING(clauseElement: ClauseElement): JoinSelectStatement = USING(listOf(clauseElement)) +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public infix fun JoinStatementWithoutCondition.USING(clauseElements: Iterable): JoinSelectStatement = convertToJoinSelectStatement(clauseElements) \ No newline at end of file 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 9d895476..dee44476 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 @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/CrossJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/CrossJoinClause.kt index 36687ceb..d1401deb 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/CrossJoinClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/CrossJoinClause.kt @@ -47,5 +47,6 @@ internal class CrossJoinClause(vararg tables: Table<*>) : NaturalJoinClause CROSS_JOIN(vararg tables: Table<*>): NaturalJoinClause = CrossJoinClause(*tables) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt index d028707b..519cb547 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.FunctionDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt index cf4cb1e8..c1824149 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/HavingClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/HavingClause.kt index 2cd0c316..6309dbbc 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/HavingClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/HavingClause.kt @@ -41,6 +41,7 @@ internal class HavingClause(val selectCondition: SelectCondition) : Condition override val clauseName: String = "HAVING" } +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public infix fun GroupBySelectStatement.HAVING(condition: SelectCondition): HavingSelectStatement = appendToHaving(HavingClause(condition)).also { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/InnerJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/InnerJoinClause.kt index 78bc8ebe..fb7728a5 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/InnerJoinClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/InnerJoinClause.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LeftOuterJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LeftOuterJoinClause.kt index 9ba0235e..951e3041 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LeftOuterJoinClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LeftOuterJoinClause.kt @@ -46,6 +46,7 @@ internal class LeftOuterJoinClause( * // Returns all users, including those without orders * ``` */ +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public fun LEFT_OUTER_JOIN(vararg tables: Table<*>): JoinClause = LeftOuterJoinClause(*tables) @@ -75,5 +76,6 @@ internal class NaturalLeftOuterJoinClause( * SELECT(user) NATURAL_LEFT_OUTER_JOIN (profile) * ``` */ +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public fun NATURAL_LEFT_OUTER_JOIN(vararg tables: Table<*>): NaturalJoinClause = NaturalLeftOuterJoinClause(*tables) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt index 6ab9df05..f74dee3e 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt index 08b63330..8e332c19 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.KeyWordDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt index ca846a5e..e34fc9b0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/SetClause.kt @@ -81,5 +81,6 @@ public class SetClause : Clause { }.toString() } +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker public inline fun SET(block: SetClause.() -> Unit): SetClause = SetClause().apply(block) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt index c4e5426d..24d0c207 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/WhereClause.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker 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 9ac72741..283b2165 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 @@ -161,6 +161,9 @@ class ClauseProcessor( writer.write("import com.ctrip.sqllin.dsl.sql.PrimaryKeyInfo\n") writer.write("import com.ctrip.sqllin.dsl.sql.Table\n\n") + // The column properties carry @ColumnNameDslMaker for IntelliJ IDEA's DSL highlighting, a target the compiler + // flags as having no effect on scope control. This code is compiled in the user's module, so keep it quiet. + writer.write("@Suppress(\"DSL_MARKER_APPLIED_TO_WRONG_TARGET\")\n") writer.write("${visibilityModifier}object $objectName : Table<$className>(\"$tableName\") {\n\n") writer.write(" override fun kSerializer() = $className.serializer()\n\n") From 362f10af00b0e9edde613472a79d21c3d02dc47c Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 23:49:38 +0100 Subject: [PATCH 15/18] Generate a safe call only for a nullable enum column's setter (B18) The setter generated for an enum column's SetClause property always appended `value?.ordinal`. The type of `value` is the property's type, which, since the fix to that property's nullability (B12), is non-null whenever the column is. For such a column the safe call is unnecessary, and the module compiling the generated code reported it, once per non-null enum column. B12 made this more common: before it, every column declared after a `Long?` primary key had been generated as nullable, which happened to make the safe call necessary. The setter is now given the same nullability that decides the property's type, and only a nullable enum column keeps the safe call. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../ctrip/sqllin/processor/ClauseProcessor.kt | 33 ++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d364e1ab..48ba34a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ * Fix: a computed property of a `@DBRow` class, one without a backing field such as `val title: String get() = ...`, no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a `NOT NULL` column that `INSERT` never wrote, so every insert failed with `NOT NULL constraint failed`, and an accessor that looked its column up past the end of the serializer's descriptor * Fix: a `@DBRow` property of a type no column can hold, such as a `List`, is now a compile-time error naming the property. It used to be skipped silently: left out of `CREATE TABLE` while its serializer still wrote and read it, so `INSERT` and `SELECT` failed at runtime with "no column named", and as the last property it left a trailing comma that made `CREATE TABLE` itself fail. Annotate such a property with `kotlinx.serialization.Transient` to keep it out of the table * Fix: the generated table objects no longer produce a `DSL_MARKER_APPLIED_TO_WRONG_TARGET` warning for every column, which Kotlin 2.3.20 and later report in the module that compiles them. Their `@ColumnNameDslMaker` is there for IntelliJ IDEA's DSL highlighting rather than for the compiler's DSL scope control, so the warning is now suppressed on each generated object +* Fix: the generated `SetClause` setter of a non-null enum column no longer uses a safe call, `value?.ordinal`, which the module compiling the generated code reported as unnecessary. Only a nullable enum column keeps it ## 2.3.0 / 2026-08-20 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 283b2165..73d62224 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 @@ -206,7 +206,7 @@ class ClauseProcessor( writer.write(" var SetClause<$className>.$propertyName: ${property.typeName}") writer.write(if (isNotNull) "\n" else "?\n") writer.write(" get() = ${getSetClauseGetterValue(property)}\n") - writer.write(" set(value) = ${appendFunction(elementName, property)}\n\n") + writer.write(" set(value) = ${appendFunction(elementName, property, isNotNull)}\n\n") } columnConstraintParser.generateCodeForPrimaryKey(writer, createSQLBuilder) @@ -341,27 +341,30 @@ 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. + * For enum types, converts the enum value to its ordinal before appending, with a safe call only + * when the enum is nullable. * * @param elementName The serialized element name * @param property The property declaration + * @param isNotNull Whether the setter's `value` is non-null, as for the SetClause property it belongs to * @return The append function call string, or null if unsupported type */ - private fun appendFunction(elementName: String, property: KSPropertyDeclaration): String? = when ( - val declaration = property.type.resolve().declaration - ) { - 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 + private fun appendFunction(elementName: String, property: KSPropertyDeclaration, isNotNull: Boolean): String? { + // A safe call on a non-null value is reported as unnecessary, in the module compiling the generated code + val appendEnum = "appendAny($elementName, value${if (isNotNull) "" else "?"}.ordinal)" + return when (val declaration = property.type.resolve().declaration) { + is KSTypeAlias -> { + val realDeclaration = declaration.type.resolve().declaration + appendFunctionByTypeName(elementName, realDeclaration.typeName) ?: kotlin.run { + if (realDeclaration is KSClassDeclaration && realDeclaration.classKind == ClassKind.ENUM_CLASS) + appendEnum + else + null + } } + is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> appendEnum + else -> appendFunctionByTypeName(elementName, declaration.typeName) } - is KSClassDeclaration if declaration.classKind == ClassKind.ENUM_CLASS -> "appendAny($elementName, value?.ordinal)" - else -> appendFunctionByTypeName(elementName, declaration.typeName) } /** From cb5d95cfb5153fa751ecf0b223b59aec121ee2d6 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Thu, 1 Oct 2026 23:52:09 +0100 Subject: [PATCH 16/18] Mark the SQL string functions added in 2.2.0 with @FunctionDslMaker (B17) Every SQL function in Function.kt carries @FunctionDslMaker, which is how IntelliJ IDEA gives their calls a DSL highlighting style, except the seven string functions added in 2.2.0: substr, trim, ltrim, rtrim, replace, instr and printf. Their calls were therefore not highlighted like the rest. They now carry the marker too. The annotation has no effect at runtime, and the file already suppresses the compiler's warning about markers applied to functions, so nothing else changes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ba34a6..46573108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ * Fix: the ALTER operations emitted the invalid keyword `ALERT TABLE` instead of `ALTER TABLE`, so `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO`, `RENAME_COLUMN` and `DROP_COLUMN` all failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticed * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws * Fix documentation: the `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` examples referenced a `KClass.table` extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object +* Fix: the SQL string functions added in 2.2.0, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr` and `printf`, now carry the same DSL marker as the other SQL functions, so IntelliJ IDEA highlights their calls the same way ### sqllin-driver diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt index 519cb547..ddd579dc 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt @@ -223,6 +223,7 @@ public fun Table.length(element: ClauseBlob): ClauseNumber = * @param len The length of the substring to extract * @return ClauseString representing the extracted substring */ +@FunctionDslMaker public fun Table.substr(element: ClauseString, start: Int, len: Int): ClauseString = ClauseString("substr(${element.valueName},$start,$len)", this, true) @@ -240,6 +241,7 @@ public fun Table.substr(element: ClauseString, start: Int, len: Int): Cla * @param element The string to trim * @return ClauseString with whitespace removed from both ends */ +@FunctionDslMaker public fun Table.trim(element: ClauseString): ClauseString = ClauseString("trim(${element.valueName})", this, true) @@ -257,6 +259,7 @@ public fun Table.trim(element: ClauseString): ClauseString = * @param element The string to trim * @return ClauseString with leading whitespace removed */ +@FunctionDslMaker public fun Table.ltrim(element: ClauseString): ClauseString = ClauseString("ltrim(${element.valueName})", this, true) @@ -274,6 +277,7 @@ public fun Table.ltrim(element: ClauseString): ClauseString = * @param element The string to trim * @return ClauseString with trailing whitespace removed */ +@FunctionDslMaker public fun Table.rtrim(element: ClauseString): ClauseString = ClauseString("rtrim(${element.valueName})", this, true) @@ -293,6 +297,7 @@ public fun Table.rtrim(element: ClauseString): ClauseString = * @param new The replacement string * @return ClauseString with replacements applied */ +@FunctionDslMaker public fun Table.replace(element: ClauseString, old: String, new: String): ClauseString = ClauseString("replace(${element.valueName},'$old','$new')", this, true) @@ -312,6 +317,7 @@ public fun Table.replace(element: ClauseString, old: String, new: String) * @param sub The substring to find * @return ClauseNumber representing the position (1-indexed) or 0 if not found */ +@FunctionDslMaker public fun Table.instr(element: ClauseString, sub: String): ClauseNumber = ClauseNumber("instr(${element.valueName},'$sub')", this, true) @@ -331,5 +337,6 @@ public fun Table.instr(element: ClauseString, sub: String): ClauseNumber * @param element The value to format * @return ClauseString with the formatted result */ +@FunctionDslMaker public fun Table.printf(format: String, element: ClauseString): ClauseString = ClauseString("printf('$format',${element.valueName})", this, true) \ No newline at end of file From c0d02e24de082c00c037a808646bd01a3e380ed1 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 00:01:26 +0100 Subject: [PATCH 17/18] Mark the SET DEFAULT requirement as a breaking change in the change log (B13) The entry for b15d963 called it a fix, but one of the cases it now rejects used to work: a nullable column in a @ForeignKeyGroup with an ON ... SET DEFAULT trigger and no @Default compiled, and deleting the parent row set the column to NULL. Such code no longer compiles, so the entry is now marked as a breaking change and says how to migrate. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46573108..a9f87026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ * Fix: the visibility of the class annotated with `@DBRow` is now propagated to the generated table object. An `internal` `@DBRow` class used to produce a `public` object, which failed to compile with `EXPOSED_SUPER_CLASS`, `EXPOSED_FUNCTION_RETURN_TYPE` and `EXPOSED_RECEIVER_TYPE`. A `@DBRow` class that is neither `public` nor `internal` is now reported as an error * Fix: every `SetClause` property generated for a column declared after a nullable `Long` `@PrimaryKey` was typed nullable regardless of the column's own declaration, so `UPDATE ... SET { column = null }` compiled against `NOT NULL` columns and failed only at runtime. Each property now takes the nullability its own column declares * Fix: the columns of a `@CompositePrimaryKey` are now declared `NOT NULL`. SQLite, unlike standard SQL, does not let a table-level `PRIMARY KEY` imply it on a rowid table, so such a key used to accept `NULL`, and any number of rows sharing the same key once a `NULL` was part of it. SQLlin itself could not write those `NULL`s, but anything else writing to the database could, and SQLlin then read them back as `0` or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add `NOT NULL` to an existing column -* Fix: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all. Both are now a compile-time error, whichever order `@Default` and the foreign key annotation are written in +* **Breaking change**: an `ON_DELETE_SET_DEFAULT` or `ON_UPDATE_SET_DEFAULT` foreign key now requires the column to declare `@Default`, as the documentation always said. On `@References` the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (`NOT NULL constraint failed`), and rejected a nullable one. On `@ForeignKey` groups there was no check at all, so a nullable column without a default compiled and was set to `NULL`; that is now rejected too. To migrate, add `@Default` to the column, or use `ON_DELETE_SET_NULL` / `ON_UPDATE_SET_NULL` where setting it to `NULL` is the intent. Both checks hold whichever order `@Default` and the foreign key annotation are written in * Fix: a computed property of a `@DBRow` class, one without a backing field such as `val title: String get() = ...`, no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a `NOT NULL` column that `INSERT` never wrote, so every insert failed with `NOT NULL constraint failed`, and an accessor that looked its column up past the end of the serializer's descriptor * Fix: a `@DBRow` property of a type no column can hold, such as a `List`, is now a compile-time error naming the property. It used to be skipped silently: left out of `CREATE TABLE` while its serializer still wrote and read it, so `INSERT` and `SELECT` failed at runtime with "no column named", and as the last property it left a trailing comma that made `CREATE TABLE` itself fail. Annotate such a property with `kotlinx.serialization.Transient` to keep it out of the table * Fix: the generated table objects no longer produce a `DSL_MARKER_APPLIED_TO_WRONG_TARGET` warning for every column, which Kotlin 2.3.20 and later report in the module that compiles them. Their `@ColumnNameDslMaker` is there for IntelliJ IDEA's DSL highlighting rather than for the compiler's DSL scope control, so the warning is now suppressed on each generated object From 531f7f2d5d2fe5d2fb52bf9bf937b65780b38019 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 00:23:49 +0100 Subject: [PATCH 18/18] Document the KSP task dependencies and the generated object's name (B19) The installation guide added the generated directory to commonMain's sources but never made the tasks that read it depend on kspCommonMainKotlinMetadata. Gradle fails the build when a task reads another task's output without depending on it, and the generated sources are read by every Kotlin compilation and, once a module also runs another KSP processor such as Room or Koin Annotations, by that processor's KSP tasks as well, which a rule matching only compilation tasks does not cover. SQLlin's own sample and test builds have always declared the rule that covers both, matching compilation tasks by type and KSP tasks by name. The guide now shows exactly that rule, in English and in Chinese, and says why it is needed. It also states that each @DBRow class gets an object named after the class with a Table suffix, whatever the table is called, which the guide only implied through its examples. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + sqllin-dsl/doc/getting-start-cn.md | 17 +++++++++++++++++ sqllin-dsl/doc/getting-start.md | 18 ++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f87026..41888bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * Fix documentation: the KDoc of `DatabaseScope` now states that statement execution is deferred until the scope exits, and its example no longer reads a `SelectStatement`'s results while the scope is still open, which throws * Fix documentation: the `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` examples referenced a `KClass.table` extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object * Fix: the SQL string functions added in 2.2.0, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr` and `printf`, now carry the same DSL marker as the other SQL functions, so IntelliJ IDEA highlights their calls the same way +* Fix documentation: the installation guide now declares the task dependencies the generated code needs, as SQLlin's own builds always did. Without them Gradle fails the build, in particular when another KSP processor runs in the same module. It also states that each generated object is named after its class with a `Table` suffix, not after the table ### sqllin-driver diff --git a/sqllin-dsl/doc/getting-start-cn.md b/sqllin-dsl/doc/getting-start-cn.md index 6f694dd9..f08a727e 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -7,6 +7,8 @@ 将 _sqllin-dsl_、_sqllin-driver_ 以及 _sqllin-processor_ 依赖添加到你的 `build.gradle.kts`: ```kotlin +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + plugins { kotlin("multiplatform") kotlin("plugin.serialization") @@ -43,7 +45,19 @@ dependencies { // sqllin-processor add("kspCommonMainMetadata", "com.ctrip.kotlin:sqllin-processor:$sqllinVersion") } + +// The generated code is a source directory of commonMain, so every task that reads it has to run after KSP +afterEvaluate { + tasks { + matching { (it is KotlinCompilationTask<*> || it.name.startsWith("ksp")) && it.name != "kspCommonMainKotlinMetadata" } + .configureEach { dependsOn("kspCommonMainKotlinMetadata") } + } +} ``` + +最后一段是必需的。生成的表对象作为 `commonMain` 的源码目录加入工程,因此每个 Kotlin 编译任务都会读取 +`kspCommonMainKotlinMetadata` 的输出;如果你的工程还运行着其他 KSP 处理器(比如 Room 或 Koin Annotations),它们的 KSP 任务也会读取。 +一个任务读取另一个任务的输出却没有声明对它的依赖时,Gradle 会让构建失败。KSP 自己的任务按名字匹配,因为不同 KSP 版本的任务类型不同。 > 注意:如果你想将 SQLlin 的依赖添加到你的 Kotlin/Native 可执行程序工程,有时你需要正确添加对 SQLite 的 `linkerOpts` 到你的 > `build.gradle.kts`。你可以参考 [issue #48](https://github.com/ctripcorp/SQLlin/issues/48) 来获取更多信息。 @@ -191,6 +205,9 @@ data class Person( `@DBRow` 的参数 `tableName` 表示数据库中的表名,请确保传入正确的值。如果不手动传入,_sqllin-processor_ 将会使用类名作为表名,比如 `Person` 类的默认表名是"Person"。 +对于每个 `@DBRow` 类,_sqllin-processor_ 都会生成一个以类名加 `Table` 后缀命名的对象,比如 `Person` 对应 `PersonTable`, +与 `tableName` 的取值无关。使用 DSL 编写 SQL 时用的就是这个对象。 + 在 _sqllin-dsl_ 中,对象序列化为 SQL 语句,或者从游标中反序列化依赖 _kotlinx.serialization_,所以你需要在你的 data class 上添加 `@Serializable` 注解。因此,如果你想在序列化或反序列化以及 `Table` 类生成的时候忽略某些属性,你可以给你的属性添加 `kotlinx.serialization.Transient` 注解。 diff --git a/sqllin-dsl/doc/getting-start.md b/sqllin-dsl/doc/getting-start.md index 72537dd3..5687b3fa 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -9,6 +9,8 @@ Add the dependencies of _sqllin-dsl_, _sqllin-driver_ and _sqllin-processor_ into your `build.gradle.kts`: ```kotlin +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + plugins { kotlin("multiplatform") kotlin("plugin.serialization") @@ -45,8 +47,21 @@ dependencies { // sqllin-processor add("kspCommonMainMetadata", "com.ctrip.kotlin:sqllin-processor:$sqllinVersion") } + +// The generated code is a source directory of commonMain, so every task that reads it has to run after KSP +afterEvaluate { + tasks { + matching { (it is KotlinCompilationTask<*> || it.name.startsWith("ksp")) && it.name != "kspCommonMainKotlinMetadata" } + .configureEach { dependsOn("kspCommonMainKotlinMetadata") } + } +} ``` +The last block is required. The generated table objects are added to `commonMain` as a source directory, so every Kotlin +compilation reads the output of `kspCommonMainKotlinMetadata`, and so does every other KSP task when your project also runs +another KSP processor, such as Room or Koin Annotations. Gradle fails the build when a task reads the output of another task +without depending on it. KSP's own tasks are matched by name, because their types differ between KSP versions. + > Note: If you want to add dependencies of SQLlin into your Kotlin/Native executable program projects, sometimes you need to add the `linkerOpts` > of SQLite into your `build.gradle.kts` correctly. You can refer to [issue #48](https://github.com/ctripcorp/SQLlin/issues/48) to get more information. @@ -201,6 +216,9 @@ The `@DBRow`'s param `tableName` represents the table name in Database, please e the correct value. If you don't pass the parameter manually, _sqllin-processor_ will use the class name as table name, for example, `Person`'s default table name is "Person". +For each `@DBRow` class, _sqllin-processor_ generates an object named after the class with a `Table` suffix, such as +`PersonTable` for `Person`, whatever its `tableName` is. That object is what you write SQL against with the DSL. + In _sqllin-dsl_, objects are serialized to SQL and deserialized from cursor depend on _kotlinx.serialization_. So, you also need to add the `@Serializable` onto your data classes. Therefore, if you want to ignore some properties when serialization or deserialization and `Table` classes generation, you can annotate your properties with `kotlinx.serialization.Transient`.