From da96b10a11f5dee2ddcf2c8bf27a000f68632535 Mon Sep 17 00:00:00 2001 From: Yuang Qiao Date: Thu, 17 Sep 2026 10:05:38 +0100 Subject: [PATCH 01/32] Refactor Android tests (#123) * Migrate Android tests from instrumented tests to Robolectric. Upgrade libraries' and Kotlin's version Move test code from sqllin-driver-test back to sqllin-driver * Replace deprecated `by project` property delegates The `val name: Type by project` delegate syntax is deprecated and scheduled for removal in Gradle 10. Use `project.property(name)` as the deprecation warning recommends, in the group/version declarations and in the `mavenPublishing` POM blocks of sqllin-driver, sqllin-dsl and sqllin-processor. Verified by generating the POM for each module: groupId, version, url, license, developer and scm are all still populated. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .github/workflows/build.yml | 67 ++++-------- .gitignore | 1 - CHANGELOG.md | 13 +++ gradle/gradle-daemon-jvm.properties | 13 +++ gradle/libs.versions.toml | 13 +-- settings.gradle.kts | 4 +- sqllin-driver-test/build.gradle.kts | 100 ------------------ sqllin-driver/build.gradle.kts | 37 +++++-- .../ctrip/sqllin/driver/test/AndroidTest.kt | 23 ++-- .../ctrip/sqllin/driver/test/PlatformApple.kt | 0 .../sqllin/driver/test/CommonBasicTest.kt | 0 .../com/ctrip/sqllin/driver/test/SQL.kt | 0 .../com/ctrip/sqllin/driver/test/JvmTest.kt | 0 .../ctrip/sqllin/driver/test/PlatformLinux.kt | 0 .../ctrip/sqllin/driver/test/PlatformMingw.kt | 0 .../ctrip/sqllin/driver/test/NativeTest.kt | 0 .../com/ctrip/sqllin/driver/test/Platform.kt | 0 sqllin-dsl-test/build.gradle.kts | 16 ++- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 18 ++-- sqllin-dsl/build.gradle.kts | 18 ++-- sqllin-processor/build.gradle.kts | 18 ++-- 21 files changed, 132 insertions(+), 209 deletions(-) create mode 100644 gradle/gradle-daemon-jvm.properties delete mode 100644 sqllin-driver-test/build.gradle.kts rename {sqllin-driver-test/src/androidDeviceTest => sqllin-driver/src/androidHostTest}/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt (70%) rename {sqllin-driver-test => sqllin-driver}/src/appleTest/kotlin/com/ctrip/sqllin/driver/test/PlatformApple.kt (100%) rename {sqllin-driver-test/src/commonMain => sqllin-driver/src/commonTest}/kotlin/com/ctrip/sqllin/driver/test/CommonBasicTest.kt (100%) rename {sqllin-driver-test/src/commonMain => sqllin-driver/src/commonTest}/kotlin/com/ctrip/sqllin/driver/test/SQL.kt (100%) rename {sqllin-driver-test => sqllin-driver}/src/jvmTest/kotlin/com/ctrip/sqllin/driver/test/JvmTest.kt (100%) rename {sqllin-driver-test => sqllin-driver}/src/linuxTest/kotlin/com/ctrip/sqllin/driver/test/PlatformLinux.kt (100%) rename {sqllin-driver-test => sqllin-driver}/src/mingwTest/kotlin/com/ctrip/sqllin/driver/test/PlatformMingw.kt (100%) rename {sqllin-driver-test => sqllin-driver}/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/NativeTest.kt (100%) rename {sqllin-driver-test => sqllin-driver}/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/Platform.kt (100%) rename sqllin-dsl-test/src/{androidDeviceTest => androidHostTest}/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt (89%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b1e5ecea..4227e531 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,10 +44,10 @@ jobs: run: ./gradlew :sqllin-driver:assemble -PonCICD - name: Run sqllin-driver macOS Arm64 Tests - run: ./gradlew :sqllin-driver-test:cleanMacosArm64Test :sqllin-driver-test:macosArm64Test --stacktrace + run: ./gradlew :sqllin-driver:cleanMacosArm64Test :sqllin-driver:macosArm64Test --stacktrace - name: Run sqllin-driver JVM Unit Tests on macOS Arm64 - run: ./gradlew :sqllin-driver-test:cleanJvmTest :sqllin-driver-test:jvmTest --stacktrace + run: ./gradlew :sqllin-driver:cleanJvmTest :sqllin-driver:jvmTest --stacktrace - name: Build sqllin-dsl run: ./gradlew :sqllin-dsl:assemble -PonCICD @@ -164,10 +164,10 @@ jobs: run: ./gradlew :sqllin-driver:linuxX64MainKlibrary :sqllin-driver:jvmJar - name: Run sqllin-driver Linux X64 Tests - run: ./gradlew :sqllin-driver-test:cleanLinuxX64Test :sqllin-driver-test:linuxX64Test --stacktrace + run: ./gradlew :sqllin-driver:cleanLinuxX64Test :sqllin-driver:linuxX64Test --stacktrace - name: Run sqllin-driver JVM Unit Tests on Linux X64 - run: ./gradlew :sqllin-driver-test:cleanJvmTest :sqllin-driver-test:jvmTest --stacktrace + run: ./gradlew :sqllin-driver:cleanJvmTest :sqllin-driver:jvmTest --stacktrace - name: Build sqllin-processor run: ./gradlew :sqllin-processor:assemble @@ -198,15 +198,6 @@ jobs: build-android-and-test: runs-on: ubuntu-latest timeout-minutes: 60 - strategy: - matrix: - include: - - api-level: 26 - target: default - device: pixel_2 - - api-level: 36 - target: google_apis - device: pixel_6 steps: - name: Checkout @@ -227,52 +218,30 @@ jobs: - name: Build Android run: ./gradlew :sqllin-driver:assembleAndroidMain :sqllin-dsl:assembleAndroidMain - - name: AVD Cache + - name: Cache Robolectric Android-All Jars uses: actions/cache@v4 - id: avd-cache with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} - - - name: Create AVD and Generate Snapshot for Caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} - arch: x86_64 - profile: ${{ matrix.device }} - emulator-build: 15368433 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: echo "Generated AVD snapshot for caching." - - - name: Run Android Instrumented Tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - target: ${{ matrix.target }} - arch: x86_64 - profile: ${{ matrix.device }} - emulator-build: 15368433 - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew :sqllin-driver-test:connectedAndroidDeviceTest --stacktrace & ./gradlew :sqllin-dsl-test:connectedAndroidDeviceTest --stacktrace + path: ~/.m2/repository/org/robolectric + key: ${{ runner.os }}-robolectric-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-robolectric- + + - name: Run sqllin-driver Android Unit Tests + run: ./gradlew :sqllin-driver:testAndroidHostTest --stacktrace + + - name: Run sqllin-dsl Android Unit Tests + run: ./gradlew :sqllin-dsl-test:testAndroidHostTest --stacktrace - name: Upload sqllin-driver Reports uses: actions/upload-artifact@v4 with: - name: Test-Reports-Android-driver-API${{ matrix.api-level }} + name: Test-Reports-Android-driver path: sqllin-driver/build/reports if: failure() - name: Upload sqllin-dsl Reports uses: actions/upload-artifact@v4 with: - name: Test-Reports-Android-dsl-API${{ matrix.api-level }} - path: sqllin-dsl/build/reports + name: Test-Reports-Android-dsl + path: sqllin-dsl-test/build/reports if: failure() diff --git a/.gitignore b/.gitignore index 44bd9a7d..857e32ee 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ local.properties /sqllin-driver/build /sqllin-dsl/build /sqllin-processor/build -/sqllin-driver-test/build /sqllin-dsl-test/build /sample/build *.podspec diff --git a/CHANGELOG.md b/CHANGELOG.md index bdba5d03..71b6e19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ - Date format: YYYY-MM-dd +## 2.4.0 / 2026-xx-xx + +### All + +* Update `Kotlin`'s version to `2.4.20` +* Update `AGP`'s version to `9.4.0` +* 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-driver + +* Update `sqlite-jdbc`'s version to `3.53.4.0` + ## 2.3.0 / 2026-08-20 ### All diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..2a29db8d --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/97b61e8788c7490a5f53d8951b11ea7a/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/feb8af2956f2e263086e0a8f9a1df390/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b96cc4b9c59b5bff244af296e50d5ea3/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fdfa2f9fadcf75fb9616fde209d60b55/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/eff42deed1d8947129a4e66cd07a89f4/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/8fe418192094ee8db0da620bcb6b1629/redirect +toolchainVendor=JETBRAINS +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0886eca4..367f1121 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,15 @@ [versions] -kotlin = "2.4.10" -agp = "9.3.1" +kotlin = "2.4.20" +agp = "9.4.0" ksp = "2.3.11" serialization = "1.11.0" coroutines = "1.11.0" androidx-annotation = "1.10.0" androidx-test = "1.7.0" -androidx-test-runner = "1.7.0" -sqlite-jdbc = "3.53.2.1" +robolectric = "4.17" +junit = "4.13.2" +sqlite-jdbc = "3.53.4.0" jvm-toolchain = "21" android-sdk-compile = "37" android-sdk-min = "24" @@ -24,8 +25,8 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidx-annotation" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidx-test" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidx-test-runner" } -androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidx-test" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +junit = { group = "junit", name = "junit", version.ref = "junit" } sqlite-jdbc = { group = "org.xerial", name = "sqlite-jdbc", version.ref = "sqlite-jdbc" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3c49385c..60c10adc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,7 +3,6 @@ include(":sqllin-driver") include(":sqllin-dsl") include(":sqllin-processor") include(":sqllin-dsl-test") -include(":sqllin-driver-test") include(":sample") pluginManagement { @@ -13,6 +12,9 @@ pluginManagement { mavenCentral() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { @Suppress("UnstableApiUsage") diff --git a/sqllin-driver-test/build.gradle.kts b/sqllin-driver-test/build.gradle.kts deleted file mode 100644 index e71a424f..00000000 --- a/sqllin-driver-test/build.gradle.kts +++ /dev/null @@ -1,100 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.konan.target.HostManager - -plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.android.library) -} - -version = "1.0" - -kotlin { - jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) - android { - namespace = "com.ctrip.sqllin.driver.test" - compileSdk = libs.versions.android.sdk.compile.get().toInt() - minSdk = libs.versions.android.sdk.min.get().toInt() - withDeviceTest { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - } - - jvm { - compilerOptions.jvmTarget.set(JvmTarget.JVM_11) - } - - listOf( - iosArm64(), - iosSimulatorArm64(), - - macosArm64(), - - watchosArm32(), - watchosArm64(), - watchosSimulatorArm64(), - watchosDeviceArm64(), - - tvosArm64(), - tvosSimulatorArm64(), - - linuxX64(), - linuxArm64(), - - mingwX64(), - ).forEach { - it.setupNativeConfig() - } - - compilerOptions { - freeCompilerArgs.addAll("-Xexpect-actual-classes") - } - - sourceSets { - all { - languageSettings { - optIn("kotlin.RequiresOptIn") - } - } - commonMain { - dependencies { - implementation(project(":sqllin-driver")) - implementation(kotlin("test")) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.test) - } - } - getByName("androidDeviceTest").dependencies { - implementation(libs.androidx.test.core) - implementation(libs.androidx.test.runner) - implementation(libs.androidx.test.rules) - } - } -} - -gradle.taskGraph.whenReady { - if (!project.hasProperty("onCICD")) - return@whenReady - tasks.forEach { - when { - it.name.contains("linux", true) -> it.enabled = HostManager.hostIsLinux - it.name.contains("mingw", true) -> it.enabled = HostManager.hostIsMingw - it.name.contains("ios", true) - || it.name.contains("macos", true) - || it.name.contains("watchos", true) - || it.name.contains("tvos", true) -> it.enabled = HostManager.hostIsMac - } - } -} - -fun KotlinNativeTarget.setupNativeConfig() { - binaries { - all { - linkerOpts += when { - HostManager.hostIsLinux -> listOf("-lsqlite3", "-L$rootDir/libs/linux", "-L/usr/lib/x86_64-linux-gnu", "-L/usr/lib", "-L/usr/lib64") - HostManager.hostIsMingw -> listOf("-Lc:\\msys64\\mingw64\\lib", "-L$rootDir\\libs\\windows", "-lsqlite3") - else -> listOf("-lsqlite3") - } - } - } -} diff --git a/sqllin-driver/build.gradle.kts b/sqllin-driver/build.gradle.kts index c410c16c..d2c19862 100644 --- a/sqllin-driver/build.gradle.kts +++ b/sqllin-driver/build.gradle.kts @@ -8,8 +8,8 @@ plugins { alias(libs.plugins.vanniktech.maven.publish) } -val GROUP_ID: String by project -val VERSION: String by project +val GROUP_ID = project.property("GROUP_ID") as String +val VERSION = project.property("VERSION") as String group = GROUP_ID version = VERSION @@ -21,6 +21,9 @@ kotlin { namespace = "com.ctrip.sqllin.driver" compileSdk = libs.versions.android.sdk.compile.get().toInt() minSdk = libs.versions.android.sdk.min.get().toInt() + withHostTest { + isIncludeAndroidResources = true + } } jvm { @@ -59,15 +62,31 @@ kotlin { optIn("kotlin.RequiresOptIn") } } + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.test) + } androidMain.dependencies { implementation(libs.androidx.annotation) } + getByName("androidHostTest").dependencies { + implementation(libs.junit) + implementation(libs.androidx.test.core) + implementation(libs.robolectric) + } jvmMain.dependencies { implementation(libs.sqlite.jdbc) } } } +// Robolectric reflects into JDK internals when setting up newer Android SDKs, +// which the module system blocks by default since JDK 17. +tasks.withType().matching { it.name == "testAndroidHostTest" }.configureEach { + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") +} + gradle.taskGraph.whenReady { if (!project.hasProperty("onCICD")) return@whenReady @@ -111,29 +130,29 @@ mavenPublishing { pom { name.set(artifactId) description.set("Low-level API for SQLite on Kotlin Multiplatform") - val githubURL: String by project + val githubURL = project.property("githubURL") as String url.set(githubURL) licenses { license { - val licenseName: String by project + val licenseName = project.property("licenseName") as String name.set(licenseName) - val licenseURL: String by project + val licenseURL = project.property("licenseURL") as String url.set(licenseURL) } } developers { developer { - val developerID: String by project + val developerID = project.property("developerID") as String id.set(developerID) - val developerName: String by project + val developerName = project.property("developerName") as String name.set(developerName) - val developerEmail: String by project + val developerEmail = project.property("developerEmail") as String email.set(developerEmail) } } scm { url.set(githubURL) - val scmURL: String by project + val scmURL = project.property("scmURL") as String connection.set(scmURL) developerConnection.set(scmURL) } diff --git a/sqllin-driver-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt b/sqllin-driver/src/androidHostTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt similarity index 70% rename from sqllin-driver-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt rename to sqllin-driver/src/androidHostTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt index 215ebefd..84ca5eab 100644 --- a/sqllin-driver-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt +++ b/sqllin-driver/src/androidHostTest/kotlin/com/ctrip/sqllin/driver/test/AndroidTest.kt @@ -18,24 +18,26 @@ package com.ctrip.sqllin.driver.test import android.content.Context import androidx.test.core.app.ApplicationProvider -import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner -import androidx.test.platform.app.InstrumentationRegistry import com.ctrip.sqllin.driver.toDatabasePath -import org.junit.After -import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.Test /** - * Android instrumented test + * Android unit test that runs on the JVM via Robolectric. The `sdk` levels cover both + * the `SQLiteDatabase.OpenParams` code path (Android P and above) and the legacy one. * @author Yuang Qiao */ -@RunWith(AndroidJUnit4ClassRunner::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 37]) class AndroidTest { - private val commonTest = CommonBasicTest( - ApplicationProvider.getApplicationContext().toDatabasePath() - ) + private val context = ApplicationProvider.getApplicationContext() + + private val commonTest = CommonBasicTest(context.toDatabasePath()) @Test fun testCreateAndUpgrade() = commonTest.testCreateAndUpgrade() @@ -55,9 +57,8 @@ class AndroidTest { @Test fun testConcurrency() = commonTest.testConcurrency() - @After + @AfterTest fun setDown() { - val context = InstrumentationRegistry.getInstrumentation().targetContext context.deleteDatabase(SQL.DATABASE_NAME) } } diff --git a/sqllin-driver-test/src/appleTest/kotlin/com/ctrip/sqllin/driver/test/PlatformApple.kt b/sqllin-driver/src/appleTest/kotlin/com/ctrip/sqllin/driver/test/PlatformApple.kt similarity index 100% rename from sqllin-driver-test/src/appleTest/kotlin/com/ctrip/sqllin/driver/test/PlatformApple.kt rename to sqllin-driver/src/appleTest/kotlin/com/ctrip/sqllin/driver/test/PlatformApple.kt diff --git a/sqllin-driver-test/src/commonMain/kotlin/com/ctrip/sqllin/driver/test/CommonBasicTest.kt b/sqllin-driver/src/commonTest/kotlin/com/ctrip/sqllin/driver/test/CommonBasicTest.kt similarity index 100% rename from sqllin-driver-test/src/commonMain/kotlin/com/ctrip/sqllin/driver/test/CommonBasicTest.kt rename to sqllin-driver/src/commonTest/kotlin/com/ctrip/sqllin/driver/test/CommonBasicTest.kt diff --git a/sqllin-driver-test/src/commonMain/kotlin/com/ctrip/sqllin/driver/test/SQL.kt b/sqllin-driver/src/commonTest/kotlin/com/ctrip/sqllin/driver/test/SQL.kt similarity index 100% rename from sqllin-driver-test/src/commonMain/kotlin/com/ctrip/sqllin/driver/test/SQL.kt rename to sqllin-driver/src/commonTest/kotlin/com/ctrip/sqllin/driver/test/SQL.kt diff --git a/sqllin-driver-test/src/jvmTest/kotlin/com/ctrip/sqllin/driver/test/JvmTest.kt b/sqllin-driver/src/jvmTest/kotlin/com/ctrip/sqllin/driver/test/JvmTest.kt similarity index 100% rename from sqllin-driver-test/src/jvmTest/kotlin/com/ctrip/sqllin/driver/test/JvmTest.kt rename to sqllin-driver/src/jvmTest/kotlin/com/ctrip/sqllin/driver/test/JvmTest.kt diff --git a/sqllin-driver-test/src/linuxTest/kotlin/com/ctrip/sqllin/driver/test/PlatformLinux.kt b/sqllin-driver/src/linuxTest/kotlin/com/ctrip/sqllin/driver/test/PlatformLinux.kt similarity index 100% rename from sqllin-driver-test/src/linuxTest/kotlin/com/ctrip/sqllin/driver/test/PlatformLinux.kt rename to sqllin-driver/src/linuxTest/kotlin/com/ctrip/sqllin/driver/test/PlatformLinux.kt diff --git a/sqllin-driver-test/src/mingwTest/kotlin/com/ctrip/sqllin/driver/test/PlatformMingw.kt b/sqllin-driver/src/mingwTest/kotlin/com/ctrip/sqllin/driver/test/PlatformMingw.kt similarity index 100% rename from sqllin-driver-test/src/mingwTest/kotlin/com/ctrip/sqllin/driver/test/PlatformMingw.kt rename to sqllin-driver/src/mingwTest/kotlin/com/ctrip/sqllin/driver/test/PlatformMingw.kt diff --git a/sqllin-driver-test/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/NativeTest.kt b/sqllin-driver/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/NativeTest.kt similarity index 100% rename from sqllin-driver-test/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/NativeTest.kt rename to sqllin-driver/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/NativeTest.kt diff --git a/sqllin-driver-test/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/Platform.kt b/sqllin-driver/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/Platform.kt similarity index 100% rename from sqllin-driver-test/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/Platform.kt rename to sqllin-driver/src/nativeTest/kotlin/com/ctrip/sqllin/driver/test/Platform.kt diff --git a/sqllin-dsl-test/build.gradle.kts b/sqllin-dsl-test/build.gradle.kts index 287bdd40..7eb4e64c 100644 --- a/sqllin-dsl-test/build.gradle.kts +++ b/sqllin-dsl-test/build.gradle.kts @@ -18,8 +18,8 @@ kotlin { namespace = "com.ctrip.sqllin.dsl.test" compileSdk = libs.versions.android.sdk.compile.get().toInt() minSdk = libs.versions.android.sdk.min.get().toInt() - withDeviceTest { - instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + withHostTest { + isIncludeAndroidResources = true } } @@ -67,14 +67,20 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } } - getByName("androidDeviceTest").dependencies { + getByName("androidHostTest").dependencies { + implementation(libs.junit) implementation(libs.androidx.test.core) - implementation(libs.androidx.test.runner) - implementation(libs.androidx.test.rules) + implementation(libs.robolectric) } } } +// Robolectric reflects into JDK internals when setting up newer Android SDKs, +// which the module system blocks by default since JDK 17. +tasks.withType().matching { it.name == "testAndroidHostTest" }.configureEach { + jvmArgs("--add-exports=java.base/jdk.internal.access=ALL-UNNAMED") +} + gradle.taskGraph.whenReady { if (!project.hasProperty("onCICD")) return@whenReady diff --git a/sqllin-dsl-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt similarity index 89% rename from sqllin-dsl-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt rename to sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index 2dc174b8..f25d179c 100644 --- a/sqllin-dsl-test/src/androidDeviceTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -2,25 +2,27 @@ package com.ctrip.sqllin.dsl.test import android.content.Context import androidx.test.core.app.ApplicationProvider -import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner -import androidx.test.platform.app.InstrumentationRegistry import com.ctrip.sqllin.driver.toDatabasePath import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config /** - * Android instrumented test + * Android unit test that runs on the JVM via Robolectric. The `sdk` levels cover both + * the `SQLiteDatabase.OpenParams` code path (Android P and above) and the legacy one. * @author Yuang Qiao */ -@RunWith(AndroidJUnit4ClassRunner::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 37]) class AndroidTest { - private val commonTest = CommonBasicTest( - ApplicationProvider.getApplicationContext().toDatabasePath() - ) + private val context = ApplicationProvider.getApplicationContext() + + private val commonTest = CommonBasicTest(context.toDatabasePath()) @Test fun testInsert() = commonTest.testInsert() @@ -156,13 +158,11 @@ class AndroidTest { @Before fun setUp() { - val context = InstrumentationRegistry.getInstrumentation().targetContext context.deleteDatabase(CommonBasicTest.DATABASE_NAME) } @After fun setDown() { - val context = InstrumentationRegistry.getInstrumentation().targetContext context.deleteDatabase(CommonBasicTest.DATABASE_NAME) } } \ No newline at end of file diff --git a/sqllin-dsl/build.gradle.kts b/sqllin-dsl/build.gradle.kts index a795658b..cc05bb08 100644 --- a/sqllin-dsl/build.gradle.kts +++ b/sqllin-dsl/build.gradle.kts @@ -9,8 +9,8 @@ plugins { alias(libs.plugins.vanniktech.maven.publish) } -val GROUP_ID: String by project -val VERSION: String by project +val GROUP_ID = project.property("GROUP_ID") as String +val VERSION = project.property("VERSION") as String group = GROUP_ID version = VERSION @@ -93,29 +93,29 @@ mavenPublishing { pom { name.set(artifactId) description.set("SQL DSL APIs for SQLite on Kotlin Multiplatform") - val githubURL: String by project + val githubURL = project.property("githubURL") as String url.set(githubURL) licenses { license { - val licenseName: String by project + val licenseName = project.property("licenseName") as String name.set(licenseName) - val licenseURL: String by project + val licenseURL = project.property("licenseURL") as String url.set(licenseURL) } } developers { developer { - val developerID: String by project + val developerID = project.property("developerID") as String id.set(developerID) - val developerName: String by project + val developerName = project.property("developerName") as String name.set(developerName) - val developerEmail: String by project + val developerEmail = project.property("developerEmail") as String email.set(developerEmail) } } scm { url.set(githubURL) - val scmURL: String by project + val scmURL = project.property("scmURL") as String connection.set(scmURL) developerConnection.set(scmURL) } diff --git a/sqllin-processor/build.gradle.kts b/sqllin-processor/build.gradle.kts index 01d3ed3e..09d1fffc 100644 --- a/sqllin-processor/build.gradle.kts +++ b/sqllin-processor/build.gradle.kts @@ -3,8 +3,8 @@ plugins { alias(libs.plugins.vanniktech.maven.publish) } -val GROUP_ID: String by project -val VERSION: String by project +val GROUP_ID = project.property("GROUP_ID") as String +val VERSION = project.property("VERSION") as String group = GROUP_ID version = VERSION @@ -36,29 +36,29 @@ mavenPublishing { pom { name.set(artifactId) description.set("KSP code be used to generate the database column properties") - val githubURL: String by project + val githubURL = project.property("githubURL") as String url.set(githubURL) licenses { license { - val licenseName: String by project + val licenseName = project.property("licenseName") as String name.set(licenseName) - val licenseURL: String by project + val licenseURL = project.property("licenseURL") as String url.set(licenseURL) } } developers { developer { - val developerID: String by project + val developerID = project.property("developerID") as String id.set(developerID) - val developerName: String by project + val developerName = project.property("developerName") as String name.set(developerName) - val developerEmail: String by project + val developerEmail = project.property("developerEmail") as String email.set(developerEmail) } } scm { url.set(githubURL) - val scmURL: String by project + val scmURL = project.property("scmURL") as String connection.set(scmURL) developerConnection.set(scmURL) } From 06bcbb7d31ca7645f78c3c1db1734cb1692f9cf8 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sun, 27 Sep 2026 21:07:59 +0100 Subject: [PATCH 02/32] 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 03/32] 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 04/32] 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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 09/32] 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 10/32] 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 11/32] 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 12/32] 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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 19/32] 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`. From 96d05b5651ee47c6fb6583aae1f49f515dbe7ea7 Mon Sep 17 00:00:00 2001 From: Yuang Qiao Date: Fri, 2 Oct 2026 09:06:04 +0100 Subject: [PATCH 20/32] Bug fixes for 2.4.0 in sqllin-dsl and sqllin-processor (#124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * Fix the index examples referencing a non-existent `KClass.table` (B8) 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 * 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 * Fix the ALTER operations emitting "ALERT TABLE", and rewrite their tests (B10) `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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5 --- CHANGELOG.md | 23 ++ .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 281 +++++++++--------- .../com/ctrip/sqllin/dsl/test/Entities.kt | 122 ++++++-- .../dsl/test/TestPrimitiveTypeForKSP.kt | 7 +- .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + sqllin-dsl/doc/getting-start-cn.md | 87 ++++-- sqllin-dsl/doc/getting-start.md | 88 ++++-- .../doc/modify-database-and-transaction-cn.md | 12 +- .../doc/modify-database-and-transaction.md | 12 +- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 75 +++-- .../annotation/CreateStatementModifiers.kt | 41 +-- .../ctrip/sqllin/dsl/annotation/DslMakers.kt | 23 +- .../ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt | 13 +- .../kotlin/com/ctrip/sqllin/dsl/sql/Table.kt | 2 +- .../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 | 9 + .../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 + .../dsl/sql/compiler/EncodeEntities2SQL.kt | 10 +- .../dsl/sql/compiler/InsertValuesEncoder.kt | 6 +- .../dsl/sql/operation/{Alert.kt => Alter.kt} | 19 +- .../dsl/sql/statement/OtherStatement.kt | 2 +- .../ctrip/sqllin/processor/ClauseProcessor.kt | 92 ++++-- .../processor/ColumnConstraintParser.kt | 66 ++-- .../sqllin/processor/ForeignKeyParser.kt | 18 +- 35 files changed, 677 insertions(+), 360 deletions(-) rename sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/{Alert.kt => Alter.kt} (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b6e19b..41888bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,33 @@ * 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 +* **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 +* 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 * 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 +* 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 +* **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 +* 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 ### All 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 d4743474..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 @@ -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 @@ -550,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 { @@ -563,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) @@ -688,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 @@ -1095,194 +1098,193 @@ 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: ALERT_ADD_COLUMN - // Note: ALERT operations have a typo in the DSL - should be "ALTER TABLE" not "ALERT TABLE" - // This test verifies the DSL compiles and the statement can be created - val person = PersonWithId(id = null, name = "Charlie", age = 35) - database { - PersonWithIdTable { table -> - table INSERT person + CREATE(AlterBeforeTable) + AlterBeforeTable { table -> + table INSERT AlterBefore(id = null, name = "Charlie", legacy = 7) } } - try { - database { - PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.name - } - } catch (e: Exception) { - // Expected to fail with current implementation due to "ALERT TABLE" typo - e.printStackTrace() - } + // 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", + ) - lateinit var personStatement: SelectStatement + // 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 { - personStatement = PersonWithIdTable SELECT X + AlterAfterTable ALTER_ADD_COLUMN AlterAfterTable.nickname } - assertEquals(1, personStatement.getResults().size) - assertEquals("Charlie", personStatement.getResults().first().name) - - // Test 2: ALERT_RENAME_TABLE_TO with TableObject - val student1 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 90) - val student2 = StudentWithAutoincrement(id = null, studentName = "Ethan", grade = 85) + // RENAME COLUMN, naming the old column by string. Both entities map to 'alter_target'. database { - StudentWithAutoincrementTable { table -> - table INSERT listOf(student1, student2) - } + AlterAfterTable.RENAME_COLUMN("name", AlterAfterTable.fullName) } - lateinit var studentStatement1: SelectStatement + // Both steps landed: the table now has 'fullName' and 'nickname', and still 'legacy'. + lateinit var withLegacy: SelectStatement database { - studentStatement1 = StudentWithAutoincrementTable SELECT X + withLegacy = AlterWithLegacyTable SELECT X } - assertEquals(2, studentStatement1.getResults().size) + assertEquals(1, withLegacy.getResults().size) + assertEquals("Charlie", withLegacy.getResults().first().fullName) + assertEquals(null, withLegacy.getResults().first().nickname) + assertEquals(7, withLegacy.getResults().first().legacy) - try { - database { - StudentWithAutoincrementTable ALERT_RENAME_TABLE_TO StudentWithAutoincrementTable - } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() - } - - lateinit var studentStatement2: SelectStatement + lateinit var migrated: SelectStatement database { - studentStatement2 = StudentWithAutoincrementTable SELECT X + migrated = AlterAfterTable SELECT X } - assertEquals(2, studentStatement2.getResults().size) - - // Test 3: ALERT_RENAME_TABLE_TO with String - val enrollment = Enrollment(studentId = 1, courseId = 101, semester = "Spring 2025") + 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 { - EnrollmentTable { table -> - table INSERT enrollment + transaction { + AlterAfterTable ALTER_RENAME_TABLE_TO AlterRenamedTable } } - try { - database { - "enrollment" ALERT_RENAME_TABLE_TO EnrollmentTable - } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() - } - - lateinit var enrollmentStatement: SelectStatement + lateinit var renamed: SelectStatement database { - enrollmentStatement = EnrollmentTable SELECT X + renamed = AlterRenamedTable SELECT X } - 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) + assertEquals(1, renamed.getResults().size) + assertEquals("Charlie", renamed.getResults().first().fullName) - 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() - } + assertEquals( + true, + database.selectFails { AlterAfterTable SELECT X }, + "'alter_target' should not exist after RENAME TO", + ) - lateinit var bookStatement: SelectStatement + // RENAME TO again, this time through the String receiver overload, renaming it back. database { - bookStatement = BookTable SELECT X + "alter_renamed" ALTER_RENAME_TABLE_TO AlterAfterTable } - assertEquals(1, bookStatement.getResults().size) - - // Test 5: RENAME_COLUMN with String - val category = Category(name = "Fiction", code = 100) + lateinit var renamedBack: SelectStatement database { - CategoryTable { table -> - table INSERT category - } + 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 { - CategoryTable.RENAME_COLUMN("name", CategoryTable.code) + AlterBeforeTable DROP_COLUMN AlterBeforeTable.legacy } } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() + legacyDropped = false } - - lateinit var categoryStatement: SelectStatement - database { - categoryStatement = CategoryTable SELECT X + if (legacyDropped) { + assertEquals( + true, + database.selectFails { AlterWithLegacyTable SELECT X }, + "'legacy' should be gone after DROP COLUMN", + ) } - assertEquals(1, categoryStatement.getResults().size) - assertEquals(100, categoryStatement.getResults().first().code) + } + } - // Test 6: DROP_COLUMN - val dropPerson = PersonWithId(id = null, name = "Frank", age = 40) + /** + * 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 + } - database { - PersonWithIdTable { table -> - table INSERT dropPerson - } - } + /** + * 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 + } - try { - database { - PersonWithIdTable DROP_COLUMN PersonWithIdTable.age - } - } catch (e: Exception) { - // Expected to fail with current implementation or SQLite version - e.printStackTrace() - } + /** + * 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,")) - lateinit var dropStatement: SelectStatement + Database(getNewAPIDBConfig()).databaseAutoClose { database -> database { - dropStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Frank") + CREATE(RemoteMovieTable) } - assertEquals(1, dropStatement.getResults().size) - - // Test 7: ALERT operations within a transaction - val txPerson1 = PersonWithId(id = null, name = "Grace", age = 28) - val txPerson2 = PersonWithId(id = null, name = "Henry", age = 32) + // A caller-supplied Long key is written by a plain INSERT rather than left for the database. + lateinit var movies: SelectStatement database { - PersonWithIdTable { table -> - table INSERT listOf(txPerson1, txPerson2) + 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 { - transaction { - PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.age - PersonWithIdTable.RENAME_COLUMN("name", PersonWithIdTable.name) - } + RemoteMovieTable INSERT RemoteMovie(id = 603, title = "The Matrix Reloaded") } } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() + duplicateFailed = true } + assertEquals(true, duplicateFailed, "A duplicate caller-supplied key should be rejected") - lateinit var txStatement: SelectStatement + // A Long? key is still assigned by the database. + lateinit var people: SelectStatement database { - txStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Grace" OR (PersonWithIdTable.name EQ "Henry")) + PersonWithIdTable { table -> + table INSERT PersonWithId(id = null, name = "Ivy", age = 21) + people = table SELECT X + } } - assertEquals(2, txStatement.getResults().size) - assertEquals(true, txStatement.getResults().any { it.name == "Grace" }) - assertEquals(true, txStatement.getResults().any { it.name == "Henry" }) + assertNotEquals(null, people.getResults().first().id) } } @@ -1687,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 { @@ -1784,10 +1787,18 @@ 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")) 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-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..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 @@ -116,7 +120,7 @@ data class PersonWithId( @DBRow("product") @Serializable data class Product( - @PrimaryKey val sku: String?, + @PrimaryKey val sku: String, val name: String, val price: Price, ) @@ -124,7 +128,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 +144,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 +179,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 +193,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 +206,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 +218,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 +231,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 +246,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 +260,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 +276,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 +287,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 +304,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 +321,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 +355,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 +370,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 +398,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 +413,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 +426,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,9 +442,83 @@ 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, 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, +) + +/** + * 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?, +) + +/** + * 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/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-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 8b70fd08..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) 来获取更多信息。 @@ -150,7 +164,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 } } } @@ -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` 注解。 @@ -217,11 +234,23 @@ data class Person( ) ``` -**重要的类型和可空性规则:** +**重要的类型和可空性规则:** 属性的可空性决定了主键的值由谁提供。 + +- **`Long?`,由数据库分配**:映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 + +- **`Long`,由你提供**:同样映射到 `INTEGER PRIMARY KEY`,因此仍然是 `rowid` 的别名,但每次插入都会写入你提供的值。适用于来自外部的数字主键,例如远端服务分配的 ID: -- **对于自增的 `Long` 主键**:属性**必须**声明为可空类型(`Long?`)。这会映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 +```kotlin +@DBRow +@Serializable +data class Movie( + @PrimaryKey + val id: Long, // Non-nullable, user-provided, still a rowid alias + val title: String, +) +``` -- **对于其他类型(String、Int 等)**:属性**必须**是非空的。插入时必须提供唯一值: +- **其他类型(String、Int 等),由你提供**:属性**必须**是非空的,映射为 `TEXT PRIMARY KEY NOT NULL` 这样的列。除 `Long` 以外任何类型的可空主键都会导致编译错误。插入时必须提供唯一值: ```kotlin @DBRow @@ -233,7 +262,7 @@ data class User( ) ``` -`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。这仅对 `Long?` 属性有意义。 +`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。它要求属性为 `Long?`,这是唯一一种由数据库分配值的主键。 #### 使用 @CompositePrimaryKey 定义组合主键 @@ -257,8 +286,8 @@ data class Enrollment( **重要规则:** -- 你可以在同一个类中对**多个属性**应用 `@CompositePrimaryKey` -- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的** +- 必须在同一个类中对**至少两个属性**应用 `@CompositePrimaryKey`;只标注一个会导致编译错误,单列主键应使用 `@PrimaryKey` +- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的**,并在生成的表中声明为 `NOT NULL` - 你**不能**在同一个类中混合使用 `@PrimaryKey` 和 `@CompositePrimaryKey` - 只能使用其中一个 - 所有 `@CompositePrimaryKey` 属性的组合形成表的组合主键 @@ -279,7 +308,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 +338,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 +359,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 +392,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 +422,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 +442,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 +474,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"], @@ -478,7 +507,7 @@ val status: String ### 支持的类型 -SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性: +SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性。其他任何类型的属性都会导致编译错误;如果想让这样的属性不进入表中,请为它加上 `kotlinx.serialization.Transient` 注解: #### 数值类型 - **整数类型:** `Byte`、`Short`、`Int`、`Long` @@ -534,7 +563,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 +634,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 +642,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 +691,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 +719,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 +732,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 +745,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 +758,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 +793,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 +813,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 +830,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 +865,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 +874,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 +885,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..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. @@ -158,7 +173,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 } } } @@ -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`. @@ -227,11 +245,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. + +- **`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. + +- **`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: -- **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. +```kotlin +@DBRow +@Serializable +data class Movie( + @PrimaryKey + val id: Long, // Non-nullable, user-provided, still a rowid alias + val title: String, +) +``` -- **For other types (String, Int, etc.)**: The property **must** be non-nullable. You must provide a unique value when inserting: +- **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 +273,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 @@ -267,8 +297,8 @@ data class Enrollment( **Important rules:** -- You can apply `@CompositePrimaryKey` to **multiple properties** in the same class -- All properties with `@CompositePrimaryKey` **must be non-nullable** +- 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**, 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 @@ -289,7 +319,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 +349,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 +370,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 +403,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 +433,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 +453,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 +485,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"], @@ -488,7 +518,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` @@ -544,7 +574,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 +645,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 +653,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 +702,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 +730,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 +743,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 +756,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 +769,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 +804,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 +824,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 +841,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 +876,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 +885,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 +896,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/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 07f58cd9..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 @@ -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,34 +53,50 @@ 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 * - 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 ALTER_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() * } * ``` * * @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, @@ -204,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 @@ -627,8 +646,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) * } * ``` * @@ -652,8 +671,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) * } * ``` * @@ -712,7 +731,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. @@ -724,7 +743,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_ADD_COLUMN email + * PersonTable ALTER_ADD_COLUMN email * } * ``` * @@ -732,8 +751,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) } @@ -743,7 +762,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -751,8 +770,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) } @@ -764,7 +783,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * "old_person" ALERT_RENAME_TABLE_TO NewPersonTable + * "old_person" ALTER_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -773,8 +792,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) } @@ -797,7 +816,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) } @@ -820,7 +839,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) } @@ -843,7 +862,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/annotation/CreateStatementModifiers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/CreateStatementModifiers.kt index 6f7b6555..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 @@ -30,32 +30,36 @@ 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. * - * @property isAutoincrement Indicates whether to append the `AUTOINCREMENT` keyword to the + * - **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 */ @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. @@ -66,12 +70,15 @@ public annotation class PrimaryKey(val isAutoincrement: 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. * - 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-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/PrimaryKeyInfo.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt index 07be95d1..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) - * - [isAutomaticIncrement] is `true` if `@PrimaryKey(isAutoincrement = true)` was specified + * - [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/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-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..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 @@ -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 @@ -221,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) @@ -238,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) @@ -255,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) @@ -272,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) @@ -291,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) @@ -310,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) @@ -329,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 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-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-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 90% 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..b3dd96cf 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,16 +52,16 @@ 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 " + get() = "ALTER TABLE " private const val ADD_COLUMN = " ADD COLUMN " private const val RENAME_TABLE = " RENAME TO " 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. 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..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 @@ -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) @@ -103,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, @@ -122,12 +161,14 @@ 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") + // 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") 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) @@ -137,14 +178,9 @@ 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) } - }.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 @@ -168,14 +204,9 @@ 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") + writer.write(" set(value) = ${appendFunction(elementName, property, isNotNull)}\n\n") } columnConstraintParser.generateCodeForPrimaryKey(writer, createSQLBuilder) @@ -310,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) } /** 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..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 @@ -65,11 +65,13 @@ 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 + * - [@CompositePrimaryKey] needs at least two properties; a single-column key uses [@PrimaryKey] * * @param resolver KSP resolver for looking up annotation types * @@ -92,7 +94,9 @@ 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_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." } @@ -105,8 +109,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 @@ -130,16 +133,24 @@ class ColumnConstraintParser(resolver: Resolver) { * * #### Primary Key * ```kotlin - * @PrimaryKey(isAutoincrement = true) - * val id: Long? + * @PrimaryKey(autoIncrement = true) + * 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 * ```kotlin * @CompositePrimaryKey * val userId: Long - * // Column: userId BIGINT + * // Column: userId BIGINT NOT NULL * // Later appended: ,PRIMARY KEY(userId,productId) * ``` * @@ -163,7 +174,7 @@ class ColumnConstraintParser(resolver: Resolver) { * 1. Determine SQLite type via [getSQLiteType] * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present * 3. Collect [@CompositePrimaryKey] columns for table-level constraint - * 4. Apply NOT NULL for 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 @@ -173,7 +184,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 @@ -200,34 +211,42 @@ 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 } - check(!isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } check(!isContainsPrimaryKey) { PROMPT_PRIMARY_KEY_USE_COUNT } isContainsPrimaryKey = true primaryKeyName = propertyName + // 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 + 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 } 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 } @@ -286,7 +305,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = "id", * isAutomaticIncrement = true, - * isRowId = true, + * isGeneratedByDatabase = true, * compositePrimaryKeys = null, * ) * ``` @@ -296,7 +315,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = null, * isAutomaticIncrement = false, - * isRowId = false, + * isGeneratedByDatabase = false, * compositePrimaryKeys = listOf( * "userId", * "productId", @@ -321,7 +340,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 * @@ -332,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()) { @@ -344,7 +368,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 { 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 8b35e845533d4f0e8053e4d3e7e2f7100997407e Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 09:22:49 +0100 Subject: [PATCH 21/32] Revert "Bug fixes for 2.4.0 in sqllin-dsl and sqllin-processor (#124)" This reverts commit 96d05b5, the squash merge of #124. #124 fixes a set of independent issues, one commit each, and each commit message documents its own issue. Squashing folded them into a single commit and lost that per-issue history. This undoes the squash so that the same branch, feature/bug-fixes-2.4.0, can be merged again with a merge commit that keeps its individual commits. The re-merge brings back exactly the reverted content: the squashed commits were never part of this branch's history, so their merge base is still da96b10, where release/2.4.0 stood before the squash. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 -- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 - .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 281 +++++++++--------- .../com/ctrip/sqllin/dsl/test/Entities.kt | 122 ++------ .../dsl/test/TestPrimitiveTypeForKSP.kt | 7 +- .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 - .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 - sqllin-dsl/doc/getting-start-cn.md | 87 ++---- sqllin-dsl/doc/getting-start.md | 88 ++---- .../doc/modify-database-and-transaction-cn.md | 12 +- .../doc/modify-database-and-transaction.md | 12 +- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 75 ++--- .../annotation/CreateStatementModifiers.kt | 41 ++- .../ctrip/sqllin/dsl/annotation/DslMakers.kt | 23 +- .../ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt | 13 +- .../kotlin/com/ctrip/sqllin/dsl/sql/Table.kt | 2 +- .../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 | 9 - .../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 - .../dsl/sql/compiler/EncodeEntities2SQL.kt | 10 +- .../dsl/sql/compiler/InsertValuesEncoder.kt | 6 +- .../dsl/sql/operation/{Alter.kt => Alert.kt} | 19 +- .../dsl/sql/statement/OtherStatement.kt | 2 +- .../ctrip/sqllin/processor/ClauseProcessor.kt | 92 ++---- .../processor/ColumnConstraintParser.kt | 66 ++-- .../sqllin/processor/ForeignKeyParser.kt | 18 +- 35 files changed, 360 insertions(+), 677 deletions(-) rename sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/{Alter.kt => Alert.kt} (90%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41888bc5..71b6e19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,33 +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 -* **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 -* 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 * 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 -* 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 -* **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 -* 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 ### All 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 08252927..f25d179c 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,9 +87,6 @@ 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 fe35213f..d4743474 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,7 +20,6 @@ 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 @@ -551,8 +550,8 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(30, personResults[1].age) // Test 2: String primary key - val product1 = Product(sku = "SKU-WIDGET", name = "Widget", price = 19.99) - val product2 = Product(sku = "SKU-GADGET", name = "Gadget", price = 29.99) + val product1 = Product(sku = null, name = "Widget", price = 19.99) + val product2 = Product(sku = null, name = "Gadget", price = 29.99) lateinit var productStatement: SelectStatement database { @@ -564,10 +563,8 @@ 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) @@ -691,7 +688,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 = "SKU-THING", name = "Thingamajig", price = 49.99) + val product = Product(sku = null, name = "Thingamajig", price = 49.99) lateinit var personStatement: SelectStatement lateinit var productStatement: SelectStatement @@ -1098,193 +1095,194 @@ 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: ALERT_ADD_COLUMN + // Note: ALERT operations have a typo in the DSL - should be "ALTER TABLE" not "ALERT TABLE" + // This test verifies the DSL compiles and the statement can be created + val person = PersonWithId(id = null, name = "Charlie", age = 35) + database { - CREATE(AlterBeforeTable) - AlterBeforeTable { table -> - table INSERT AlterBefore(id = null, name = "Charlie", legacy = 7) + PersonWithIdTable { table -> + table INSERT person } } - // 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", - ) + try { + database { + PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.name + } + } catch (e: Exception) { + // Expected to fail with current implementation due to "ALERT TABLE" typo + e.printStackTrace() + } - // 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. + lateinit var personStatement: SelectStatement database { - AlterAfterTable ALTER_ADD_COLUMN AlterAfterTable.nickname + personStatement = PersonWithIdTable SELECT X } + assertEquals(1, personStatement.getResults().size) + assertEquals("Charlie", personStatement.getResults().first().name) + + // Test 2: ALERT_RENAME_TABLE_TO with TableObject + val student1 = StudentWithAutoincrement(id = null, studentName = "Diana", grade = 90) + val student2 = StudentWithAutoincrement(id = null, studentName = "Ethan", grade = 85) - // RENAME COLUMN, naming the old column by string. Both entities map to 'alter_target'. database { - AlterAfterTable.RENAME_COLUMN("name", AlterAfterTable.fullName) + StudentWithAutoincrementTable { table -> + table INSERT listOf(student1, student2) + } } - // Both steps landed: the table now has 'fullName' and 'nickname', and still 'legacy'. - lateinit var withLegacy: SelectStatement + lateinit var studentStatement1: SelectStatement database { - withLegacy = AlterWithLegacyTable SELECT X + studentStatement1 = StudentWithAutoincrementTable 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) + assertEquals(2, studentStatement1.getResults().size) - lateinit var migrated: SelectStatement - database { - migrated = AlterAfterTable SELECT X + try { + database { + StudentWithAutoincrementTable ALERT_RENAME_TABLE_TO StudentWithAutoincrementTable + } + } catch (e: Exception) { + // Expected to fail with current implementation + e.printStackTrace() } - 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. + lateinit var studentStatement2: SelectStatement database { - transaction { - AlterAfterTable ALTER_RENAME_TABLE_TO AlterRenamedTable - } + studentStatement2 = StudentWithAutoincrementTable SELECT X } + assertEquals(2, studentStatement2.getResults().size) + + // Test 3: ALERT_RENAME_TABLE_TO with String + val enrollment = Enrollment(studentId = 1, courseId = 101, semester = "Spring 2025") - lateinit var renamed: SelectStatement database { - renamed = AlterRenamedTable SELECT X + EnrollmentTable { table -> + table INSERT enrollment + } } - assertEquals(1, renamed.getResults().size) - assertEquals("Charlie", renamed.getResults().first().fullName) - assertEquals( - true, - database.selectFails { AlterAfterTable SELECT X }, - "'alter_target' should not exist after RENAME TO", - ) + try { + database { + "enrollment" ALERT_RENAME_TABLE_TO EnrollmentTable + } + } catch (e: Exception) { + // Expected to fail with current implementation + e.printStackTrace() + } - // RENAME TO again, this time through the String receiver overload, renaming it back. + lateinit var enrollmentStatement: SelectStatement database { - "alter_renamed" ALTER_RENAME_TABLE_TO AlterAfterTable + enrollmentStatement = EnrollmentTable SELECT X } + 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) - lateinit var renamedBack: SelectStatement database { - renamedBack = AlterAfterTable SELECT X + BookTable { table -> + table INSERT book + } } - 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 { - AlterBeforeTable DROP_COLUMN AlterBeforeTable.legacy + BookTable.RENAME_COLUMN(BookTable.name, BookTable.author) } } catch (e: Exception) { - legacyDropped = false + // Expected to fail with current implementation + e.printStackTrace() } - if (legacyDropped) { - assertEquals( - true, - database.selectFails { AlterWithLegacyTable SELECT X }, - "'legacy' should be gone after DROP COLUMN", - ) + + lateinit var bookStatement: SelectStatement + database { + bookStatement = BookTable SELECT X } - } - } + assertEquals(1, bookStatement.getResults().size) - /** - * 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 - } + // Test 5: RENAME_COLUMN with String + val category = Category(name = "Fiction", code = 100) - /** - * 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 - } + database { + CategoryTable { table -> + table INSERT category + } + } - /** - * 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,")) + try { + database { + CategoryTable.RENAME_COLUMN("name", CategoryTable.code) + } + } catch (e: Exception) { + // Expected to fail with current implementation + e.printStackTrace() + } - Database(getNewAPIDBConfig()).databaseAutoClose { database -> + lateinit var categoryStatement: SelectStatement database { - CREATE(RemoteMovieTable) + categoryStatement = CategoryTable 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) - // 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) + PersonWithIdTable { table -> + table INSERT dropPerson } } - 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") + PersonWithIdTable DROP_COLUMN PersonWithIdTable.age } } catch (e: Exception) { - duplicateFailed = true + // Expected to fail with current implementation or SQLite version + e.printStackTrace() } - assertEquals(true, duplicateFailed, "A duplicate caller-supplied key should be rejected") - // A Long? key is still assigned by the database. - lateinit var people: SelectStatement + lateinit var dropStatement: SelectStatement + database { + dropStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Frank") + } + assertEquals(1, dropStatement.getResults().size) + + // Test 7: ALERT operations within a transaction + val txPerson1 = PersonWithId(id = null, name = "Grace", age = 28) + val txPerson2 = PersonWithId(id = null, name = "Henry", age = 32) + database { PersonWithIdTable { table -> - table INSERT PersonWithId(id = null, name = "Ivy", age = 21) - people = table SELECT X + table INSERT listOf(txPerson1, txPerson2) + } + } + + try { + database { + transaction { + PersonWithIdTable ALERT_ADD_COLUMN PersonWithIdTable.age + PersonWithIdTable.RENAME_COLUMN("name", PersonWithIdTable.name) + } } + } catch (e: Exception) { + // Expected to fail with current implementation + e.printStackTrace() } - assertNotEquals(null, people.getResults().first().id) + + lateinit var txStatement: SelectStatement + database { + txStatement = PersonWithIdTable SELECT WHERE (PersonWithIdTable.name EQ "Grace" OR (PersonWithIdTable.name EQ "Henry")) + } + assertEquals(2, txStatement.getResults().size) + assertEquals(true, txStatement.getResults().any { it.name == "Grace" }) + assertEquals(true, txStatement.getResults().any { it.name == "Henry" }) } } @@ -1689,16 +1687,15 @@ class CommonBasicTest(private val path: DatabasePath) { ProductTable.CREATE_UNIQUE_INDEX("idx_unique_product_name", ProductTable.name) } - val product1 = Product(sku = "SKU-WIDGET-1", name = "Widget", price = 19.99) + val product1 = Product(sku = null, name = "Widget", price = 19.99) database { ProductTable { table -> table INSERT product1 } } - // 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) + // Try to insert duplicate - should fail + val product2 = Product(sku = null, name = "Widget", price = 29.99) var duplicateFailed = false try { database { @@ -1787,18 +1784,10 @@ 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")) 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-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 de7610c3..da6cd9a6 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,11 +71,7 @@ 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 @@ -120,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, ) @@ -128,7 +124,7 @@ data class Product( @DBRow("student_with_autoincrement") @Serializable data class StudentWithAutoincrement( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val studentName: String, val grade: Grade, ) @@ -144,7 +140,7 @@ data class Enrollment( @DBRow("file_data") @Serializable data class FileData( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val fileName: String, val content: ByteArray, val metadata: String, @@ -179,7 +175,7 @@ data class FileData( @DBRow("user_account") @Serializable data class UserAccount( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val username: String, val email: String, val status: UserStatus, @@ -193,7 +189,7 @@ data class UserAccount( @DBRow("task") @Serializable data class Task( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val title: String, val priority: Priority?, val description: String, @@ -206,7 +202,7 @@ data class Task( @DBRow("unique_email_test") @Serializable data class UniqueEmailTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -218,7 +214,7 @@ data class UniqueEmailTest( @DBRow("collate_nocase_test") @Serializable data class CollateNoCaseTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CollateNoCase val username: String, @CollateNoCase @Unique val email: String, val description: String, @@ -231,7 +227,7 @@ data class CollateNoCaseTest( @DBRow("composite_unique_test") @Serializable data class CompositeUniqueTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0) val groupA: String, @CompositeUnique(0) val groupB: Int, @CompositeUnique(1) val groupC: String, @@ -246,7 +242,7 @@ data class CompositeUniqueTest( @DBRow("multi_group_unique_test") @Serializable data class MultiGroupUniqueTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0, 1) val userId: Int, @CompositeUnique(0) val eventType: String, @CompositeUnique(1) val timestamp: Long, @@ -260,7 +256,7 @@ data class MultiGroupUniqueTest( @DBRow("combined_constraints_test") @Serializable data class CombinedConstraintsTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique @CollateNoCase val code: String, @Unique val serial: String, val value: Int, @@ -276,7 +272,7 @@ data class CombinedConstraintsTest( @DBRow("fk_user") @Serializable data class FKUser( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -287,7 +283,7 @@ data class FKUser( @DBRow("fk_order") @Serializable data class FKOrder( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -304,7 +300,7 @@ data class FKOrder( @DBRow("fk_post") @Serializable data class FKPost( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -321,7 +317,7 @@ data class FKPost( @DBRow("fk_profile") @Serializable data class FKProfile( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -355,7 +351,7 @@ data class FKProduct( trigger = com.ctrip.sqllin.dsl.annotation.Trigger.ON_DELETE_CASCADE ) data class FKOrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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") @@ -370,7 +366,7 @@ data class FKOrderItem( @DBRow("fk_comment") @Serializable data class FKComment( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.References( tableName = "fk_user", foreignKeys = ["id"], @@ -398,7 +394,7 @@ data class FKComment( @DBRow("default_values_test") @Serializable data class DefaultValuesTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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, @@ -413,7 +409,7 @@ data class DefaultValuesTest( @DBRow("default_nullable_test") @Serializable data class DefaultNullableTest( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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?, @@ -426,7 +422,7 @@ data class DefaultNullableTest( @DBRow("default_fk_parent") @Serializable data class DefaultFKParent( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val name: String, ) @@ -442,83 +438,9 @@ data class DefaultFKParent( trigger = com.ctrip.sqllin.dsl.annotation.Trigger.ON_DELETE_SET_DEFAULT ) data class DefaultFKChild( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @com.ctrip.sqllin.dsl.annotation.ForeignKey(group = 0, reference = "id") @com.ctrip.sqllin.dsl.annotation.Default("0") val parentId: Long, val description: String, -) -/** - * 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, -) - -/** - * 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?, -) - -/** - * 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, -) +) \ No newline at end of file diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/TestPrimitiveTypeForKSP.kt index 9f0a29cf..9b62e20f 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,9 +45,4 @@ class TestPrimitiveTypeForKSP( val testEnum: Priority, val testTypeAlias: Code, @Transient val testTransient: Int = 0, - // 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 +) \ No newline at end of file diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 08a65ba2..e44a5bf5 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,9 +79,6 @@ 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 12695beb..ef1f1367 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,9 +95,6 @@ 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 f08a727e..8b70fd08 100644 --- a/sqllin-dsl/doc/getting-start-cn.md +++ b/sqllin-dsl/doc/getting-start-cn.md @@ -7,8 +7,6 @@ 将 _sqllin-dsl_、_sqllin-driver_ 以及 _sqllin-processor_ 依赖添加到你的 `build.gradle.kts`: ```kotlin -import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask - plugins { kotlin("multiplatform") kotlin("plugin.serialization") @@ -45,19 +43,7 @@ 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) 来获取更多信息。 @@ -164,7 +150,7 @@ val database = Database( when (oldVersion) { 1 -> { // Example: Add a new column in version 2 - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_ADD_COLUMN PersonTable.email } } } @@ -205,9 +191,6 @@ 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` 注解。 @@ -234,23 +217,11 @@ data class Person( ) ``` -**重要的类型和可空性规则:** 属性的可空性决定了主键的值由谁提供。 - -- **`Long?`,由数据库分配**:映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 - -- **`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, -) -``` +- **对于自增的 `Long` 主键**:属性**必须**声明为可空类型(`Long?`)。这会映射到 SQLite 的 `INTEGER PRIMARY KEY`,它作为内部 `rowid` 的别名。当插入 `id = null` 的新记录时,SQLite 会自动生成 ID。 -- **其他类型(String、Int 等),由你提供**:属性**必须**是非空的,映射为 `TEXT PRIMARY KEY NOT NULL` 这样的列。除 `Long` 以外任何类型的可空主键都会导致编译错误。插入时必须提供唯一值: +- **对于其他类型(String、Int 等)**:属性**必须**是非空的。插入时必须提供唯一值: ```kotlin @DBRow @@ -262,7 +233,7 @@ data class User( ) ``` -`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。它要求属性为 `Long?`,这是唯一一种由数据库分配值的主键。 +`autoIncrement` 参数启用更严格的自增行为(使用 `AUTOINCREMENT` 关键字),确保行 ID 永远不会被重用。这仅对 `Long?` 属性有意义。 #### 使用 @CompositePrimaryKey 定义组合主键 @@ -286,8 +257,8 @@ data class Enrollment( **重要规则:** -- 必须在同一个类中对**至少两个属性**应用 `@CompositePrimaryKey`;只标注一个会导致编译错误,单列主键应使用 `@PrimaryKey` -- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的**,并在生成的表中声明为 `NOT NULL` +- 你可以在同一个类中对**多个属性**应用 `@CompositePrimaryKey` +- 所有带有 `@CompositePrimaryKey` 的属性**必须是非空的** - 你**不能**在同一个类中混合使用 `@PrimaryKey` 和 `@CompositePrimaryKey` - 只能使用其中一个 - 所有 `@CompositePrimaryKey` 属性的组合形成表的组合主键 @@ -308,7 +279,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, // Each email must be unique @Unique val username: String, // Each username must be unique val displayName: String, @@ -338,7 +309,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class Enrollment( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0) val studentId: Int, @CompositeUnique(0) val courseId: Int, val enrollmentDate: String, @@ -359,7 +330,7 @@ data class Enrollment( @DBRow @Serializable data class Event( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0, 1) val userId: Int, // Part of groups 0 and 1 @CompositeUnique(0) val eventType: String, // Part of group 0 @CompositeUnique(1) val timestamp: Long, // Part of group 1 @@ -392,7 +363,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CollateNoCase @Unique val email: String, // Case-insensitive unique email @CollateNoCase val username: String, // Case-insensitive username val bio: String, @@ -422,7 +393,7 @@ data class User( @DBRow @Serializable data class Product( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique @CollateNoCase val code: String, // Unique and case-insensitive val name: String, val price: Double, @@ -442,7 +413,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val name: String, @Default("'active'") val status: String, // String default @Default("0") val loginCount: Int, // Numeric default @@ -474,7 +445,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -507,7 +478,7 @@ val status: String ### 支持的类型 -SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性。其他任何类型的属性都会导致编译错误;如果想让这样的属性不进入表中,请为它加上 `kotlinx.serialization.Transient` 注解: +SQLlin 支持以下 Kotlin 类型用于 `@DBRow` 数据类的属性: #### 数值类型 - **整数类型:** `Byte`、`Short`、`Int`、`Long` @@ -563,7 +534,7 @@ enum class UserStatus { @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val username: String, val status: UserStatus, // Stored as 0, 1, 2, or 3 val priority: Priority?, // Nullable enum is also supported @@ -634,7 +605,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val name: String, val email: String, ) @@ -642,7 +613,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -691,7 +662,7 @@ data class Product( constraintName = "fk_product" ) data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @ForeignKey(group = 0, reference = "categoryId") val productCategory: Int, @ForeignKey(group = 0, reference = "productCode") @@ -719,7 +690,7 @@ data class OrderItem( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -732,7 +703,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Must be nullable! val content: String, @@ -745,7 +716,7 @@ data class Post( @DBRow @Serializable data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "Order", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) val orderId: Long, val productId: Long, @@ -758,7 +729,7 @@ data class OrderItem( @DBRow @Serializable data class Comment( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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, @@ -793,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(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @ForeignKey(group = 0, reference = "id") val userId: Long, @ForeignKey(group = 1, reference = "id") val productId: Long, val quantity: Int, @@ -813,7 +784,7 @@ data class OrderItem( @DBRow @Serializable data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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) @@ -830,7 +801,7 @@ data class OrderItem( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -865,7 +836,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -874,7 +845,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -885,7 +856,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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 5687b3fa..8b721c8d 100644 --- a/sqllin-dsl/doc/getting-start.md +++ b/sqllin-dsl/doc/getting-start.md @@ -9,8 +9,6 @@ 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") @@ -47,21 +45,8 @@ 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. @@ -173,7 +158,7 @@ val database = Database( when (oldVersion) { 1 -> { // Example: Add a new column in version 2 - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_ADD_COLUMN PersonTable.email } } } @@ -216,9 +201,6 @@ 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`. @@ -245,23 +227,11 @@ data class Person( ) ``` -**Important type and nullability rules:** the nullability of the property decides who supplies the key's value. - -- **`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. - -- **`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: +**Important type and nullability rules:** -```kotlin -@DBRow -@Serializable -data class Movie( - @PrimaryKey - val id: Long, // Non-nullable, user-provided, still a rowid alias - val title: String, -) -``` +- **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. -- **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: +- **For other types (String, Int, etc.)**: The property **must** be non-nullable. You must provide a unique value when inserting: ```kotlin @DBRow @@ -273,7 +243,7 @@ data class User( ) ``` -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. +The `autoIncrement` parameter enables stricter auto-incrementing behavior (using `AUTOINCREMENT` keyword), ensuring row IDs are never reused. This is only meaningful for `Long?` properties. #### Composite Primary Key with @CompositePrimaryKey @@ -297,8 +267,8 @@ 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**, and are declared `NOT NULL` in the generated table +- You can apply `@CompositePrimaryKey` to **multiple properties** in the same class +- 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 @@ -319,7 +289,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, // Each email must be unique @Unique val username: String, // Each username must be unique val displayName: String, @@ -349,7 +319,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class Enrollment( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0) val studentId: Int, @CompositeUnique(0) val courseId: Int, val enrollmentDate: String, @@ -370,7 +340,7 @@ data class Enrollment( @DBRow @Serializable data class Event( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CompositeUnique(0, 1) val userId: Int, // Part of groups 0 and 1 @CompositeUnique(0) val eventType: String, // Part of group 0 @CompositeUnique(1) val timestamp: Long, // Part of group 1 @@ -403,7 +373,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @CollateNoCase @Unique val email: String, // Case-insensitive unique email @CollateNoCase val username: String, // Case-insensitive username val bio: String, @@ -433,7 +403,7 @@ You can combine multiple constraint annotations on the same property: @DBRow @Serializable data class Product( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique @CollateNoCase val code: String, // Unique and case-insensitive val name: String, val price: Double, @@ -453,7 +423,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val name: String, @Default("'active'") val status: String, // String default @Default("0") val loginCount: Int, // Numeric default @@ -485,7 +455,7 @@ Default values are **required** when using `ON_DELETE_SET_DEFAULT` or `ON_UPDATE @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -518,7 +488,7 @@ val status: String ### Supported Types -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`: +SQLlin supports the following Kotlin types for properties in `@DBRow` data classes: #### Numeric Types - **Integer types:** `Byte`, `Short`, `Int`, `Long` @@ -574,7 +544,7 @@ enum class UserStatus { @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val username: String, val status: UserStatus, // Stored as 0, 1, 2, or 3 val priority: Priority?, // Nullable enum is also supported @@ -645,7 +615,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, val name: String, val email: String, ) @@ -653,7 +623,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -702,7 +672,7 @@ data class Product( constraintName = "fk_product" ) data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @ForeignKey(group = 0, reference = "categoryId") val productCategory: Int, @ForeignKey(group = 0, reference = "productCode") @@ -730,7 +700,7 @@ Triggers define what happens when a referenced row is deleted or updated. SQLlin @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -743,7 +713,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_SET_NULL) val authorId: Long?, // Must be nullable! val content: String, @@ -756,7 +726,7 @@ data class Post( @DBRow @Serializable data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "Order", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_RESTRICT) val orderId: Long, val productId: Long, @@ -769,7 +739,7 @@ data class OrderItem( @DBRow @Serializable data class Comment( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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, @@ -804,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(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @ForeignKey(group = 0, reference = "id") val userId: Long, @ForeignKey(group = 1, reference = "id") val productId: Long, val quantity: Int, @@ -824,7 +794,7 @@ Or using `@References`: @DBRow @Serializable data class OrderItem( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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) @@ -841,7 +811,7 @@ You can optionally name your foreign key constraints for better error messages a @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References( tableName = "User", foreignKeys = ["id"], @@ -876,7 +846,7 @@ import kotlinx.serialization.Serializable @DBRow @Serializable data class User( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @Unique val email: String, val name: String, ) @@ -885,7 +855,7 @@ data class User( @DBRow @Serializable data class Order( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = true) val id: Long?, @References(tableName = "User", foreignKeys = ["id"], trigger = Trigger.ON_DELETE_CASCADE) val userId: Long, val amount: Double, @@ -896,7 +866,7 @@ data class Order( @DBRow @Serializable data class Post( - @PrimaryKey(autoIncrement = true) val id: Long?, + @PrimaryKey(isAutoincrement = 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/modify-database-and-transaction-cn.md b/sqllin-dsl/doc/modify-database-and-transaction-cn.md index e2962a58..805da063 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。 +SQLlin 提供了用于管理表结构的类型安全 DSL 操作:CREATE、DROP 和 ALTER(在 API 中称为 ALERT)。 ### CREATE - 创建表 @@ -61,7 +61,7 @@ fun sample() { ### ALTER - 修改表结构 -SQLlin 提供了多种 ALTER 操作来修改现有的表结构: +SQLlin 提供了多种 ALTER(ALERT)操作来修改现有的表结构: #### 添加列 @@ -78,7 +78,7 @@ data class Person( fun sample() { database { - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_ADD_COLUMN PersonTable.email } } ``` @@ -91,10 +91,10 @@ fun sample() { fun sample() { database { // Rename using Table object - PersonTable ALTER_RENAME_TABLE_TO NewPersonTable + PersonTable ALERT_RENAME_TABLE_TO NewPersonTable // Or rename using old table name as String - "old_person" ALTER_RENAME_TABLE_TO NewPersonTable + "old_person" ALERT_RENAME_TABLE_TO NewPersonTable } } ``` @@ -149,7 +149,7 @@ val database = Database( when (oldVersion) { 1 -> { // Upgrade from version 1 to 2 - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_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 fde5524c..2f480b94 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. +SQLlin provides type-safe DSL operations for managing table structures: CREATE, DROP, and ALTER (referred to as ALERT in the API). ### CREATE - Creating Tables @@ -64,7 +64,7 @@ fun sample() { ### ALTER - Modifying Table Structure -SQLlin provides several ALTER operations for modifying existing table structures: +SQLlin provides several ALTER (ALERT) operations for modifying existing table structures: #### Add Column @@ -81,7 +81,7 @@ data class Person( fun sample() { database { - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_ADD_COLUMN PersonTable.email } } ``` @@ -94,10 +94,10 @@ Rename an existing table to a new name: fun sample() { database { // Rename using Table object - PersonTable ALTER_RENAME_TABLE_TO NewPersonTable + PersonTable ALERT_RENAME_TABLE_TO NewPersonTable // Or rename using old table name as String - "old_person" ALTER_RENAME_TABLE_TO NewPersonTable + "old_person" ALERT_RENAME_TABLE_TO NewPersonTable } } ``` @@ -152,7 +152,7 @@ val database = Database( when (oldVersion) { 1 -> { // Upgrade from version 1 to 2 - PersonTable ALTER_ADD_COLUMN PersonTable.email + PersonTable ALERT_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 7b995311..07f58cd9 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.Alter +import com.ctrip.sqllin.dsl.sql.operation.Alert 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,50 +53,34 @@ 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 - * - **ALTER**: Modify table structures (add columns, rename tables/columns, drop columns) + * - **ALERT (ALTER)**: Modify table structures (add columns, rename tables/columns, drop columns) * * Transaction support: * - 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 ALTER_ADD_COLUMN PersonTable.email - * } + * PersonTable ALERT_ADD_COLUMN email * - * // 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 + * // Data manipulation + * transaction { + * PersonTable INSERT person + * PersonTable UPDATE SET { name = "Alice" } WHERE (age GTE 18) * } - * } - * // Every statement above ran when the scope exited, so the results are available only here - * val results = adults.getResults() + * val adults = PersonTable SELECT WHERE(age GTE 18) LIMIT 10 * - * // Cleanup - * database { + * // Cleanup * PersonTable.DROP() * } * ``` * * @author Yuang Qiao */ -@Suppress("UNCHECKED_CAST", "DSL_MARKER_APPLIED_TO_WRONG_TARGET") +@Suppress("UNCHECKED_CAST") public class DatabaseScope internal constructor( private val databaseConnection: DatabaseConnection, private val enableSimpleSQLLog: Boolean, @@ -220,9 +204,6 @@ 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 @@ -646,8 +627,8 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * UserTable.CREATE_INDEX("idx_user_email", UserTable.email) - * UserTable.CREATE_INDEX("idx_user_name_age", UserTable.name, UserTable.age) + * User::class.table.CREATE_INDEX("idx_user_email", User::email) + * User::class.table.CREATE_INDEX("idx_user_name_age", User::name, User::age) * } * ``` * @@ -671,8 +652,8 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * UserTable.CREATE_UNIQUE_INDEX("idx_unique_email", UserTable.email) - * ProductTable.CREATE_UNIQUE_INDEX("idx_unique_sku", ProductTable.sku) + * User::class.table.CREATE_UNIQUE_INDEX("idx_unique_email", User::email) + * Product::class.table.CREATE_UNIQUE_INDEX("idx_unique_sku", Product::sku) * } * ``` * @@ -731,7 +712,7 @@ public class DatabaseScope internal constructor( @JvmName("drop") public fun Table.DROP(): Unit = DROP(this) - // ========== ALTER Operations ========== + // ========== ALERT (ALTER) Operations ========== /** * Adds a new column to an existing table. @@ -743,7 +724,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALTER_ADD_COLUMN email + * PersonTable ALERT_ADD_COLUMN email * } * ``` * @@ -751,8 +732,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement) { - val statement = Alter.addColumn(this, column, databaseConnection) + public infix fun Table.ALERT_ADD_COLUMN(column: ClauseElement) { + val statement = Alert.addColumn(this, column, databaseConnection) addStatement(statement) } @@ -762,7 +743,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable + * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -770,8 +751,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALTER_RENAME_TABLE_TO(newTable: Table<*>) { - val statement = Alter.renameTable(tableName, newTable, databaseConnection) + public infix fun Table.ALERT_RENAME_TABLE_TO(newTable: Table<*>) { + val statement = Alert.renameTable(tableName, newTable, databaseConnection) addStatement(statement) } @@ -783,7 +764,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * "old_person" ALTER_RENAME_TABLE_TO NewPersonTable + * "old_person" ALERT_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -792,8 +773,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun String.ALTER_RENAME_TABLE_TO(newTable: Table<*>) { - val statement = Alter.renameTable(this, newTable, databaseConnection) + public infix fun String.ALERT_RENAME_TABLE_TO(newTable: Table<*>) { + val statement = Alert.renameTable(this, newTable, databaseConnection) addStatement(statement) } @@ -816,7 +797,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public fun Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { - val statement = Alter.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) + val statement = Alert.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) addStatement(statement) } @@ -839,7 +820,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement) { - val statement = Alter.renameColumn(this, oldColumnName, newColumn, databaseConnection) + val statement = Alert.renameColumn(this, oldColumnName, newColumn, databaseConnection) addStatement(statement) } @@ -862,7 +843,7 @@ public class DatabaseScope internal constructor( @ExperimentalDSLDatabaseAPI @StatementDslMaker public infix fun Table.DROP_COLUMN(column: ClauseElement) { - val statement = Alter.dropColumn(this, column, databaseConnection) + val statement = Alert.dropColumn(this, column, databaseConnection) addStatement(statement) } 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 f53966f2..6f7b6555 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,36 +30,32 @@ 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 nullability of the property decides who supplies the key's value: + * The behavior of this annotation differs based on the type of property it annotates. + * The following rules must be followed: * - * - **`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 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`: 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. + * - **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. * - * - **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 + * @property isAutoincrement Indicates whether to append the `AUTOINCREMENT` keyword to the * `INTEGER PRIMARY KEY` column in the `CREATE TABLE` statement. This enables a stricter * auto-incrementing strategy that ensures row IDs are never reused. - * **Important Note**: This parameter 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. + * **Important Note**: This parameter is only meaningful when annotating a property of type `Long?`. + * Setting this to `true` on non-Long properties will result in a compile-time error. * * @see DBRow * @see CompositePrimaryKey */ @Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.BINARY) -public annotation class PrimaryKey(val autoIncrement: Boolean = false) +public annotation class PrimaryKey(val isAutoincrement: Boolean = false) /** * Marks a property as a part of a composite primary key for the table. @@ -70,15 +66,12 @@ public annotation class PrimaryKey(val autoIncrement: Boolean = false) * will form the table's composite primary key. * * ### Important Rules - * - 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]. + * - A class can have multiple properties annotated with [CompositePrimaryKey]. * - If a class uses [CompositePrimaryKey] on any of its properties, it **cannot** also use * the [PrimaryKey] annotation on any other property. A table can only have one primary key, * which is either a single column or a composite of multiple columns. * - All properties annotated with [CompositePrimaryKey] must be of a **non-nullable** type - * (e.g., `String`, `Int`, `Long`), as primary key columns cannot contain `NULL` values. They are - * declared `NOT NULL` in the generated table, because SQLite, unlike standard SQL, does not let - * `PRIMARY KEY` imply it. + * (e.g., `String`, `Int`, `Long`), as primary key columns cannot contain `NULL` values. * * @see DBRow * @see PrimaryKey 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 d2377b9d..faea3ce2 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,17 +16,10 @@ 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 that highlights calls to SQL statement functions (SELECT, INSERT, UPDATE, DELETE, WHERE, ...) in - * IntelliJ IDEA. + * DSL marker for SQL statement functions to prevent implicit receiver nesting. + * + * Applied to top-level SQL statement functions (SELECT, INSERT, UPDATE, DELETE). * * @author Yuang Qiao */ @@ -36,7 +29,9 @@ package com.ctrip.sqllin.dsl.annotation internal annotation class StatementDslMaker /** - * DSL marker that highlights SQL keywords, such as `X` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. + * 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. * * @author Yuang Qiao */ @@ -46,7 +41,9 @@ internal annotation class StatementDslMaker internal annotation class KeyWordDslMaker /** - * DSL marker that highlights calls to SQL functions (aggregate, numeric and string functions) in IntelliJ IDEA. + * DSL marker for SQL function builders to prevent implicit receiver nesting. + * + * Applied to SQL function builder functions (aggregate functions, etc.). * * @author Yuang Qiao */ @@ -56,7 +53,7 @@ internal annotation class KeyWordDslMaker internal annotation class FunctionDslMaker /** - * DSL marker that highlights the generated column properties in IntelliJ IDEA. + * DSL marker for generated column name properties. * * 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/PrimaryKeyInfo.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/PrimaryKeyInfo.kt index 8841e5ff..07be95d1 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,16 +28,14 @@ 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` - * - [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 + * - [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 * * **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 - * - [isGeneratedByDatabase] is `false` (the caller supplies every column of a composite key) + * - [isRowId] is `false` (composite keys cannot use rowid alias) * - [isAutomaticIncrement] is `false` (composite keys cannot auto-increment) * * **No Primary Key:** @@ -45,8 +43,7 @@ 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 isGeneratedByDatabase Whether the database assigns the primary key's value, in which case - * a plain INSERT leaves the column out + * @property isRowId Whether the primary key is a `Long?` type that maps to SQLite's rowid * @property compositePrimaryKeys List of column names forming a composite primary key, or `null` for single keys * * @author Yuang Qiao @@ -54,6 +51,6 @@ package com.ctrip.sqllin.dsl.sql public class PrimaryKeyInfo( internal val primaryKeyName: String?, internal val isAutomaticIncrement: Boolean, - internal val isGeneratedByDatabase: Boolean, + internal val isRowId: Boolean, internal val compositePrimaryKeys: List?, ) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/Table.kt index fa7e8b0d..710491cb 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(autoIncrement = true) val id: Long?, + * @PrimaryKey(isAutoincrement = true) val id: Long?, * @Unique @CollateNoCase val email: String, * val name: String, * val age: Int 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 f852622c..6cb79ad0 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,17 +65,14 @@ 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 dee44476..9d895476 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,8 +14,6 @@ * 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 d1401deb..36687ceb 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,6 +47,5 @@ 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 ddd579dc..d028707b 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,8 +14,6 @@ * 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 @@ -223,7 +221,6 @@ 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) @@ -241,7 +238,6 @@ 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) @@ -259,7 +255,6 @@ 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) @@ -277,7 +272,6 @@ 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) @@ -297,7 +291,6 @@ 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) @@ -317,7 +310,6 @@ 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) @@ -337,6 +329,5 @@ 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 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 c1824149..cf4cb1e8 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,8 +14,6 @@ * 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 6309dbbc..2cd0c316 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,7 +41,6 @@ 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 fb7728a5..78bc8ebe 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,8 +14,6 @@ * 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 951e3041..9ba0235e 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,7 +46,6 @@ 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) @@ -76,6 +75,5 @@ 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 f74dee3e..6ab9df05 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,8 +14,6 @@ * 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 8e332c19..08b63330 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,8 +14,6 @@ * 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 e34fc9b0..ca846a5e 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,6 +81,5 @@ 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 24d0c207..c4e5426d 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,8 +14,6 @@ * 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/compiler/EncodeEntities2SQL.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/compiler/EncodeEntities2SQL.kt index 227f6e88..229d5b50 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,16 +34,14 @@ import kotlinx.serialization.descriptors.SerialDescriptor * ``` * * Handles primary key logic: - * - 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 + * - 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 * * @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 even when the database would assign it + * @param isInsertWithId Whether to include the primary key column for rowid-backed keys */ internal fun encodeEntities2InsertValues( table: Table, @@ -53,7 +51,7 @@ internal fun encodeEntities2InsertValues( isInsertWithId: Boolean, ) = with(builder) { val isInsertId = table.primaryKeyInfo?.run { - !isGeneratedByDatabase || isInsertWithId + !isRowId || 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 1774afd8..5dd86bde 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. * - * Leaves out the primary key field named [primaryKeyName] when [isInsertId] is `false`, so that the - * database assigns its value. + * Automatically skips the primary key field if [primaryKeyName] is provided, allowing + * database auto-increment to generate the 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 to encode the primary key field; `false` leaves it for the database to assign + * @param isInsertId whether ignore encoding the special primary key that represents rowid in SQLite * * @author Yuang Qiao */ diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt similarity index 90% rename from sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alter.kt rename to sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Alert.kt index b3dd96cf..a897c29c 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/Alert.kt @@ -23,7 +23,10 @@ import com.ctrip.sqllin.dsl.sql.statement.SingleStatement import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement /** - * ALTER operation for modifying database table structures. + * 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. * * Supports common table modification operations: * - **ADD COLUMN**: Add a new column to an existing table @@ -35,12 +38,12 @@ import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement * ```kotlin * database { * // Add a new column - * PersonTable ALTER_ADD_COLUMN email + * PersonTable ALERT_ADD_COLUMN email * * // Rename table - * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable + * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable * // or from old name - * "old_person" ALTER_RENAME_TABLE_TO NewPersonTable + * "old_person" ALERT_RENAME_TABLE_TO NewPersonTable * * // Rename column * PersonTable.RENAME_COLUMN(oldName, newName) @@ -52,16 +55,16 @@ import com.ctrip.sqllin.dsl.sql.statement.TableStructureStatement * } * ``` * - * @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.ALERT_ADD_COLUMN + * @see com.ctrip.sqllin.dsl.DatabaseScope.ALERT_RENAME_TABLE_TO * @see com.ctrip.sqllin.dsl.DatabaseScope.RENAME_COLUMN * @see com.ctrip.sqllin.dsl.DatabaseScope.DROP_COLUMN * @author Yuang Qiao */ -internal object Alter : Operation { +internal object Alert : Operation { override val sqlStr: String - get() = "ALTER TABLE " + get() = "ALERT TABLE " private const val ADD_COLUMN = " ADD COLUMN " private const val RENAME_TABLE = " RENAME TO " 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 4822c357..3c2bdc99 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, ALTER statement (final form). + * CREATE, DROP, ALERT statement (final form). * * Represents a complete CREATE TABLE operation. Does not support parameterized queries * since DDL statements use direct SQL execution. 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 73d62224..d2906bac 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,7 +17,6 @@ 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 @@ -94,19 +93,6 @@ 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) @@ -117,31 +103,6 @@ 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, @@ -161,14 +122,12 @@ 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("object $objectName : Table<$className>(\"$tableName\") {\n\n") 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) @@ -178,9 +137,14 @@ 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) } + }.toList() + // Process each property to generate column definitions propertyList.forEachIndexed { index, property -> - val clauseElementTypeName = checkNotNull(getClauseElementTypeStr(property)) // Rejected above + val clauseElementTypeName = getClauseElementTypeStr(property) ?: return@forEachIndexed val propertyName = property.simpleName.asString() val elementName = "$className.serializer().descriptor.getElementName($index)" val isNotNull = property.type.resolve().nullability == Nullability.NOT_NULL @@ -204,9 +168,14 @@ class ClauseProcessor( writer.write(" get() = $clauseElementTypeName($elementName, this)\n\n") writer.write(" @ColumnNameDslMaker\n") writer.write(" var SetClause<$className>.$propertyName: ${property.typeName}") - writer.write(if (isNotNull) "\n" else "?\n") + val nullableSymbol = when { + columnConstraintParser.isRowId -> "?\n" + isNotNull -> "\n" + else -> "?\n" + } + writer.write(nullableSymbol) writer.write(" get() = ${getSetClauseGetterValue(property)}\n") - writer.write(" set(value) = ${appendFunction(elementName, property, isNotNull)}\n\n") + writer.write(" set(value) = ${appendFunction(elementName, property)}\n\n") } columnConstraintParser.generateCodeForPrimaryKey(writer, createSQLBuilder) @@ -341,30 +310,27 @@ class ClauseProcessor( * Generates the appropriate append function call for SetClause setters. * Supports typealiases by resolving them to their underlying types. * - * For enum types, converts the enum value to its ordinal before appending, with a safe call only - * when the enum is nullable. + * For enum types, converts the enum value to its ordinal before appending. + * Handles nullable enums with safe-call operator. * * @param elementName The serialized element name * @param property The property declaration - * @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, 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 - } + 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 } - 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) } /** 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 db37295c..854afadc 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,13 +65,11 @@ import java.io.Writer * * ### Validation Rules * - Cannot use both [@PrimaryKey] and [@CompositePrimaryKey] on the same property - * - 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 + * - Primary key properties must be nullable (SQLite rowid aliasing requirement) * - Only one [@PrimaryKey] annotation allowed per table - * - AUTOINCREMENT requires a `Long?` key, the only kind of key the database assigns + * - AUTOINCREMENT requires Long type (mapped to INTEGER in SQLite) * - [@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 * @@ -94,9 +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_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_TYPE = """The primary key's type must be Long when you set the the parameter "isAutoincrement = true" in annotation PrimaryKey.""" const val PROMPT_PRIMARY_KEY_USE_COUNT = "You only could use PrimaryKey to annotate one property in a class." const val PROMPT_NO_CASE_MUST_FOR_TEXT = "You only could add annotation @CollateNoCase for a String or Char typed property." } @@ -109,7 +105,8 @@ class ColumnConstraintParser(resolver: Resolver) { // Primary key tracking for metadata generation private var primaryKeyName: String? = null private var isAutomaticIncrement = false - private var isGeneratedByDatabase = false + var isRowId = false + private set private val compositePrimaryKeys = ArrayList() private var isContainsPrimaryKey = false @@ -133,24 +130,16 @@ class ColumnConstraintParser(resolver: Resolver) { * * #### Primary Key * ```kotlin - * @PrimaryKey(autoIncrement = true) - * val id: Long? // assigned by the database + * @PrimaryKey(isAutoincrement = true) + * val id: Long? * // 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 * ```kotlin * @CompositePrimaryKey * val userId: Long - * // Column: userId BIGINT NOT NULL + * // Column: userId BIGINT * // Later appended: ,PRIMARY KEY(userId,productId) * ``` * @@ -174,7 +163,7 @@ class ColumnConstraintParser(resolver: Resolver) { * 1. Determine SQLite type via [getSQLiteType] * 2. Apply PRIMARY KEY constraint if [@PrimaryKey] present * 3. Collect [@CompositePrimaryKey] columns for table-level constraint - * 4. Apply NOT NULL to every non-nullable column except a rowid alias, primary key columns included + * 4. Apply NOT NULL for 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 @@ -184,7 +173,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 [isGeneratedByDatabase] flags + * - Updates [isAutomaticIncrement] and [isRowId] flags * * @param createSQLBuilder StringBuilder to append column definition and constraints to * @param property The property declaration to process @@ -211,41 +200,33 @@ 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 } + check(!isNotNull) { PROMPT_PRIMARY_KEY_MUST_NOT_NULL } check(!isContainsPrimaryKey) { PROMPT_PRIMARY_KEY_USE_COUNT } isContainsPrimaryKey = true primaryKeyName = propertyName - // 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 - 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(isGeneratedByDatabase) { PROMPT_AUTO_INCREMENT_REQUIRES_NULLABLE_LONG } + check(isLong) { PROMPT_PRIMARY_KEY_TYPE } append(" AUTOINCREMENT") } + isRowId = isLong } 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) - } - - // 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) + } else if (isNotNull) { + // Add NOT NULL constraint for non-nullable, non-PK columns append(" NOT NULL") + } // Handle @CollateNoCase annotation - must be on text columns if (annotationKSType.any { it.isAssignableFrom(noCaseAnnotationName) }) { @@ -305,7 +286,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = "id", * isAutomaticIncrement = true, - * isGeneratedByDatabase = true, + * isRowId = true, * compositePrimaryKeys = null, * ) * ``` @@ -315,7 +296,7 @@ class ColumnConstraintParser(resolver: Resolver) { * override val primaryKeyInfo = PrimaryKeyInfo( * primaryKeyName = null, * isAutomaticIncrement = false, - * isGeneratedByDatabase = false, + * isRowId = false, * compositePrimaryKeys = listOf( * "userId", * "productId", @@ -340,7 +321,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 - * - [isGeneratedByDatabase]: Whether the database assigns the primary key's value + * - [isRowId]: Whether the primary key can serve as SQLite rowid alias * - [compositePrimaryKeys]: List of columns in composite primary key * - [compositeUniqueColumns]: Map of group number to columns for UNIQUE constraints * @@ -351,11 +332,6 @@ 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()) { @@ -368,7 +344,7 @@ class ColumnConstraintParser(resolver: Resolver) { write(" primaryKeyName = \"$primaryKeyName\",\n") } write(" isAutomaticIncrement = $isAutomaticIncrement,\n") - write(" isGeneratedByDatabase = $isGeneratedByDatabase,\n") + write(" isRowId = $isRowId,\n") if (compositePrimaryKeys.isEmpty()) { write(" compositePrimaryKeys = null,\n") } else { 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 abf8614f..586a1977 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,7 +64,6 @@ 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 * @@ -187,8 +186,6 @@ 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) @@ -205,7 +202,6 @@ class ForeignKeyParser { isNotNull: Boolean, ) { val columnReferenceEntities = ArrayList() - val setDefaultGroups = ArrayList() var defaultValue = "" annotations.forEach { annotation -> when (annotation.annotationType.resolve().declaration.qualifiedName?.asString()) { @@ -253,9 +249,6 @@ 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) } @@ -269,15 +262,8 @@ 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) @@ -301,7 +287,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(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." } + 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'" } } append(' ') append(it.triggerSQL) From d1c2d422e9feab5a2753a5f5a6046c142cc3ae5c Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 18:25:04 +0100 Subject: [PATCH 22/32] Add INSERT_OR_IGNORE (N2) INSERT_OR_REPLACE resolves a PRIMARY KEY or UNIQUE conflict by deleting the existing row and inserting the new one, which replaces the existing row's other columns. When the existing row should win instead, as when a paged list fetches an item it already holds and must keep that item's position, there was no way to say so. INSERT_OR_IGNORE writes INSERT OR IGNORE INTO: each entity that conflicts with an existing row is skipped and that row is left exactly as it is, while the other entities are inserted. Like INSERT_OR_REPLACE it always writes the primary key column, as a conflict on a key left out of the statement could never be seen. A null key that the database assigns still can't conflict. As SQLite documents, and as checked here, OR IGNORE also skips a row that would violate NOT NULL, which can't happen for a non-null property, while a FOREIGN KEY violation still fails the statement. The KDoc says so. The test covers a conflict on the primary key, on another UNIQUE column and on a composite key, and it fails if the primary key isn't written. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ROADMAP.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 67 +++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 49 ++++++++++++++ .../ctrip/sqllin/dsl/sql/operation/Insert.kt | 12 ++++ 8 files changed, 139 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41888bc5..f88ea938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### sqllin-dsl +* New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` * **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` diff --git a/ROADMAP.md b/ROADMAP.md index 7feef9f1..a19e8c14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,7 @@ ## Supported +* Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) * Support FOREIGN KEY DSL (2.2.0 ✅) * Support CREATE INDEX DSL (2.2.0 ✅) \ No newline at end of file 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 08252927..b19b2fa6 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 @@ -72,6 +72,9 @@ class AndroidTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 fe35213f..92f31f43 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 @@ -688,6 +688,73 @@ class CommonBasicTest(private val path: DatabasePath) { } } + @OptIn(AdvancedInsertAPI::class) + fun testInsertOrIgnore() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // A conflict on the primary key leaves the existing row exactly as it is, while the entities that + // don't conflict are inserted. Seeing the conflict at all depends on the key being written. + database { + PersonWithIdTable INSERT_WITH_ID PersonWithId(id = 100L, name = "Eve", age = 28) + } + database { + PersonWithIdTable INSERT_OR_IGNORE listOf( + PersonWithId(id = 100L, name = "Eve Updated", age = 29), + PersonWithId(id = 101L, name = "Grace", age = 30), + ) + } + lateinit var people: SelectStatement + database { + people = PersonWithIdTable SELECT X + } + assertEquals(2, people.getResults().size) + val eve = people.getResults().first { it.id == 100L } + assertEquals("Eve", eve.name) + assertEquals(28, eve.age) + assertEquals("Grace", people.getResults().first { it.id == 101L }.name) + + // A null ID is still assigned by the database, so it can't conflict on the primary key + database { + PersonWithIdTable INSERT_OR_IGNORE PersonWithId(id = null, name = "Frank", age = 35) + } + database { + people = PersonWithIdTable SELECT X + } + assertEquals(3, people.getResults().size) + assertNotEquals(null, people.getResults().first { it.name == "Frank" }.id) + + // A conflict on a UNIQUE column other than the key is ignored as well + database { + UniqueEmailTestTable INSERT UniqueEmailTest(id = null, email = "ivy@example.com", name = "Ivy") + UniqueEmailTestTable INSERT_OR_IGNORE UniqueEmailTest(id = null, email = "ivy@example.com", name = "Ivy Again") + } + lateinit var accounts: SelectStatement + database { + accounts = UniqueEmailTestTable SELECT X + } + assertEquals(1, accounts.getResults().size) + assertEquals("Ivy", accounts.getResults().first().name) + + // With a composite key, inserting a pair again keeps its existing row, other columns included, + // which is what tells INSERT_OR_IGNORE apart from INSERT_OR_REPLACE + database { + EnrollmentTable INSERT Enrollment(studentId = 1, courseId = 101, semester = "Spring") + } + database { + EnrollmentTable INSERT_OR_IGNORE listOf( + Enrollment(studentId = 1, courseId = 101, semester = "Fall"), + Enrollment(studentId = 1, courseId = 102, semester = "Fall"), + ) + } + lateinit var enrollments: SelectStatement + database { + enrollments = EnrollmentTable SELECT X + } + assertEquals(2, enrollments.getResults().size) + assertEquals("Spring", enrollments.getResults().first { it.courseId == 101L }.semester) + assertEquals("Fall", enrollments.getResults().first { it.courseId == 102L }.semester) + } + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) 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 08a65ba2..00fca0f2 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 @@ -64,6 +64,9 @@ class JvmTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 12695beb..913fb9ad 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 @@ -80,6 +80,9 @@ class NativeTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 7b995311..bd99f0ea 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 @@ -48,6 +48,7 @@ import kotlin.jvm.JvmName * Supported operations: * - **INSERT**: Add entities to tables * - **INSERT OR REPLACE**: Insert or replace entities on PRIMARY KEY / UNIQUE conflict + * - **INSERT OR IGNORE**: Insert entities, skipping those that conflict on PRIMARY KEY / UNIQUE * - **UPDATE**: Modify existing records with SET and WHERE clauses * - **DELETE**: Remove records with WHERE clauses * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION @@ -316,6 +317,54 @@ public class DatabaseScope internal constructor( public infix fun Table.INSERT_OR_REPLACE(entity: T): Unit = INSERT_OR_REPLACE(listOf(entity)) + /** + * Inserts multiple entities into the table, skipping each one that conflicts with an existing + * row on a PRIMARY KEY or UNIQUE constraint (`INSERT OR IGNORE INTO ...`). + * + * Unlike [INSERT_OR_REPLACE], the existing row is left exactly as it is: it isn't deleted and + * re-inserted, so its other columns keep their values. The entities that don't conflict are + * inserted as by a plain [INSERT]. + * + * The primary key column is always included in the VALUES clause so that SQLite can detect + * conflicts on it. If the primary key field is `null` for a key the database assigns, SQLite + * generates the ID and no conflict can occur on the primary key. + * + * SQLite also skips a row that would violate a NOT NULL constraint, which can't happen for a + * non-null property. A FOREIGN KEY violation is not ignored and still fails the statement. + * + * Example: + * ```kotlin + * // Leaves the existing row with ID 42 untouched, and inserts the row with ID 43 + * PersonWithIdTable INSERT_OR_IGNORE listOf( + * PersonWithId(id = 42L, name = "Alice", age = 26), + * PersonWithId(id = 43L, name = "Bob", age = 31), + * ) + * ``` + * + * @see INSERT_OR_REPLACE to replace the conflicting row instead + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_IGNORE(entities: Iterable) { + val statement = Insert.insertOrIgnore(this, databaseConnection, entities) + addStatement(statement) + } + + /** + * Inserts a single entity into the table, unless it conflicts with an existing row on a + * PRIMARY KEY or UNIQUE constraint, in which case the existing row is left as it is. + * + * Example: + * ```kotlin + * PersonWithIdTable INSERT_OR_IGNORE PersonWithId(id = 42L, name = "Alice", age = 26) + * ``` + * + * @see INSERT_OR_IGNORE for batch inserts that skip conflicting entities + * @see INSERT_OR_REPLACE to replace the conflicting row instead + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_IGNORE(entity: T): Unit = + INSERT_OR_IGNORE(listOf(entity)) + // ========== UPDATE Operations ========== /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt index dec56682..1cf0cd49 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt @@ -70,4 +70,16 @@ internal object Insert : Operation { } return InsertStatement(sql, connection, parameters) } + + fun insertOrIgnore(table: Table, connection: DatabaseConnection, entities: Iterable): SingleStatement { + val parameters = ArrayList() + val sql = buildString { + append("INSERT OR IGNORE INTO ") + append(table.tableName) + append(' ') + // Write the primary key even when the database would assign it, or a conflict on it could never be seen + encodeEntities2InsertValues(table, this, entities, parameters, isInsertWithId = true) + } + return InsertStatement(sql, connection, parameters) + } } \ No newline at end of file From bda5d270144aa681eb00106d448f8ef1e319ba80 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 18:25:04 +0100 Subject: [PATCH 23/32] Add projection to SELECT (N4) A SELECT always read rows into the table's own row type, so reading a few columns meant fetching and decoding all of them. The column list was already built from the deserializer's descriptor, and JOIN already took a result type of its own, but a single-table SELECT tied its result type to the table's. The result type is now given to the clause function, as with JOIN: PersonTable SELECT X() PersonTable SELECT WHERE(cond) ORDER_BY PersonTable.age LIMIT 10 for X, WHERE, ORDER BY, LIMIT and GROUP BY, after both SELECT and SELECT_DISTINCT, and the chained clauses keep it. Only the columns named by the type's properties are selected. Without a type argument a SELECT reads the table's row type exactly as before, and every existing call resolves as it did. A projection type has to fit the table: each property must be a column, of that column's type, and nullable if the column is, as a NULL read into a non-null property would quietly become 0 or "". A mismatch throws an IllegalArgumentException while the statement is built. Two other shapes were ruled out by compiling them. An overload of SELECT(X) that was generic only in its return type made every existing `SELECT X` ambiguous, so the no-clause form is a function, X(), declared next to the object X. And the projection can't be inferred from the type the statement is assigned to, as the existing overload is the more specific one, so the type argument is required. The public DatabaseScope.select functions now take a result type separate from the table's. That is source-compatible and keeps their JVM signatures, but on Kotlin/Native a library compiled against an earlier version may need to be recompiled. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ROADMAP.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 95 +++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 28 +++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + sqllin-dsl/doc/advanced-query-cn.md | 35 ++++ sqllin-dsl/doc/advanced-query.md | 38 +++++ .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 161 +++++++++++++++++- .../ctrip/sqllin/dsl/annotation/DslMakers.kt | 4 +- .../kotlin/com/ctrip/sqllin/dsl/sql/X.kt | 23 ++- .../ctrip/sqllin/dsl/sql/operation/Select.kt | 104 ++++++++--- 13 files changed, 462 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88ea938..5ac86be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### sqllin-dsl * New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` +* New DSL API: projection, which reads `SELECT` results into a narrower `@Serializable` type naming the columns to select, given as the type argument of the clause function: `X()`, `WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after `SELECT` or `SELECT_DISTINCT`. A type that doesn't fit the table is rejected with an `IllegalArgumentException` when the statement is built. To support it, the public `DatabaseScope#select` functions now take a result type separate from the table's; this is source-compatible, but on Kotlin/Native a library compiled against an earlier version may have to be recompiled * **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` diff --git a/ROADMAP.md b/ROADMAP.md index a19e8c14..697140e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,7 @@ ## Supported +* Support SELECT projection (2.4.0 ✅) * Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) * Support FOREIGN KEY DSL (2.2.0 ✅) 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 b19b2fa6..ecba9f92 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 @@ -75,6 +75,9 @@ class AndroidTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 92f31f43..0a48dd21 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 @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.test.runTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals /** @@ -755,6 +756,100 @@ class CommonBasicTest(private val path: DatabasePath) { } } + /** + * Covers projection: a SELECT that reads rows into a narrower @Serializable type than the table's own row type, + * given to the clause function, as in `X()` or `WHERE(...)`. Only the columns that type's + * properties name are selected, and a type that doesn't fit the table is rejected while the statement is built. + */ + fun testProjection() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + database { + BookTable INSERT listOf( + Book(name = "The Da Vinci Code", author = "Dan Brown", price = 16.96, pages = 454), + Book(name = "The Lost Symbol", author = "Dan Brown", price = 19.95, pages = 510), + Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251), + ) + } + + // No clause: SELECT name,author FROM book, and SELECT DISTINCT author FROM book. Three books but two authors, + // which only holds if DISTINCT compares the projected column alone, so nothing else is selected. + lateinit var titles: SelectStatement + lateinit var authors: SelectStatement + database { + titles = BookTable SELECT X() + authors = BookTable SELECT_DISTINCT X() + } + assertEquals(3, titles.getResults().size) + assertEquals(true, BookTitle("Kotlin Cookbook", "Ken Kousen") in titles.getResults()) + assertEquals(listOf("Dan Brown", "Ken Kousen"), authors.getResults().map { it.author }.sorted()) + + // Each clause can start a projection, and the projection carries through the rest of the chain + lateinit var longestByBrown: SelectStatement + lateinit var byPages: SelectStatement + lateinit var firstTwo: SelectStatement + lateinit var grouped: SelectStatement + database { + BookTable { table -> + longestByBrown = table SELECT WHERE(author EQ "Dan Brown") ORDER_BY (pages to DESC) LIMIT 1 + byPages = table SELECT ORDER_BY(pages to ASC) + firstTwo = table SELECT LIMIT(2) + grouped = table SELECT GROUP_BY(author) + } + } + assertEquals(listOf(BookTitle("The Lost Symbol", "Dan Brown")), longestByBrown.getResults()) + assertEquals(listOf("Kotlin Cookbook", "The Da Vinci Code", "The Lost Symbol"), byPages.getResults().map { it.name }) + assertEquals(2, firstTwo.getResults().size) + assertEquals(listOf("Dan Brown", "Ken Kousen"), grouped.getResults().map { it.author }.sorted()) + + // The DISTINCT variant of each clause + lateinit var distinctWhere: SelectStatement + lateinit var distinctOrderBy: SelectStatement + lateinit var distinctLimit: SelectStatement + lateinit var distinctGroupBy: SelectStatement + database { + BookTable { table -> + distinctWhere = table SELECT_DISTINCT WHERE(price GT 10.0) + distinctOrderBy = table SELECT_DISTINCT ORDER_BY(author to DESC) + distinctLimit = table SELECT_DISTINCT LIMIT(1) + distinctGroupBy = table SELECT_DISTINCT GROUP_BY(author) + } + } + assertEquals(2, distinctWhere.getResults().size) + assertEquals(listOf("Ken Kousen", "Dan Brown"), distinctOrderBy.getResults().map { it.author }) + assertEquals(1, distinctLimit.getResults().size) + assertEquals(2, distinctGroupBy.getResults().size) + + // A nullable column is read into a nullable property + database { + UserAccountTable INSERT UserAccount( + id = null, + username = "ivy", + email = "ivy@example.com", + status = UserStatus.ACTIVE, + priority = Priority.LOW, + notes = null, + ) + } + lateinit var notes: SelectStatement + database { + notes = UserAccountTable SELECT X() + } + assertEquals(listOf(UserNotes("ivy", null)), notes.getResults()) + + // A type that doesn't fit the table is rejected while the statement is built, before anything runs + val notAColumn = assertFailsWith { + database { BookTable SELECT X() } + } + assertEquals(true, notAColumn.message!!.contains("'isbn' isn't a column")) + val wrongType = assertFailsWith { + database { BookTable SELECT WHERE(BookTable.pages GT 0) } + } + assertEquals(true, wrongType.message!!.contains("'pages' is a kotlin.String, but the column holds a kotlin.Int")) + val notNullable = assertFailsWith { + database { UserAccountTable SELECT X() } + } + assertEquals(true, notNullable.message!!.contains("column 'notes' is nullable")) + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) 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 de7610c3..6e787cb9 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 @@ -522,3 +522,31 @@ data class RemoteMovie( @PrimaryKey val id: Long, val title: String, ) + +/** + * Projections of [Book]: plain @Serializable types rather than tables, whose properties name the columns a SELECT + * reads, as in `BookTable SELECT X()`. + */ +@Serializable +data class BookTitle(val name: String, val author: String) + +@Serializable +data class BookAuthor(val author: String) + +/** + * A projection of [UserAccount] that reads its nullable `notes` column into a nullable property. + */ +@Serializable +data class UserNotes(val username: String, val notes: String?) + +/** + * Projections that don't fit their table, each breaking one of the rules a projection is checked against. + */ +@Serializable +data class BookWithIsbn(val name: String, val isbn: String) // 'isbn' isn't a column of book + +@Serializable +data class BookPagesAsText(val pages: String) // 'pages' holds an Int + +@Serializable +data class UserNotesNonNull(val notes: String) // 'notes' is nullable 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 00fca0f2..61dfc8f9 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 @@ -67,6 +67,9 @@ class JvmTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 913fb9ad..cbd218d8 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 @@ -83,6 +83,9 @@ class NativeTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/doc/advanced-query-cn.md b/sqllin-dsl/doc/advanced-query-cn.md index f064525a..b9e23974 100644 --- a/sqllin-dsl/doc/advanced-query-cn.md +++ b/sqllin-dsl/doc/advanced-query-cn.md @@ -151,6 +151,41 @@ fun joinSample() { `LEFT_OUTER_JOIN` 的用法与 `INNER_JOIN` 非常相似,不同之处仅仅是它们的 API 名字。 +## 投影 + +`SELECT` 默认把每一行读成表自己的行类型。如果只想读取其中一部分列,可以声明一个更窄的 `@Serializable` 类型,用它的属性名 +指明要读取的列,再把它作为类型参数交给子句函数: + +```kotlin +@Serializable +data class PersonName( + val name: String, +) + +fun sample() { + lateinit var names: SelectStatement + lateinit var adultNames: SelectStatement + database { + PersonTable { table -> + // SELECT name FROM person + names = table SELECT X() + // SELECT name FROM person WHERE age >= ? ORDER BY name LIMIT 10 + adultNames = table SELECT WHERE(age GTE 18) ORDER_BY name LIMIT 10 + } + } +} +``` + +和 Join 的结果类型一样,投影类型不需要 `@DBRow` 注解。它可以作为 `X()`、`WHERE(...)`、`ORDER_BY(...)`、 +`LIMIT(...)` 和 `GROUP_BY(...)` 的类型参数,用在 `SELECT` 和 `SELECT_DISTINCT` 之后,后面链式调用的子句也会沿用它。 +使用 `SELECT_DISTINCT` 时只比较投影出来的列,所以 `table SELECT_DISTINCT X()` 中每个名字只会出现一次。 + +类型参数必须显式写出。如果不写,`SELECT` 会读成表自己的行类型,即使它的结果被赋值给一个投影类型的语句也是如此。 + +投影类型的每个属性都必须是这张表的列,类型与列一致,并且当列可空时属性也必须可空,因为把 `NULL` 读进非空属性时,它会被 +悄无声息地读成 `0` 或空字符串。不满足这些规则的投影类型会让 `SELECT` 在构建语句时、执行之前就抛出 `IllegalArgumentException`。 +`COUNT(*)` 这样的表达式目前还不能投影。 + ## 最后 你已经学习了所有的 SQLlin 用法,享受你的 SQLlin 的编程旅程并对它的更新保持关注吧 :) \ No newline at end of file diff --git a/sqllin-dsl/doc/advanced-query.md b/sqllin-dsl/doc/advanced-query.md index ee4686cf..91f250a7 100644 --- a/sqllin-dsl/doc/advanced-query.md +++ b/sqllin-dsl/doc/advanced-query.md @@ -157,6 +157,44 @@ fun joinSample() { The `LEFT_OUTER_JOIN`'s usage is very similar with `INNER_JOIN`, the difference just is their API names. +## Projection + +A `SELECT` reads each row into the table's own row type. To read only some of the columns, declare a narrower +`@Serializable` type whose properties name the columns you want, and give it to the clause function as a type argument: + +```kotlin +@Serializable +data class PersonName( + val name: String, +) + +fun sample() { + lateinit var names: SelectStatement + lateinit var adultNames: SelectStatement + database { + PersonTable { table -> + // SELECT name FROM person + names = table SELECT X() + // SELECT name FROM person WHERE age >= ? ORDER BY name LIMIT 10 + adultNames = table SELECT WHERE(age GTE 18) ORDER_BY name LIMIT 10 + } + } +} +``` + +Like a join's result type, a projection type doesn't need `@DBRow`. It works as the type argument of `X()`, +`WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after both `SELECT` and +`SELECT_DISTINCT`, and the clauses chained after it keep it. With `SELECT_DISTINCT`, only the projected columns are +compared, so `table SELECT_DISTINCT X()` gives each name once. + +The type argument is required. Without it, a `SELECT` reads the table's own row type, even when its result is assigned +to a statement of the projection type. + +Each property of a projection type has to be a column of the table, of the same type, and nullable if the column is +nullable, as a `NULL` read into a non-null property would quietly become `0` or an empty string. A projection type that +breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is built, before it +runs. Expressions such as `COUNT(*)` can't be projected yet. + ## Finally You have learned all usages with SQLlin, enjoy it and stay Stay tuned for SQLlin's updates :) \ No newline at end of file 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 bd99f0ea..5da6a1d6 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 @@ -21,6 +21,7 @@ import com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI import com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.Table +import com.ctrip.sqllin.dsl.sql.ProjectedX import com.ctrip.sqllin.dsl.sql.X import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.operation.Alter @@ -51,7 +52,8 @@ import kotlin.jvm.JvmName * - **INSERT OR IGNORE**: Insert entities, skipping those that conflict on PRIMARY KEY / UNIQUE * - **UPDATE**: Modify existing records with SET and WHERE clauses * - **DELETE**: Remove records with WHERE clauses - * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION + * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION, into the table's own row type + * or a narrower projection type, such as `PersonTable SELECT WHERE(...)` * - **CREATE**: Create tables from data class definitions * - **DROP**: Remove tables from the database * - **ALTER**: Modify table structures (add columns, rename tables/columns, drop columns) @@ -438,7 +440,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(x: X): FinalSelectStatement = select(kSerializer(), true) - public fun Table.select(serializer: KSerializer, isDistinct: Boolean): FinalSelectStatement { + public fun Table.select(serializer: KSerializer, isDistinct: Boolean): FinalSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -463,7 +465,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: WhereClause): WhereSelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: WhereClause, isDistinct: Boolean): WhereSelectStatement { + public fun Table.select(serializer: KSerializer, clause: WhereClause, isDistinct: Boolean): WhereSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -486,7 +488,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: OrderByClause): OrderBySelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: OrderByClause, isDistinct: Boolean): OrderBySelectStatement { + public fun Table.select(serializer: KSerializer, clause: OrderByClause, isDistinct: Boolean): OrderBySelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -509,7 +511,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: LimitClause): LimitSelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: LimitClause, isDistinct: Boolean): LimitSelectStatement { + public fun Table.select(serializer: KSerializer, clause: LimitClause, isDistinct: Boolean): LimitSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -532,7 +534,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: GroupByClause, isDistinct: Boolean): GroupBySelectStatement { + public fun Table.select(serializer: KSerializer, clause: GroupByClause, isDistinct: Boolean): GroupBySelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -544,6 +546,153 @@ public class DatabaseScope internal constructor( */ public inline fun getKSerializer(): KSerializer = EmptySerializersModule().serializer() + // ========== SELECT with Projection ========== + // + // These read rows into a type R other than the table's own row type, given to the clause function, as in + // `PersonTable SELECT WHERE(...)`. R's properties name the columns to select. Each overload taking a + // clause has the same JVM signature as its counterpart above, hence its @JvmName. + + /** + * Selects all records, reading each into [R], so that only the columns [R]'s properties name are selected. + * + * Example: + * ```kotlin + * @Serializable + * data class NameAndAge(val name: String, val age: Int) + * + * val people = PersonTable SELECT X() + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @StatementDslMaker + public inline infix fun Table.SELECT(x: ProjectedX): FinalSelectStatement = + select(getKSerializer(), false) + + /** + * Selects distinct records, reading each into [R], so that only the columns [R] names are selected and compared. + * + * Example: + * ```kotlin + * val authors = BookTable SELECT_DISTINCT X() + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(x: ProjectedX): FinalSelectStatement = + select(getKSerializer(), true) + + /** + * Selects records matching the WHERE clause, reading each into [R]. + * + * Example: + * ```kotlin + * val adults = PersonTable SELECT WHERE(PersonTable.age GTE 18) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectWhereProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: WhereClause): WhereSelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records matching the WHERE clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctWhereProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: WhereClause): WhereSelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects records in the order of the ORDER BY clause, reading each into [R]. + * + * Example: + * ```kotlin + * val byAge = PersonTable SELECT ORDER_BY(PersonTable.age to ASC) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectOrderByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: OrderByClause): OrderBySelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records in the order of the ORDER BY clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctOrderByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: OrderByClause): OrderBySelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects at most as many records as the LIMIT clause allows, reading each into [R]. + * + * Example: + * ```kotlin + * val firstTen = PersonTable SELECT LIMIT(10) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectLimitProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: LimitClause): LimitSelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects at most as many distinct records as the LIMIT clause allows, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctLimitProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: LimitClause): LimitSelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects records grouped by the GROUP BY clause, reading each into [R]. + * + * Example: + * ```kotlin + * val authors = BookTable SELECT GROUP_BY(BookTable.author) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectGroupByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: GroupByClause): GroupBySelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records grouped by the GROUP BY clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctGroupByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = + select(getKSerializer(), clause, true) + // ========== UNION Operations ========== private val unionSelectStatementGroupStack by lazy { ArrayDeque>() } 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 d2377b9d..48d5f397 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 @@ -36,12 +36,12 @@ package com.ctrip.sqllin.dsl.annotation internal annotation class StatementDslMaker /** - * DSL marker that highlights SQL keywords, such as `X` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. + * DSL marker that highlights SQL keywords, such as `X`, `X()` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. * * @author Yuang Qiao */ @DslMarker -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.BINARY) internal annotation class KeyWordDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt index 6d2de9d0..b05cc50d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt @@ -37,4 +37,25 @@ import com.ctrip.sqllin.dsl.annotation.KeyWordDslMaker * @author Yuang Qiao */ @KeyWordDslMaker -public object X \ No newline at end of file +public object X + +/** + * Selects every row like [X], but reads each into [R] rather than into the table's own row type, so that only the + * columns [R]'s properties name are selected. + * + * Example: + * ```kotlin + * // SELECT name,age FROM PersonTable + * val people = PersonTable SELECT X() + * ``` + * + * @see ProjectedX + */ +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") +@KeyWordDslMaker +public fun X(): ProjectedX = ProjectedX() + +/** + * The selector of every row, [X], carrying the type [R] that a SELECT reads rows into. Created by `X()`. + */ +public class ProjectedX internal constructor() diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt index feecfabf..523416a0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt @@ -22,6 +22,8 @@ import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.compiler.appendDBColumnName import com.ctrip.sqllin.dsl.sql.statement.* import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encoding.CompositeDecoder /** * SELECT operation builder. @@ -42,60 +44,68 @@ internal object Select : Operation { * * @return Statement that can be followed by GROUP BY, ORDER BY, or LIMIT */ - fun select( - table: Table, - clause: WhereClause, + fun select( + table: Table<*>, + clause: WhereClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): WhereSelectStatement = - WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters) + ): WhereSelectStatement { + checkProjection(table, deserializer) + return WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters) + } /** * Builds a SELECT statement with ORDER BY clause. * * @return Statement that can be followed by LIMIT */ - fun select( - table: Table, - clause: OrderByClause, + fun select( + table: Table<*>, + clause: OrderByClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): OrderBySelectStatement { + checkProjection(table, deserializer) + return OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with LIMIT clause. * * @return Statement that can be followed by OFFSET */ - fun select( - table: Table, - clause: LimitClause, + fun select( + table: Table<*>, + clause: LimitClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): LimitSelectStatement = - LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): LimitSelectStatement { + checkProjection(table, deserializer) + return LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with GROUP BY clause. * * @return Statement that can be followed by HAVING or ORDER BY */ - fun select( - table: Table, - clause: GroupByClause, + fun select( + table: Table<*>, + clause: GroupByClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): GroupBySelectStatement = - GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): GroupBySelectStatement { + checkProjection(table, deserializer) + return GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with NATURAL JOIN clause. @@ -138,6 +148,43 @@ internal object Select : Operation { addSelectStatement, ) + /** + * Checks that [deserializer] can read rows of [table], when it reads them into a type other than the table's + * own row type. + * + * The columns a SELECT reads are the element names of [deserializer]'s descriptor, so a projection type has to + * fit the table. Every property must name a column, have that column's type, and be nullable when the column is, + * as a NULL read into a non-null property would quietly become `0` or `""`. A mismatch fails here, while the + * statement is built, rather than in SQLite or not at all. + * + * @throws IllegalArgumentException if the projection type doesn't fit the table + */ + @OptIn(ExperimentalSerializationApi::class) + private fun checkProjection(table: Table<*>, deserializer: DeserializationStrategy<*>) { + val columns = table.kSerializer().descriptor + val projection = deserializer.descriptor + if (projection == columns) + return + val projectionName = projection.serialName + for (index in 0 ..< projection.elementsCount) { + val name = projection.getElementName(index) + val columnIndex = columns.getElementIndex(name) + require(columnIndex != CompositeDecoder.UNKNOWN_NAME) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' isn't a column of that table." + } + val column = columns.getElementDescriptor(columnIndex) + val property = projection.getElementDescriptor(index) + val columnType = column.serialName.removeSuffix("?") + val propertyType = property.serialName.removeSuffix("?") + require(propertyType == columnType) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is a $propertyType, but the column holds a $columnType." + } + require(property.isNullable || !column.isNullable) { + "Can't select '$projectionName' from table '${table.tableName}': column '$name' is nullable, so property '$name' has to be nullable too." + } + } + } + private fun buildSQL( table: Table<*>, clause: SelectClause, @@ -160,13 +207,14 @@ internal object Select : Operation { * * @return Final SELECT statement ready for execution */ - fun select( - table: Table, + fun select( + table: Table<*>, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): FinalSelectStatement { + ): FinalSelectStatement { + checkProjection(table, deserializer) val sql = buildString { append(sqlStr) if (isDistinct) From 0ba59f8a9246f02c7f992d90626df67ce9664488 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 23:32:17 +0100 Subject: [PATCH 24/32] Add result columns to SELECT (N5) A SELECT could only read columns: the column list came from the result type's property names, and the SQL functions returned elements usable only in conditions, with no Kotlin type and no way to name a result. So `count(*)` or a per-group `sum` could not be selected at all. An expression is now selected into a property of the result type with AS, and the type's other properties are read from their columns, as in a projection: table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author table SELECT (count(X) AS BookCount::books) WHERE (price LT 20.0) It follows the existing convention of a single argument or a Kotlin collection, as INSERT and GROUP_BY do, rather than adding a function that would look like a SQL keyword without being one. It works after SELECT and SELECT_DISTINCT, and is followed by WHERE, GROUP BY, ORDER BY and LIMIT, through a new ResultColumnSelectStatement. To check the type of a property at compile time, ClauseElement, ClauseNumber and ClauseString take a type parameter, the type of their values. The generated accessors give each column its property's type, and each function the type of the values SQLite returns for it: count, length, instr and random a Long, avg and round a Double, the string functions and group_concat a String, max, min and abs the type of their argument. sum is overloaded by column type, Long for integers and Booleans, Double for reals; it no longer takes a String, BLOB, enum or ULong column. AS takes a KProperty1 of the element's type P, so count(X) goes into a Long property, not an Int or a String one. Mixing result types in one listOf doesn't compile either. Nullability can't be checked through a property reference, as KProperty1 is covariant, so it is checked when the statement is built: an element knows whether it can be NULL in a row, or in a group for an aggregate function. One case depends on what follows: without GROUP BY an aggregate query returns one row even when no rows match, in which every column and every aggregate except count is NULL. As GROUP BY can still be appended then, the statements carry that error until GROUP BY clears it, and the scope reports it when it ends, before any of its statements runs, transactions included. A property given two expressions, renamed with @SerialName, or given another table's column is rejected too. max and min now return an element of their argument's kind, so they can be a Boolean, BLOB or enum element as well; these now respect isFunction like the numeric and string ones, so that a condition on such a function isn't prefixed with the table name. Documentation: a result columns section in the advanced query guide, and the SQL functions guide no longer says functions are for conditions only. It also listed a sign function, which is disabled, and had an example using `>` instead of GT, which didn't compile. ROADMAP: N5 is supported. This also commits the earlier roadmap decisions: observable queries (N1) as high priority, type converters merged with the kotlinx.datetime item (N8) as medium priority, and using a query's results within the same transaction (N6) as low priority. Tests: jvmTest (49) and testAndroidHostTest on API 26 and 37 (98) pass. The native test sources compile for macosArm64, linuxX64, mingwX64 and watchosArm32 but were not run, as this machine is an Intel Mac. The compile-time rejections were checked with a temporary file. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + ROADMAP.md | 8 +- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 6 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 292 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 50 +++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 6 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 6 + sqllin-dsl/doc/advanced-query-cn.md | 64 +++- sqllin-dsl/doc/advanced-query.md | 67 +++- sqllin-dsl/doc/sql-functions-cn.md | 29 +- sqllin-dsl/doc/sql-functions.md | 31 +- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 89 +++++- .../sqllin/dsl/sql/clause/BaseJoinClause.kt | 4 +- .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 43 ++- .../sqllin/dsl/sql/clause/ClauseBoolean.kt | 31 +- .../sqllin/dsl/sql/clause/ClauseElement.kt | 27 +- .../ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt | 34 +- .../sqllin/dsl/sql/clause/ClauseNumber.kt | 38 ++- .../sqllin/dsl/sql/clause/ClauseString.kt | 38 ++- .../sqllin/dsl/sql/clause/ConditionClause.kt | 60 ++-- .../ctrip/sqllin/dsl/sql/clause/Function.kt | 181 ++++++++--- .../sqllin/dsl/sql/clause/GroupByClause.kt | 24 +- .../sqllin/dsl/sql/clause/LimitClause.kt | 6 + .../sqllin/dsl/sql/clause/OrderByClause.kt | 60 ++-- .../sqllin/dsl/sql/clause/ResultColumn.kt | 66 ++++ .../sqllin/dsl/sql/clause/WhereClause.kt | 7 + .../ctrip/sqllin/dsl/sql/operation/Alter.kt | 6 +- .../ctrip/sqllin/dsl/sql/operation/Create.kt | 6 +- .../ctrip/sqllin/dsl/sql/operation/Select.kt | 163 ++++++++-- .../sql/statement/DatabaseExecuteEngine.kt | 9 + .../JoinStatementWithoutCondition.kt | 6 +- .../dsl/sql/statement/SelectStatement.kt | 95 ++++-- .../statement/TransactionStatementsGroup.kt | 9 + .../statement/UnionSelectStatementGroup.kt | 2 + .../ctrip/sqllin/processor/ClauseProcessor.kt | 19 +- 35 files changed, 1338 insertions(+), 248 deletions(-) create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac86be5..66561e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ * New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` * New DSL API: projection, which reads `SELECT` results into a narrower `@Serializable` type naming the columns to select, given as the type argument of the clause function: `X()`, `WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after `SELECT` or `SELECT_DISTINCT`. A type that doesn't fit the table is rejected with an `IllegalArgumentException` when the statement is built. To support it, the public `DatabaseScope#select` functions now take a result type separate from the table's; this is source-compatible, but on Kotlin/Native a library compiled against an earlier version may have to be recompiled +* New DSL API: result columns, which select expressions such as aggregate functions into properties of a result type with `AS`, as in `table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author`, or `table SELECT (count(X) AS BookCount::books)` for a single one. The result type's other properties are read from their columns, as in a projection. They work after `SELECT` and `SELECT_DISTINCT`, followed by `WHERE`, `GROUP_BY`, `ORDER_BY` and `LIMIT`. A property must have the type of its expression's values, which is checked at compile time, and be nullable when its expression can be `NULL`, which is checked when the statement is built. An aggregate query without `GROUP BY` returns a row even when no rows match, in which every column and every aggregate function except `count` is `NULL`; as `GROUP_BY` can still follow when the statement is built, this is checked when the scope ends, before any of its statements runs +* **Breaking change**: `ClauseElement`, `ClauseNumber` and `ClauseString` now take a type parameter, the type of their values, such as `ClauseNumber` for an `Int` column and `ClauseNumber` for `count(X)`, which is what lets `AS` check the type of a property. Code that only uses the DSL is unaffected; code that names these types has to add a type argument, such as `ClauseElement<*>` where any element is accepted. The public constructors of `ClauseNumber`, `ClauseString`, `ClauseBoolean`, `ClauseBlob` and `ClauseEnum`, which the generated table objects call, now take whether the column is nullable instead of whether the element is a function. The generated code is regenerated by the build, but on Kotlin/Native a library compiled against an earlier version has to be recompiled +* **Breaking change**: The SQL functions now return elements of the type of the values SQLite returns for them: `count`, `length`, `instr` and `random` a `ClauseNumber`, `avg` and `round` a `ClauseNumber`, the string functions and `group_concat` a `ClauseString`, and `max`, `min` and `abs` an element of the same kind and type as their argument. So `max` and `min` of a String column are now a `ClauseString`, compared with strings in `HAVING`, where they used to be a `ClauseNumber`. `sum` is overloaded by the type of its column: of a column of integers or Booleans it is a `ClauseNumber`, and of a `Float` or `Double` column a `ClauseNumber`. A `sum` of a String, BLOB, enum or `ULong` column no longer compiles; the last because SQLite stores a `ULong` above `Long.MAX_VALUE` as a negative number, which made the sum wrong +* Fix documentation: the SQL functions guide listed a `sign` function, which isn't available, and its `HAVING (count(X) > 2)` example didn't compile; it is `HAVING (count(X) GT 2)`. It no longer says that functions can only be used in conditions * **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` diff --git a/ROADMAP.md b/ROADMAP.md index 697140e9..575d43ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # SQLlin Roadmap +## High Priority + +* Support observable queries that return a `Flow` re-emitting whenever the tables they read change, e.g. to invalidate a Paging source + ## Medium Priority * Support WASM platform DSL @@ -8,14 +12,16 @@ * Support CREATE TRIGGER DSL * Support JOIN sub-query DSL * Support more functions +* Support type converters: store a property of any type through a serializer that encodes it to a type SQLite supports, with type-safe WHERE and SET on its column, e.g. to store instances of kotlinx.datetime ## Low Priority -* Support store instances of kotlinx.datetime * Support CHECK keyword +* Support using a query's results within the same transaction, so that a read-modify-write is one transaction ## Supported +* Support SQL functions in SELECT results (2.4.0 ✅) * Support SELECT projection (2.4.0 ✅) * Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) 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 ecba9f92..496c51fe 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 @@ -78,6 +78,12 @@ class AndroidTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 0a48dd21..59668c35 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 @@ -850,6 +850,285 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(true, notNullable.message!!.contains("column 'notes' is nullable")) } + /** + * Covers result columns: expressions, such as aggregate functions, selected into properties of a result type with + * AS, as in `table SELECT listOf(count(X) AS AuthorStats::books)`, while every other property is read from its + * column. Each function reads into the type of the values SQLite returns for it, and NULL into a nullable property. + */ + fun testResultColumns() = Database(getResultColumnDBConfig()).databaseAutoClose { database -> + // Not grouped, an aggregate query returns one row even when no rows match: count is 0, and the others NULL + lateinit var noTotals: SelectStatement + database { + BookTable { table -> + noTotals = table SELECT listOf( + count(X) AS BookTotals::books, + max(pages) AS BookTotals::maxPages, + avg(price) AS BookTotals::averagePrice, + sum(price) AS BookTotals::totalPrice, + ) + } + } + assertEquals(listOf(BookTotals(books = 0, maxPages = null, averagePrice = null, totalPrice = null)), noTotals.getResults()) + + database { + BookTable INSERT listOf( + Book(name = "The Da Vinci Code", author = "Dan Brown", price = 16.96, pages = 454), + Book(name = "The Lost Symbol", author = "Dan Brown", price = 19.95, pages = 510), + Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251), + ) + } + + // A group of GROUP BY always has rows, so aggregates of NOT NULL columns are non-null in it. 'author' has no + // expression, so it is read from its column: SELECT author,count(*) AS books,... FROM book GROUP BY author + lateinit var stats: SelectStatement + lateinit var totals: SelectStatement + lateinit var bookCount: SelectStatement + database { + BookTable { table -> + stats = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) GROUP_BY author ORDER_BY (author to ASC) + totals = table SELECT listOf( + count(X) AS BookTotals::books, + max(pages) AS BookTotals::maxPages, + avg(price) AS BookTotals::averagePrice, + sum(price) AS BookTotals::totalPrice, + ) + bookCount = table SELECT (count(X) AS BookCount::books) + } + } + assertEquals( + listOf( + AuthorStats("Dan Brown", books = 2, totalPages = 964, maxPrice = 19.95, firstTitle = "The Da Vinci Code"), + AuthorStats("Ken Kousen", books = 1, totalPages = 251, maxPrice = 37.72, firstTitle = "Kotlin Cookbook"), + ), + stats.getResults(), + ) + val total = totals.getResults().single() + assertEquals(3L, total.books) + assertEquals(510, total.maxPages) + assertEquals(74.63 / 3, total.averagePrice!!, 1e-9) + assertEquals(74.63, total.totalPrice!!, 1e-9) + assertEquals(3L, bookCount.getResults().single().books) + + // The clauses that can follow result columns, and those that follow them + lateinit var prolific: SelectStatement + lateinit var cheap: SelectStatement + lateinit var secondMostBooks: SelectStatement + lateinit var kotlinBook: SelectStatement + lateinit var shortestBook: SelectStatement + lateinit var upperAuthors: SelectStatement + database { + BookTable { table -> + prolific = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) WHERE (price LT 30.0) GROUP_BY author HAVING (count(X) GT 1) + cheap = table SELECT (count(X) AS BookCount::books) WHERE (price LT 20.0) + secondMostBooks = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) GROUP_BY author ORDER_BY (count(X) to DESC) LIMIT 1 OFFSET 1 + kotlinBook = table SELECT listOf( + upper(name) AS BookFunctions::upperName, + length(name) AS BookFunctions::nameLength, + round(price, 0) AS BookFunctions::roundedPrice, + abs(pages) AS BookFunctions::absPages, + ) WHERE (author EQ "Ken Kousen") + shortestBook = table SELECT listOf( + upper(name) AS BookFunctions::upperName, + length(name) AS BookFunctions::nameLength, + round(price, 0) AS BookFunctions::roundedPrice, + abs(pages) AS BookFunctions::absPages, + ) ORDER_BY (pages to ASC) LIMIT 1 + // An expression can take the place of the column of the same name + upperAuthors = table SELECT_DISTINCT (upper(author) AS BookAuthor::author) + } + } + assertEquals( + listOf(AuthorStats("Dan Brown", books = 2, totalPages = 964, maxPrice = 19.95, firstTitle = "The Da Vinci Code")), + prolific.getResults(), + ) + assertEquals(2L, cheap.getResults().single().books) + assertEquals(listOf("Ken Kousen"), secondMostBooks.getResults().map { it.author }) + val functions = BookFunctions("Kotlin Cookbook", upperName = "KOTLIN COOKBOOK", nameLength = 15, roundedPrice = 38.0, absPages = 251) + assertEquals(listOf(functions), kotlinBook.getResults()) + assertEquals(listOf(functions), shortestBook.getResults()) + assertEquals(listOf("DAN BROWN", "KEN KOUSEN"), upperAuthors.getResults().map { it.author }.sorted()) + + // An aggregate of a nullable column is NULL for a group whose values are all NULL, and max of an enum column + // is an entry of that enum + database { + UserAccountTable INSERT listOf( + UserAccount(id = null, username = "ann", email = "ann@example.com", status = UserStatus.ACTIVE, priority = Priority.LOW, notes = null), + UserAccount(id = null, username = "bob", email = "bob@example.com", status = UserStatus.ACTIVE, priority = Priority.HIGH, notes = "vip"), + UserAccount(id = null, username = "cat", email = "cat@example.com", status = UserStatus.INACTIVE, priority = Priority.MEDIUM, notes = null), + ) + } + lateinit var byStatus: SelectStatement + database { + UserAccountTable { table -> + byStatus = table SELECT listOf( + count(X) AS StatusStats::users, + group_concat(notes, ",") AS StatusStats::notes, + max(priority) AS StatusStats::highestPriority, + ) GROUP_BY status ORDER_BY (status to ASC) + } + } + assertEquals( + listOf( + StatusStats(UserStatus.ACTIVE, users = 2, notes = "vip", highestPriority = Priority.HIGH), + StatusStats(UserStatus.INACTIVE, users = 1, notes = null, highestPriority = Priority.MEDIUM), + ), + byStatus.getResults(), + ) + + // sum of a Boolean column counts its true values + database { + DefaultValuesTestTable INSERT listOf(true, false, true).mapIndexed { index, isEnabled -> + DefaultValuesTest(id = null, name = "row$index", status = "active", loginCount = 0, isEnabled = isEnabled, createdAt = "2026-10-02") + } + } + lateinit var enabled: SelectStatement + database { + DefaultValuesTestTable { table -> + enabled = table SELECT (sum(isEnabled) AS EnabledCount::enabled) + } + } + assertEquals(2L, enabled.getResults().single().enabled) + } + + /** + * Covers how result columns are checked against their result type. Most of it is checked while the statement is + * built: a property has to be serialized under its own name, get one expression at most, and be nullable when its + * expression can be NULL in a row or a group. Whether a property can be NULL because an aggregate query isn't + * grouped depends on whether GROUP BY follows, so that is checked when the scope ends, before any statement runs. + */ + fun testResultColumnChecks() = Database(getResultColumnDBConfig()).databaseAutoClose { database -> + // Checked while the statement is built + val nullInGroup = assertFailsWith { + database { + UserAccountTable { table -> + table SELECT (group_concat(notes, ",") AS UserNotesNonNull::notes) GROUP_BY status + } + } + } + assertEquals(true, nullInGroup.message!!.contains("'group_concat(notes,',')' can be NULL, so property 'notes' has to be nullable")) + val twice = assertFailsWith { + database { + BookTable { table -> + table SELECT listOf(count(X) AS BookCount::books, count(name) AS BookCount::books) + } + } + } + assertEquals(true, twice.message!!.contains("'books' is given more than one expression")) + val renamed = assertFailsWith { + database { BookTable { table -> table SELECT (count(X) AS RenamedBookCount::books) } } + } + assertEquals(true, renamed.message!!.contains("doesn't serialize its property 'books' under that name")) + val none = assertFailsWith { + database { BookTable SELECT emptyList>() } + } + assertEquals(true, none.message!!.contains("no result columns")) + val otherTable = assertFailsWith { + database { BookTable SELECT (UserAccountTable.username AS BookAuthor::author) } + } + assertEquals(true, otherTable.message!!.contains("belongs to table 'user_account'")) + val notAColumn = assertFailsWith { + database { BookTable { table -> table SELECT (upper(name) AS BookWithIsbn::name) } } + } + assertEquals(true, notAColumn.message!!.contains("'isbn' isn't a column")) + + // Checked when the scope ends. Without GROUP BY, 'maxPages' would be NULL when no rows match, while 'books', + // a count, would be 0. Nothing in the scope runs, not even the INSERT before it. + val ungrouped = assertFailsWith { + database { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + table SELECT listOf(count(X) AS BookCountAndMaxPages::books, max(pages) AS BookCountAndMaxPages::maxPages) + } + } + } + assertEquals(true, ungrouped.message!!.contains("without GROUP BY")) + assertEquals(true, ungrouped.message!!.contains("'maxPages'")) + assertEquals(false, ungrouped.message!!.contains("'books'")) + // A column is NULL in that row as well, and the check follows the statement through the clauses after it + val ungroupedColumn = assertFailsWith { + database { + BookTable { table -> + table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) WHERE (price GT 0.0) ORDER_BY (pages to ASC) LIMIT 1 + } + } + } + assertEquals(true, ungroupedColumn.message!!.contains("'author'")) + // In a transaction too + val inTransaction = assertFailsWith { + database { + transaction { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + table SELECT listOf(count(X) AS BookCountAndMaxPages::books, max(pages) AS BookCountAndMaxPages::maxPages) + } + } + } + } + assertEquals(true, inTransaction.message!!.contains("without GROUP BY")) + lateinit var bookCount: SelectStatement + database { + BookTable { table -> bookCount = table SELECT (count(X) AS BookCount::books) } + } + assertEquals(0L, bookCount.getResults().single().books) + + // GROUP BY settles it, after WHERE as well + lateinit var grouped: SelectStatement + database { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + grouped = table SELECT listOf( + count(X) AS BookCountAndMaxPages::books, + max(pages) AS BookCountAndMaxPages::maxPages, + ) WHERE (price GT 0.0) GROUP_BY author + } + } + assertEquals(listOf(BookCountAndMaxPages(books = 1, maxPages = 251)), grouped.getResults()) + } + + /** + * Compile-time check, never called: each function reads into the type of the values SQLite returns for it, which + * is what lets AS select it only into a property of that type. + */ + @Suppress("unused", "UNUSED_VARIABLE") + private fun checkFunctionResultTypes(): Unit = BookTable { table -> + val countAll: ClauseNumber = count(X) + val countColumn: ClauseNumber = count(name) + val sumOfInt: ClauseNumber = sum(pages) + val sumOfDouble: ClauseNumber = sum(price) + val sumOfBoolean: ClauseNumber = DefaultValuesTestTable.sum(DefaultValuesTestTable.isEnabled) + val average: ClauseNumber = avg(pages) + val maxOfInt: ClauseNumber = max(pages) + val minOfString: ClauseString = min(name) + val maxOfEnum: ClauseEnum = UserAccountTable.max(UserAccountTable.status) + val absolute: ClauseNumber = abs(pages) + val rounded: ClauseNumber = round(pages, 1) + val randomNumber: ClauseNumber = random() + val upperCase: ClauseString = upper(name) + val nameLength: ClauseNumber = length(name) + val position: ClauseNumber = instr(name, "a") + val concatenated: ClauseString = group_concat(name, ",") + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) @@ -3031,6 +3310,19 @@ class CommonBasicTest(private val path: DatabasePath) { } ) + @OptIn(ExperimentalDSLDatabaseAPI::class) + private fun getResultColumnDBConfig(): DSLDBConfiguration = + DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(BookTable) + CREATE(UserAccountTable) + CREATE(DefaultValuesTestTable) + } + ) + @OptIn(ExperimentalDSLDatabaseAPI::class) private fun getForeignKeyDBConfig(): DSLDBConfiguration = DSLDBConfiguration( 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 6e787cb9..e5fbba5f 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 @@ -22,6 +22,7 @@ import com.ctrip.sqllin.dsl.annotation.CompositeUnique import com.ctrip.sqllin.dsl.annotation.DBRow import com.ctrip.sqllin.dsl.annotation.PrimaryKey import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -550,3 +551,52 @@ data class BookPagesAsText(val pages: String) // 'pages' holds an Int @Serializable data class UserNotesNonNull(val notes: String) // 'notes' is nullable + +/** + * Result types of SELECTs with result columns, as in `BookTable SELECT listOf(count(X) AS AuthorStats::books)`: the + * properties given an expression with AS hold it, and every other property is read from its column. + */ +@Serializable +data class AuthorStats( + val author: String, // read from its column + val books: Long, + val totalPages: Long, + val maxPrice: Price, + val firstTitle: String, +) + +/** + * Aggregates of a whole table, not grouped: all but `count` are NULL when no rows match, so they are nullable. + */ +@Serializable +data class BookTotals(val books: Long, val maxPages: PageCount?, val averagePrice: Double?, val totalPrice: Double?) + +@Serializable +data class BookCount(val books: Long) + +/** + * Scalar functions of the columns of a book, next to its `name`, which is read from its column. + */ +@Serializable +data class BookFunctions( + val name: String, + val upperName: String, + val nameLength: Long, + val roundedPrice: Double, + val absPages: PageCount, +) + +@Serializable +data class StatusStats(val status: UserStatus, val users: Long, val notes: String?, val highestPriority: Priority) + +@Serializable +data class EnabledCount(val enabled: Long?) + +/** + * Result types that don't fit their query, each breaking one of the rules result columns are checked against. + */ +@Serializable +data class BookCountAndMaxPages(val books: Long, val maxPages: PageCount) // 'maxPages' is NULL when no rows match + +@Serializable +data class RenamedBookCount(@SerialName("total") val books: Long) // 'books' isn't serialized under its own name diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 61dfc8f9..fbb7635f 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 @@ -70,6 +70,12 @@ class JvmTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 cbd218d8..dac763d1 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 @@ -86,6 +86,12 @@ class NativeTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/doc/advanced-query-cn.md b/sqllin-dsl/doc/advanced-query-cn.md index b9e23974..b23eefd4 100644 --- a/sqllin-dsl/doc/advanced-query-cn.md +++ b/sqllin-dsl/doc/advanced-query-cn.md @@ -184,7 +184,69 @@ fun sample() { 投影类型的每个属性都必须是这张表的列,类型与列一致,并且当列可空时属性也必须可空,因为把 `NULL` 读进非空属性时,它会被 悄无声息地读成 `0` 或空字符串。不满足这些规则的投影类型会让 `SELECT` 在构建语句时、执行之前就抛出 `IllegalArgumentException`。 -`COUNT(*)` 这样的表达式目前还不能投影。 +要查询 `count(*)` 这样的表达式,请使用结果列。 + +## 结果列 + +要查询一个表达式,比如聚合函数,可以用 `AS` 把它交给结果类型的一个属性。结果类型的其余属性和投影一样,从同名的列读取, +所以只需要列出表达式:单个表达式直接写,多个表达式放进 `listOf`: + +```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + +@Serializable +data class PersonCount( + val people: Long, +) + +fun sample() { + lateinit var stats: SelectStatement + lateinit var adults: SelectStatement + database { + PersonTable { table -> + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name + // SELECT count(*) AS people FROM person WHERE age >= ? + adults = table SELECT (count(X) AS PersonCount::people) WHERE (age GTE 18) + } + } + val adultCount = adults.getResults().single().people +} +``` + +和投影类型一样,结果类型就是普通的 `@Serializable` 类型。单个结果列必须加括号,因为 `SELECT` 和 `AS` 都是中缀函数。 +结果列可以用在 `SELECT` 和 `SELECT_DISTINCT` 之后,后面可以接 `WHERE`、`GROUP_BY`、`ORDER_BY` 和 `LIMIT`。表达式也可以 +占用某一列对应的属性:`table SELECT (upper(name) AS PersonName::name)` 会把所有名字读成大写。 + +属性的类型必须和表达式的值的类型一致,这一点在编译期检查。所以 `count(X)` 只能交给 `Long` 属性,不能交给 `Int` 或 +`String` 属性: + +| 函数 | 值的类型 | +|---|---| +| `count`、`length`、`instr`、`random` | `Long` | +| `avg`、`round` | `Double` | +| `sum` | 整数列和 Boolean 列为 `Long`,`Float` 和 `Double` 列为 `Double` | +| `max`、`min`、`abs` | 与它们的列相同 | +| `upper`、`lower`、`trim`、`ltrim`、`rtrim`、`substr`、`replace`、`printf`、`group_concat` | `String` | + +当表达式可能为 `NULL` 时,属性还必须可空,原因和投影相同: + +- 可空列的函数可能为 `NULL`,`count` 除外。 +- 没有 `GROUP_BY` 时,即使没有任何行匹配,聚合查询也会返回一行,这一行里除 `count` 以外的聚合函数都是 `NULL`,所有的列也是 + `NULL`。所以在没有 `GROUP_BY` 的聚合查询中,除了 `count` 对应的属性,其余属性都必须可空,比如要写成 `maxAge: Int?`。 + 有 `GROUP_BY` 时,每个分组都至少有一行,所以只有列可空时,属性才必须可空。 + +不满足这些规则的结果类型,以及同一个属性被交给了两个表达式的情况,都会让 `SELECT` 在构建语句时、执行之前就抛出 +`IllegalArgumentException`。唯独后面是否还会接 `GROUP_BY`,在构建时还无法得知,所以需要 `GROUP_BY` 的属性会在数据库作用域 +结束时报错,此时作用域中的任何语句都还没有执行。 + +交给表达式的属性按属性名查找,所以不能用 `@SerialName` 重命名。结果列只能查询单张表:目前还不能和 Join 一起使用, +也不支持算术运算、`CASE` 和子查询。 ## 最后 diff --git a/sqllin-dsl/doc/advanced-query.md b/sqllin-dsl/doc/advanced-query.md index 91f250a7..00e426e6 100644 --- a/sqllin-dsl/doc/advanced-query.md +++ b/sqllin-dsl/doc/advanced-query.md @@ -193,7 +193,72 @@ to a statement of the projection type. Each property of a projection type has to be a column of the table, of the same type, and nullable if the column is nullable, as a `NULL` read into a non-null property would quietly become `0` or an empty string. A projection type that breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is built, before it -runs. Expressions such as `COUNT(*)` can't be projected yet. +runs. To select expressions such as `count(*)`, use result columns. + +## Result Columns + +To select an expression, such as an aggregate function, give it a property of the result type with `AS`. Every other +property of the result type is read from its column, as in a projection, so only the expressions are listed: one alone, +or several in a `listOf`: + +```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + +@Serializable +data class PersonCount( + val people: Long, +) + +fun sample() { + lateinit var stats: SelectStatement + lateinit var adults: SelectStatement + database { + PersonTable { table -> + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name + // SELECT count(*) AS people FROM person WHERE age >= ? + adults = table SELECT (count(X) AS PersonCount::people) WHERE (age GTE 18) + } + } + val adultCount = adults.getResults().single().people +} +``` + +Like a projection type, a result type is a plain `@Serializable` type. A single result column has to be put in +parentheses, as `SELECT` and `AS` are both infix functions. Result columns work after `SELECT` and `SELECT_DISTINCT`, and +can be followed by `WHERE`, `GROUP_BY`, `ORDER_BY` and `LIMIT`. An expression can take the property of a column, too: +`table SELECT (upper(name) AS PersonName::name)` reads every name in upper case. + +The property has to have the type of the expression's values, which is checked at compile time. So `count(X)` goes into +a `Long` property, not an `Int` or a `String` one: + +| Function | Type of its values | +|---|---| +| `count`, `length`, `instr`, `random` | `Long` | +| `avg`, `round` | `Double` | +| `sum` | `Long` for a column of integers or Booleans, `Double` for a `Float` or `Double` column | +| `max`, `min`, `abs` | the type of their column | +| `upper`, `lower`, `trim`, `ltrim`, `rtrim`, `substr`, `replace`, `printf`, `group_concat` | `String` | + +The property also has to be nullable when its expression can be `NULL`, for the same reason as in a projection: + +- A function of a nullable column can be `NULL`, except `count`. +- Without `GROUP_BY`, an aggregate query returns one row even when no rows match, in which every aggregate function except + `count` is `NULL`, and so is every column. So in an aggregate query without `GROUP_BY`, every property but those of + `count` has to be nullable, as `maxAge: Int?` would be. With `GROUP_BY`, every group has rows, so a property only has to + be nullable when its column is. + +A result type that breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is +built, before it runs, as does a property given two expressions. Only whether `GROUP_BY` follows can't be known yet then, +so a property that needs it is reported when the database scope ends, before any statement of the scope runs. + +A property given an expression is found by its name, so it can't be renamed with `@SerialName`. Result columns select from +a single table: they can't be used with a join yet, nor can arithmetic, `CASE` or subqueries. ## Finally diff --git a/sqllin-dsl/doc/sql-functions-cn.md b/sqllin-dsl/doc/sql-functions-cn.md index 4ef19ed7..95eed4b0 100644 --- a/sqllin-dsl/doc/sql-functions-cn.md +++ b/sqllin-dsl/doc/sql-functions-cn.md @@ -9,7 +9,7 @@ fun sample() { database { PersonTable { table -> table SELECT WHERE(abs(age) LTE 5) - table SELECT GROUP_BY(name) HAVING (count(X) > 2) + table SELECT GROUP_BY(name) HAVING (count(X) GT 2) } } } @@ -20,31 +20,34 @@ fun sample() { > **聚合函数**: `count`, `max`, `min`, `avg`, `sum`, `group_concat` > -> **数值函数**: `abs`, `round`, `random`, `sign` +> **数值函数**: `abs`, `round`, `random` > > **字符串函数**: `upper`, `lower`, `length`, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr`, `printf` `count` 函数有一个不同点,它可以接收一个 `X` 作为参数用于表示 SQL 中的 `count(*)`, 如前面的示例所示。 -SQLlin 当前只支持在条件语句中使用函数。我们将会考虑在未来的版本中支持在 _SELECT_ 关键字后使用函数。现在, -如果你有类似的需求,你可以使用 *[Kotlin 集合 API](https://kotlinlang.org/docs/collection-aggregate.html)* 来处理查询结果: +要在 _SELECT_ 关键字之后使用函数,可以用 `AS` 把它们交给结果类型的属性: ```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + fun sample() { - lateinit var selectStatement: SelectStatement + lateinit var stats: SelectStatement database { PersonTable { table -> - selectStatement = table SELECT X + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name } } - // Get the max value - selectStatement.getResult().maxOrNull() - // Get the min value - selectStatement.getResult().minOrNull() - // Get the count of query results - selectStatement.getResult().count() - // ...... } ``` +每个函数的结果都具有 SQLite 为它返回的值的类型,比如 `count` 为 `Long`,`avg` 为 `Double`,`AS` 只能把它交给这个类型的属性。 +结果列的详细用法请见[《高级查询》](advanced-query-cn.md#结果列)。 + 最后,让我们来学习[《高级查询》](advanced-query-cn.md)吧。 \ No newline at end of file diff --git a/sqllin-dsl/doc/sql-functions.md b/sqllin-dsl/doc/sql-functions.md index 17fc5be5..17203d9a 100644 --- a/sqllin-dsl/doc/sql-functions.md +++ b/sqllin-dsl/doc/sql-functions.md @@ -12,7 +12,7 @@ fun sample() { database { PersonTable { table -> table SELECT WHERE(abs(age) LTE 5) - table SELECT GROUP_BY(name) HAVING (count(X) > 2) + table SELECT GROUP_BY(name) HAVING (count(X) GT 2) } } } @@ -24,33 +24,36 @@ a `ClauseElement` as the result. The functions supported by SQLlin are as follow > **Aggregate functions**: `count`, `max`, `min`, `avg`, `sum`, `group_concat` > -> **Numeric functions**: `abs`, `round`, `random`, `sign` +> **Numeric functions**: `abs`, `round`, `random` > > **String functions**: `upper`, `lower`, `length`, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr`, `printf` The `count` function has a different point, it could receive `X` as parameter be used for representing `count(*)` in SQL, as shown in the example above. -SQLlin only supports using functions in conditions now. We will consider supporting using functions after the _SELECT_ keyword in -future versions. Now, if you have similar demands, you can use -[Kotlin Collections API](https://kotlinlang.org/docs/collection-aggregate.html) to handle query results: +To use functions after the _SELECT_ keyword, select them into properties of a result type with `AS`: ```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + fun sample() { - lateinit var selectStatement: SelectStatement + lateinit var stats: SelectStatement database { PersonTable { table -> - selectStatement = table SELECT X + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name } } - // Get the max value - selectStatement.getResult().maxOrNull() - // Get the min value - selectStatement.getResult().minOrNull() - // Get the count of query results - selectStatement.getResult().count() - // ...... } ``` +Each function's result has the type of the values SQLite returns for it, such as `Long` for `count` and `Double` for +`avg`, and `AS` only selects it into a property of that type. [Advanced Query](advanced-query.md#result-columns) describes +result columns in detail. + Finally, let's learn [Advanced Query](advanced-query.md). \ No newline at end of file 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 5da6a1d6..580d51d4 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 @@ -693,6 +693,83 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = select(getKSerializer(), clause, true) + // ========== SELECT with Result Columns ========== + // + // These select expressions, such as aggregate functions, into properties of a result type R with AS, as in + // `BookTable SELECT listOf(count(X) AS AuthorStats::books)`. Every other property of R is read from its column. + + /** + * Selects [column] into its property of [R], and every other property of [R] from its column. + * + * Example: + * ```kotlin + * @Serializable + * data class BookCount(val books: Long) + * + * val total = BookTable SELECT (BookTable.count(X) AS BookCount::books) + * // SELECT count(*) AS books FROM book + * ``` + * + * Can be followed by WHERE, GROUP BY, ORDER BY, or LIMIT. + * + * @throws IllegalArgumentException if [R] doesn't fit the query: an expression can be NULL while its property isn't + * nullable, or a property without an expression doesn't fit its column, as for [X]. A property that can only be + * non-null in a group of GROUP BY is reported when the scope ends, before anything runs, if no GROUP BY follows. + */ + @StatementDslMaker + public inline infix fun Table.SELECT(column: ResultColumn): ResultColumnSelectStatement = + select(getKSerializer(), listOf(column), false) + + /** + * Selects [column] into its property of [R], and every other property of [R] from its column, returning distinct + * rows. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT] + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(column: ResultColumn): ResultColumnSelectStatement = + select(getKSerializer(), listOf(column), true) + + /** + * Selects each of [columns] into its property of [R], and every other property of [R] from its column. + * + * Example: + * ```kotlin + * @Serializable + * data class AuthorStats(val author: String, val books: Long, val totalPages: Long) + * + * val stats = BookTable { table -> + * table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author + * } + * // SELECT author,count(*) AS books,sum(pages) AS totalPages FROM book GROUP BY author + * ``` + * + * Can be followed by WHERE, GROUP BY, ORDER BY, or LIMIT. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT], or [columns] give a property two + * expressions, or none at all + */ + @StatementDslMaker + public inline infix fun Table.SELECT(columns: Iterable>): ResultColumnSelectStatement = + select(getKSerializer(), columns, false) + + /** + * Selects each of [columns] into its property of [R], and every other property of [R] from its column, returning + * distinct rows. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT] + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(columns: Iterable>): ResultColumnSelectStatement = + select(getKSerializer(), columns, true) + + public fun Table.select(serializer: KSerializer, columns: Iterable>, isDistinct: Boolean): ResultColumnSelectStatement { + val container = getSelectStatementGroup() + val statement = Select.select(this, columns, isDistinct, serializer, databaseConnection, container) + addSelectStatement(statement) + return statement + } + // ========== UNION Operations ========== private val unionSelectStatementGroupStack by lazy { ArrayDeque>() } @@ -855,7 +932,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.CREATE_INDEX(indexName: String, vararg columns: ClauseElement) { + public fun Table.CREATE_INDEX(indexName: String, vararg columns: ClauseElement<*>) { val statement = Create.createIndex(this, databaseConnection, indexName, *columns) addStatement(statement) } @@ -880,7 +957,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.CREATE_UNIQUE_INDEX(indexName: String, vararg columns: ClauseElement) { + public fun Table.CREATE_UNIQUE_INDEX(indexName: String, vararg columns: ClauseElement<*>) { val statement = Create.createUniqueIndex(this, databaseConnection, indexName, *columns) addStatement(statement) } @@ -949,7 +1026,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement) { + public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement<*>) { val statement = Alter.addColumn(this, column, databaseConnection) addStatement(statement) } @@ -1013,7 +1090,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { + public fun > Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { val statement = Alter.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) addStatement(statement) } @@ -1036,7 +1113,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement) { + public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement<*>) { val statement = Alter.renameColumn(this, oldColumnName, newColumn, databaseConnection) addStatement(statement) } @@ -1059,7 +1136,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.DROP_COLUMN(column: ClauseElement) { + public infix fun Table.DROP_COLUMN(column: ClauseElement<*>) { val statement = Alter.dropColumn(this, column, databaseConnection) addStatement(statement) } 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 f852622c..16bf7385 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 @@ -72,10 +72,10 @@ public infix fun JoinStatementWithoutCondition.ON(condition: SelectCondit @Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker -public inline infix fun JoinStatementWithoutCondition.USING(clauseElement: ClauseElement): JoinSelectStatement = +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 = +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/ClauseBlob.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt index 75b8678b..b14d0611 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt @@ -42,10 +42,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseBlob( +public class ClauseBlob internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseBlob = + ClauseBlob(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Creates an equality comparison condition (=). @@ -153,8 +168,10 @@ public class ClauseBlob( private fun appendNullableBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) if (blob == null) { append(nullSymbol) @@ -168,8 +185,10 @@ public class ClauseBlob( private fun appendBlob(symbol: String, blob: ByteArray): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(symbol) } @@ -204,8 +223,10 @@ public class ClauseBlob( val parameters = blobs.toMutableList() require(parameters.isNotEmpty()) { "Param 'blobs' must not be empty!!!" } val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(" IN (") @@ -228,8 +249,10 @@ public class ClauseBlob( */ internal infix fun between(range: Pair): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(" BETWEEN ? AND ?") } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt index 2810a03e..497f12ca 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt @@ -28,10 +28,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseBoolean( +public class ClauseBoolean internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseBoolean = + ClauseBoolean(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Creates a condition comparing this Boolean column/function to a value. @@ -47,8 +62,10 @@ public class ClauseBoolean( */ internal infix fun _is(bool: Boolean?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append( when { @@ -82,8 +99,10 @@ public class ClauseBoolean( */ internal infix fun _isNot(bool: Boolean?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append( when { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt index 1588468a..2e864416 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt @@ -37,15 +37,38 @@ import com.ctrip.sqllin.dsl.sql.Table * - GROUP BY columns * - SET assignments * - JOIN USING clauses + * - Result columns, as in `count(X) AS AuthorStats::books` * + * An element knows what it reads into, so a [ResultColumn] can only put it into a property that can hold it: the type + * of its values, and whether it can be NULL. + * + * @param V The type of the element's values, not counting NULL: `Int` for an `Int` or `Int?` column, `Long` for + * `count(*)`, `Double` for `avg(...)` * @property valueName The column name or function expression * @property table The table this element belongs to * @property isFunction Whether this represents a function call (e.g., COUNT, SUM) + * @property isNullable Whether the element can be NULL: for a column, whether the column is nullable, and for an + * aggregate function, whether it can be NULL for a group of rows, as `sum` of a nullable column is when all its + * values in the group are NULL + * @property isAggregate Whether the element is or contains an aggregate function, which makes a query that selects it + * an aggregate query + * @property isNullOnNoRows Whether the element is NULL when an aggregate query without GROUP BY matches no rows. Such a + * query still returns one row, in which a column, and every aggregate function except `count`, is NULL. * * @author Yuang Qiao */ -public sealed class ClauseElement( +public sealed class ClauseElement( internal val valueName: String, internal val table: Table<*>, internal val isFunction: Boolean, -) \ No newline at end of file + internal val isNullable: Boolean, + internal val isAggregate: Boolean, + internal val isNullOnNoRows: Boolean, +) { + + /** + * Creates the element of an aggregate function of this element that has values of the same type, as `max` and + * `min` do. + */ + internal abstract fun toAggregate(valueName: String, table: Table<*>): ClauseElement +} diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt index aa9c3da0..82e3cf5d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt @@ -23,7 +23,8 @@ import com.ctrip.sqllin.dsl.sql.Table * * Enables type-safe enum comparisons in WHERE, HAVING, and other conditional clauses. * Enums are stored as integers (ordinal values) in SQLite and automatically converted - * during serialization/deserialization. + * during serialization/deserialization. `max` and `min` of an enum column are elements + * of the same enum type. * * Available operators: * - `lt`: Less than (<) - compares ordinal values @@ -59,10 +60,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseEnum>( +public class ClauseEnum> internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseEnum = + ClauseEnum(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Less than (<) comparison using the enum's ordinal value. @@ -195,8 +211,10 @@ public class ClauseEnum>( */ private fun appendEnum(symbol: String, entry: T): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(symbol) } @@ -216,8 +234,10 @@ public class ClauseEnum>( */ private fun appendNullableEnum(notNullSymbol: String, nullSymbol: String, entry: T?): SelectCondition { val builder = StringBuilder() - builder.append(table.tableName) - builder.append('.') + if (!isFunction) { + builder.append(table.tableName) + builder.append('.') + } builder.append(valueName) val parameters = if (entry == null){ builder.append(nullSymbol) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt index f6cfcb9e..404b6859 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt @@ -35,13 +35,29 @@ import com.ctrip.sqllin.dsl.sql.Table * - `inIterable`: IN (?, ?, ...) - all values parameterized * - `between`: BETWEEN ? AND ? - both boundaries parameterized * + * @param V The type of the element's values: `Int` for an `Int` column, `Long` for `count(*)`, `Double` for `avg(...)` + * * @author Yuang Qiao */ -public class ClauseNumber( +public class ClauseNumber internal constructor( valueName: String, table: Table<*>, - isFunction: Boolean = false, -) : ClauseElement(valueName, table, isFunction) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseNumber = + ClauseNumber(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Less than (<) comparison using parameterized binding. @@ -54,7 +70,7 @@ public class ClauseNumber( internal infix fun lt(number: Number): SelectCondition = appendNumber("): SelectCondition = appendClauseNumber("<", clauseNumber) /** * Less than or equal (<=) comparison using parameterized binding. @@ -67,7 +83,7 @@ public class ClauseNumber( internal infix fun lte(number: Number): SelectCondition = appendNumber("<=?", number) /** Less than or equal (<=) - compare against another column/function */ - internal infix fun lte(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("<=", clauseNumber) + internal infix fun lte(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("<=", clauseNumber) /** * Equals (=) comparison using parameterized binding, or IS NULL for null values. @@ -80,7 +96,7 @@ public class ClauseNumber( internal infix fun eq(number: Number?): SelectCondition = appendNullableNumber("=", " IS NULL", number) /** Equals (=) - compare against another column/function */ - internal infix fun eq(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("=", clauseNumber) + internal infix fun eq(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("=", clauseNumber) /** * Not equals (!=) comparison using parameterized binding, or IS NOT NULL for null values. @@ -93,7 +109,7 @@ public class ClauseNumber( internal infix fun neq(number: Number?): SelectCondition = appendNullableNumber("!=", " IS NOT NULL", number) /** Not equals (!=) - compare against another column/function */ - internal infix fun neq(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("!=", clauseNumber) + internal infix fun neq(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("!=", clauseNumber) /** * Greater than (>) comparison using parameterized binding. @@ -106,7 +122,7 @@ public class ClauseNumber( internal infix fun gt(number: Number): SelectCondition = appendNumber(">?", number) /** Greater than (>) - compare against another column/function */ - internal infix fun gt(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">", clauseNumber) + internal infix fun gt(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber(">", clauseNumber) /** * Greater than or equal (>=) comparison using parameterized binding. @@ -119,7 +135,7 @@ public class ClauseNumber( internal infix fun gte(number: Number): SelectCondition = appendNumber(">=?", number) /** Greater than or equal (>=) - compare against another column/function */ - internal infix fun gte(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">=", clauseNumber) + internal infix fun gte(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber(">=", clauseNumber) /** * IN operator - checks if value is in the given set. @@ -202,7 +218,7 @@ public class ClauseNumber( return SelectCondition(builder.toString(), parameters) } - private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber): SelectCondition { + private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber<*>): SelectCondition { val sql = buildString { append(table.tableName) append('.') @@ -216,7 +232,7 @@ public class ClauseNumber( } override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() - override fun equals(other: Any?): Boolean = (other as? ClauseNumber)?.let { + override fun equals(other: Any?): Boolean = (other as? ClauseNumber<*>)?.let { it.valueName == valueName && it.table.tableName == table.tableName } ?: false } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt index 495f3320..6e07f41a 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt @@ -36,25 +36,41 @@ import com.ctrip.sqllin.dsl.sql.Table * - `like`: LIKE pattern matching (case-insensitive, supports % and _ wildcards) * - `glob`: GLOB pattern matching (case-sensitive, supports * and ? wildcards) * + * @param V The type of the element's values: `String`, or `Char` for a `Char` column + * * @author Yuang Qiao */ -public class ClauseString( +public class ClauseString internal constructor( valueName: String, table: Table<*>, - isFunction: Boolean = false, -) : ClauseElement(valueName, table, isFunction) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseString = + ClauseString(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** Equals (=), or IS NULL if value is null */ internal infix fun eq(str: String?): SelectCondition = appendNullableString("=", " IS NULL", str) /** Equals (=) - compare against another column/function */ - internal infix fun eq(clauseString: ClauseString): SelectCondition = appendClauseString("=", clauseString) + internal infix fun eq(clauseString: ClauseString<*>): SelectCondition = appendClauseString("=", clauseString) /** Not equals (!=), or IS NOT NULL if value is null */ internal infix fun neq(str: String?): SelectCondition = appendNullableString("!=", " IS NOT NULL", str) /** Not equals (!=) - compare against another column/function */ - internal infix fun neq(clauseString: ClauseString): SelectCondition = appendClauseString("!=", clauseString) + internal infix fun neq(clauseString: ClauseString<*>): SelectCondition = appendClauseString("!=", clauseString) /** * Creates a less than comparison condition (<). @@ -70,7 +86,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun lt(clauseString: ClauseString): SelectCondition = appendClauseString("<", clauseString) + internal infix fun lt(clauseString: ClauseString<*>): SelectCondition = appendClauseString("<", clauseString) /** * Creates a less than or equal to comparison condition (<=). @@ -86,7 +102,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun lte(clauseString: ClauseString): SelectCondition = appendClauseString("<=", clauseString) + internal infix fun lte(clauseString: ClauseString<*>): SelectCondition = appendClauseString("<=", clauseString) /** * Creates a greater than comparison condition (>). @@ -102,7 +118,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun gt(clauseString: ClauseString): SelectCondition = appendClauseString(">", clauseString) + internal infix fun gt(clauseString: ClauseString<*>): SelectCondition = appendClauseString(">", clauseString) /** * Creates a greater than or equal to comparison condition (>=). @@ -118,7 +134,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun gte(clauseString: ClauseString): SelectCondition = appendClauseString(">=", clauseString) + internal infix fun gte(clauseString: ClauseString<*>): SelectCondition = appendClauseString(">=", clauseString) /** * LIKE operator - case-insensitive pattern matching. @@ -179,7 +195,7 @@ public class ClauseString( return SelectCondition(sql, mutableListOf(str)) } - private fun appendClauseString(symbol: String, clauseString: ClauseString): SelectCondition { + private fun appendClauseString(symbol: String, clauseString: ClauseString<*>): SelectCondition { val sql = buildString { append(table.tableName) append('.') @@ -244,7 +260,7 @@ public class ClauseString( } override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() - override fun equals(other: Any?): Boolean = (other as? ClauseString)?.let { + override fun equals(other: Any?): Boolean = (other as? ClauseString<*>)?.let { it.valueName == valueName && it.table.tableName == table.tableName } ?: false } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt index dee44476..5f54f4b0 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 @@ -48,123 +48,123 @@ public sealed class ConditionClause(private val selectCondition: SelectCondit // Less than, < @StatementDslMaker -public infix fun ClauseNumber.LT(number: Number): SelectCondition = lt(number) +public infix fun ClauseNumber<*>.LT(number: Number): SelectCondition = lt(number) // Less than, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.LT(clauseNumber: ClauseNumber): SelectCondition = lt(clauseNumber) +public infix fun ClauseNumber<*>.LT(clauseNumber: ClauseNumber<*>): SelectCondition = lt(clauseNumber) // Less than or equal to, <= @StatementDslMaker -public infix fun ClauseNumber.LTE(number: Number): SelectCondition = lte(number) +public infix fun ClauseNumber<*>.LTE(number: Number): SelectCondition = lte(number) // Less than or equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.LTE(clauseNumber: ClauseNumber): SelectCondition = lte(clauseNumber) +public infix fun ClauseNumber<*>.LTE(clauseNumber: ClauseNumber<*>): SelectCondition = lte(clauseNumber) // Equals, == @StatementDslMaker -public infix fun ClauseNumber.EQ(number: Number?): SelectCondition = eq(number) +public infix fun ClauseNumber<*>.EQ(number: Number?): SelectCondition = eq(number) // Equals, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.EQ(clauseNumber: ClauseNumber): SelectCondition = eq(clauseNumber) +public infix fun ClauseNumber<*>.EQ(clauseNumber: ClauseNumber<*>): SelectCondition = eq(clauseNumber) // Not equal to, != @StatementDslMaker -public infix fun ClauseNumber.NEQ(number: Number?): SelectCondition = neq(number) +public infix fun ClauseNumber<*>.NEQ(number: Number?): SelectCondition = neq(number) // Not equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.NEQ(clauseNumber: ClauseNumber): SelectCondition = neq(clauseNumber) +public infix fun ClauseNumber<*>.NEQ(clauseNumber: ClauseNumber<*>): SelectCondition = neq(clauseNumber) // Greater than, > @StatementDslMaker -public infix fun ClauseNumber.GT(number: Number): SelectCondition = gt(number) +public infix fun ClauseNumber<*>.GT(number: Number): SelectCondition = gt(number) // Greater than, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.GT(clauseNumber: ClauseNumber): SelectCondition = gt(clauseNumber) +public infix fun ClauseNumber<*>.GT(clauseNumber: ClauseNumber<*>): SelectCondition = gt(clauseNumber) // Greater than or equal to, >= @StatementDslMaker -public infix fun ClauseNumber.GTE(number: Number): SelectCondition = gte(number) +public infix fun ClauseNumber<*>.GTE(number: Number): SelectCondition = gte(number) // Greater than or equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.GTE(clauseNumber: ClauseNumber): SelectCondition = gte(clauseNumber) +public infix fun ClauseNumber<*>.GTE(clauseNumber: ClauseNumber<*>): SelectCondition = gte(clauseNumber) // If the 'number' in the 'numbers' @StatementDslMaker -public infix fun ClauseNumber.IN(numbers: Iterable): SelectCondition = inIterable(numbers) +public infix fun ClauseNumber<*>.IN(numbers: Iterable): SelectCondition = inIterable(numbers) // If the 'number' between the 'range' @StatementDslMaker -public infix fun ClauseNumber.BETWEEN(range: LongRange): SelectCondition = between(range) +public infix fun ClauseNumber<*>.BETWEEN(range: LongRange): SelectCondition = between(range) // Equals, == @StatementDslMaker -public infix fun ClauseString.EQ(str: String?): SelectCondition = eq(str) +public infix fun ClauseString<*>.EQ(str: String?): SelectCondition = eq(str) // Equals, append another ClauseString @StatementDslMaker -public infix fun ClauseString.EQ(clauseString: ClauseString): SelectCondition = eq(clauseString) +public infix fun ClauseString<*>.EQ(clauseString: ClauseString<*>): SelectCondition = eq(clauseString) // Not equals to, != @StatementDslMaker -public infix fun ClauseString.NEQ(str: String?): SelectCondition = neq(str) +public infix fun ClauseString<*>.NEQ(str: String?): SelectCondition = neq(str) // Not equals to, append another ClauseString @StatementDslMaker -public infix fun ClauseString.NEQ(clauseString: ClauseString): SelectCondition = neq(clauseString) +public infix fun ClauseString<*>.NEQ(clauseString: ClauseString<*>): SelectCondition = neq(clauseString) // SQL LIKE operator @StatementDslMaker -public infix fun ClauseString.LIKE(regex: String): SelectCondition = like(regex) +public infix fun ClauseString<*>.LIKE(regex: String): SelectCondition = like(regex) // SQL GLOB operator @StatementDslMaker -public infix fun ClauseString.GLOB(regex: String): SelectCondition = glob(regex) +public infix fun ClauseString<*>.GLOB(regex: String): SelectCondition = glob(regex) // Less than, < @StatementDslMaker -public infix fun ClauseString.LT(str: String): SelectCondition = lt(str) +public infix fun ClauseString<*>.LT(str: String): SelectCondition = lt(str) // Less than, append to ClauseString @StatementDslMaker -public infix fun ClauseString.LT(clauseString: ClauseString): SelectCondition = lt(clauseString) +public infix fun ClauseString<*>.LT(clauseString: ClauseString<*>): SelectCondition = lt(clauseString) // Less than or equal to, <= @StatementDslMaker -public infix fun ClauseString.LTE(str: String): SelectCondition = lte(str) +public infix fun ClauseString<*>.LTE(str: String): SelectCondition = lte(str) // Less than or equal to, append to ClauseString @StatementDslMaker -public infix fun ClauseString.LTE(clauseString: ClauseString): SelectCondition = lte(clauseString) +public infix fun ClauseString<*>.LTE(clauseString: ClauseString<*>): SelectCondition = lte(clauseString) // Greater than, > @StatementDslMaker -public infix fun ClauseString.GT(str: String): SelectCondition = gt(str) +public infix fun ClauseString<*>.GT(str: String): SelectCondition = gt(str) // Greater than, append to ClauseString @StatementDslMaker -public infix fun ClauseString.GT(clauseString: ClauseString): SelectCondition = gt(clauseString) +public infix fun ClauseString<*>.GT(clauseString: ClauseString<*>): SelectCondition = gt(clauseString) // Greater than or equal to, >= @StatementDslMaker -public infix fun ClauseString.GTE(str: String): SelectCondition = gte(str) +public infix fun ClauseString<*>.GTE(str: String): SelectCondition = gte(str) // Greater than or equal to, append to ClauseString @StatementDslMaker -public infix fun ClauseString.GTE(clauseString: ClauseString): SelectCondition = gte(clauseString) +public infix fun ClauseString<*>.GTE(clauseString: ClauseString<*>): SelectCondition = gte(clauseString) // If the 'string' in the 'strings' @StatementDslMaker -public infix fun ClauseString.IN(strings: Iterable): SelectCondition = inIterable(strings) +public infix fun ClauseString<*>.IN(strings: Iterable): SelectCondition = inIterable(strings) // If the 'string' between the 'range' @StatementDslMaker -public infix fun ClauseString.BETWEEN(range: Pair): SelectCondition = between(range) +public infix fun ClauseString<*>.BETWEEN(range: Pair): SelectCondition = between(range) // Less than, < @StatementDslMaker 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 ddd579dc..8c3a75c8 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 @@ -21,16 +21,35 @@ package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.FunctionDslMaker import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.X +import kotlin.jvm.JvmName /** * SQLite aggregate and scalar functions for use in SELECT clauses. * * These functions can be used in WHERE, HAVING, ORDER BY, and SELECT expressions. - * All functions return [ClauseElement] wrappers that can be compared with operators. + * All functions return [ClauseElement] wrappers that can be compared with operators, + * and selected into a property of a result type with [AS]. + * + * Each function's result has the type of the values SQLite returns for it: `count` and `length` give a `Long`, + * `avg` and `round` a `Double`, `sum` a `Long` or a `Double` as its input holds integers or reals, and `max`, `min` + * and `abs` the type of their input. Whether the result can be NULL follows SQLite too: an aggregate function other + * than `count` is NULL for a group whose values are all NULL, and, without GROUP BY, when no rows match. * * @author Yuang Qiao */ +/** An aggregate function of [element] with values of type [V]: NULL when all its values are, or no rows match. */ +private fun Table<*>.numberAggregate(valueName: String, element: ClauseElement<*>): ClauseNumber = + ClauseNumber(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) + +/** A scalar function of [element] with values of type [V]: NULL when [element] is. */ +private fun Table<*>.numberFunction(valueName: String, element: ClauseElement<*>): ClauseNumber = + ClauseNumber(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = element.isAggregate, isNullOnNoRows = element.isNullOnNoRows) + +/** A scalar function of [element] with `String` values: NULL when [element] is. */ +private fun Table<*>.stringFunction(valueName: String, element: ClauseElement<*>): ClauseString = + ClauseString(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = element.isAggregate, isNullOnNoRows = element.isNullOnNoRows) + /** * COUNT aggregate function - counts non-NULL values. * @@ -40,8 +59,8 @@ import com.ctrip.sqllin.dsl.sql.X * ``` */ @FunctionDslMaker -public fun Table.count(element: ClauseElement): ClauseNumber = - ClauseNumber("count(${element.valueName})", this, true) +public fun Table.count(element: ClauseElement<*>): ClauseNumber = + ClauseNumber("count(${element.valueName})", this, isFunction = true, isNullable = false, isAggregate = true, isNullOnNoRows = false) /** * COUNT(*) aggregate function - counts all rows (including NULLs). @@ -52,36 +71,98 @@ public fun Table.count(element: ClauseElement): ClauseNumber = * ``` */ @FunctionDslMaker -public fun Table.count(x: X): ClauseNumber = - ClauseNumber("count(*)", this, true) +public fun Table.count(x: X): ClauseNumber = + ClauseNumber("count(*)", this, isFunction = true, isNullable = false, isAggregate = true, isNullOnNoRows = false) /** - * AVG aggregate function - returns average value. + * AVG aggregate function - returns average value, as a `Double`. */ @FunctionDslMaker -public fun Table.avg(element: ClauseElement): ClauseNumber = - ClauseNumber("avg(${element.valueName})", this, true) +public fun Table.avg(element: ClauseElement<*>): ClauseNumber = + numberAggregate("avg(${element.valueName})", element) /** * SUM aggregate function - returns sum of values. + * + * The sum of integers is a `Long`, and the sum of reals a `Double`, so there is one overload per column type, and + * one for a Boolean column, whose sum counts the `true` values. There is none for `ULong`, as SQLite stores values + * above `Long.MAX_VALUE` as negative numbers, which would make the sum wrong. */ @FunctionDslMaker -public fun Table.sum(element: ClauseElement): ClauseNumber = - ClauseNumber("sum(${element.valueName})", this, true) +@JvmName("sumOfByte") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Short` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfShort") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of an `Int` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfInt") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Long` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfLong") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UByte` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUByte") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UShort` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUShort") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UInt` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUInt") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Float` column - returns a `Double`. */ +@FunctionDslMaker +@JvmName("sumOfFloat") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Double` column - returns a `Double`. */ +@FunctionDslMaker +@JvmName("sumOfDouble") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a Boolean column - returns the number of `true` values, as a `Long`. */ +@FunctionDslMaker +public fun Table.sum(element: ClauseBoolean): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) /** - * MAX aggregate function - returns maximum value. + * MAX aggregate function - returns maximum value, of the same type as [element]: a `max` of an `Int` column is an + * `Int`, and of a String column a String. */ +@Suppress("UNCHECKED_CAST") @FunctionDslMaker -public fun Table.max(element: ClauseElement): ClauseNumber = - ClauseNumber("max(${element.valueName})", this, true) +public fun > Table.max(element: E): E = + element.toAggregate("max(${element.valueName})", this) as E /** - * MIN aggregate function - returns minimum value. + * MIN aggregate function - returns minimum value, of the same type as [element]: a `min` of an `Int` column is an + * `Int`, and of a String column a String. */ +@Suppress("UNCHECKED_CAST") @FunctionDslMaker -public fun Table.min(element: ClauseElement): ClauseNumber = - ClauseNumber("min(${element.valueName})", this, true) +public fun > Table.min(element: E): E = + element.toAggregate("min(${element.valueName})", this) as E /** * GROUP_CONCAT aggregate function - concatenates all non-NULL values in a group with a separator. @@ -100,15 +181,15 @@ public fun Table.min(element: ClauseElement): ClauseNumber = * @return ClauseString representing the concatenated result */ @FunctionDslMaker -public fun Table.group_concat(element: ClauseString, infix: String): ClauseString = - ClauseString("group_concat(${element.valueName},'$infix')", this, true) +public fun Table.group_concat(element: ClauseString<*>, infix: String): ClauseString = + ClauseString("group_concat(${element.valueName},'$infix')", this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) /** - * ABS scalar function - returns absolute value. + * ABS scalar function - returns absolute value, of the same type as [element]. */ @FunctionDslMaker -public fun Table.abs(element: ClauseNumber): ClauseNumber = - ClauseNumber("abs(${element.valueName})", this, true) +public fun Table.abs(element: ClauseNumber): ClauseNumber = + numberFunction("abs(${element.valueName})", element) /** * ROUND scalar function - rounds a number to a specified number of decimal places. @@ -124,11 +205,11 @@ public fun Table.abs(element: ClauseNumber): ClauseNumber = * * @param element The numeric value to round * @param digits The number of decimal places to round to - * @return ClauseNumber representing the rounded value + * @return ClauseNumber representing the rounded value, a `Double` even for an integer column */ @FunctionDslMaker -public fun Table.round(element: ClauseNumber, digits: Int): ClauseNumber = - ClauseNumber("round(${element.valueName},$digits)", this, true) +public fun Table.round(element: ClauseNumber<*>, digits: Int): ClauseNumber = + numberFunction("round(${element.valueName},$digits)", element) /** * RANDOM scalar function - returns a pseudo-random integer. @@ -144,8 +225,8 @@ public fun Table.round(element: ClauseNumber, digits: Int): ClauseNumber * @return ClauseNumber representing the random integer */ @FunctionDslMaker -public fun Table.random(): ClauseNumber = - ClauseNumber("random()", this, true) +public fun Table.random(): ClauseNumber = + ClauseNumber("random()", this, isFunction = true, isNullable = false, isAggregate = false, isNullOnNoRows = false) /** * SIGN scalar function - returns the sign of a number. @@ -164,29 +245,29 @@ public fun Table.random(): ClauseNumber = * @return ClauseNumber representing -1, 0, or 1 */ /* @FunctionDslMaker - public fun Table.sign(element: ClauseNumber): ClauseNumber = - ClauseNumber("sign(${element.valueName})", this, true) */ + public fun Table.sign(element: ClauseNumber<*>): ClauseNumber = + numberFunction("sign(${element.valueName})", element) */ /** * UPPER scalar function - converts string to uppercase. */ @FunctionDslMaker -public fun Table.upper(element: ClauseString): ClauseString = - ClauseString("upper(${element.valueName})", this, true) +public fun Table.upper(element: ClauseString<*>): ClauseString = + stringFunction("upper(${element.valueName})", element) /** * LOWER scalar function - converts string to lowercase. */ @FunctionDslMaker -public fun Table.lower(element: ClauseString): ClauseString = - ClauseString("lower(${element.valueName})", this, true) +public fun Table.lower(element: ClauseString<*>): ClauseString = + stringFunction("lower(${element.valueName})", element) /** * LENGTH scalar function - returns string/blob length in bytes. */ @FunctionDslMaker -public fun Table.length(element: ClauseString): ClauseNumber = - ClauseNumber("length(${element.valueName})", this, true) +public fun Table.length(element: ClauseString<*>): ClauseNumber = + numberFunction("length(${element.valueName})", element) /** * LENGTH scalar function - returns the length of a BLOB in bytes. @@ -203,8 +284,8 @@ public fun Table.length(element: ClauseString): ClauseNumber = * @return ClauseNumber representing the length in bytes */ @FunctionDslMaker -public fun Table.length(element: ClauseBlob): ClauseNumber = - ClauseNumber("length(${element.valueName})", this, true) +public fun Table.length(element: ClauseBlob): ClauseNumber = + numberFunction("length(${element.valueName})", element) /** * SUBSTR scalar function - extracts a substring from a string. @@ -224,8 +305,8 @@ public fun Table.length(element: ClauseBlob): ClauseNumber = * @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) +public fun Table.substr(element: ClauseString<*>, start: Int, len: Int): ClauseString = + stringFunction("substr(${element.valueName},$start,$len)", element) /** * TRIM scalar function - removes leading and trailing whitespace from a string. @@ -242,8 +323,8 @@ public fun Table.substr(element: ClauseString, start: Int, len: Int): Cla * @return ClauseString with whitespace removed from both ends */ @FunctionDslMaker -public fun Table.trim(element: ClauseString): ClauseString = - ClauseString("trim(${element.valueName})", this, true) +public fun Table.trim(element: ClauseString<*>): ClauseString = + stringFunction("trim(${element.valueName})", element) /** * LTRIM scalar function - removes leading (left) whitespace from a string. @@ -260,8 +341,8 @@ public fun Table.trim(element: ClauseString): ClauseString = * @return ClauseString with leading whitespace removed */ @FunctionDslMaker -public fun Table.ltrim(element: ClauseString): ClauseString = - ClauseString("ltrim(${element.valueName})", this, true) +public fun Table.ltrim(element: ClauseString<*>): ClauseString = + stringFunction("ltrim(${element.valueName})", element) /** * RTRIM scalar function - removes trailing (right) whitespace from a string. @@ -278,8 +359,8 @@ public fun Table.ltrim(element: ClauseString): ClauseString = * @return ClauseString with trailing whitespace removed */ @FunctionDslMaker -public fun Table.rtrim(element: ClauseString): ClauseString = - ClauseString("rtrim(${element.valueName})", this, true) +public fun Table.rtrim(element: ClauseString<*>): ClauseString = + stringFunction("rtrim(${element.valueName})", element) /** * REPLACE scalar function - replaces all occurrences of a substring with another string. @@ -298,8 +379,8 @@ public fun Table.rtrim(element: ClauseString): ClauseString = * @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) +public fun Table.replace(element: ClauseString<*>, old: String, new: String): ClauseString = + stringFunction("replace(${element.valueName},'$old','$new')", element) /** * INSTR scalar function - finds the first occurrence of a substring. @@ -318,8 +399,8 @@ public fun Table.replace(element: ClauseString, old: String, new: String) * @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) +public fun Table.instr(element: ClauseString<*>, sub: String): ClauseNumber = + numberFunction("instr(${element.valueName},'$sub')", element) /** * PRINTF scalar function - formats a string according to a format specification. @@ -338,5 +419,5 @@ public fun Table.instr(element: ClauseString, sub: String): ClauseNumber * @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 +public fun Table.printf(format: String, element: ClauseString<*>): ClauseString = + stringFunction("printf('$format',${element.valueName})", element) \ No newline at end of file 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 c1824149..d9626307 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 @@ -21,6 +21,7 @@ package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.statement.GroupBySelectStatement import com.ctrip.sqllin.dsl.sql.statement.JoinSelectStatement +import com.ctrip.sqllin.dsl.sql.statement.ResultColumnSelectStatement import com.ctrip.sqllin.dsl.sql.statement.WhereSelectStatement /** @@ -37,7 +38,7 @@ import com.ctrip.sqllin.dsl.sql.statement.WhereSelectStatement * * @author Yuang Qiao */ -public class GroupByClause internal constructor(private val columnNames: Iterable) : SelectClause { +public class GroupByClause internal constructor(private val columnNames: Iterable>) : SelectClause { override val clauseStr: String get() = buildString { @@ -56,29 +57,40 @@ public class GroupByClause internal constructor(private val columnNames: Iter * Creates a GROUP BY clause for aggregating rows. */ @StatementDslMaker -public fun GROUP_BY(vararg elements: ClauseElement): GroupByClause = GroupByClause(elements.toList()) +public fun GROUP_BY(vararg elements: ClauseElement<*>): GroupByClause = GroupByClause(elements.toList()) @StatementDslMaker -public infix fun WhereSelectStatement.GROUP_BY(element: ClauseElement): GroupBySelectStatement = +public infix fun WhereSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = appendToGroupBy(GroupByClause(listOf(element))).also { container changeLastStatement it } @StatementDslMaker -public infix fun WhereSelectStatement.GROUP_BY(elements: Iterable): GroupBySelectStatement { +public infix fun WhereSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { val statement = appendToGroupBy(GroupByClause(elements)) container changeLastStatement statement return statement } @StatementDslMaker -public infix fun JoinSelectStatement.GROUP_BY(element: ClauseElement): GroupBySelectStatement = +public infix fun JoinSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = appendToGroupBy(GroupByClause(listOf(element))).also { container changeLastStatement it } @StatementDslMaker -public infix fun JoinSelectStatement.GROUP_BY(elements: Iterable): GroupBySelectStatement { +public infix fun JoinSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { + val statement = appendToGroupBy(GroupByClause(elements)) + container changeLastStatement statement + return statement +} + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = + GROUP_BY(listOf(element)) + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { val statement = appendToGroupBy(GroupByClause(elements)) container changeLastStatement statement return statement 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 f74dee3e..7f961434 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 @@ -72,6 +72,12 @@ public infix fun JoinSelectStatement.LIMIT(count: Int): LimitSelectStatem container changeLastStatement it } +@StatementDslMaker +public infix fun ResultColumnSelectStatement.LIMIT(count: Int): LimitSelectStatement = + appendToLimit(LimitClause(count)).also { + container changeLastStatement it + } + /** * OFFSET clause for skipping rows in a SELECT query (pagination). * 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 8e332c19..90d1e69c 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 @@ -37,7 +37,7 @@ import com.ctrip.sqllin.dsl.sql.statement.* */ public sealed interface OrderByClause : SelectClause -internal class CompleteOrderByClause(private val column2WayMap: Map) : OrderByClause { +internal class CompleteOrderByClause(private val column2WayMap: Map, OrderByWay>) : OrderByClause { override val clauseStr: String get() { @@ -69,50 +69,60 @@ public enum class OrderByWay(internal val str: String) { } @StatementDslMaker -public fun ORDER_BY(vararg column2Ways: Pair): OrderByClause = +public fun ORDER_BY(vararg column2Ways: Pair, OrderByWay>): OrderByClause = CompleteOrderByClause(mapOf(*column2Ways)) @StatementDslMaker -public inline infix fun WhereSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun WhereSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun WhereSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun WhereSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun HavingSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun HavingSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun HavingSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun HavingSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun GroupBySelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun GroupBySelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun GroupBySelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun GroupBySelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun JoinSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun JoinSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun JoinSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun JoinSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } -internal class SimpleOrderByClause(private val columns: Iterable) : OrderByClause { +@StatementDslMaker +public infix fun ResultColumnSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = + ORDER_BY(mapOf(column2Way)) + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = + appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { + container changeLastStatement it + } + +internal class SimpleOrderByClause(private val columns: Iterable>) : OrderByClause { override val clauseStr: String get() { @@ -130,45 +140,55 @@ internal class SimpleOrderByClause(private val columns: Iterable ORDER_BY(vararg elements: ClauseElement): OrderByClause = +public fun ORDER_BY(vararg elements: ClauseElement<*>): OrderByClause = SimpleOrderByClause(elements.toList()) @StatementDslMaker -public inline infix fun WhereSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun WhereSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = + ORDER_BY(listOf(column)) + +@StatementDslMaker +public infix fun WhereSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = + appendToOrderBy(SimpleOrderByClause(columns)).also { + container changeLastStatement it + } + +@StatementDslMaker +public inline infix fun HavingSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun WhereSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun HavingSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun HavingSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun GroupBySelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun HavingSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun GroupBySelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun GroupBySelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun JoinSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun GroupBySelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun JoinSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun JoinSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public infix fun ResultColumnSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun JoinSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun ResultColumnSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt new file mode 100644 index 00000000..ceb5a3f3 --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Ctrip.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + +package com.ctrip.sqllin.dsl.sql.clause + +import com.ctrip.sqllin.dsl.annotation.StatementDslMaker +import kotlin.reflect.KProperty1 + +/** + * An expression selected into a property of the result type [R], as `count(X) AS AuthorStats::books` selects + * `count(*) AS books` into `AuthorStats.books`. + * + * A SELECT reads its rows into [R] by the names of [R]'s properties: a property is read from the column of the same + * name, unless a result column gives it an expression. So the result columns of a SELECT name only the properties that + * hold expressions, such as aggregate functions, and every other property is read from its column: + * + * ```kotlin + * @Serializable + * data class AuthorStats(val author: String, val books: Long, val totalPages: Long?) + * + * BookTable { table -> + * table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author + * } + * // SELECT author,count(*) AS books,sum(pages) AS totalPages FROM book GROUP BY author + * ``` + * + * @param R The result type the expression is selected into + * + * @author Yuang Qiao + */ +public class ResultColumn internal constructor( + internal val element: ClauseElement<*>, + internal val propertyName: String, +) + +/** + * Selects this element into [property] of the result type [R], as the SQL `expression AS name` does. + * + * The property's type has to be the type of the element's values, which is checked at compile time: `count(X)` goes + * into a `Long` property, and not into an `Int` or a `String` one. Whether the property has to be nullable depends on + * the rest of the query, as an aggregate function such as `sum` is NULL when no rows match, but not in a group of + * GROUP BY, so it is checked when the statement is built, before anything runs. + * + * The property is matched by its name, so it can't be renamed with `@SerialName`. + * + * @param property The property of the result type that receives the element's value + * @return The result column, to give to `SELECT`, alone or in a list + */ +@StatementDslMaker +public infix fun ClauseElement

.AS(property: KProperty1): ResultColumn = + ResultColumn(this, property.name) 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 24d0c207..ca0971d8 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 @@ -20,6 +20,7 @@ package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.statement.JoinSelectStatement +import com.ctrip.sqllin.dsl.sql.statement.ResultColumnSelectStatement import com.ctrip.sqllin.dsl.sql.statement.UpdateDeleteStatement import com.ctrip.sqllin.dsl.sql.statement.UpdateStatementWithoutWhereClause import com.ctrip.sqllin.dsl.sql.statement.WhereSelectStatement @@ -59,6 +60,12 @@ public infix fun JoinSelectStatement.WHERE(condition: SelectCondition): W container changeLastStatement it } +@StatementDslMaker +public infix fun ResultColumnSelectStatement.WHERE(condition: SelectCondition): WhereSelectStatement = + appendToWhere(WhereClause(condition)).also { + container changeLastStatement it + } + /** * Attaches a WHERE clause to an UPDATE statement and merges parameters. * 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 b3dd96cf..f0fef5b1 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 @@ -78,7 +78,7 @@ internal object Alter : Operation { * @param connection The database connection for executing the statement * @return A [TableStructureStatement] representing the ADD COLUMN operation */ - fun addColumn(table: Table<*>, newColumn: ClauseElement, connection: DatabaseConnection): SingleStatement { + fun addColumn(table: Table<*>, newColumn: ClauseElement<*>, connection: DatabaseConnection): SingleStatement { val sql = buildString { append(sqlStr) append(table.tableName) @@ -123,7 +123,7 @@ internal object Alter : Operation { * @param connection The database connection for executing the statement * @return A [TableStructureStatement] representing the RENAME COLUMN operation */ - fun renameColumn(table: Table<*>, oldName: String, newColumn: ClauseElement, connection: DatabaseConnection): SingleStatement { + fun renameColumn(table: Table<*>, oldName: String, newColumn: ClauseElement<*>, connection: DatabaseConnection): SingleStatement { val sql = buildString { append(sqlStr) append(table.tableName) @@ -145,7 +145,7 @@ internal object Alter : Operation { * @param connection The database connection for executing the statement * @return A [TableStructureStatement] representing the DROP COLUMN operation */ - fun dropColumn(table: Table<*>, column: ClauseElement, connection: DatabaseConnection): SingleStatement { + fun dropColumn(table: Table<*>, column: ClauseElement<*>, connection: DatabaseConnection): SingleStatement { val sql = buildString { append(sqlStr) append(table.tableName) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt index 72ae297e..0ab6e67d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Create.kt @@ -62,7 +62,7 @@ internal object Create : Operation { * @return CREATE INDEX statement ready for execution * @throws IllegalArgumentException if no columns are specified */ - fun createIndex(table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement): SingleStatement { + fun createIndex(table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement<*>): SingleStatement { require(columns.isNotEmpty()) { "You must create an index for at least one column." } return createIndex(INDEX, table, connection, indexName, *columns) } @@ -81,7 +81,7 @@ internal object Create : Operation { * @return CREATE UNIQUE INDEX statement ready for execution * @throws IllegalArgumentException if no columns are specified */ - fun createUniqueIndex(table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement): SingleStatement { + fun createUniqueIndex(table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement<*>): SingleStatement { require(columns.isNotEmpty()) { "You must create an index for at least one column." } return createIndex(UNIQUE_INDEX, table, connection, indexName, *columns) } @@ -99,7 +99,7 @@ internal object Create : Operation { * @return CREATE INDEX statement ready for execution * @throws IllegalArgumentException if no columns are specified */ - private fun createIndex(prefix: String, table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement): SingleStatement { + private fun createIndex(prefix: String, table: Table, connection: DatabaseConnection, indexName: String, vararg columns: ClauseElement<*>): SingleStatement { val sql = buildString { append(sqlStr) append(prefix) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt index 523416a0..087c4a46 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt @@ -23,13 +23,14 @@ import com.ctrip.sqllin.dsl.sql.compiler.appendDBColumnName import com.ctrip.sqllin.dsl.sql.statement.* import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder /** * SELECT operation builder. * * Constructs SELECT statements by combining table information with clauses (WHERE, ORDER BY, - * LIMIT, GROUP BY, JOIN). Creates the appropriate statement type based on which clauses are + * LIMIT, GROUP BY, JOIN) or result columns. Creates the appropriate statement type based on which clauses are * initially provided, enforcing compile-time clause ordering through the statement hierarchy. * * @author Yuang Qiao @@ -53,7 +54,7 @@ internal object Select : Operation { container: StatementContainer, ): WhereSelectStatement { checkProjection(table, deserializer) - return WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters) + return WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters, null) } /** @@ -70,7 +71,7 @@ internal object Select : Operation { container: StatementContainer, ): OrderBySelectStatement { checkProjection(table, deserializer) - return OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + return OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null, null) } /** @@ -87,7 +88,7 @@ internal object Select : Operation { container: StatementContainer, ): LimitSelectStatement { checkProjection(table, deserializer) - return LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + return LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null, null) } /** @@ -104,7 +105,7 @@ internal object Select : Operation { container: StatementContainer, ): GroupBySelectStatement { checkProjection(table, deserializer) - return GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + return GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null, null) } /** @@ -122,7 +123,7 @@ internal object Select : Operation { connection: DatabaseConnection, container: StatementContainer, ) : JoinSelectStatement = - JoinSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + JoinSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null, null) /** * Builds a SELECT statement with JOIN clause (requires ON or USING). @@ -165,24 +166,146 @@ internal object Select : Operation { val projection = deserializer.descriptor if (projection == columns) return + for (index in 0 ..< projection.elementsCount) + checkColumnProperty(table, columns, projection, index) + } + + /** + * Checks that property [index] of [projection] can be read from the column of [table] it names, as described by + * [checkProjection]. + */ + @OptIn(ExperimentalSerializationApi::class) + private fun checkColumnProperty(table: Table<*>, columns: SerialDescriptor, projection: SerialDescriptor, index: Int) { + val projectionName = projection.serialName + val name = projection.getElementName(index) + val columnIndex = columns.getElementIndex(name) + require(columnIndex != CompositeDecoder.UNKNOWN_NAME) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' isn't a column of that table." + } + val column = columns.getElementDescriptor(columnIndex) + val property = projection.getElementDescriptor(index) + val columnType = column.serialName.removeSuffix("?") + val propertyType = property.serialName.removeSuffix("?") + require(propertyType == columnType) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is a $propertyType, but the column holds a $columnType." + } + require(property.isNullable || !column.isNullable) { + "Can't select '$projectionName' from table '${table.tableName}': column '$name' is nullable, so property '$name' has to be nullable too." + } + } + + /** + * Builds a SELECT statement with result columns: expressions selected into properties of the result type with + * `AS`, while every other property is read from its column. + * + * Generates SQL in the format: `SELECT column, expression AS property, ... FROM table`, in the order of the result + * type's properties. + * + * @return Statement that can be followed by WHERE, GROUP BY, ORDER BY, or LIMIT + */ + fun select( + table: Table<*>, + resultColumns: Iterable>, + isDistinct: Boolean, + deserializer: DeserializationStrategy, + connection: DatabaseConnection, + container: StatementContainer, + ): ResultColumnSelectStatement { + val expressions = checkResultColumns(table, resultColumns, deserializer) + val projection = deserializer.descriptor + val sql = buildString { + append(sqlStr) + if (isDistinct) + append("DISTINCT ") + for (index in 0 ..< projection.elementsCount) { + if (index > 0) + append(',') + val name = projection.getElementName(index) + expressions[name]?.let { + append(it.valueName) + append(" AS ") + } + append(name) + } + append(" FROM ") + append(table.tableName) + } + return ResultColumnSelectStatement(sql, deserializer, connection, container, null, ungroupedError(table, expressions, deserializer)) + } + + /** + * Checks that [resultColumns] fit the result type of [deserializer], and returns their expressions by the names of + * the properties they are selected into. + * + * Every result column has to name a property that is serialized under its own name, which is how the result is + * read, and no property can be given two expressions. A property given an expression has to be nullable when the + * expression can be NULL in a row, or in a group of GROUP BY. Every other property is read from its column, and + * is checked as [checkProjection] does. Whether a property has to be nullable because the query isn't grouped is + * left to [ungroupedError]. + * + * @throws IllegalArgumentException if the result columns don't fit the result type + */ + @OptIn(ExperimentalSerializationApi::class) + private fun checkResultColumns( + table: Table<*>, + resultColumns: Iterable>, + deserializer: DeserializationStrategy, + ): Map> { + val projection = deserializer.descriptor val projectionName = projection.serialName - for (index in 0 ..< projection.elementsCount) { - val name = projection.getElementName(index) - val columnIndex = columns.getElementIndex(name) - require(columnIndex != CompositeDecoder.UNKNOWN_NAME) { - "Can't select '$projectionName' from table '${table.tableName}': its property '$name' isn't a column of that table." + val expressions = HashMap>() + for (resultColumn in resultColumns) { + val name = resultColumn.propertyName + val index = projection.getElementIndex(name) + require(index != CompositeDecoder.UNKNOWN_NAME) { + "Can't select '$projectionName' from table '${table.tableName}': it doesn't serialize its property '$name' under that name. The property may be @Transient or computed, or renamed with @SerialName, which a property given an expression with AS can't be." } - val column = columns.getElementDescriptor(columnIndex) - val property = projection.getElementDescriptor(index) - val columnType = column.serialName.removeSuffix("?") - val propertyType = property.serialName.removeSuffix("?") - require(propertyType == columnType) { - "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is a $propertyType, but the column holds a $columnType." + require(name !in expressions) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is given more than one expression." } - require(property.isNullable || !column.isNullable) { - "Can't select '$projectionName' from table '${table.tableName}': column '$name' is nullable, so property '$name' has to be nullable too." + val element = resultColumn.element + require(element.table.tableName == table.tableName) { + "Can't select '$projectionName' from table '${table.tableName}': the expression '${element.valueName}' of property '$name' belongs to table '${element.table.tableName}'." } + require(!element.isNullable || projection.getElementDescriptor(index).isNullable) { + "Can't select '$projectionName' from table '${table.tableName}': '${element.valueName}' can be NULL, so property '$name' has to be nullable." + } + expressions[name] = element + } + require(expressions.isNotEmpty()) { + "Can't select '$projectionName' from table '${table.tableName}' with no result columns. To select columns only, use X<$projectionName>()." } + val columns = table.kSerializer().descriptor + for (index in 0 ..< projection.elementsCount) + if (projection.getElementName(index) !in expressions) + checkColumnProperty(table, columns, projection, index) + return expressions + } + + /** + * Returns the error to report if the statement isn't grouped with GROUP BY, or null if it can do without. + * + * A query that selects an aggregate function is an aggregate query, and without GROUP BY, SQLite returns one row + * for it even when no rows match. In that row, every column and every aggregate function except `count` is NULL, + * so the properties that hold them have to be nullable, unless the query is grouped: then each group has rows. + */ + @OptIn(ExperimentalSerializationApi::class) + private fun ungroupedError( + table: Table<*>, + expressions: Map>, + deserializer: DeserializationStrategy<*>, + ): String? { + if (expressions.values.none { it.isAggregate }) + return null + val projection = deserializer.descriptor + val nonNullProperties = (0 ..< projection.elementsCount).filter { index -> + !projection.getElementDescriptor(index).isNullable && + expressions[projection.getElementName(index)]?.isNullOnNoRows != false + }.map { projection.getElementName(it) } + if (nonNullProperties.isEmpty()) + return null + val properties = nonNullProperties.joinToString { "'$it'" } + return "Can't select '${projection.serialName}' from table '${table.tableName}' without GROUP BY: an aggregate query that isn't grouped returns one row even when no rows match, in which $properties would be NULL. Make them nullable, or append GROUP BY." } private fun buildSQL( @@ -223,6 +346,6 @@ internal object Select : Operation { append(" FROM ") append(table.tableName) } - return FinalSelectStatement(sql, deserializer, connection, container, null) + return FinalSelectStatement(sql, deserializer, connection, container, null, null) } } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt index b8ac0855..bd2b02e0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt @@ -47,6 +47,15 @@ internal class DatabaseExecuteEngine( } fun executeAllStatement() { + // Some checks can only run once a statement is complete, which it is now. Run all of them first, so that a + // failing one runs nothing. + statementList.forEach { + when (it) { + is SelectStatement<*> -> it.checkComplete() + is TransactionStatementsGroup -> it.checkComplete() + else -> Unit + } + } statementList.forEach { when (it) { is SingleStatement -> { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt index 2e469076..b137d8b5 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/JoinStatementWithoutCondition.kt @@ -52,7 +52,7 @@ public class JoinStatementWithoutCondition internal constructor( * @param clauseElements Column elements to join on (must not be empty) * @return Completed JOIN statement that can accept further clauses */ - internal infix fun convertToJoinSelectStatement(clauseElements: Iterable): JoinSelectStatement { + internal infix fun convertToJoinSelectStatement(clauseElements: Iterable>): JoinSelectStatement { val iterator = clauseElements.iterator() require(iterator.hasNext()) { "Param 'clauseElements' must not be empty!!!" } val sql = buildString { @@ -65,7 +65,7 @@ public class JoinStatementWithoutCondition internal constructor( } append(')') } - val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, null) + val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, null, null) addSelectStatement(joinStatement) return joinStatement } @@ -85,7 +85,7 @@ public class JoinStatementWithoutCondition internal constructor( append(" ON ") append(condition.conditionSQL) } - val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, condition.parameters) + val joinStatement = JoinSelectStatement(sql, deserializer, connection, container, condition.parameters, null) addSelectStatement(joinStatement) return joinStatement } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt index eea0d96a..0ecbeea0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/SelectStatement.kt @@ -38,6 +38,10 @@ import kotlin.concurrent.Volatile * @property connection Database connection for executing the query * @property container Statement container for managing this statement in the DSL scope * @property parameters Parameterized query values, or null if none + * @property ungroupedError The error to report if no GROUP BY is appended, or null if there is none. It is set for an + * aggregate query whose result type has non-null properties that can only be relied on in a group: without GROUP BY, + * an aggregate query returns one row even when no rows match, in which they are NULL. GROUP BY clears it, and + * [checkComplete] reports it. * * @author Yuang Qiao */ @@ -47,6 +51,7 @@ public sealed class SelectStatement( internal val connection: DatabaseConnection, internal val container: StatementContainer, final override val parameters: MutableList?, + internal val ungroupedError: String?, ) : SingleStatement(sqlStr) { @Volatile @@ -77,6 +82,16 @@ public sealed class SelectStatement( result!! } ?: throw IllegalStateException("You have to call 'execute' function before call 'getResults'!!!") + /** + * Checks what can only be checked once no clause can be appended anymore: that a statement that needs GROUP BY has + * it. Called for every statement of a scope before any of them runs, so that a failing check runs nothing. + * + * @throws IllegalArgumentException if the statement isn't complete + */ + internal fun checkComplete() { + ungroupedError?.let { throw IllegalArgumentException(it) } + } + protected fun buildSQL(clause: SelectClause): String = buildString { append(sqlStr) append(clause.clauseStr) @@ -99,16 +114,50 @@ public class WhereSelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { + + internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = + LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) + + internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = + OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) + + internal infix fun appendToGroupBy(clause: GroupByClause): GroupBySelectStatement = + GroupBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, null) +} + +/** + * SELECT statement with result columns, as `table SELECT listOf(count(X) AS AuthorStats::books)` starts. + * + * Can be followed by: + * - WHERE + * - GROUP BY + * - ORDER BY + * - LIMIT + * + * @author Yuang Qiao + */ +public class ResultColumnSelectStatement internal constructor( + sqlStr: String, + deserializer: DeserializationStrategy, + connection: DatabaseConnection, + container: StatementContainer, + parameters: MutableList?, + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { + + internal infix fun appendToWhere(clause: WhereClause): WhereSelectStatement = + WhereSelectStatement(buildSQL(clause), deserializer, connection, container, clause.selectCondition.parameters, ungroupedError) internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = - LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToGroupBy(clause: GroupByClause): GroupBySelectStatement = - GroupBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + GroupBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, null) } /** @@ -128,7 +177,8 @@ public class JoinSelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { internal infix fun appendToWhere(clause: WhereClause): WhereSelectStatement { val clauseParams = clause.selectCondition.parameters @@ -137,17 +187,17 @@ public class JoinSelectStatement internal constructor( it.addAll(p) } } ?: clauseParams - return WhereSelectStatement(buildSQL(clause), deserializer, connection, container, params) + return WhereSelectStatement(buildSQL(clause), deserializer, connection, container, params, ungroupedError) } internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = - LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToGroupBy(clause: GroupByClause): GroupBySelectStatement = - GroupBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + GroupBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, null) } /** @@ -165,10 +215,11 @@ public class GroupBySelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToHaving(clause: HavingClause): HavingSelectStatement { val clauseParams = clause.selectCondition.parameters @@ -177,7 +228,7 @@ public class GroupBySelectStatement internal constructor( it.addAll(p) } } ?: clauseParams - return HavingSelectStatement(buildSQL(clause), deserializer, connection, container, params) + return HavingSelectStatement(buildSQL(clause), deserializer, connection, container, params, ungroupedError) } } @@ -196,13 +247,14 @@ public class HavingSelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { internal infix fun appendToOrderBy(clause: OrderByClause): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + OrderBySelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = - LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) } /** @@ -219,10 +271,11 @@ public class OrderBySelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { internal infix fun appendToLimit(clause: LimitClause): LimitSelectStatement = - LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + LimitSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) } /** @@ -239,10 +292,11 @@ public class LimitSelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) { + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) { internal infix fun appendToFinal(clause: OffsetClause): FinalSelectStatement = - FinalSelectStatement(buildSQL(clause), deserializer, connection, container, parameters) + FinalSelectStatement(buildSQL(clause), deserializer, connection, container, parameters, ungroupedError) } /** @@ -259,4 +313,5 @@ public class FinalSelectStatement internal constructor( connection: DatabaseConnection, container: StatementContainer, parameters: MutableList?, -) : SelectStatement(sqlStr, deserializer, connection, container, parameters) \ No newline at end of file + ungroupedError: String?, +) : SelectStatement(sqlStr, deserializer, connection, container, parameters, ungroupedError) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt index 1739a5c0..007905fd 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt @@ -42,6 +42,15 @@ internal class TransactionStatementsGroup( statementList.add(statement) } + /** + * Checks that every statement of the transaction is complete, before any of them runs. + * + * @see SelectStatement.checkComplete + */ + fun checkComplete() = statementList.forEach { + (it as? SelectStatement<*>)?.checkComplete() + } + override fun execute() = databaseConnection.withTransaction { statementList.forEach { if (enableSimpleSQLLog) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt index 73e64711..e27d2dd3 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt @@ -51,6 +51,7 @@ internal class UnionSelectStatementGroup : StatementContainer { check(statementList.size > 1) { "Please write at least two 'select' statements on 'UNION' scope" } val unionKeyWord = if (isUnionAll) " UNION ALL " else " UNION " statementList.forEachIndexed { index, statement -> + statement.checkComplete() append(statement.sqlStr) if (parameters == null) parameters = statement.parameters @@ -69,6 +70,7 @@ internal class UnionSelectStatementGroup : StatementContainer { connection = connection, container = container, parameters, + ungroupedError = null, ) } } diff --git a/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt b/sqllin-processor/src/main/kotlin/com/ctrip/sqllin/processor/ClauseProcessor.kt index 73d62224..c51bf1b5 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 @@ -201,7 +201,7 @@ class ClauseProcessor( // Write 'SelectClause' code. writer.write(" @ColumnNameDslMaker\n") writer.write(" val $propertyName\n") - writer.write(" get() = $clauseElementTypeName($elementName, this)\n\n") + writer.write(" get() = $clauseElementTypeName($elementName, this, ${!isNotNull})\n\n") writer.write(" @ColumnNameDslMaker\n") writer.write(" var SetClause<$className>.$propertyName: ${property.typeName}") writer.write(if (isNotNull) "\n" else "?\n") @@ -227,10 +227,13 @@ class ClauseProcessor( * Handles three categories: * - **Typealiases**: Resolves to underlying type and maps to appropriate clause type * - **Enum classes**: Maps to `ClauseEnum` for type-safe enum operations - * - **Standard types**: Maps to ClauseNumber, ClauseString, ClauseBoolean, or ClauseBlob + * - **Standard types**: Maps to `ClauseNumber`, `ClauseString`, ClauseBoolean, or ClauseBlob + * + * The type argument is the property's type without nullability, as an element knows the type of its values, so + * that `AS` only selects it into a property of that type. * * @param property The property declaration to analyze - * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob, ClauseEnum), or null if unsupported + * @return The clause type (such as `ClauseNumber` or `ClauseEnum`), or null if unsupported */ private fun getClauseElementTypeStr(property: KSPropertyDeclaration): String? = when ( val declaration = property.type.resolve().declaration @@ -252,15 +255,15 @@ class ClauseProcessor( * Maps a fully qualified type name to its corresponding clause element type. * * Supports primitive types and their unsigned variants: - * - Numeric types (Byte, Short, Int, Long, Float, Double, UByte, UShort, UInt, ULong) → ClauseNumber - * - Text types (Char, String) → ClauseString + * - Numeric types (Byte, Short, Int, Long, Float, Double, UByte, UShort, UInt, ULong) → `ClauseNumber` + * - Text types (Char, String) → `ClauseString` * - Boolean → ClauseBoolean * - ByteArray → ClauseBlob * * Note: Enum types are handled separately by [getClauseElementTypeStr]. * * @param typeName The fully qualified type name to map - * @return The clause type name (ClauseNumber, ClauseString, ClauseBoolean, ClauseBlob), or null if unsupported + * @return The clause type (such as `ClauseNumber`), or null if unsupported */ private fun getClauseElementTypeStrByTypeName(typeName: String?): String? = when (typeName) { FullNameCache.INT, @@ -272,10 +275,10 @@ class ClauseProcessor( FullNameCache.UINT, FullNameCache.ULONG, FullNameCache.USHORT, - FullNameCache.UBYTE, -> "ClauseNumber" + FullNameCache.UBYTE, -> "ClauseNumber<$typeName>" FullNameCache.CHAR, - FullNameCache.STRING, -> "ClauseString" + FullNameCache.STRING, -> "ClauseString<$typeName>" FullNameCache.BOOLEAN -> "ClauseBoolean" From 41ae7b7349b30361f6cbeb3733ad14ab1ccf79ab Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 00:18:39 +0100 Subject: [PATCH 25/32] Add upsert to the roadmap as low priority (N3) The full upsert, INSERT ... ON CONFLICT (target) DO UPDATE or DO NOTHING, updates the conflicting row in place, where INSERT OR REPLACE deletes it and inserts a new one, firing delete triggers and cascades. It is deferred: it needs SQLite 3.24, which the Android framework only has from API 30 on, while SQLlin supports API 24; the DSL can't attach a clause to INSERT yet; and the common cases already have a way, INSERT_OR_IGNORE for de-duplication, and an UPDATE followed by INSERT_OR_IGNORE in a transaction for an update in place. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index 575d43ca..33ab1eed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,7 @@ * Support CHECK keyword * Support using a query's results within the same transaction, so that a read-modify-write is one transaction +* Support upsert, `INSERT ... ON CONFLICT (target) DO UPDATE` and `DO NOTHING`, which updates the conflicting row in place where `INSERT OR REPLACE` deletes and re-inserts it ## Supported From 3592f2dd01f79e8f99b39ffe74d70cf99db05695 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 00:18:39 +0100 Subject: [PATCH 26/32] Update AGP to 9.4.1, KSP to 2.3.12 and androidx.annotation to 1.11.0 jvmTest and testAndroidHostTest of sqllin-dsl-test and sqllin-driver pass, and the macosArm64 tests and the sample compile. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- gradle/libs.versions.toml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66561e2a..a129a34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ ### All * Update `Kotlin`'s version to `2.4.20` -* Update `AGP`'s version to `9.4.0` +* Update `AGP`'s version to `9.4.1` * 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 @@ -35,6 +35,7 @@ ### sqllin-processor +* Update `KSP`'s version to `2.3.12` * 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 367f1121..787c12ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,11 @@ [versions] kotlin = "2.4.20" -agp = "9.4.0" -ksp = "2.3.11" +agp = "9.4.1" +ksp = "2.3.12" serialization = "1.11.0" coroutines = "1.11.0" -androidx-annotation = "1.10.0" +androidx-annotation = "1.11.0" androidx-test = "1.7.0" robolectric = "4.17" junit = "4.13.2" From 365123bfd1687e1a2744cc26689ae2716d281405 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 13:22:37 +0100 Subject: [PATCH 27/32] Add INSERT INTO ... SELECT and Table.withName (N9) SQLite's ALTER TABLE can only rename a table, and add, rename or drop a column; dropping one needs SQLite 3.35, which Android only has from API 34 on. Any other change, such as adding a constraint or changing the primary key, rebuilds the table: create the new structure under a temporary name, copy the rows with INSERT INTO ... SELECT, drop the old table and rename the new one. The DSL could do all of it but the copy, and could only create a table under the name its @DBRow class fixes at compile time. INSERT, INSERT_OR_IGNORE and INSERT_OR_REPLACE now also take a SELECT of the table's row type: ArchiveTable INSERT (PersonTable SELECT WHERE(PersonTable.age GT 60)) The column list is the row type's properties, in the order the SELECT selects them, so every column gets a value and the primary key is copied as it is selected. Requiring the table's own row type makes that complete at compile time. The SELECT becomes part of the INSERT: it is removed from the statements of its scope, so it no longer runs on its own, and its deferred GROUP BY check from N5 runs when it is taken. Table.withName returns a table with the same structure under another name, its CREATE TABLE statement renamed. It is a plain function, not a SQL keyword, so it is lowercase and carries no DSL marker. Its KDoc and the guide explain the rebuild, and why the new table is renamed rather than the old one: with SQLite's default settings, renaming a table also renames the references to it in other tables' foreign keys, which would then point at the dropped table. That was checked with sqlite3: with legacy_alter_table off, renaming the old table first rewrote the child's REFERENCES, while the documented order left it in place. The guide to modifying the database gains a section on rebuilding a table, and its Insert section covers INSERT ... SELECT. It also documents INSERT_OR_IGNORE and INSERT_OR_REPLACE, which it didn't mention. Tests: testInsertSelect covers copying with a WHERE parameter, a SELECT taken by an INSERT having no results of its own, filling a table from a grouped aggregate and rejecting the ungrouped one, and the three INSERTs on a primary key conflict. testTableRebuild runs a real migration from version 1 to 2: it renames a column, adds a UNIQUE constraint and drops a column, then checks the rows and keys, the constraint, and that another table's foreign key still points at the rebuilt table. jvmTest (51) and testAndroidHostTest on API 26 and 37 (102) pass, so the rebuild works on API 26, where DROP COLUMN doesn't. The native test sources compile for macosArm64, linuxX64, mingwX64 and watchosArm32 but were not run, as this machine is an Intel Mac. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + ROADMAP.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 6 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 171 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 37 ++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 6 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 6 + .../doc/modify-database-and-transaction-cn.md | 73 +++++++- .../doc/modify-database-and-transaction.md | 80 +++++++- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 57 ++++++ .../kotlin/com/ctrip/sqllin/dsl/sql/Table.kt | 49 +++++ .../ctrip/sqllin/dsl/sql/operation/Insert.kt | 32 ++++ .../sql/statement/DatabaseExecuteEngine.kt | 4 + .../dsl/sql/statement/StatementContainer.kt | 10 +- .../statement/TransactionStatementsGroup.kt | 4 + .../statement/UnionSelectStatementGroup.kt | 4 + 16 files changed, 538 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a129a34c..2f3b9cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ * New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` * New DSL API: projection, which reads `SELECT` results into a narrower `@Serializable` type naming the columns to select, given as the type argument of the clause function: `X()`, `WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after `SELECT` or `SELECT_DISTINCT`. A type that doesn't fit the table is rejected with an `IllegalArgumentException` when the statement is built. To support it, the public `DatabaseScope#select` functions now take a result type separate from the table's; this is source-compatible, but on Kotlin/Native a library compiled against an earlier version may have to be recompiled * New DSL API: result columns, which select expressions such as aggregate functions into properties of a result type with `AS`, as in `table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author`, or `table SELECT (count(X) AS BookCount::books)` for a single one. The result type's other properties are read from their columns, as in a projection. They work after `SELECT` and `SELECT_DISTINCT`, followed by `WHERE`, `GROUP_BY`, `ORDER_BY` and `LIMIT`. A property must have the type of its expression's values, which is checked at compile time, and be nullable when its expression can be `NULL`, which is checked when the statement is built. An aggregate query without `GROUP BY` returns a row even when no rows match, in which every column and every aggregate function except `count` is `NULL`; as `GROUP_BY` can still follow when the statement is built, this is checked when the scope ends, before any of its statements runs +* New DSL API: `INSERT`, `INSERT_OR_IGNORE` and `INSERT_OR_REPLACE` taking a `SelectStatement` of the table's row type, for SQL syntax `INSERT INTO ... SELECT`, as in `ArchiveTable INSERT (PersonTable SELECT WHERE(PersonTable.age GT 60))`. Every column is inserted, the primary key copied as it is selected. The `SELECT` becomes part of the `INSERT`, so it no longer runs on its own +* New API: `Table#withName`, which returns a table with the same structure under another name. With `INSERT INTO ... SELECT`, it rebuilds a table in a migration, for a change `ALTER TABLE` can't make, such as adding a constraint, or dropping a column on SQLite older than 3.35, which on Android means below API 34. The guide to modifying the database now describes the procedure, and documents `INSERT_OR_IGNORE` and `INSERT_OR_REPLACE` * **Breaking change**: `ClauseElement`, `ClauseNumber` and `ClauseString` now take a type parameter, the type of their values, such as `ClauseNumber` for an `Int` column and `ClauseNumber` for `count(X)`, which is what lets `AS` check the type of a property. Code that only uses the DSL is unaffected; code that names these types has to add a type argument, such as `ClauseElement<*>` where any element is accepted. The public constructors of `ClauseNumber`, `ClauseString`, `ClauseBoolean`, `ClauseBlob` and `ClauseEnum`, which the generated table objects call, now take whether the column is nullable instead of whether the element is a function. The generated code is regenerated by the build, but on Kotlin/Native a library compiled against an earlier version has to be recompiled * **Breaking change**: The SQL functions now return elements of the type of the values SQLite returns for them: `count`, `length`, `instr` and `random` a `ClauseNumber`, `avg` and `round` a `ClauseNumber`, the string functions and `group_concat` a `ClauseString`, and `max`, `min` and `abs` an element of the same kind and type as their argument. So `max` and `min` of a String column are now a `ClauseString`, compared with strings in `HAVING`, where they used to be a `ClauseNumber`. `sum` is overloaded by the type of its column: of a column of integers or Booleans it is a `ClauseNumber`, and of a `Float` or `Double` column a `ClauseNumber`. A `sum` of a String, BLOB, enum or `ULong` column no longer compiles; the last because SQLite stores a `ULong` above `Long.MAX_VALUE` as a negative number, which made the sum wrong * Fix documentation: the SQL functions guide listed a `sign` function, which isn't available, and its `HAVING (count(X) > 2)` example didn't compile; it is `HAVING (count(X) GT 2)`. It no longer says that functions can only be used in conditions diff --git a/ROADMAP.md b/ROADMAP.md index 33ab1eed..8f0fc79b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,7 @@ ## Supported +* Support INSERT INTO ... SELECT (2.4.0 ✅) * Support SQL functions in SELECT results (2.4.0 ✅) * Support SELECT projection (2.4.0 ✅) * Support INSERT OR IGNORE (2.4.0 ✅) 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 496c51fe..b6746bc2 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 @@ -84,6 +84,12 @@ class AndroidTest { @Test fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 59668c35..9f2770d0 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 @@ -24,6 +24,7 @@ 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 +import com.ctrip.sqllin.dsl.sql.withName import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.clause.OrderByWay.ASC import com.ctrip.sqllin.dsl.sql.clause.OrderByWay.DESC @@ -34,6 +35,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.test.runTest import kotlin.test.assertEquals +import kotlin.test.assertFails import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals @@ -1129,6 +1131,175 @@ class CommonBasicTest(private val path: DatabasePath) { val concatenated: ClauseString = group_concat(name, ",") } + /** + * Covers `INSERT INTO ... SELECT`: `INSERT`, `INSERT_OR_IGNORE` and `INSERT_OR_REPLACE` given a SELECT of the + * table's row type insert the rows it returns, and the SELECT no longer runs on its own. The target is a copy of a + * table made with `withName`. + */ + @OptIn(ExperimentalDSLDatabaseAPI::class) + fun testInsertSelect() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + val bookCopy = BookTable.withName("book_copy") + assertEquals(true, bookCopy.createSQL.startsWith("CREATE TABLE book_copy(")) + assertEquals(BookTable.createSQL.substringAfter('('), bookCopy.createSQL.substringAfter('(')) + + // Copy the rows a WHERE selects, its parameter included. The SELECT is part of the INSERT now, so it doesn't + // run by itself and has no results of its own. + lateinit var source: SelectStatement + database { + CREATE(bookCopy) + CREATE(AuthorBookCountTable) + BookTable INSERT listOf( + Book(name = "The Da Vinci Code", author = "Dan Brown", price = 16.96, pages = 454), + Book(name = "The Lost Symbol", author = "Dan Brown", price = 19.95, pages = 510), + Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251), + ) + source = BookTable SELECT WHERE(BookTable.price LT 30.0) + bookCopy INSERT source + } + assertFailsWith { source.getResults() } + lateinit var copied: SelectStatement + database { + copied = bookCopy SELECT X + } + assertEquals(listOf("The Da Vinci Code", "The Lost Symbol"), copied.getResults().map { it.name }.sorted()) + + // Result columns produce rows of another table's type: here a table of aggregates + lateinit var counts: SelectStatement + database { + BookTable { table -> + AuthorBookCountTable INSERT (table SELECT (count(X) AS AuthorBookCount::books) GROUP_BY author) + } + counts = AuthorBookCountTable SELECT X + } + assertEquals( + listOf(AuthorBookCount("Dan Brown", 2), AuthorBookCount("Ken Kousen", 1)), + counts.getResults().sortedBy { it.author }, + ) + // The SELECT is checked when it becomes part of the INSERT: without GROUP BY, 'author' would be NULL when no + // rows match + val ungrouped = assertFailsWith { + database { + BookTable { table -> + AuthorBookCountTable INSERT (table SELECT (count(X) AS AuthorBookCount::books)) + } + } + } + assertEquals(true, ungrouped.message!!.contains("without GROUP BY")) + + // On a conflict with the primary key, INSERT fails, INSERT_OR_IGNORE keeps the row, and INSERT_OR_REPLACE + // replaces it. The key is copied as it is selected. + val personCopy = PersonWithIdTable.withName("person_copy") + database { + CREATE(personCopy) + PersonWithIdTable INSERT listOf( + PersonWithId(id = null, name = "Ann", age = 30), + PersonWithId(id = null, name = "Bob", age = 40), + ) + personCopy INSERT (PersonWithIdTable SELECT WHERE(PersonWithIdTable.name EQ "Ann")) + } + assertFails { + database { personCopy INSERT (PersonWithIdTable SELECT X) } + } + lateinit var afterIgnore: SelectStatement + database { + PersonWithIdTable { table -> + table UPDATE SET { age = 31 } WHERE (name EQ "Ann") + } + personCopy INSERT_OR_IGNORE (PersonWithIdTable SELECT X) + afterIgnore = personCopy SELECT X + } + assertEquals(listOf("Ann" to 30, "Bob" to 40), afterIgnore.getResults().map { it.name to it.age }.sortedBy { it.first }) + lateinit var afterReplace: SelectStatement + lateinit var people: SelectStatement + database { + PersonWithIdTable { table -> + personCopy INSERT_OR_REPLACE (table SELECT listOf(upper(name) AS PersonWithId::name)) + } + afterReplace = personCopy SELECT X + people = PersonWithIdTable SELECT X + } + assertEquals(listOf("ANN" to 31, "BOB" to 40), afterReplace.getResults().map { it.name to it.age }.sortedBy { it.first }) + assertEquals(people.getResults().map { it.id }.sortedBy { it }, afterReplace.getResults().map { it.id }.sortedBy { it }) + } + + /** + * Covers rebuilding a table in a migration, for a change `ALTER TABLE` can't make: the new structure is created + * under a temporary name with `withName`, filled with `INSERT INTO ... SELECT` from the old one, which is dropped, + * and renamed to the table's name. Renaming the new table rather than the old one leaves the foreign keys of other + * tables pointing at the rebuilt table. + */ + @OptIn(ExperimentalDSLDatabaseAPI::class) + fun testTableRebuild() { + val version1 = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(RebuildPersonV1Table) + CREATE(RebuildPetTable) + }, + ) + Database(version1).databaseAutoClose { database -> + database { + RebuildPersonV1Table INSERT listOf( + RebuildPersonV1(id = null, name = "Ann", legacy = 1), + RebuildPersonV1(id = null, name = "Bob", legacy = 2), + ) + RebuildPetTable INSERT RebuildPet(id = 1, ownerId = 1) + } + } + + val version2 = DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 2, + create = { + CREATE(RebuildPersonTable) + CREATE(RebuildPetTable) + }, + upgrade = { oldVersion, _ -> + if (oldVersion < 2) { + val newPerson = RebuildPersonTable.withName("rebuild_person_new") + CREATE(newPerson) + RebuildPersonV1Table { table -> + newPerson INSERT (table SELECT (name AS RebuildPerson::fullName)) + } + DROP(RebuildPersonV1Table) + "rebuild_person_new" ALTER_RENAME_TABLE_TO RebuildPersonTable + } + }, + ) + Database(version2).databaseAutoClose { database -> + // The rows and their keys are kept, under the new structure, and 'legacy' is gone + lateinit var people: SelectStatement + database { + people = RebuildPersonTable SELECT X + } + assertEquals(listOf(RebuildPerson(1, "Ann"), RebuildPerson(2, "Bob")), people.getResults().sortedBy { it.id }) + assertEquals(true, database.selectFails { RebuildPersonV1Table SELECT X }) + + // The new constraint holds + assertFails { + database { RebuildPersonTable INSERT RebuildPerson(id = null, fullName = "Ann") } + } + + // The pet's foreign key still points at 'rebuild_person': an existing owner is accepted and a missing one + // rejected. Had the reference followed a renamed table, both would fail. + database { + PRAGMA_FOREIGN_KEYS(true) + RebuildPetTable INSERT RebuildPet(id = 2, ownerId = 2) + } + assertFails { + database { RebuildPetTable INSERT RebuildPet(id = 3, ownerId = 99) } + } + lateinit var pets: SelectStatement + database { + pets = RebuildPetTable SELECT X + } + assertEquals(listOf(1L, 2L), pets.getResults().map { it.id }.sorted()) + } + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) 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 e5fbba5f..d074fdda 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 @@ -600,3 +600,40 @@ data class BookCountAndMaxPages(val books: Long, val maxPages: PageCount) // 'ma @Serializable data class RenamedBookCount(@SerialName("total") val books: Long) // 'books' isn't serialized under its own name + +/** + * A table of aggregates, filled with `INSERT INTO ... SELECT` from a grouped query of [Book]. + */ +@DBRow("author_book_count") +@Serializable +data class AuthorBookCount(val author: String, val books: Long) + +/** + * The `rebuild_person` table before and after a rebuild, which renames `name` to `fullName`, makes it unique, a change + * `ALTER TABLE` can't make, and drops `legacy`. + */ +@DBRow("rebuild_person") +@Serializable +data class RebuildPersonV1( + @PrimaryKey(autoIncrement = true) val id: Long?, + val name: String, + val legacy: Int, +) + +@DBRow("rebuild_person") +@Serializable +data class RebuildPerson( + @PrimaryKey(autoIncrement = true) val id: Long?, + @Unique val fullName: String, +) + +/** + * References `rebuild_person`, so its foreign key shows whether the rebuild left the reference in place. + */ +@DBRow("rebuild_pet") +@Serializable +data class RebuildPet( + @PrimaryKey val id: Long, + @com.ctrip.sqllin.dsl.annotation.References(tableName = "rebuild_person", foreignKeys = ["id"]) + val ownerId: Long, +) 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 fbb7635f..a7b3888b 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -76,6 +76,12 @@ class JvmTest { @Test fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() 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 dac763d1..f480c9db 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -92,6 +92,12 @@ class NativeTest { @Test fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/doc/modify-database-and-transaction-cn.md b/sqllin-dsl/doc/modify-database-and-transaction-cn.md index e2962a58..95be08f2 100644 --- a/sqllin-dsl/doc/modify-database-and-transaction-cn.md +++ b/sqllin-dsl/doc/modify-database-and-transaction-cn.md @@ -127,7 +127,7 @@ fun sample() { } ``` -**⚠️ 警告**:DROP COLUMN 会永久删除列及其所有数据。请注意,SQLite 的 DROP COLUMN 支持是在 3.35.0 版本中添加的,因此较旧的 SQLite 版本可能需要重建表。 +**⚠️ 警告**:DROP COLUMN 会永久删除列及其所有数据。请注意,SQLite 的 DROP COLUMN 支持是在 3.35.0 版本中添加的,Android 要到 API 34 才具备,因此较旧的 SQLite 版本需要[重建表](#重建表)。 ### 在 DSLDBConfiguration 中使用结构操作 @@ -158,6 +158,60 @@ val database = Database( ) ``` +### 重建表 + +SQLite 的 `ALTER TABLE` 只能重命名表,以及添加、重命名、删除列。其他改动,比如添加约束、把列改成 `NOT NULL`、更换主键, +都需要重建表:用临时名创建新结构的表,用 `INSERT INTO ... SELECT` 把数据拷过去,删除旧表,再把新表改名。在 SQLite 低于 +3.35 的环境中删除列也是这样做,在 Android 上就是 API 34 以下。 + +要从旧表读取数据,需要保留一个描述旧结构的 `@DBRow` 类,表名不变。`withName` 用来给新结构起一个临时名: + +```kotlin +import com.ctrip.sqllin.dsl.sql.withName + +@DBRow("person") +@Serializable +data class PersonV1( // 版本 1 中 'person' 的结构 + @PrimaryKey(autoIncrement = true) val id: Long?, + val name: String, + val legacy: Int, +) + +@DBRow("person") +@Serializable +data class Person( // 版本 2 起的结构 + @PrimaryKey(autoIncrement = true) val id: Long?, + @Unique val fullName: String, +) + +val database = Database( + DSLDBConfiguration( + // ... + version = 2, + upgrade = { oldVersion, newVersion -> + if (oldVersion < 2) { + val newPerson = PersonTable.withName("person_new") + CREATE(newPerson) + PersonV1Table { table -> + // INSERT INTO person_new(id,fullName) SELECT id,name AS fullName FROM person + newPerson INSERT (table SELECT (name AS Person::fullName)) + } + DROP(PersonV1Table) + "person_new" ALTER_RENAME_TABLE_TO PersonTable + } + } + ) +) +``` + +要重命名的是新表,而不是旧表。在 SQLite 的默认设置下,重命名一张表时,其他表外键中对它的引用也会跟着改名,所以如果先重命名 +旧表,这些引用就会跟着旧表走,等旧表被删除后,它们就指向了一张不存在的表。索引会随旧表一起删除,所以要在重建后的表上重新创建。 + +`withName` 返回的表与原表有相同的列、约束和行类型,但没有列属性,因为那些属性指向的是原表。它用于针对整张表的语句: +`CREATE`、`INSERT`、`DROP` 和 `ALTER_RENAME_TABLE_TO`。 + +数据的转换由 `SELECT` 完成,可以使用投影或结果列,详见[《高级查询》](advanced-query-cn.md)。转换值类型、替换 `NULL` 的函数目前还不支持。 + ## 插入 `Database` 类重载了类型为 ` Database.(Database.() -> T) -> T` 的函数操作符。当你调用该操作符函数时,它将产生一个 _DatabaseScope_ (数据库作用域)。 @@ -198,6 +252,23 @@ fun sample() { _INSERT_ 语句可以直接插入对象,你可以一次插入一个或多个对象。 +`INSERT_OR_IGNORE` 会跳过与表中已有行冲突的对象,`INSERT_OR_REPLACE` 则会替换那一行。它们接受的参数与 `INSERT` 相同。 + +_INSERT_ 还可以插入一条 _SELECT_ 返回的行,就像 `INSERT INTO ... SELECT` 那样,前提是这条 _SELECT_ 读出的是这张表的行类型。 +这些行可以来自任意一张表,借助投影或结果列: + +```kotlin +fun sample() { + database { + // INSERT INTO person_archive(id,name,age) SELECT id,name,age FROM person WHERE age > ? + PersonArchiveTable INSERT (PersonTable SELECT WHERE(PersonTable.age GT 60)) + } +} +``` + +这条 _SELECT_ 会成为 _INSERT_ 的一部分,所以它不再单独执行,也无法读取它的结果。主键按查询出的值原样拷贝。 +`INSERT_OR_IGNORE` 和 `INSERT_OR_REPLACE` 同样可以接受 _SELECT_。 + ## 删除 _DELETE_ 语句将会比 _INSERT_ 语句稍微复杂。SQLlin 不像 [Jetpack Room](https://developer.android.com/training/data-storage/room) diff --git a/sqllin-dsl/doc/modify-database-and-transaction.md b/sqllin-dsl/doc/modify-database-and-transaction.md index fde5524c..bdf15121 100644 --- a/sqllin-dsl/doc/modify-database-and-transaction.md +++ b/sqllin-dsl/doc/modify-database-and-transaction.md @@ -130,7 +130,7 @@ fun sample() { } ``` -**⚠️ WARNING**: DROP COLUMN permanently deletes the column and all its data. Note that SQLite's DROP COLUMN support was added in version 3.35.0, so older SQLite versions may require table recreation. +**⚠️ WARNING**: DROP COLUMN permanently deletes the column and all its data. Note that SQLite's DROP COLUMN support was added in version 3.35.0, which Android only has from API 34 on, so older SQLite versions require [rebuilding the table](#rebuilding-a-table). ### Using Structure Operations with DSLDBConfiguration @@ -161,6 +161,66 @@ val database = Database( ) ``` +### Rebuilding a Table + +SQLite's `ALTER TABLE` can only rename a table, and add, rename or drop a column. Any other change, such as adding a +constraint, making a column `NOT NULL` or changing the primary key, needs the table to be rebuilt: create the new +structure under a temporary name, copy the rows into it with `INSERT INTO ... SELECT`, drop the old table, and rename +the new one. That is also how to drop a column where SQLite is older than 3.35, which on Android means below API 34. + +To select from the old table, keep a `@DBRow` class with its old structure, under the same table name. `withName` +gives the new structure a temporary name: + +```kotlin +import com.ctrip.sqllin.dsl.sql.withName + +@DBRow("person") +@Serializable +data class PersonV1( // the structure of 'person' in version 1 + @PrimaryKey(autoIncrement = true) val id: Long?, + val name: String, + val legacy: Int, +) + +@DBRow("person") +@Serializable +data class Person( // the structure from version 2 on + @PrimaryKey(autoIncrement = true) val id: Long?, + @Unique val fullName: String, +) + +val database = Database( + DSLDBConfiguration( + // ... + version = 2, + upgrade = { oldVersion, newVersion -> + if (oldVersion < 2) { + val newPerson = PersonTable.withName("person_new") + CREATE(newPerson) + PersonV1Table { table -> + // INSERT INTO person_new(id,fullName) SELECT id,name AS fullName FROM person + newPerson INSERT (table SELECT (name AS Person::fullName)) + } + DROP(PersonV1Table) + "person_new" ALTER_RENAME_TABLE_TO PersonTable + } + } + ) +) +``` + +Rename the new table, not the old one. Renaming a table also renames the references to it in the foreign keys of +other tables, with SQLite's default settings, so the references would follow the old table, and be left pointing at a +table that no longer exists once it is dropped. Indexes are dropped with the old table, so create them again on the +rebuilt one. + +The table `withName` returns has the columns, constraints and row type of the original, but no column properties, as +those name the original table. It is meant for statements on the table as a whole: `CREATE`, `INSERT`, `DROP` and +`ALTER_RENAME_TABLE_TO`. + +The rows are converted by the `SELECT`, with a projection or with result columns, as described in +[Advanced Query](advanced-query.md). Functions to convert a value's type, or to replace a `NULL`, aren't available yet. + ## Insert The class `Database` has overloaded function operator that type is ` Database.(Database.() -> T) -> T`. When you invoke @@ -205,6 +265,24 @@ fun sample() { The _INSERT_ statements could insert objects directly. You can insert one or multiple objects once. +`INSERT_OR_IGNORE` skips the objects that conflict with a row already in the table, and `INSERT_OR_REPLACE` replaces +that row. They take the same arguments as `INSERT`. + +_INSERT_ can also insert the rows a _SELECT_ returns, as `INSERT INTO ... SELECT` does, if the _SELECT_ reads rows of the +table's type. They can come from any table, through a projection or result columns: + +```kotlin +fun sample() { + database { + // INSERT INTO person_archive(id,name,age) SELECT id,name,age FROM person WHERE age > ? + PersonArchiveTable INSERT (PersonTable SELECT WHERE(PersonTable.age GT 60)) + } +} +``` + +The _SELECT_ becomes part of the _INSERT_, so it no longer runs on its own, and its results can't be read. The primary +key is copied as it is selected. `INSERT_OR_IGNORE` and `INSERT_OR_REPLACE` take a _SELECT_ as well. + ## Delete The _DELETE_ statements will be slightly more complex than _INSERT_. SQLlin doesn't delete objects like 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 580d51d4..c379d9e3 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 @@ -367,6 +367,63 @@ public class DatabaseScope internal constructor( public infix fun Table.INSERT_OR_IGNORE(entity: T): Unit = INSERT_OR_IGNORE(listOf(entity)) + // ========== INSERT INTO ... SELECT ========== + // + // These insert the rows a SELECT returns, as in `PersonTable INSERT (PersonV1Table SELECT X())`. The + // SELECT's result type is the table's row type, so every column gets a value, the primary key included. + + /** + * Inserts the rows [select] returns, as the SQL `INSERT INTO table SELECT ...` does. + * + * [select] reads rows of this table's type, from any table: a projection or result columns turn the rows of + * another table into them. It becomes part of this statement, so it no longer runs on its own, and its + * `getResults` can't be called. The primary key is copied as it is selected. + * + * This is how a table is rebuilt, for a change `ALTER TABLE` can't make, such as adding a constraint or + * changing the primary key, or dropping a column on SQLite older than 3.35: + * ```kotlin + * val newPerson = PersonTable.withName("person_new") + * CREATE(newPerson) + * newPerson INSERT (PersonV1Table SELECT listOf(PersonV1Table.name AS Person::fullName)) + * DROP(PersonV1Table) + * "person_new" ALTER_RENAME_TABLE_TO PersonTable + * ``` + * + * @throws IllegalArgumentException if [select] is incomplete, as an aggregate query that needs GROUP BY + */ + @StatementDslMaker + public infix fun Table.INSERT(select: SelectStatement): Unit = + insert("INSERT INTO ", select) + + /** + * Inserts the rows [select] returns, skipping those that violate a constraint, as the SQL + * `INSERT OR IGNORE INTO table SELECT ...` does. + * + * @see INSERT + * @see INSERT_OR_IGNORE + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_IGNORE(select: SelectStatement): Unit = + insert("INSERT OR IGNORE INTO ", select) + + /** + * Inserts the rows [select] returns, replacing the rows they conflict with, as the SQL + * `INSERT OR REPLACE INTO table SELECT ...` does. + * + * @see INSERT + * @see INSERT_OR_REPLACE + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_REPLACE(select: SelectStatement): Unit = + insert("INSERT OR REPLACE INTO ", select) + + private fun Table.insert(insert: String, select: SelectStatement) { + select.checkComplete() + select.container removeStatement select + val statement = Insert.insert(insert, this, databaseConnection, select) + addStatement(statement) + } + // ========== UPDATE Operations ========== /** 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 fa7e8b0d..d0a70c9b 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 @@ -119,4 +119,53 @@ public abstract class Table( * @see com.ctrip.sqllin.dsl.annotation.CollateNoCase */ public abstract val createSQL: String +} + +/** + * Returns a table with the structure of this one under another [name], as needed to rebuild a table. + * + * SQLite's `ALTER TABLE` can only rename a table, and add, rename or drop a column; dropping one needs SQLite 3.35, + * which Android only has from API 34 on. Any other change, such as adding a constraint or changing the primary key, + * rebuilds the table: create the new structure under a temporary name, copy the rows with `INSERT INTO ... SELECT`, + * drop the old table, and rename the new one: + * ```kotlin + * val newPerson = PersonTable.withName("person_new") + * CREATE(newPerson) + * newPerson INSERT (PersonV1Table SELECT listOf(PersonV1Table.name AS Person::fullName)) + * DROP(PersonV1Table) + * "person_new" ALTER_RENAME_TABLE_TO PersonTable + * ``` + * Here `PersonV1` is a `@DBRow` class that keeps the old structure, under the same table name as `Person`. + * + * Rename the new table, not the old one: renaming a table also renames the references to it in other tables' + * foreign keys, with SQLite's default settings, so the references would follow the old table and be left pointing + * at a table that no longer exists once it's dropped. + * + * The returned table has no column properties, as those of this table name this table. It is meant for statements + * on the table as a whole: `CREATE`, `INSERT`, `DROP` and `ALTER_RENAME_TABLE_TO`. + * + * @param name The name of the returned table + * @return A table with this table's columns, constraints and row type, named [name] + */ +public fun Table.withName(name: String): Table = NamedTable(this, name) + +/** + * A table with the structure of [table] under another name. + */ +private class NamedTable(private val table: Table, name: String) : Table(name) { + + init { + require(name.isNotBlank()) { "The name of a table can't be blank." } + } + + override fun kSerializer(): KSerializer = table.kSerializer() + + override val primaryKeyInfo: PrimaryKeyInfo? + get() = table.primaryKeyInfo + + override val createSQL: String = run { + val prefix = "CREATE TABLE ${table.tableName}(" + check(table.createSQL.startsWith(prefix)) { "The CREATE TABLE statement of table '${table.tableName}' doesn't start with '$prefix'." } + "CREATE TABLE $name(${table.createSQL.substring(prefix.length)}" + } } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt index 1cf0cd49..21128547 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt @@ -19,7 +19,9 @@ package com.ctrip.sqllin.dsl.sql.operation import com.ctrip.sqllin.driver.DatabaseConnection import com.ctrip.sqllin.dsl.sql.statement.SingleStatement import com.ctrip.sqllin.dsl.sql.statement.InsertStatement +import com.ctrip.sqllin.dsl.sql.statement.SelectStatement import com.ctrip.sqllin.dsl.sql.Table +import com.ctrip.sqllin.dsl.sql.compiler.appendDBColumnName import com.ctrip.sqllin.dsl.sql.compiler.encodeEntities2InsertValues /** @@ -82,4 +84,34 @@ internal object Insert : Operation { } return InsertStatement(sql, connection, parameters) } + + /** + * Builds an INSERT statement that inserts the rows [select] returns. + * + * Generates SQL in the format: + * ``` + * INSERT INTO table_name (column1, column2, ...) SELECT ... + * ``` + * + * The columns are the properties of [select]'s result type, the table's row type, in the order the SELECT + * selects them, so every column gets a value, the primary key included. + * + * @param insert The keywords that start the statement: `INSERT INTO `, `INSERT OR IGNORE INTO ` or + * `INSERT OR REPLACE INTO ` + * @param table The table to insert into + * @param connection Database connection for execution + * @param select The SELECT whose rows are inserted + * @return INSERT statement ready for execution + */ + fun insert(insert: String, table: Table, connection: DatabaseConnection, select: SelectStatement): SingleStatement { + val sql = buildString { + append(insert) + append(table.tableName) + append('(') + appendDBColumnName(select.deserializer.descriptor) + append(") ") + append(select.sqlStr) + } + return InsertStatement(sql, connection, select.parameters?.toMutableList()) + } } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt index bd2b02e0..995a35f6 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/DatabaseExecuteEngine.kt @@ -46,6 +46,10 @@ internal class DatabaseExecuteEngine( statementList.add(statement) } + override infix fun removeStatement(statement: SingleStatement) { + statementList.remove(statement) + } + fun executeAllStatement() { // Some checks can only run once a statement is complete, which it is now. Run all of them first, so that a // failing one runs nothing. diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/StatementContainer.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/StatementContainer.kt index 6cbd3cb3..4765ae03 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/StatementContainer.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/StatementContainer.kt @@ -21,7 +21,8 @@ package com.ctrip.sqllin.dsl.sql.statement * * Used by statement builders (e.g., UPDATE, JOIN) to replace or update the last statement * in a collection when DSL operations refine or extend it. For example, when an UPDATE - * statement adds a WHERE clause, it replaces the initial UPDATE statement. + * statement adds a WHERE clause, it replaces the initial UPDATE statement. A SELECT that + * becomes part of an `INSERT INTO ... SELECT` is removed, as it no longer runs on its own. * * Implementations: * - [DatabaseExecuteEngine]: Executes standalone statements @@ -30,7 +31,7 @@ package com.ctrip.sqllin.dsl.sql.statement * * @author Yuang Qiao */ -internal fun interface StatementContainer { +internal interface StatementContainer { /** * Replaces the most recently added statement with a modified version. @@ -38,4 +39,9 @@ internal fun interface StatementContainer { * Used when DSL operations progressively build up a statement (e.g., adding WHERE to UPDATE). */ infix fun changeLastStatement(statement: SingleStatement) + + /** + * Removes [statement] if this container holds it, wherever it is. + */ + infix fun removeStatement(statement: SingleStatement) } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt index 007905fd..068b4826 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/TransactionStatementsGroup.kt @@ -42,6 +42,10 @@ internal class TransactionStatementsGroup( statementList.add(statement) } + override infix fun removeStatement(statement: SingleStatement) { + statementList.remove(statement) + } + /** * Checks that every statement of the transaction is complete, before any of them runs. * diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt index e27d2dd3..a256a491 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/statement/UnionSelectStatementGroup.kt @@ -35,6 +35,10 @@ internal class UnionSelectStatementGroup : StatementContainer { statementList.add(selectStatement) } + override infix fun removeStatement(statement: SingleStatement) { + statementList.remove(statement) + } + /** * Combines all accumulated SELECT statements into a single UNION query. * From 3eb7342009006de45fb8ee018c3baad538c099cd Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 13:22:37 +0100 Subject: [PATCH 28/32] Add value conversion functions to the roadmap INSERT INTO ... SELECT copies rows into a rebuilt table, but converting them on the way needs what the DSL doesn't have yet: CAST to change a value's type, coalesce and ifnull to replace a NULL, such as when making a column NOT NULL, and literal values. They join the medium priority item for more functions. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8f0fc79b..b53c26ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ * Support CREATE VIEW DSL * Support CREATE TRIGGER DSL * Support JOIN sub-query DSL -* Support more functions +* Support more functions, such as `coalesce` and `ifnull`, as well as `CAST` and literal values in expressions, e.g. to convert data while copying it to a rebuilt table with `INSERT INTO ... SELECT` * Support type converters: store a property of any type through a serializer that encodes it to a type SQLite supports, with type-safe WHERE and SET on its column, e.g. to store instances of kotlinx.datetime ## Low Priority From c6b6cd715c7934a2ba59da45eb527a117b0a64a3 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 13:30:31 +0100 Subject: [PATCH 29/32] Add UNION of other result types to the roadmap as high priority (B20) Table.UNION returns a statement typed by the table's row type, but decodes the rows with its first SELECT's deserializer. A union of projections, result columns or joins therefore compiles, and its results fail with a ClassCastException when used; checked with a union of two projections. This predates 2.4.0, as joins already had their own result type, but projections and result columns make it easier to reach. Supporting such unions changes the public UNION functions, which are inline, so it is deferred to the roadmap rather than fixed in 2.4.0. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ROADMAP.md b/ROADMAP.md index b53c26ca..2a023b43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,7 @@ ## High Priority * Support observable queries that return a `Flow` re-emitting whenever the tables they read change, e.g. to invalidate a Paging source +* Support UNION of SELECTs whose result type isn't the table's row type, such as projections, result columns and joins. `UNION` is typed by the table's row type but reads the rows into its first SELECT's result type, so using the results of such a union fails with a `ClassCastException` ## Medium Priority From 907351dd46a57f4b25b1a9dee7176c6f2e927040 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 13:33:31 +0100 Subject: [PATCH 30/32] Escape the string arguments of SQL functions (B22) replace, instr, printf and group_concat put their string arguments into the SQL between single quotes as they were. A ' in one broke the statement with a syntax error, and a crafted one could rewrite it: checked with sqlite-jdbc, instr(name, "zzz') + 1 + ('") GT 0 became WHERE instr(name,'zzz') + 1 + ('')>? which matched every book, though no name contains that string. Any of these arguments that comes from user input was an injection point. The arguments are now written as SQL string literals with each ' doubled, the only escape SQLite has in one, so whatever the string holds stays inside the literal. Binding them as parameters was considered, but a function element is SQL text without parameters, and making elements carry them through WHERE, HAVING, ORDER BY, GROUP BY and result columns, in order, is a much larger change that escaping makes unnecessary. testFunctionStringArguments covers a ' in each of the four functions, and the crafted argument above now matches no rows; the test fails with the old code. jvmTest (52) and testAndroidHostTest on API 26 and 37 (104) pass, and the macosArm64 tests compile. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 ++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 33 +++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 6 ++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 ++ .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 ++ .../ctrip/sqllin/dsl/sql/clause/Function.kt | 14 +++++--- 7 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3b9cb3..f08ad80e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ * 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 +* Fix: the string arguments of `replace`, `instr`, `printf` and `group_concat` were put into the SQL between single quotes without escaping, so a `'` in one broke the statement, and a crafted one could change what the statement does, such as making a condition true for every row. A `'` is now escaped as `''`, the only escape SQLite has in a string literal, so any string stays a literal ### sqllin-driver 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 b6746bc2..2239ea8f 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 @@ -138,6 +138,9 @@ class AndroidTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @Test + fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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 9f2770d0..6a6cb505 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 @@ -2249,6 +2249,39 @@ class CommonBasicTest(private val path: DatabasePath) { assertNotEquals(null, printfStatement?.getResults()) } + /** + * Covers the string arguments of `replace`, `instr`, `printf` and `group_concat`, which are written into the SQL + * as literals: a `'` in one is part of the string, so it neither breaks the statement nor changes what it does. + */ + fun testFunctionStringArguments() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + database { + BookTable INSERT listOf( + Book(name = "It's Kotlin", author = "Pat O'Brien", price = 10.0, pages = 100), + Book(name = "Plain Title", author = "Sam Lee", price = 20.0, pages = 200), + ) + } + lateinit var replaced: SelectStatement + lateinit var withQuote: SelectStatement + lateinit var injected: SelectStatement + lateinit var names: SelectStatement + lateinit var labels: SelectStatement + database { + BookTable { table -> + replaced = table SELECT WHERE(replace(name, "It's", "It is") EQ "It is Kotlin") + withQuote = table SELECT WHERE(instr(author, "'") GT 0) + // Before the fix, this ended the literal and made the condition true for every row + injected = table SELECT WHERE(instr(name, "zzz') + 1 + ('") GT 0) + names = table SELECT (group_concat(name, "' ") AS BookNames::names) + labels = table SELECT (printf("it's %s", name) AS BookLabel::label) ORDER_BY (name to ASC) + } + } + assertEquals(listOf("It's Kotlin"), replaced.getResults().map { it.name }) + assertEquals(listOf("Pat O'Brien"), withQuote.getResults().map { it.author }) + assertEquals(0, injected.getResults().size) + assertEquals(listOf("It's Kotlin", "Plain Title"), names.getResults().single().names!!.split("' ").sorted()) + assertEquals(listOf("it's It's Kotlin", "it's Plain Title"), labels.getResults().map { it.label }) + } + /** * Test for CREATE_INDEX and CREATE_UNIQUE_INDEX operations * Verifies index creation functionality 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 d074fdda..7964b03e 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 @@ -592,6 +592,12 @@ data class StatusStats(val status: UserStatus, val users: Long, val notes: Strin @Serializable data class EnabledCount(val enabled: Long?) +@Serializable +data class BookNames(val names: String?) + +@Serializable +data class BookLabel(val label: String) + /** * Result types that don't fit their query, each breaking one of the rules result columns are checked against. */ 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 a7b3888b..23927aa4 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 @@ -130,6 +130,9 @@ class JvmTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @Test + fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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 f480c9db..168527a7 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 @@ -146,6 +146,9 @@ class NativeTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @Test + fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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 8c3a75c8..a03e5f09 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 @@ -50,6 +50,12 @@ private fun Table<*>.numberFunction(valueName: String, element: Clause private fun Table<*>.stringFunction(valueName: String, element: ClauseElement<*>): ClauseString = ClauseString(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = element.isAggregate, isNullOnNoRows = element.isNullOnNoRows) +/** + * Writes [string] as a SQL string literal. The only character SQLite escapes in one is `'`, by doubling it, so this + * keeps any string a literal: a `'` in it can't end the literal and turn the rest into SQL. + */ +private fun sqlString(string: String): String = "'${string.replace("'", "''")}'" + /** * COUNT aggregate function - counts non-NULL values. * @@ -182,7 +188,7 @@ public fun > Table.min(element: E): E = */ @FunctionDslMaker public fun Table.group_concat(element: ClauseString<*>, infix: String): ClauseString = - ClauseString("group_concat(${element.valueName},'$infix')", this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) + ClauseString("group_concat(${element.valueName},${sqlString(infix)})", this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) /** * ABS scalar function - returns absolute value, of the same type as [element]. @@ -380,7 +386,7 @@ public fun Table.rtrim(element: ClauseString<*>): ClauseString = */ @FunctionDslMaker public fun Table.replace(element: ClauseString<*>, old: String, new: String): ClauseString = - stringFunction("replace(${element.valueName},'$old','$new')", element) + stringFunction("replace(${element.valueName},${sqlString(old)},${sqlString(new)})", element) /** * INSTR scalar function - finds the first occurrence of a substring. @@ -400,7 +406,7 @@ public fun Table.replace(element: ClauseString<*>, old: String, new: Stri */ @FunctionDslMaker public fun Table.instr(element: ClauseString<*>, sub: String): ClauseNumber = - numberFunction("instr(${element.valueName},'$sub')", element) + numberFunction("instr(${element.valueName},${sqlString(sub)})", element) /** * PRINTF scalar function - formats a string according to a format specification. @@ -420,4 +426,4 @@ public fun Table.instr(element: ClauseString<*>, sub: String): ClauseNumb */ @FunctionDslMaker public fun Table.printf(format: String, element: ClauseString<*>): ClauseString = - stringFunction("printf('$format',${element.valueName})", element) \ No newline at end of file + stringFunction("printf(${sqlString(format)},${element.valueName})", element) \ No newline at end of file From 22201470147eb5104ed525540282d1154ec6edc8 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 13:37:07 +0100 Subject: [PATCH 31/32] Don't qualify functions compared with another element (B21) A comparison between two elements, such as length(name) GT pages, wrote both as table.valueName. For a column that is right, but a function's value name is the call itself, so it produced WHERE book.length(name) --- CHANGELOG.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 ++ .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 47 +++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 ++ .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 ++ .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 8 +--- .../sqllin/dsl/sql/clause/ClauseElement.kt | 12 +++++ .../ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt | 11 ++--- .../sqllin/dsl/sql/clause/ClauseNumber.kt | 8 +--- .../sqllin/dsl/sql/clause/ClauseString.kt | 8 +--- 10 files changed, 79 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f08ad80e..1e70734c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ * 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 * Fix: the string arguments of `replace`, `instr`, `printf` and `group_concat` were put into the SQL between single quotes without escaping, so a `'` in one broke the statement, and a crafted one could change what the statement does, such as making a condition true for every row. A `'` is now escaped as `''`, the only escape SQLite has in a string literal, so any string stays a literal +* Fix: a comparison between two elements, such as `length(name) GT pages` or `HAVING (max(pages) GT min(pages))`, qualified both with their table's name even when one was a function, producing invalid SQL such as `book.length(name)`. A function is now written as it is ### sqllin-driver 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 2239ea8f..dfca3725 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 @@ -141,6 +141,9 @@ class AndroidTest { @Test fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test + fun testFunctionComparisons() = commonTest.testFunctionComparisons() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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 6a6cb505..460f73ce 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 @@ -2282,6 +2282,53 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(listOf("it's It's Kotlin", "it's Plain Title"), labels.getResults().map { it.label }) } + /** + * Covers comparing two elements when one or both are functions: a column is qualified by its table's name, and a + * function is written as it is, as `book.length(name)` isn't SQL. + */ + fun testFunctionComparisons() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + database { + BookTable INSERT listOf( + Book(name = "Short", author = "Ann", price = 10.0, pages = 3), + Book(name = "A Longer Title", author = "Ann", price = 20.0, pages = 300), + Book(name = "Equal", author = "Bob", price = 30.0, pages = 5), + ) + UserAccountTable INSERT listOf( + UserAccount(id = null, username = "ann", email = "ann@example.com", status = UserStatus.ACTIVE, priority = Priority.LOW, notes = null), + UserAccount(id = null, username = "bob", email = "bob@example.com", status = UserStatus.ACTIVE, priority = Priority.HIGH, notes = null), + UserAccount(id = null, username = "cat", email = "cat@example.com", status = UserStatus.INACTIVE, priority = Priority.MEDIUM, notes = null), + ) + } + lateinit var functionToColumn: SelectStatement + lateinit var columnToFunction: SelectStatement + lateinit var functionToFunction: SelectStatement + lateinit var strings: SelectStatement + lateinit var enums: SelectStatement + database { + BookTable { table -> + // Names longer than their page count + functionToColumn = table SELECT WHERE(length(name) GT pages) + columnToFunction = table SELECT WHERE(pages EQ length(name)) + // Authors whose books differ in length + functionToFunction = table SELECT GROUP_BY(author) HAVING (max(pages) GT min(pages)) + strings = table SELECT WHERE(upper(author) NEQ author) + } + UserAccountTable { table -> + // Statuses whose users differ in priority + enums = table SELECT listOf( + count(X) AS StatusStats::users, + group_concat(notes, ",") AS StatusStats::notes, + max(priority) AS StatusStats::highestPriority, + ) GROUP_BY status HAVING (max(priority) NEQ min(priority)) + } + } + assertEquals(listOf("Short"), functionToColumn.getResults().map { it.name }) + assertEquals(listOf("Equal"), columnToFunction.getResults().map { it.name }) + assertEquals(listOf("Ann"), functionToFunction.getResults().map { it.author }) + assertEquals(3, strings.getResults().size) + assertEquals(listOf(UserStatus.ACTIVE), enums.getResults().map { it.status }) + } + /** * Test for CREATE_INDEX and CREATE_UNIQUE_INDEX operations * Verifies index creation functionality 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 23927aa4..28b93304 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 @@ -133,6 +133,9 @@ class JvmTest { @Test fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test + fun testFunctionComparisons() = commonTest.testFunctionComparisons() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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 168527a7..5dd36280 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 @@ -149,6 +149,9 @@ class NativeTest { @Test fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + @Test + fun testFunctionComparisons() = commonTest.testFunctionComparisons() + @Test fun testIndexOperations() = commonTest.testIndexOperations() diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt index b14d0611..7b8eeaca 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt @@ -197,15 +197,11 @@ public class ClauseBlob internal constructor( private fun appendClauseBlob(symbol: String, clauseBlob: ClauseBlob): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') - append(valueName) + appendSQL(this) append(' ') append(symbol) append(' ') - append(clauseBlob.table.tableName) - append('.') - append(clauseBlob.valueName) + clauseBlob.appendSQL(this) } return SelectCondition(sql, null) } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt index 2e864416..6c913b88 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt @@ -71,4 +71,16 @@ public sealed class ClauseElement( * `min` do. */ internal abstract fun toAggregate(valueName: String, table: Table<*>): ClauseElement + + /** + * Appends this element as SQL to [builder]: a column qualified by its table's name, so that it can be told apart + * from a column of another table, and a function as it is, as a function call can't be qualified. + */ + internal fun appendSQL(builder: StringBuilder) { + if (!isFunction) { + builder.append(table.tableName) + builder.append('.') + } + builder.append(valueName) + } } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt index 82e3cf5d..06e446f0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt @@ -254,7 +254,8 @@ public class ClauseEnum> internal constructor( * Builds a comparison condition between two enum columns. * * Generates SQL: `table1.column1table2.column2` with no parameters. - * Both columns are referenced directly in the SQL without binding. + * Both columns are referenced directly in the SQL without binding; a function, + * such as `max(column)`, is written as it is. * * @param symbol The comparison operator (e.g., "<", "=", ">=") * @param clauseEnum The enum column to compare against @@ -262,13 +263,9 @@ public class ClauseEnum> internal constructor( */ private fun appendClauseEnum(symbol: String, clauseEnum: ClauseEnum): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') - append(valueName) + appendSQL(this) append(symbol) - append(clauseEnum.table.tableName) - append('.') - append(clauseEnum.valueName) + clauseEnum.appendSQL(this) } return SelectCondition(sql, null) } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt index 404b6859..34a703d9 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt @@ -220,13 +220,9 @@ public class ClauseNumber internal constructor( private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber<*>): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') - append(valueName) + appendSQL(this) append(symbol) - append(clauseNumber.table.tableName) - append('.') - append(clauseNumber.valueName) + clauseNumber.appendSQL(this) } return SelectCondition(sql, null) } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt index 6e07f41a..3390c595 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt @@ -197,15 +197,11 @@ public class ClauseString internal constructor( private fun appendClauseString(symbol: String, clauseString: ClauseString<*>): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') - append(valueName) + appendSQL(this) append(' ') append(symbol) append(' ') - append(clauseString.table.tableName) - append('.') - append(clauseString.valueName) + clauseString.appendSQL(this) } return SelectCondition(sql, null) } From 512a390815a3d8bfb8d6d9af3623df36df85a8a2 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Sat, 3 Oct 2026 14:26:20 +0100 Subject: [PATCH 32/32] Set the version to 2.4.0 and its release date Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e70734c..686a1705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ - Date format: YYYY-MM-dd -## 2.4.0 / 2026-xx-xx +## 2.4.0 / 2026-10-03 ### All diff --git a/gradle.properties b/gradle.properties index b206ad9b..6f07e8f2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -VERSION=2.3.0 +VERSION=2.4.0 GROUP_ID=com.ctrip.kotlin #Maven Publishing Information