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..686a1705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,53 @@ - Date format: YYYY-MM-dd +## 2.4.0 / 2026-10-03 + +### All + +* Update `Kotlin`'s version to `2.4.20` +* 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 + +### 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 +* 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 +* **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 +* 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 + +* Update `sqlite-jdbc`'s version to `3.53.4.0` + +### 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 +* **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/ROADMAP.md b/ROADMAP.md index 7feef9f1..2a023b43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # 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 +* 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 * Support WASM platform DSL @@ -7,15 +12,21 @@ * 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 -* 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 +* 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 +* 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 ✅) * 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/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 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..787c12ec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,15 @@ [versions] -kotlin = "2.4.10" -agp = "9.3.1" -ksp = "2.3.11" +kotlin = "2.4.20" +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" -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 77% 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..dfca3725 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() @@ -70,6 +72,24 @@ class AndroidTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + + @Test + fun testProjection() = commonTest.testProjection() + + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() @@ -85,6 +105,9 @@ class AndroidTest { @Test fun testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() @@ -115,6 +138,12 @@ class AndroidTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @Test + fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + + @Test + fun testFunctionComparisons() = commonTest.testFunctionComparisons() + @Test fun testIndexOperations() = commonTest.testIndexOperations() @@ -156,13 +185,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-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..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 @@ -20,9 +20,11 @@ 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 +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 @@ -33,6 +35,8 @@ 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 /** @@ -550,8 +554,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 +567,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) @@ -685,10 +691,619 @@ 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) + } + } + + /** + * 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")) + } + + /** + * 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, ",") + } + + /** + * 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) - 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 +1710,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 - } - } - - try { - database { - "enrollment" ALERT_RENAME_TABLE_TO EnrollmentTable + transaction { + AlterAfterTable ALTER_RENAME_TABLE_TO AlterRenamedTable } - } catch (e: Exception) { - // Expected to fail with current implementation - e.printStackTrace() - } - - lateinit var enrollmentStatement: SelectStatement - database { - 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 renamed: SelectStatement database { - BookTable { table -> - table INSERT book - } + renamed = AlterRenamedTable SELECT X } + assertEquals(1, renamed.getResults().size) + assertEquals("Charlie", renamed.getResults().first().fullName) - 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) } } @@ -1635,6 +2249,86 @@ 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 }) + } + + /** + * 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 @@ -1687,15 +2381,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 +2479,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 @@ -2858,6 +3561,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 da6cd9a6..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 @@ -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 /** @@ -71,7 +72,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 +121,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 +129,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 +145,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 +180,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 +194,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 +207,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 +219,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 +232,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 +247,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 +261,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 +277,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 +288,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 +305,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 +322,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 +356,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 +371,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 +399,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 +414,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 +427,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 +443,203 @@ 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, +) + +/** + * 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 + +/** + * 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?) + +@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. + */ +@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 + +/** + * 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/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..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 @@ -64,6 +64,24 @@ class JvmTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + + @Test + fun testProjection() = commonTest.testProjection() + + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() @@ -79,6 +97,9 @@ class JvmTest { @Test fun testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() @@ -109,6 +130,12 @@ class JvmTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @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 ef1f1367..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 @@ -80,6 +80,24 @@ class NativeTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + + @Test + fun testProjection() = commonTest.testProjection() + + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + + @Test + fun testInsertSelect() = commonTest.testInsertSelect() + + @Test + fun testTableRebuild() = commonTest.testTableRebuild() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() @@ -95,6 +113,9 @@ class NativeTest { @Test fun testSchemaModification() = commonTest.testSchemaModification() + @Test + fun testPrimaryKeyNullability() = commonTest.testPrimaryKeyNullability() + @Test fun testStringOperators() = commonTest.testStringOperators() @@ -125,6 +146,12 @@ class NativeTest { @Test fun testStringAggregateFunctions() = commonTest.testStringAggregateFunctions() + @Test + fun testFunctionStringArguments() = commonTest.testFunctionStringArguments() + + @Test + fun testFunctionComparisons() = commonTest.testFunctionComparisons() + @Test fun testIndexOperations() = commonTest.testIndexOperations() 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-dsl/doc/advanced-query-cn.md b/sqllin-dsl/doc/advanced-query-cn.md index f064525a..b23eefd4 100644 --- a/sqllin-dsl/doc/advanced-query-cn.md +++ b/sqllin-dsl/doc/advanced-query-cn.md @@ -151,6 +151,103 @@ 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(*)` 这样的表达式,请使用结果列。 + +## 结果列 + +要查询一个表达式,比如聚合函数,可以用 `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` 和子查询。 + ## 最后 你已经学习了所有的 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..00e426e6 100644 --- a/sqllin-dsl/doc/advanced-query.md +++ b/sqllin-dsl/doc/advanced-query.md @@ -157,6 +157,109 @@ 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. 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 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/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..95be08f2 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 } } ``` @@ -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 中使用结构操作 @@ -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) } } @@ -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 2f480b94..bdf15121 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 } } ``` @@ -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 @@ -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) } } @@ -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/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 07f58cd9..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 @@ -21,9 +21,10 @@ 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.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 @@ -48,39 +49,57 @@ 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 + * - **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 - * - **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 +223,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 @@ -297,6 +319,111 @@ 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)) + + // ========== 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 ========== /** @@ -370,7 +497,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) @@ -395,7 +522,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) @@ -418,7 +545,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) @@ -441,7 +568,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) @@ -464,7 +591,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) @@ -476,6 +603,230 @@ 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) + + // ========== 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>() } @@ -627,8 +978,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) * } * ``` * @@ -638,7 +989,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) } @@ -652,8 +1003,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) * } * ``` * @@ -663,7 +1014,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) } @@ -712,7 +1063,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 +1075,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_ADD_COLUMN email + * PersonTable ALTER_ADD_COLUMN email * } * ``` * @@ -732,8 +1083,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 +1094,7 @@ public class DatabaseScope internal constructor( * Example: * ```kotlin * database { - * PersonTable ALERT_RENAME_TABLE_TO NewPersonTable + * PersonTable ALTER_RENAME_TABLE_TO NewPersonTable * } * ``` * @@ -751,8 +1102,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 +1115,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 +1124,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) } @@ -796,8 +1147,8 @@ 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) + public fun > Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { + val statement = Alter.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) addStatement(statement) } @@ -819,8 +1170,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement) { - val statement = Alert.renameColumn(this, oldColumnName, newColumn, databaseConnection) + public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement<*>) { + val statement = Alter.renameColumn(this, oldColumnName, newColumn, databaseConnection) addStatement(statement) } @@ -842,8 +1193,8 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.DROP_COLUMN(column: ClauseElement) { - val statement = Alert.dropColumn(this, column, databaseConnection) + 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/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..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 @@ -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,21 +36,17 @@ 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`, `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 /** - * 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..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 @@ -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 @@ -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/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/clause/BaseJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt index 6cb79ad0..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 @@ -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 = +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..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 @@ -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) } @@ -178,15 +197,11 @@ public class ClauseBlob( 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) } @@ -204,8 +219,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 +245,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..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 @@ -37,15 +37,50 @@ 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 + + /** + * 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 aa9c3da0..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 @@ -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) @@ -234,7 +254,8 @@ public class ClauseEnum>( * 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 @@ -242,13 +263,9 @@ public class ClauseEnum>( */ 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 f6cfcb9e..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 @@ -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,21 +218,17 @@ 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('.') - append(valueName) + appendSQL(this) append(symbol) - append(clauseNumber.table.tableName) - append('.') - append(clauseNumber.valueName) + clauseNumber.appendSQL(this) } return SelectCondition(sql, null) } 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..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 @@ -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,17 +195,13 @@ 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('.') - append(valueName) + appendSQL(this) append(' ') append(symbol) append(' ') - append(clauseString.table.tableName) - append('.') - append(clauseString.valueName) + clauseString.appendSQL(this) } return SelectCondition(sql, null) } @@ -244,7 +256,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 9d895476..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 @@ -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 @@ -46,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/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..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 @@ -14,21 +14,48 @@ * 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 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) + +/** + * 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. * @@ -38,8 +65,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). @@ -50,36 +77,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. @@ -98,15 +187,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},${sqlString(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. @@ -122,11 +211,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. @@ -142,8 +231,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. @@ -162,29 +251,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. @@ -201,8 +290,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. @@ -221,8 +310,9 @@ public fun Table.length(element: ClauseBlob): ClauseNumber = * @param len The length of the substring to extract * @return ClauseString representing the extracted substring */ -public fun Table.substr(element: ClauseString, start: Int, len: Int): ClauseString = - ClauseString("substr(${element.valueName},$start,$len)", this, true) +@FunctionDslMaker +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. @@ -238,8 +328,9 @@ 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 */ -public fun Table.trim(element: ClauseString): ClauseString = - ClauseString("trim(${element.valueName})", this, true) +@FunctionDslMaker +public fun Table.trim(element: ClauseString<*>): ClauseString = + stringFunction("trim(${element.valueName})", element) /** * LTRIM scalar function - removes leading (left) whitespace from a string. @@ -255,8 +346,9 @@ public fun Table.trim(element: ClauseString): ClauseString = * @param element The string to trim * @return ClauseString with leading whitespace removed */ -public fun Table.ltrim(element: ClauseString): ClauseString = - ClauseString("ltrim(${element.valueName})", this, true) +@FunctionDslMaker +public fun Table.ltrim(element: ClauseString<*>): ClauseString = + stringFunction("ltrim(${element.valueName})", element) /** * RTRIM scalar function - removes trailing (right) whitespace from a string. @@ -272,8 +364,9 @@ public fun Table.ltrim(element: ClauseString): ClauseString = * @param element The string to trim * @return ClauseString with trailing whitespace removed */ -public fun Table.rtrim(element: ClauseString): ClauseString = - ClauseString("rtrim(${element.valueName})", this, true) +@FunctionDslMaker +public fun Table.rtrim(element: ClauseString<*>): ClauseString = + stringFunction("rtrim(${element.valueName})", element) /** * REPLACE scalar function - replaces all occurrences of a substring with another string. @@ -291,8 +384,9 @@ public fun Table.rtrim(element: ClauseString): ClauseString = * @param new The replacement string * @return ClauseString with replacements applied */ -public fun Table.replace(element: ClauseString, old: String, new: String): ClauseString = - ClauseString("replace(${element.valueName},'$old','$new')", this, true) +@FunctionDslMaker +public fun Table.replace(element: ClauseString<*>, old: String, new: String): ClauseString = + stringFunction("replace(${element.valueName},${sqlString(old)},${sqlString(new)})", element) /** * INSTR scalar function - finds the first occurrence of a substring. @@ -310,8 +404,9 @@ 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 */ -public fun Table.instr(element: ClauseString, sub: String): ClauseNumber = - ClauseNumber("instr(${element.valueName},'$sub')", this, true) +@FunctionDslMaker +public fun Table.instr(element: ClauseString<*>, sub: String): ClauseNumber = + numberFunction("instr(${element.valueName},${sqlString(sub)})", element) /** * PRINTF scalar function - formats a string according to a format specification. @@ -329,5 +424,6 @@ public fun Table.instr(element: ClauseString, sub: String): ClauseNumber * @param element The value to format * @return ClauseString with the formatted result */ -public fun Table.printf(format: String, element: ClauseString): ClauseString = - ClauseString("printf('$format',${element.valueName})", this, true) \ No newline at end of file +@FunctionDslMaker +public fun Table.printf(format: String, element: ClauseString<*>): ClauseString = + stringFunction("printf(${sqlString(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 cf4cb1e8..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 @@ -14,11 +14,14 @@ * 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 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 /** @@ -35,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 { @@ -54,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/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..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 @@ -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 @@ -70,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 08b63330..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 @@ -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 @@ -35,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() { @@ -67,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, OrderByWay>): OrderBySelectStatement = + appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { + container changeLastStatement it + } + +@StatementDslMaker +public inline infix fun HavingSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun WhereSelectStatement.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 HavingSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun GroupBySelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun HavingSelectStatement.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 GroupBySelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun JoinSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun GroupBySelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun JoinSelectStatement.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 infix fun ResultColumnSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun JoinSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +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 { +internal class SimpleOrderByClause(private val columns: Iterable>) : OrderByClause { override val clauseStr: String get() { @@ -128,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/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..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 @@ -14,10 +14,13 @@ * 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 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 @@ -57,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/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 86% 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..f0fef5b1 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 " @@ -81,7 +78,7 @@ internal object Alert : 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) @@ -126,7 +123,7 @@ internal object Alert : 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) @@ -148,7 +145,7 @@ internal object Alert : 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/Insert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt index dec56682..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 /** @@ -70,4 +72,46 @@ 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) + } + + /** + * 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/operation/Select.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt index feecfabf..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 @@ -22,12 +22,15 @@ 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.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 @@ -42,60 +45,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, null) + } /** * 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, 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, 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, null) + } /** * Builds a SELECT statement with NATURAL JOIN clause. @@ -112,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). @@ -138,6 +149,165 @@ 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 + 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 + 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." + } + require(name !in expressions) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is given more than one expression." + } + 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( table: Table<*>, clause: SelectClause, @@ -160,13 +330,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) @@ -175,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..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,7 +46,20 @@ 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. + 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/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-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/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 1739a5c0..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,19 @@ 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. + * + * @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..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. * @@ -51,6 +55,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 +74,7 @@ internal class UnionSelectStatementGroup : StatementContainer { connection = connection, container = container, parameters, + ungroupedError = null, ) } } 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) } 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..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 @@ -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 @@ -165,17 +201,12 @@ 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}") - 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) @@ -196,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 @@ -221,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, @@ -241,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" @@ -310,27 +344,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)