From d1c2d422e9feab5a2753a5f5a6046c142cc3ae5c Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 18:25:04 +0100 Subject: [PATCH 01/10] Add INSERT_OR_IGNORE (N2) INSERT_OR_REPLACE resolves a PRIMARY KEY or UNIQUE conflict by deleting the existing row and inserting the new one, which replaces the existing row's other columns. When the existing row should win instead, as when a paged list fetches an item it already holds and must keep that item's position, there was no way to say so. INSERT_OR_IGNORE writes INSERT OR IGNORE INTO: each entity that conflicts with an existing row is skipped and that row is left exactly as it is, while the other entities are inserted. Like INSERT_OR_REPLACE it always writes the primary key column, as a conflict on a key left out of the statement could never be seen. A null key that the database assigns still can't conflict. As SQLite documents, and as checked here, OR IGNORE also skips a row that would violate NOT NULL, which can't happen for a non-null property, while a FOREIGN KEY violation still fails the statement. The KDoc says so. The test covers a conflict on the primary key, on another UNIQUE column and on a composite key, and it fails if the primary key isn't written. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ROADMAP.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 67 +++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 49 ++++++++++++++ .../ctrip/sqllin/dsl/sql/operation/Insert.kt | 12 ++++ 8 files changed, 139 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41888bc5..f88ea938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### sqllin-dsl +* New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * **Breaking change**: The nullability of a `@PrimaryKey` property now decides who supplies its value. A `Long?` key is assigned by the database, as before. A non-null `Long` key is now allowed: it remains an `INTEGER PRIMARY KEY`, a rowid alias, but is supplied by the caller and written by every `INSERT`. A key of any other type must be non-null and is declared `NOT NULL`. Previously every `@PrimaryKey` was forced to be nullable, against the annotation's own documentation, and because SQLite does not let `PRIMARY KEY` imply `NOT NULL` on such a column, a `String` key could hold `NULL` in any number of rows. `autoIncrement = true` now requires a `Long?` key, and a `ULong?` key, which used to be stored as `NULL` because it was left out of `INSERT` without being a rowid alias, is now rejected. To migrate, drop the `?` from any non-`Long` `@PrimaryKey`; a single-column `@CompositePrimaryKey` that only existed to hold a caller-supplied `Long` key can become `@PrimaryKey val id: Long` * **Breaking change**: `@CompositePrimaryKey` now requires at least two properties. A single-column primary key is declared with `@PrimaryKey`, which for a `Long` key maps to `INTEGER`, a rowid alias, where a single-column `@CompositePrimaryKey` mapped it to `BIGINT`. To migrate, replace a lone `@CompositePrimaryKey` with `@PrimaryKey` diff --git a/ROADMAP.md b/ROADMAP.md index 7feef9f1..a19e8c14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,7 @@ ## Supported +* Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) * Support FOREIGN KEY DSL (2.2.0 ✅) * Support CREATE INDEX DSL (2.2.0 ✅) \ No newline at end of file diff --git a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index 08252927..b19b2fa6 100644 --- a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -72,6 +72,9 @@ class AndroidTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index fe35213f..92f31f43 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -688,6 +688,73 @@ class CommonBasicTest(private val path: DatabasePath) { } } + @OptIn(AdvancedInsertAPI::class) + fun testInsertOrIgnore() { + Database(getNewAPIDBConfig()).databaseAutoClose { database -> + // A conflict on the primary key leaves the existing row exactly as it is, while the entities that + // don't conflict are inserted. Seeing the conflict at all depends on the key being written. + database { + PersonWithIdTable INSERT_WITH_ID PersonWithId(id = 100L, name = "Eve", age = 28) + } + database { + PersonWithIdTable INSERT_OR_IGNORE listOf( + PersonWithId(id = 100L, name = "Eve Updated", age = 29), + PersonWithId(id = 101L, name = "Grace", age = 30), + ) + } + lateinit var people: SelectStatement + database { + people = PersonWithIdTable SELECT X + } + assertEquals(2, people.getResults().size) + val eve = people.getResults().first { it.id == 100L } + assertEquals("Eve", eve.name) + assertEquals(28, eve.age) + assertEquals("Grace", people.getResults().first { it.id == 101L }.name) + + // A null ID is still assigned by the database, so it can't conflict on the primary key + database { + PersonWithIdTable INSERT_OR_IGNORE PersonWithId(id = null, name = "Frank", age = 35) + } + database { + people = PersonWithIdTable SELECT X + } + assertEquals(3, people.getResults().size) + assertNotEquals(null, people.getResults().first { it.name == "Frank" }.id) + + // A conflict on a UNIQUE column other than the key is ignored as well + database { + UniqueEmailTestTable INSERT UniqueEmailTest(id = null, email = "ivy@example.com", name = "Ivy") + UniqueEmailTestTable INSERT_OR_IGNORE UniqueEmailTest(id = null, email = "ivy@example.com", name = "Ivy Again") + } + lateinit var accounts: SelectStatement + database { + accounts = UniqueEmailTestTable SELECT X + } + assertEquals(1, accounts.getResults().size) + assertEquals("Ivy", accounts.getResults().first().name) + + // With a composite key, inserting a pair again keeps its existing row, other columns included, + // which is what tells INSERT_OR_IGNORE apart from INSERT_OR_REPLACE + database { + EnrollmentTable INSERT Enrollment(studentId = 1, courseId = 101, semester = "Spring") + } + database { + EnrollmentTable INSERT_OR_IGNORE listOf( + Enrollment(studentId = 1, courseId = 101, semester = "Fall"), + Enrollment(studentId = 1, courseId = 102, semester = "Fall"), + ) + } + lateinit var enrollments: SelectStatement + database { + enrollments = EnrollmentTable SELECT X + } + assertEquals(2, enrollments.getResults().size) + assertEquals("Spring", enrollments.getResults().first { it.courseId == 101L }.semester) + assertEquals("Fall", enrollments.getResults().first { it.courseId == 102L }.semester) + } + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 08a65ba2..00fca0f2 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -64,6 +64,9 @@ class JvmTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt index 12695beb..913fb9ad 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -80,6 +80,9 @@ class NativeTest { @Test fun testInsertOrReplace() = commonTest.testInsertOrReplace() + @Test + fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 7b995311..bd99f0ea 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -48,6 +48,7 @@ import kotlin.jvm.JvmName * Supported operations: * - **INSERT**: Add entities to tables * - **INSERT OR REPLACE**: Insert or replace entities on PRIMARY KEY / UNIQUE conflict + * - **INSERT OR IGNORE**: Insert entities, skipping those that conflict on PRIMARY KEY / UNIQUE * - **UPDATE**: Modify existing records with SET and WHERE clauses * - **DELETE**: Remove records with WHERE clauses * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION @@ -316,6 +317,54 @@ public class DatabaseScope internal constructor( public infix fun Table.INSERT_OR_REPLACE(entity: T): Unit = INSERT_OR_REPLACE(listOf(entity)) + /** + * Inserts multiple entities into the table, skipping each one that conflicts with an existing + * row on a PRIMARY KEY or UNIQUE constraint (`INSERT OR IGNORE INTO ...`). + * + * Unlike [INSERT_OR_REPLACE], the existing row is left exactly as it is: it isn't deleted and + * re-inserted, so its other columns keep their values. The entities that don't conflict are + * inserted as by a plain [INSERT]. + * + * The primary key column is always included in the VALUES clause so that SQLite can detect + * conflicts on it. If the primary key field is `null` for a key the database assigns, SQLite + * generates the ID and no conflict can occur on the primary key. + * + * SQLite also skips a row that would violate a NOT NULL constraint, which can't happen for a + * non-null property. A FOREIGN KEY violation is not ignored and still fails the statement. + * + * Example: + * ```kotlin + * // Leaves the existing row with ID 42 untouched, and inserts the row with ID 43 + * PersonWithIdTable INSERT_OR_IGNORE listOf( + * PersonWithId(id = 42L, name = "Alice", age = 26), + * PersonWithId(id = 43L, name = "Bob", age = 31), + * ) + * ``` + * + * @see INSERT_OR_REPLACE to replace the conflicting row instead + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_IGNORE(entities: Iterable) { + val statement = Insert.insertOrIgnore(this, databaseConnection, entities) + addStatement(statement) + } + + /** + * Inserts a single entity into the table, unless it conflicts with an existing row on a + * PRIMARY KEY or UNIQUE constraint, in which case the existing row is left as it is. + * + * Example: + * ```kotlin + * PersonWithIdTable INSERT_OR_IGNORE PersonWithId(id = 42L, name = "Alice", age = 26) + * ``` + * + * @see INSERT_OR_IGNORE for batch inserts that skip conflicting entities + * @see INSERT_OR_REPLACE to replace the conflicting row instead + */ + @StatementDslMaker + public infix fun Table.INSERT_OR_IGNORE(entity: T): Unit = + INSERT_OR_IGNORE(listOf(entity)) + // ========== UPDATE Operations ========== /** diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt index dec56682..1cf0cd49 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Insert.kt @@ -70,4 +70,16 @@ internal object Insert : Operation { } return InsertStatement(sql, connection, parameters) } + + fun insertOrIgnore(table: Table, connection: DatabaseConnection, entities: Iterable): SingleStatement { + val parameters = ArrayList() + val sql = buildString { + append("INSERT OR IGNORE INTO ") + append(table.tableName) + append(' ') + // Write the primary key even when the database would assign it, or a conflict on it could never be seen + encodeEntities2InsertValues(table, this, entities, parameters, isInsertWithId = true) + } + return InsertStatement(sql, connection, parameters) + } } \ No newline at end of file From bda5d270144aa681eb00106d448f8ef1e319ba80 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 18:25:04 +0100 Subject: [PATCH 02/10] Add projection to SELECT (N4) A SELECT always read rows into the table's own row type, so reading a few columns meant fetching and decoding all of them. The column list was already built from the deserializer's descriptor, and JOIN already took a result type of its own, but a single-table SELECT tied its result type to the table's. The result type is now given to the clause function, as with JOIN: PersonTable SELECT X() PersonTable SELECT WHERE(cond) ORDER_BY PersonTable.age LIMIT 10 for X, WHERE, ORDER BY, LIMIT and GROUP BY, after both SELECT and SELECT_DISTINCT, and the chained clauses keep it. Only the columns named by the type's properties are selected. Without a type argument a SELECT reads the table's row type exactly as before, and every existing call resolves as it did. A projection type has to fit the table: each property must be a column, of that column's type, and nullable if the column is, as a NULL read into a non-null property would quietly become 0 or "". A mismatch throws an IllegalArgumentException while the statement is built. Two other shapes were ruled out by compiling them. An overload of SELECT(X) that was generic only in its return type made every existing `SELECT X` ambiguous, so the no-clause form is a function, X(), declared next to the object X. And the projection can't be inferred from the type the statement is assigned to, as the existing overload is the more specific one, so the type argument is required. The public DatabaseScope.select functions now take a result type separate from the table's. That is source-compatible and keeps their JVM signatures, but on Kotlin/Native a library compiled against an earlier version may need to be recompiled. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ROADMAP.md | 1 + .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 3 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 95 +++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 28 +++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 3 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 3 + sqllin-dsl/doc/advanced-query-cn.md | 35 ++++ sqllin-dsl/doc/advanced-query.md | 38 +++++ .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 161 +++++++++++++++++- .../ctrip/sqllin/dsl/annotation/DslMakers.kt | 4 +- .../kotlin/com/ctrip/sqllin/dsl/sql/X.kt | 23 ++- .../ctrip/sqllin/dsl/sql/operation/Select.kt | 104 ++++++++--- 13 files changed, 462 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f88ea938..5ac86be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### sqllin-dsl * New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` +* New DSL API: projection, which reads `SELECT` results into a narrower `@Serializable` type naming the columns to select, given as the type argument of the clause function: `X()`, `WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after `SELECT` or `SELECT_DISTINCT`. A type that doesn't fit the table is rejected with an `IllegalArgumentException` when the statement is built. To support it, the public `DatabaseScope#select` functions now take a result type separate from the table's; this is source-compatible, but on Kotlin/Native a library compiled against an earlier version may have to be recompiled * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * **Breaking change**: The nullability of a `@PrimaryKey` property now decides who supplies its value. A `Long?` key is assigned by the database, as before. A non-null `Long` key is now allowed: it remains an `INTEGER PRIMARY KEY`, a rowid alias, but is supplied by the caller and written by every `INSERT`. A key of any other type must be non-null and is declared `NOT NULL`. Previously every `@PrimaryKey` was forced to be nullable, against the annotation's own documentation, and because SQLite does not let `PRIMARY KEY` imply `NOT NULL` on such a column, a `String` key could hold `NULL` in any number of rows. `autoIncrement = true` now requires a `Long?` key, and a `ULong?` key, which used to be stored as `NULL` because it was left out of `INSERT` without being a rowid alias, is now rejected. To migrate, drop the `?` from any non-`Long` `@PrimaryKey`; a single-column `@CompositePrimaryKey` that only existed to hold a caller-supplied `Long` key can become `@PrimaryKey val id: Long` * **Breaking change**: `@CompositePrimaryKey` now requires at least two properties. A single-column primary key is declared with `@PrimaryKey`, which for a `Long` key maps to `INTEGER`, a rowid alias, where a single-column `@CompositePrimaryKey` mapped it to `BIGINT`. To migrate, replace a lone `@CompositePrimaryKey` with `@PrimaryKey` diff --git a/ROADMAP.md b/ROADMAP.md index a19e8c14..697140e9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,6 +16,7 @@ ## Supported +* Support SELECT projection (2.4.0 ✅) * Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) * Support FOREIGN KEY DSL (2.2.0 ✅) diff --git a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index b19b2fa6..ecba9f92 100644 --- a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -75,6 +75,9 @@ class AndroidTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index 92f31f43..0a48dd21 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.test.runTest import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals /** @@ -755,6 +756,100 @@ class CommonBasicTest(private val path: DatabasePath) { } } + /** + * Covers projection: a SELECT that reads rows into a narrower @Serializable type than the table's own row type, + * given to the clause function, as in `X()` or `WHERE(...)`. Only the columns that type's + * properties name are selected, and a type that doesn't fit the table is rejected while the statement is built. + */ + fun testProjection() = Database(getNewAPIDBConfig()).databaseAutoClose { database -> + database { + BookTable INSERT listOf( + Book(name = "The Da Vinci Code", author = "Dan Brown", price = 16.96, pages = 454), + Book(name = "The Lost Symbol", author = "Dan Brown", price = 19.95, pages = 510), + Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251), + ) + } + + // No clause: SELECT name,author FROM book, and SELECT DISTINCT author FROM book. Three books but two authors, + // which only holds if DISTINCT compares the projected column alone, so nothing else is selected. + lateinit var titles: SelectStatement + lateinit var authors: SelectStatement + database { + titles = BookTable SELECT X() + authors = BookTable SELECT_DISTINCT X() + } + assertEquals(3, titles.getResults().size) + assertEquals(true, BookTitle("Kotlin Cookbook", "Ken Kousen") in titles.getResults()) + assertEquals(listOf("Dan Brown", "Ken Kousen"), authors.getResults().map { it.author }.sorted()) + + // Each clause can start a projection, and the projection carries through the rest of the chain + lateinit var longestByBrown: SelectStatement + lateinit var byPages: SelectStatement + lateinit var firstTwo: SelectStatement + lateinit var grouped: SelectStatement + database { + BookTable { table -> + longestByBrown = table SELECT WHERE(author EQ "Dan Brown") ORDER_BY (pages to DESC) LIMIT 1 + byPages = table SELECT ORDER_BY(pages to ASC) + firstTwo = table SELECT LIMIT(2) + grouped = table SELECT GROUP_BY(author) + } + } + assertEquals(listOf(BookTitle("The Lost Symbol", "Dan Brown")), longestByBrown.getResults()) + assertEquals(listOf("Kotlin Cookbook", "The Da Vinci Code", "The Lost Symbol"), byPages.getResults().map { it.name }) + assertEquals(2, firstTwo.getResults().size) + assertEquals(listOf("Dan Brown", "Ken Kousen"), grouped.getResults().map { it.author }.sorted()) + + // The DISTINCT variant of each clause + lateinit var distinctWhere: SelectStatement + lateinit var distinctOrderBy: SelectStatement + lateinit var distinctLimit: SelectStatement + lateinit var distinctGroupBy: SelectStatement + database { + BookTable { table -> + distinctWhere = table SELECT_DISTINCT WHERE(price GT 10.0) + distinctOrderBy = table SELECT_DISTINCT ORDER_BY(author to DESC) + distinctLimit = table SELECT_DISTINCT LIMIT(1) + distinctGroupBy = table SELECT_DISTINCT GROUP_BY(author) + } + } + assertEquals(2, distinctWhere.getResults().size) + assertEquals(listOf("Ken Kousen", "Dan Brown"), distinctOrderBy.getResults().map { it.author }) + assertEquals(1, distinctLimit.getResults().size) + assertEquals(2, distinctGroupBy.getResults().size) + + // A nullable column is read into a nullable property + database { + UserAccountTable INSERT UserAccount( + id = null, + username = "ivy", + email = "ivy@example.com", + status = UserStatus.ACTIVE, + priority = Priority.LOW, + notes = null, + ) + } + lateinit var notes: SelectStatement + database { + notes = UserAccountTable SELECT X() + } + assertEquals(listOf(UserNotes("ivy", null)), notes.getResults()) + + // A type that doesn't fit the table is rejected while the statement is built, before anything runs + val notAColumn = assertFailsWith { + database { BookTable SELECT X() } + } + assertEquals(true, notAColumn.message!!.contains("'isbn' isn't a column")) + val wrongType = assertFailsWith { + database { BookTable SELECT WHERE(BookTable.pages GT 0) } + } + assertEquals(true, wrongType.message!!.contains("'pages' is a kotlin.String, but the column holds a kotlin.Int")) + val notNullable = assertFailsWith { + database { UserAccountTable SELECT X() } + } + assertEquals(true, notNullable.message!!.contains("column 'notes' is nullable")) + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt index de7610c3..6e787cb9 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt @@ -522,3 +522,31 @@ data class RemoteMovie( @PrimaryKey val id: Long, val title: String, ) + +/** + * Projections of [Book]: plain @Serializable types rather than tables, whose properties name the columns a SELECT + * reads, as in `BookTable SELECT X()`. + */ +@Serializable +data class BookTitle(val name: String, val author: String) + +@Serializable +data class BookAuthor(val author: String) + +/** + * A projection of [UserAccount] that reads its nullable `notes` column into a nullable property. + */ +@Serializable +data class UserNotes(val username: String, val notes: String?) + +/** + * Projections that don't fit their table, each breaking one of the rules a projection is checked against. + */ +@Serializable +data class BookWithIsbn(val name: String, val isbn: String) // 'isbn' isn't a column of book + +@Serializable +data class BookPagesAsText(val pages: String) // 'pages' holds an Int + +@Serializable +data class UserNotesNonNull(val notes: String) // 'notes' is nullable diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 00fca0f2..61dfc8f9 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -67,6 +67,9 @@ class JvmTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt index 913fb9ad..cbd218d8 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -83,6 +83,9 @@ class NativeTest { @Test fun testInsertOrIgnore() = commonTest.testInsertOrIgnore() + @Test + fun testProjection() = commonTest.testProjection() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/doc/advanced-query-cn.md b/sqllin-dsl/doc/advanced-query-cn.md index f064525a..b9e23974 100644 --- a/sqllin-dsl/doc/advanced-query-cn.md +++ b/sqllin-dsl/doc/advanced-query-cn.md @@ -151,6 +151,41 @@ fun joinSample() { `LEFT_OUTER_JOIN` 的用法与 `INNER_JOIN` 非常相似,不同之处仅仅是它们的 API 名字。 +## 投影 + +`SELECT` 默认把每一行读成表自己的行类型。如果只想读取其中一部分列,可以声明一个更窄的 `@Serializable` 类型,用它的属性名 +指明要读取的列,再把它作为类型参数交给子句函数: + +```kotlin +@Serializable +data class PersonName( + val name: String, +) + +fun sample() { + lateinit var names: SelectStatement + lateinit var adultNames: SelectStatement + database { + PersonTable { table -> + // SELECT name FROM person + names = table SELECT X() + // SELECT name FROM person WHERE age >= ? ORDER BY name LIMIT 10 + adultNames = table SELECT WHERE(age GTE 18) ORDER_BY name LIMIT 10 + } + } +} +``` + +和 Join 的结果类型一样,投影类型不需要 `@DBRow` 注解。它可以作为 `X()`、`WHERE(...)`、`ORDER_BY(...)`、 +`LIMIT(...)` 和 `GROUP_BY(...)` 的类型参数,用在 `SELECT` 和 `SELECT_DISTINCT` 之后,后面链式调用的子句也会沿用它。 +使用 `SELECT_DISTINCT` 时只比较投影出来的列,所以 `table SELECT_DISTINCT X()` 中每个名字只会出现一次。 + +类型参数必须显式写出。如果不写,`SELECT` 会读成表自己的行类型,即使它的结果被赋值给一个投影类型的语句也是如此。 + +投影类型的每个属性都必须是这张表的列,类型与列一致,并且当列可空时属性也必须可空,因为把 `NULL` 读进非空属性时,它会被 +悄无声息地读成 `0` 或空字符串。不满足这些规则的投影类型会让 `SELECT` 在构建语句时、执行之前就抛出 `IllegalArgumentException`。 +`COUNT(*)` 这样的表达式目前还不能投影。 + ## 最后 你已经学习了所有的 SQLlin 用法,享受你的 SQLlin 的编程旅程并对它的更新保持关注吧 :) \ No newline at end of file diff --git a/sqllin-dsl/doc/advanced-query.md b/sqllin-dsl/doc/advanced-query.md index ee4686cf..91f250a7 100644 --- a/sqllin-dsl/doc/advanced-query.md +++ b/sqllin-dsl/doc/advanced-query.md @@ -157,6 +157,44 @@ fun joinSample() { The `LEFT_OUTER_JOIN`'s usage is very similar with `INNER_JOIN`, the difference just is their API names. +## Projection + +A `SELECT` reads each row into the table's own row type. To read only some of the columns, declare a narrower +`@Serializable` type whose properties name the columns you want, and give it to the clause function as a type argument: + +```kotlin +@Serializable +data class PersonName( + val name: String, +) + +fun sample() { + lateinit var names: SelectStatement + lateinit var adultNames: SelectStatement + database { + PersonTable { table -> + // SELECT name FROM person + names = table SELECT X() + // SELECT name FROM person WHERE age >= ? ORDER BY name LIMIT 10 + adultNames = table SELECT WHERE(age GTE 18) ORDER_BY name LIMIT 10 + } + } +} +``` + +Like a join's result type, a projection type doesn't need `@DBRow`. It works as the type argument of `X()`, +`WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after both `SELECT` and +`SELECT_DISTINCT`, and the clauses chained after it keep it. With `SELECT_DISTINCT`, only the projected columns are +compared, so `table SELECT_DISTINCT X()` gives each name once. + +The type argument is required. Without it, a `SELECT` reads the table's own row type, even when its result is assigned +to a statement of the projection type. + +Each property of a projection type has to be a column of the table, of the same type, and nullable if the column is +nullable, as a `NULL` read into a non-null property would quietly become `0` or an empty string. A projection type that +breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is built, before it +runs. Expressions such as `COUNT(*)` can't be projected yet. + ## Finally You have learned all usages with SQLlin, enjoy it and stay Stay tuned for SQLlin's updates :) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index bd99f0ea..5da6a1d6 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -21,6 +21,7 @@ import com.ctrip.sqllin.dsl.annotation.AdvancedInsertAPI import com.ctrip.sqllin.dsl.annotation.ExperimentalDSLDatabaseAPI import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.Table +import com.ctrip.sqllin.dsl.sql.ProjectedX import com.ctrip.sqllin.dsl.sql.X import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.operation.Alter @@ -51,7 +52,8 @@ import kotlin.jvm.JvmName * - **INSERT OR IGNORE**: Insert entities, skipping those that conflict on PRIMARY KEY / UNIQUE * - **UPDATE**: Modify existing records with SET and WHERE clauses * - **DELETE**: Remove records with WHERE clauses - * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION + * - **SELECT**: Query records with WHERE, ORDER BY, LIMIT, GROUP BY, JOIN, and UNION, into the table's own row type + * or a narrower projection type, such as `PersonTable SELECT WHERE(...)` * - **CREATE**: Create tables from data class definitions * - **DROP**: Remove tables from the database * - **ALTER**: Modify table structures (add columns, rename tables/columns, drop columns) @@ -438,7 +440,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(x: X): FinalSelectStatement = select(kSerializer(), true) - public fun Table.select(serializer: KSerializer, isDistinct: Boolean): FinalSelectStatement { + public fun Table.select(serializer: KSerializer, isDistinct: Boolean): FinalSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -463,7 +465,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: WhereClause): WhereSelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: WhereClause, isDistinct: Boolean): WhereSelectStatement { + public fun Table.select(serializer: KSerializer, clause: WhereClause, isDistinct: Boolean): WhereSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -486,7 +488,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: OrderByClause): OrderBySelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: OrderByClause, isDistinct: Boolean): OrderBySelectStatement { + public fun Table.select(serializer: KSerializer, clause: OrderByClause, isDistinct: Boolean): OrderBySelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -509,7 +511,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: LimitClause): LimitSelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: LimitClause, isDistinct: Boolean): LimitSelectStatement { + public fun Table.select(serializer: KSerializer, clause: LimitClause, isDistinct: Boolean): LimitSelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -532,7 +534,7 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = select(kSerializer(), clause, true) - public fun Table.select(serializer: KSerializer, clause: GroupByClause, isDistinct: Boolean): GroupBySelectStatement { + public fun Table.select(serializer: KSerializer, clause: GroupByClause, isDistinct: Boolean): GroupBySelectStatement { val container = getSelectStatementGroup() val statement = Select.select(this, clause, isDistinct, serializer, databaseConnection, container) addSelectStatement(statement) @@ -544,6 +546,153 @@ public class DatabaseScope internal constructor( */ public inline fun getKSerializer(): KSerializer = EmptySerializersModule().serializer() + // ========== SELECT with Projection ========== + // + // These read rows into a type R other than the table's own row type, given to the clause function, as in + // `PersonTable SELECT WHERE(...)`. R's properties name the columns to select. Each overload taking a + // clause has the same JVM signature as its counterpart above, hence its @JvmName. + + /** + * Selects all records, reading each into [R], so that only the columns [R]'s properties name are selected. + * + * Example: + * ```kotlin + * @Serializable + * data class NameAndAge(val name: String, val age: Int) + * + * val people = PersonTable SELECT X() + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @StatementDslMaker + public inline infix fun Table.SELECT(x: ProjectedX): FinalSelectStatement = + select(getKSerializer(), false) + + /** + * Selects distinct records, reading each into [R], so that only the columns [R] names are selected and compared. + * + * Example: + * ```kotlin + * val authors = BookTable SELECT_DISTINCT X() + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(x: ProjectedX): FinalSelectStatement = + select(getKSerializer(), true) + + /** + * Selects records matching the WHERE clause, reading each into [R]. + * + * Example: + * ```kotlin + * val adults = PersonTable SELECT WHERE(PersonTable.age GTE 18) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectWhereProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: WhereClause): WhereSelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records matching the WHERE clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctWhereProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: WhereClause): WhereSelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects records in the order of the ORDER BY clause, reading each into [R]. + * + * Example: + * ```kotlin + * val byAge = PersonTable SELECT ORDER_BY(PersonTable.age to ASC) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectOrderByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: OrderByClause): OrderBySelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records in the order of the ORDER BY clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctOrderByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: OrderByClause): OrderBySelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects at most as many records as the LIMIT clause allows, reading each into [R]. + * + * Example: + * ```kotlin + * val firstTen = PersonTable SELECT LIMIT(10) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectLimitProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: LimitClause): LimitSelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects at most as many distinct records as the LIMIT clause allows, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctLimitProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: LimitClause): LimitSelectStatement = + select(getKSerializer(), clause, true) + + /** + * Selects records grouped by the GROUP BY clause, reading each into [R]. + * + * Example: + * ```kotlin + * val authors = BookTable SELECT GROUP_BY(BookTable.author) + * ``` + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectGroupByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT(clause: GroupByClause): GroupBySelectStatement = + select(getKSerializer(), clause, false) + + /** + * Selects distinct records grouped by the GROUP BY clause, reading each into [R]. + * + * @throws IllegalArgumentException if [R] doesn't fit this table: one of its properties isn't a column, + * is of a different type from its column, or isn't nullable while its column is + */ + @JvmName("selectDistinctGroupByProjection") + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = + select(getKSerializer(), clause, true) + // ========== UNION Operations ========== private val unionSelectStatementGroupStack by lazy { ArrayDeque>() } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt index d2377b9d..48d5f397 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/annotation/DslMakers.kt @@ -36,12 +36,12 @@ package com.ctrip.sqllin.dsl.annotation internal annotation class StatementDslMaker /** - * DSL marker that highlights SQL keywords, such as `X` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. + * DSL marker that highlights SQL keywords, such as `X`, `X()` and the `ASC` and `DESC` ordering, in IntelliJ IDEA. * * @author Yuang Qiao */ @DslMarker -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.BINARY) internal annotation class KeyWordDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt index 6d2de9d0..b05cc50d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/X.kt @@ -37,4 +37,25 @@ import com.ctrip.sqllin.dsl.annotation.KeyWordDslMaker * @author Yuang Qiao */ @KeyWordDslMaker -public object X \ No newline at end of file +public object X + +/** + * Selects every row like [X], but reads each into [R] rather than into the table's own row type, so that only the + * columns [R]'s properties name are selected. + * + * Example: + * ```kotlin + * // SELECT name,age FROM PersonTable + * val people = PersonTable SELECT X() + * ``` + * + * @see ProjectedX + */ +@Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") +@KeyWordDslMaker +public fun X(): ProjectedX = ProjectedX() + +/** + * The selector of every row, [X], carrying the type [R] that a SELECT reads rows into. Created by `X()`. + */ +public class ProjectedX internal constructor() diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt index feecfabf..523416a0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/operation/Select.kt @@ -22,6 +22,8 @@ import com.ctrip.sqllin.dsl.sql.clause.* import com.ctrip.sqllin.dsl.sql.compiler.appendDBColumnName import com.ctrip.sqllin.dsl.sql.statement.* import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.encoding.CompositeDecoder /** * SELECT operation builder. @@ -42,60 +44,68 @@ internal object Select : Operation { * * @return Statement that can be followed by GROUP BY, ORDER BY, or LIMIT */ - fun select( - table: Table, - clause: WhereClause, + fun select( + table: Table<*>, + clause: WhereClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): WhereSelectStatement = - WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters) + ): WhereSelectStatement { + checkProjection(table, deserializer) + return WhereSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, clause.selectCondition.parameters) + } /** * Builds a SELECT statement with ORDER BY clause. * * @return Statement that can be followed by LIMIT */ - fun select( - table: Table, - clause: OrderByClause, + fun select( + table: Table<*>, + clause: OrderByClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): OrderBySelectStatement = - OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): OrderBySelectStatement { + checkProjection(table, deserializer) + return OrderBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with LIMIT clause. * * @return Statement that can be followed by OFFSET */ - fun select( - table: Table, - clause: LimitClause, + fun select( + table: Table<*>, + clause: LimitClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): LimitSelectStatement = - LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): LimitSelectStatement { + checkProjection(table, deserializer) + return LimitSelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with GROUP BY clause. * * @return Statement that can be followed by HAVING or ORDER BY */ - fun select( - table: Table, - clause: GroupByClause, + fun select( + table: Table<*>, + clause: GroupByClause, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): GroupBySelectStatement = - GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + ): GroupBySelectStatement { + checkProjection(table, deserializer) + return GroupBySelectStatement(buildSQL(table, clause, isDistinct, deserializer), deserializer, connection, container, null) + } /** * Builds a SELECT statement with NATURAL JOIN clause. @@ -138,6 +148,43 @@ internal object Select : Operation { addSelectStatement, ) + /** + * Checks that [deserializer] can read rows of [table], when it reads them into a type other than the table's + * own row type. + * + * The columns a SELECT reads are the element names of [deserializer]'s descriptor, so a projection type has to + * fit the table. Every property must name a column, have that column's type, and be nullable when the column is, + * as a NULL read into a non-null property would quietly become `0` or `""`. A mismatch fails here, while the + * statement is built, rather than in SQLite or not at all. + * + * @throws IllegalArgumentException if the projection type doesn't fit the table + */ + @OptIn(ExperimentalSerializationApi::class) + private fun checkProjection(table: Table<*>, deserializer: DeserializationStrategy<*>) { + val columns = table.kSerializer().descriptor + val projection = deserializer.descriptor + if (projection == columns) + return + val projectionName = projection.serialName + for (index in 0 ..< projection.elementsCount) { + val name = projection.getElementName(index) + val columnIndex = columns.getElementIndex(name) + require(columnIndex != CompositeDecoder.UNKNOWN_NAME) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' isn't a column of that table." + } + val column = columns.getElementDescriptor(columnIndex) + val property = projection.getElementDescriptor(index) + val columnType = column.serialName.removeSuffix("?") + val propertyType = property.serialName.removeSuffix("?") + require(propertyType == columnType) { + "Can't select '$projectionName' from table '${table.tableName}': its property '$name' is a $propertyType, but the column holds a $columnType." + } + require(property.isNullable || !column.isNullable) { + "Can't select '$projectionName' from table '${table.tableName}': column '$name' is nullable, so property '$name' has to be nullable too." + } + } + } + private fun buildSQL( table: Table<*>, clause: SelectClause, @@ -160,13 +207,14 @@ internal object Select : Operation { * * @return Final SELECT statement ready for execution */ - fun select( - table: Table, + fun select( + table: Table<*>, isDistinct: Boolean, - deserializer: DeserializationStrategy, + deserializer: DeserializationStrategy, connection: DatabaseConnection, container: StatementContainer, - ): FinalSelectStatement { + ): FinalSelectStatement { + checkProjection(table, deserializer) val sql = buildString { append(sqlStr) if (isDistinct) From 0ba59f8a9246f02c7f992d90626df67ce9664488 Mon Sep 17 00:00:00 2001 From: qiaoyuang Date: Fri, 2 Oct 2026 23:32:17 +0100 Subject: [PATCH 03/10] Add result columns to SELECT (N5) A SELECT could only read columns: the column list came from the result type's property names, and the SQL functions returned elements usable only in conditions, with no Kotlin type and no way to name a result. So `count(*)` or a per-group `sum` could not be selected at all. An expression is now selected into a property of the result type with AS, and the type's other properties are read from their columns, as in a projection: table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author table SELECT (count(X) AS BookCount::books) WHERE (price LT 20.0) It follows the existing convention of a single argument or a Kotlin collection, as INSERT and GROUP_BY do, rather than adding a function that would look like a SQL keyword without being one. It works after SELECT and SELECT_DISTINCT, and is followed by WHERE, GROUP BY, ORDER BY and LIMIT, through a new ResultColumnSelectStatement. To check the type of a property at compile time, ClauseElement, ClauseNumber and ClauseString take a type parameter, the type of their values. The generated accessors give each column its property's type, and each function the type of the values SQLite returns for it: count, length, instr and random a Long, avg and round a Double, the string functions and group_concat a String, max, min and abs the type of their argument. sum is overloaded by column type, Long for integers and Booleans, Double for reals; it no longer takes a String, BLOB, enum or ULong column. AS takes a KProperty1 of the element's type P, so count(X) goes into a Long property, not an Int or a String one. Mixing result types in one listOf doesn't compile either. Nullability can't be checked through a property reference, as KProperty1 is covariant, so it is checked when the statement is built: an element knows whether it can be NULL in a row, or in a group for an aggregate function. One case depends on what follows: without GROUP BY an aggregate query returns one row even when no rows match, in which every column and every aggregate except count is NULL. As GROUP BY can still be appended then, the statements carry that error until GROUP BY clears it, and the scope reports it when it ends, before any of its statements runs, transactions included. A property given two expressions, renamed with @SerialName, or given another table's column is rejected too. max and min now return an element of their argument's kind, so they can be a Boolean, BLOB or enum element as well; these now respect isFunction like the numeric and string ones, so that a condition on such a function isn't prefixed with the table name. Documentation: a result columns section in the advanced query guide, and the SQL functions guide no longer says functions are for conditions only. It also listed a sign function, which is disabled, and had an example using `>` instead of GT, which didn't compile. ROADMAP: N5 is supported. This also commits the earlier roadmap decisions: observable queries (N1) as high priority, type converters merged with the kotlinx.datetime item (N8) as medium priority, and using a query's results within the same transaction (N6) as low priority. Tests: jvmTest (49) and testAndroidHostTest on API 26 and 37 (98) pass. The native test sources compile for macosArm64, linuxX64, mingwX64 and watchosArm32 but were not run, as this machine is an Intel Mac. The compile-time rejections were checked with a temporary file. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + ROADMAP.md | 8 +- .../com/ctrip/sqllin/dsl/test/AndroidTest.kt | 6 + .../ctrip/sqllin/dsl/test/CommonBasicTest.kt | 292 ++++++++++++++++++ .../com/ctrip/sqllin/dsl/test/Entities.kt | 50 +++ .../com/ctrip/sqllin/dsl/test/JvmTest.kt | 6 + .../com/ctrip/sqllin/dsl/test/NativeTest.kt | 6 + sqllin-dsl/doc/advanced-query-cn.md | 64 +++- sqllin-dsl/doc/advanced-query.md | 67 +++- sqllin-dsl/doc/sql-functions-cn.md | 29 +- sqllin-dsl/doc/sql-functions.md | 31 +- .../com/ctrip/sqllin/dsl/DatabaseScope.kt | 89 +++++- .../sqllin/dsl/sql/clause/BaseJoinClause.kt | 4 +- .../ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt | 43 ++- .../sqllin/dsl/sql/clause/ClauseBoolean.kt | 31 +- .../sqllin/dsl/sql/clause/ClauseElement.kt | 27 +- .../ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt | 34 +- .../sqllin/dsl/sql/clause/ClauseNumber.kt | 38 ++- .../sqllin/dsl/sql/clause/ClauseString.kt | 38 ++- .../sqllin/dsl/sql/clause/ConditionClause.kt | 60 ++-- .../ctrip/sqllin/dsl/sql/clause/Function.kt | 181 ++++++++--- .../sqllin/dsl/sql/clause/GroupByClause.kt | 24 +- .../sqllin/dsl/sql/clause/LimitClause.kt | 6 + .../sqllin/dsl/sql/clause/OrderByClause.kt | 60 ++-- .../sqllin/dsl/sql/clause/ResultColumn.kt | 66 ++++ .../sqllin/dsl/sql/clause/WhereClause.kt | 7 + .../ctrip/sqllin/dsl/sql/operation/Alter.kt | 6 +- .../ctrip/sqllin/dsl/sql/operation/Create.kt | 6 +- .../ctrip/sqllin/dsl/sql/operation/Select.kt | 163 ++++++++-- .../sql/statement/DatabaseExecuteEngine.kt | 9 + .../JoinStatementWithoutCondition.kt | 6 +- .../dsl/sql/statement/SelectStatement.kt | 95 ++++-- .../statement/TransactionStatementsGroup.kt | 9 + .../statement/UnionSelectStatementGroup.kt | 2 + .../ctrip/sqllin/processor/ClauseProcessor.kt | 19 +- 35 files changed, 1338 insertions(+), 248 deletions(-) create mode 100644 sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac86be5..66561e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ * New DSL API: `DatabaseScope#INSERT_OR_IGNORE` for SQL syntax `INSERT OR IGNORE` * New DSL API: projection, which reads `SELECT` results into a narrower `@Serializable` type naming the columns to select, given as the type argument of the clause function: `X()`, `WHERE(...)`, `ORDER_BY(...)`, `LIMIT(...)` and `GROUP_BY(...)`, after `SELECT` or `SELECT_DISTINCT`. A type that doesn't fit the table is rejected with an `IllegalArgumentException` when the statement is built. To support it, the public `DatabaseScope#select` functions now take a result type separate from the table's; this is source-compatible, but on Kotlin/Native a library compiled against an earlier version may have to be recompiled +* New DSL API: result columns, which select expressions such as aggregate functions into properties of a result type with `AS`, as in `table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author`, or `table SELECT (count(X) AS BookCount::books)` for a single one. The result type's other properties are read from their columns, as in a projection. They work after `SELECT` and `SELECT_DISTINCT`, followed by `WHERE`, `GROUP_BY`, `ORDER_BY` and `LIMIT`. A property must have the type of its expression's values, which is checked at compile time, and be nullable when its expression can be `NULL`, which is checked when the statement is built. An aggregate query without `GROUP BY` returns a row even when no rows match, in which every column and every aggregate function except `count` is `NULL`; as `GROUP_BY` can still follow when the statement is built, this is checked when the scope ends, before any of its statements runs +* **Breaking change**: `ClauseElement`, `ClauseNumber` and `ClauseString` now take a type parameter, the type of their values, such as `ClauseNumber` for an `Int` column and `ClauseNumber` for `count(X)`, which is what lets `AS` check the type of a property. Code that only uses the DSL is unaffected; code that names these types has to add a type argument, such as `ClauseElement<*>` where any element is accepted. The public constructors of `ClauseNumber`, `ClauseString`, `ClauseBoolean`, `ClauseBlob` and `ClauseEnum`, which the generated table objects call, now take whether the column is nullable instead of whether the element is a function. The generated code is regenerated by the build, but on Kotlin/Native a library compiled against an earlier version has to be recompiled +* **Breaking change**: The SQL functions now return elements of the type of the values SQLite returns for them: `count`, `length`, `instr` and `random` a `ClauseNumber`, `avg` and `round` a `ClauseNumber`, the string functions and `group_concat` a `ClauseString`, and `max`, `min` and `abs` an element of the same kind and type as their argument. So `max` and `min` of a String column are now a `ClauseString`, compared with strings in `HAVING`, where they used to be a `ClauseNumber`. `sum` is overloaded by the type of its column: of a column of integers or Booleans it is a `ClauseNumber`, and of a `Float` or `Double` column a `ClauseNumber`. A `sum` of a String, BLOB, enum or `ULong` column no longer compiles; the last because SQLite stores a `ULong` above `Long.MAX_VALUE` as a negative number, which made the sum wrong +* Fix documentation: the SQL functions guide listed a `sign` function, which isn't available, and its `HAVING (count(X) > 2)` example didn't compile; it is `HAVING (count(X) GT 2)`. It no longer says that functions can only be used in conditions * **Breaking change**: The parameter of annotation `@PrimaryKey` renamed from `isAutoincrement` to `autoIncrement`, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument `@PrimaryKey(isAutoincrement = true)` must be updated to `@PrimaryKey(autoIncrement = true)`; positional usage such as `@PrimaryKey(true)` is unaffected * **Breaking change**: The nullability of a `@PrimaryKey` property now decides who supplies its value. A `Long?` key is assigned by the database, as before. A non-null `Long` key is now allowed: it remains an `INTEGER PRIMARY KEY`, a rowid alias, but is supplied by the caller and written by every `INSERT`. A key of any other type must be non-null and is declared `NOT NULL`. Previously every `@PrimaryKey` was forced to be nullable, against the annotation's own documentation, and because SQLite does not let `PRIMARY KEY` imply `NOT NULL` on such a column, a `String` key could hold `NULL` in any number of rows. `autoIncrement = true` now requires a `Long?` key, and a `ULong?` key, which used to be stored as `NULL` because it was left out of `INSERT` without being a rowid alias, is now rejected. To migrate, drop the `?` from any non-`Long` `@PrimaryKey`; a single-column `@CompositePrimaryKey` that only existed to hold a caller-supplied `Long` key can become `@PrimaryKey val id: Long` * **Breaking change**: `@CompositePrimaryKey` now requires at least two properties. A single-column primary key is declared with `@PrimaryKey`, which for a `Long` key maps to `INTEGER`, a rowid alias, where a single-column `@CompositePrimaryKey` mapped it to `BIGINT`. To migrate, replace a lone `@CompositePrimaryKey` with `@PrimaryKey` diff --git a/ROADMAP.md b/ROADMAP.md index 697140e9..575d43ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # SQLlin Roadmap +## High Priority + +* Support observable queries that return a `Flow` re-emitting whenever the tables they read change, e.g. to invalidate a Paging source + ## Medium Priority * Support WASM platform DSL @@ -8,14 +12,16 @@ * Support CREATE TRIGGER DSL * Support JOIN sub-query DSL * Support more functions +* Support type converters: store a property of any type through a serializer that encodes it to a type SQLite supports, with type-safe WHERE and SET on its column, e.g. to store instances of kotlinx.datetime ## Low Priority -* Support store instances of kotlinx.datetime * Support CHECK keyword +* Support using a query's results within the same transaction, so that a read-modify-write is one transaction ## Supported +* Support SQL functions in SELECT results (2.4.0 ✅) * Support SELECT projection (2.4.0 ✅) * Support INSERT OR IGNORE (2.4.0 ✅) * Support INSERT OR REPLACE (2.3.0 ✅) diff --git a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt index ecba9f92..496c51fe 100644 --- a/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt +++ b/sqllin-dsl-test/src/androidHostTest/kotlin/com/ctrip/sqllin/dsl/test/AndroidTest.kt @@ -78,6 +78,12 @@ class AndroidTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt index 0a48dd21..59668c35 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/CommonBasicTest.kt @@ -850,6 +850,285 @@ class CommonBasicTest(private val path: DatabasePath) { assertEquals(true, notNullable.message!!.contains("column 'notes' is nullable")) } + /** + * Covers result columns: expressions, such as aggregate functions, selected into properties of a result type with + * AS, as in `table SELECT listOf(count(X) AS AuthorStats::books)`, while every other property is read from its + * column. Each function reads into the type of the values SQLite returns for it, and NULL into a nullable property. + */ + fun testResultColumns() = Database(getResultColumnDBConfig()).databaseAutoClose { database -> + // Not grouped, an aggregate query returns one row even when no rows match: count is 0, and the others NULL + lateinit var noTotals: SelectStatement + database { + BookTable { table -> + noTotals = table SELECT listOf( + count(X) AS BookTotals::books, + max(pages) AS BookTotals::maxPages, + avg(price) AS BookTotals::averagePrice, + sum(price) AS BookTotals::totalPrice, + ) + } + } + assertEquals(listOf(BookTotals(books = 0, maxPages = null, averagePrice = null, totalPrice = null)), noTotals.getResults()) + + database { + BookTable INSERT listOf( + Book(name = "The Da Vinci Code", author = "Dan Brown", price = 16.96, pages = 454), + Book(name = "The Lost Symbol", author = "Dan Brown", price = 19.95, pages = 510), + Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251), + ) + } + + // A group of GROUP BY always has rows, so aggregates of NOT NULL columns are non-null in it. 'author' has no + // expression, so it is read from its column: SELECT author,count(*) AS books,... FROM book GROUP BY author + lateinit var stats: SelectStatement + lateinit var totals: SelectStatement + lateinit var bookCount: SelectStatement + database { + BookTable { table -> + stats = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) GROUP_BY author ORDER_BY (author to ASC) + totals = table SELECT listOf( + count(X) AS BookTotals::books, + max(pages) AS BookTotals::maxPages, + avg(price) AS BookTotals::averagePrice, + sum(price) AS BookTotals::totalPrice, + ) + bookCount = table SELECT (count(X) AS BookCount::books) + } + } + assertEquals( + listOf( + AuthorStats("Dan Brown", books = 2, totalPages = 964, maxPrice = 19.95, firstTitle = "The Da Vinci Code"), + AuthorStats("Ken Kousen", books = 1, totalPages = 251, maxPrice = 37.72, firstTitle = "Kotlin Cookbook"), + ), + stats.getResults(), + ) + val total = totals.getResults().single() + assertEquals(3L, total.books) + assertEquals(510, total.maxPages) + assertEquals(74.63 / 3, total.averagePrice!!, 1e-9) + assertEquals(74.63, total.totalPrice!!, 1e-9) + assertEquals(3L, bookCount.getResults().single().books) + + // The clauses that can follow result columns, and those that follow them + lateinit var prolific: SelectStatement + lateinit var cheap: SelectStatement + lateinit var secondMostBooks: SelectStatement + lateinit var kotlinBook: SelectStatement + lateinit var shortestBook: SelectStatement + lateinit var upperAuthors: SelectStatement + database { + BookTable { table -> + prolific = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) WHERE (price LT 30.0) GROUP_BY author HAVING (count(X) GT 1) + cheap = table SELECT (count(X) AS BookCount::books) WHERE (price LT 20.0) + secondMostBooks = table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) GROUP_BY author ORDER_BY (count(X) to DESC) LIMIT 1 OFFSET 1 + kotlinBook = table SELECT listOf( + upper(name) AS BookFunctions::upperName, + length(name) AS BookFunctions::nameLength, + round(price, 0) AS BookFunctions::roundedPrice, + abs(pages) AS BookFunctions::absPages, + ) WHERE (author EQ "Ken Kousen") + shortestBook = table SELECT listOf( + upper(name) AS BookFunctions::upperName, + length(name) AS BookFunctions::nameLength, + round(price, 0) AS BookFunctions::roundedPrice, + abs(pages) AS BookFunctions::absPages, + ) ORDER_BY (pages to ASC) LIMIT 1 + // An expression can take the place of the column of the same name + upperAuthors = table SELECT_DISTINCT (upper(author) AS BookAuthor::author) + } + } + assertEquals( + listOf(AuthorStats("Dan Brown", books = 2, totalPages = 964, maxPrice = 19.95, firstTitle = "The Da Vinci Code")), + prolific.getResults(), + ) + assertEquals(2L, cheap.getResults().single().books) + assertEquals(listOf("Ken Kousen"), secondMostBooks.getResults().map { it.author }) + val functions = BookFunctions("Kotlin Cookbook", upperName = "KOTLIN COOKBOOK", nameLength = 15, roundedPrice = 38.0, absPages = 251) + assertEquals(listOf(functions), kotlinBook.getResults()) + assertEquals(listOf(functions), shortestBook.getResults()) + assertEquals(listOf("DAN BROWN", "KEN KOUSEN"), upperAuthors.getResults().map { it.author }.sorted()) + + // An aggregate of a nullable column is NULL for a group whose values are all NULL, and max of an enum column + // is an entry of that enum + database { + UserAccountTable INSERT listOf( + UserAccount(id = null, username = "ann", email = "ann@example.com", status = UserStatus.ACTIVE, priority = Priority.LOW, notes = null), + UserAccount(id = null, username = "bob", email = "bob@example.com", status = UserStatus.ACTIVE, priority = Priority.HIGH, notes = "vip"), + UserAccount(id = null, username = "cat", email = "cat@example.com", status = UserStatus.INACTIVE, priority = Priority.MEDIUM, notes = null), + ) + } + lateinit var byStatus: SelectStatement + database { + UserAccountTable { table -> + byStatus = table SELECT listOf( + count(X) AS StatusStats::users, + group_concat(notes, ",") AS StatusStats::notes, + max(priority) AS StatusStats::highestPriority, + ) GROUP_BY status ORDER_BY (status to ASC) + } + } + assertEquals( + listOf( + StatusStats(UserStatus.ACTIVE, users = 2, notes = "vip", highestPriority = Priority.HIGH), + StatusStats(UserStatus.INACTIVE, users = 1, notes = null, highestPriority = Priority.MEDIUM), + ), + byStatus.getResults(), + ) + + // sum of a Boolean column counts its true values + database { + DefaultValuesTestTable INSERT listOf(true, false, true).mapIndexed { index, isEnabled -> + DefaultValuesTest(id = null, name = "row$index", status = "active", loginCount = 0, isEnabled = isEnabled, createdAt = "2026-10-02") + } + } + lateinit var enabled: SelectStatement + database { + DefaultValuesTestTable { table -> + enabled = table SELECT (sum(isEnabled) AS EnabledCount::enabled) + } + } + assertEquals(2L, enabled.getResults().single().enabled) + } + + /** + * Covers how result columns are checked against their result type. Most of it is checked while the statement is + * built: a property has to be serialized under its own name, get one expression at most, and be nullable when its + * expression can be NULL in a row or a group. Whether a property can be NULL because an aggregate query isn't + * grouped depends on whether GROUP BY follows, so that is checked when the scope ends, before any statement runs. + */ + fun testResultColumnChecks() = Database(getResultColumnDBConfig()).databaseAutoClose { database -> + // Checked while the statement is built + val nullInGroup = assertFailsWith { + database { + UserAccountTable { table -> + table SELECT (group_concat(notes, ",") AS UserNotesNonNull::notes) GROUP_BY status + } + } + } + assertEquals(true, nullInGroup.message!!.contains("'group_concat(notes,',')' can be NULL, so property 'notes' has to be nullable")) + val twice = assertFailsWith { + database { + BookTable { table -> + table SELECT listOf(count(X) AS BookCount::books, count(name) AS BookCount::books) + } + } + } + assertEquals(true, twice.message!!.contains("'books' is given more than one expression")) + val renamed = assertFailsWith { + database { BookTable { table -> table SELECT (count(X) AS RenamedBookCount::books) } } + } + assertEquals(true, renamed.message!!.contains("doesn't serialize its property 'books' under that name")) + val none = assertFailsWith { + database { BookTable SELECT emptyList>() } + } + assertEquals(true, none.message!!.contains("no result columns")) + val otherTable = assertFailsWith { + database { BookTable SELECT (UserAccountTable.username AS BookAuthor::author) } + } + assertEquals(true, otherTable.message!!.contains("belongs to table 'user_account'")) + val notAColumn = assertFailsWith { + database { BookTable { table -> table SELECT (upper(name) AS BookWithIsbn::name) } } + } + assertEquals(true, notAColumn.message!!.contains("'isbn' isn't a column")) + + // Checked when the scope ends. Without GROUP BY, 'maxPages' would be NULL when no rows match, while 'books', + // a count, would be 0. Nothing in the scope runs, not even the INSERT before it. + val ungrouped = assertFailsWith { + database { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + table SELECT listOf(count(X) AS BookCountAndMaxPages::books, max(pages) AS BookCountAndMaxPages::maxPages) + } + } + } + assertEquals(true, ungrouped.message!!.contains("without GROUP BY")) + assertEquals(true, ungrouped.message!!.contains("'maxPages'")) + assertEquals(false, ungrouped.message!!.contains("'books'")) + // A column is NULL in that row as well, and the check follows the statement through the clauses after it + val ungroupedColumn = assertFailsWith { + database { + BookTable { table -> + table SELECT listOf( + count(X) AS AuthorStats::books, + sum(pages) AS AuthorStats::totalPages, + max(price) AS AuthorStats::maxPrice, + min(name) AS AuthorStats::firstTitle, + ) WHERE (price GT 0.0) ORDER_BY (pages to ASC) LIMIT 1 + } + } + } + assertEquals(true, ungroupedColumn.message!!.contains("'author'")) + // In a transaction too + val inTransaction = assertFailsWith { + database { + transaction { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + table SELECT listOf(count(X) AS BookCountAndMaxPages::books, max(pages) AS BookCountAndMaxPages::maxPages) + } + } + } + } + assertEquals(true, inTransaction.message!!.contains("without GROUP BY")) + lateinit var bookCount: SelectStatement + database { + BookTable { table -> bookCount = table SELECT (count(X) AS BookCount::books) } + } + assertEquals(0L, bookCount.getResults().single().books) + + // GROUP BY settles it, after WHERE as well + lateinit var grouped: SelectStatement + database { + BookTable INSERT Book(name = "Kotlin Cookbook", author = "Ken Kousen", price = 37.72, pages = 251) + BookTable { table -> + grouped = table SELECT listOf( + count(X) AS BookCountAndMaxPages::books, + max(pages) AS BookCountAndMaxPages::maxPages, + ) WHERE (price GT 0.0) GROUP_BY author + } + } + assertEquals(listOf(BookCountAndMaxPages(books = 1, maxPages = 251)), grouped.getResults()) + } + + /** + * Compile-time check, never called: each function reads into the type of the values SQLite returns for it, which + * is what lets AS select it only into a property of that type. + */ + @Suppress("unused", "UNUSED_VARIABLE") + private fun checkFunctionResultTypes(): Unit = BookTable { table -> + val countAll: ClauseNumber = count(X) + val countColumn: ClauseNumber = count(name) + val sumOfInt: ClauseNumber = sum(pages) + val sumOfDouble: ClauseNumber = sum(price) + val sumOfBoolean: ClauseNumber = DefaultValuesTestTable.sum(DefaultValuesTestTable.isEnabled) + val average: ClauseNumber = avg(pages) + val maxOfInt: ClauseNumber = max(pages) + val minOfString: ClauseString = min(name) + val maxOfEnum: ClauseEnum = UserAccountTable.max(UserAccountTable.status) + val absolute: ClauseNumber = abs(pages) + val rounded: ClauseNumber = round(pages, 1) + val randomNumber: ClauseNumber = random() + val upperCase: ClauseString = upper(name) + val nameLength: ClauseNumber = length(name) + val position: ClauseNumber = instr(name, "a") + val concatenated: ClauseString = group_concat(name, ",") + } + fun testCreateInDatabaseScope() { Database(getNewAPIDBConfig()).databaseAutoClose { database -> val person = PersonWithId(id = null, name = "Grace", age = 40) @@ -3031,6 +3310,19 @@ class CommonBasicTest(private val path: DatabasePath) { } ) + @OptIn(ExperimentalDSLDatabaseAPI::class) + private fun getResultColumnDBConfig(): DSLDBConfiguration = + DSLDBConfiguration( + name = DATABASE_NAME, + path = path, + version = 1, + create = { + CREATE(BookTable) + CREATE(UserAccountTable) + CREATE(DefaultValuesTestTable) + } + ) + @OptIn(ExperimentalDSLDatabaseAPI::class) private fun getForeignKeyDBConfig(): DSLDBConfiguration = DSLDBConfiguration( diff --git a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt index 6e787cb9..e5fbba5f 100644 --- a/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt +++ b/sqllin-dsl-test/src/commonMain/kotlin/com/ctrip/sqllin/dsl/test/Entities.kt @@ -22,6 +22,7 @@ import com.ctrip.sqllin.dsl.annotation.CompositeUnique import com.ctrip.sqllin.dsl.annotation.DBRow import com.ctrip.sqllin.dsl.annotation.PrimaryKey import com.ctrip.sqllin.dsl.annotation.Unique +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -550,3 +551,52 @@ data class BookPagesAsText(val pages: String) // 'pages' holds an Int @Serializable data class UserNotesNonNull(val notes: String) // 'notes' is nullable + +/** + * Result types of SELECTs with result columns, as in `BookTable SELECT listOf(count(X) AS AuthorStats::books)`: the + * properties given an expression with AS hold it, and every other property is read from its column. + */ +@Serializable +data class AuthorStats( + val author: String, // read from its column + val books: Long, + val totalPages: Long, + val maxPrice: Price, + val firstTitle: String, +) + +/** + * Aggregates of a whole table, not grouped: all but `count` are NULL when no rows match, so they are nullable. + */ +@Serializable +data class BookTotals(val books: Long, val maxPages: PageCount?, val averagePrice: Double?, val totalPrice: Double?) + +@Serializable +data class BookCount(val books: Long) + +/** + * Scalar functions of the columns of a book, next to its `name`, which is read from its column. + */ +@Serializable +data class BookFunctions( + val name: String, + val upperName: String, + val nameLength: Long, + val roundedPrice: Double, + val absPages: PageCount, +) + +@Serializable +data class StatusStats(val status: UserStatus, val users: Long, val notes: String?, val highestPriority: Priority) + +@Serializable +data class EnabledCount(val enabled: Long?) + +/** + * Result types that don't fit their query, each breaking one of the rules result columns are checked against. + */ +@Serializable +data class BookCountAndMaxPages(val books: Long, val maxPages: PageCount) // 'maxPages' is NULL when no rows match + +@Serializable +data class RenamedBookCount(@SerialName("total") val books: Long) // 'books' isn't serialized under its own name diff --git a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt index 61dfc8f9..fbb7635f 100644 --- a/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt +++ b/sqllin-dsl-test/src/jvmTest/kotlin/com/ctrip/sqllin/dsl/test/JvmTest.kt @@ -70,6 +70,12 @@ class JvmTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt index cbd218d8..dac763d1 100644 --- a/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt +++ b/sqllin-dsl-test/src/nativeTest/kotlin/com/ctrip/sqllin/dsl/test/NativeTest.kt @@ -86,6 +86,12 @@ class NativeTest { @Test fun testProjection() = commonTest.testProjection() + @Test + fun testResultColumns() = commonTest.testResultColumns() + + @Test + fun testResultColumnChecks() = commonTest.testResultColumnChecks() + @Test fun testCreateInDatabaseScope() = commonTest.testCreateInDatabaseScope() diff --git a/sqllin-dsl/doc/advanced-query-cn.md b/sqllin-dsl/doc/advanced-query-cn.md index b9e23974..b23eefd4 100644 --- a/sqllin-dsl/doc/advanced-query-cn.md +++ b/sqllin-dsl/doc/advanced-query-cn.md @@ -184,7 +184,69 @@ fun sample() { 投影类型的每个属性都必须是这张表的列,类型与列一致,并且当列可空时属性也必须可空,因为把 `NULL` 读进非空属性时,它会被 悄无声息地读成 `0` 或空字符串。不满足这些规则的投影类型会让 `SELECT` 在构建语句时、执行之前就抛出 `IllegalArgumentException`。 -`COUNT(*)` 这样的表达式目前还不能投影。 +要查询 `count(*)` 这样的表达式,请使用结果列。 + +## 结果列 + +要查询一个表达式,比如聚合函数,可以用 `AS` 把它交给结果类型的一个属性。结果类型的其余属性和投影一样,从同名的列读取, +所以只需要列出表达式:单个表达式直接写,多个表达式放进 `listOf`: + +```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + +@Serializable +data class PersonCount( + val people: Long, +) + +fun sample() { + lateinit var stats: SelectStatement + lateinit var adults: SelectStatement + database { + PersonTable { table -> + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name + // SELECT count(*) AS people FROM person WHERE age >= ? + adults = table SELECT (count(X) AS PersonCount::people) WHERE (age GTE 18) + } + } + val adultCount = adults.getResults().single().people +} +``` + +和投影类型一样,结果类型就是普通的 `@Serializable` 类型。单个结果列必须加括号,因为 `SELECT` 和 `AS` 都是中缀函数。 +结果列可以用在 `SELECT` 和 `SELECT_DISTINCT` 之后,后面可以接 `WHERE`、`GROUP_BY`、`ORDER_BY` 和 `LIMIT`。表达式也可以 +占用某一列对应的属性:`table SELECT (upper(name) AS PersonName::name)` 会把所有名字读成大写。 + +属性的类型必须和表达式的值的类型一致,这一点在编译期检查。所以 `count(X)` 只能交给 `Long` 属性,不能交给 `Int` 或 +`String` 属性: + +| 函数 | 值的类型 | +|---|---| +| `count`、`length`、`instr`、`random` | `Long` | +| `avg`、`round` | `Double` | +| `sum` | 整数列和 Boolean 列为 `Long`,`Float` 和 `Double` 列为 `Double` | +| `max`、`min`、`abs` | 与它们的列相同 | +| `upper`、`lower`、`trim`、`ltrim`、`rtrim`、`substr`、`replace`、`printf`、`group_concat` | `String` | + +当表达式可能为 `NULL` 时,属性还必须可空,原因和投影相同: + +- 可空列的函数可能为 `NULL`,`count` 除外。 +- 没有 `GROUP_BY` 时,即使没有任何行匹配,聚合查询也会返回一行,这一行里除 `count` 以外的聚合函数都是 `NULL`,所有的列也是 + `NULL`。所以在没有 `GROUP_BY` 的聚合查询中,除了 `count` 对应的属性,其余属性都必须可空,比如要写成 `maxAge: Int?`。 + 有 `GROUP_BY` 时,每个分组都至少有一行,所以只有列可空时,属性才必须可空。 + +不满足这些规则的结果类型,以及同一个属性被交给了两个表达式的情况,都会让 `SELECT` 在构建语句时、执行之前就抛出 +`IllegalArgumentException`。唯独后面是否还会接 `GROUP_BY`,在构建时还无法得知,所以需要 `GROUP_BY` 的属性会在数据库作用域 +结束时报错,此时作用域中的任何语句都还没有执行。 + +交给表达式的属性按属性名查找,所以不能用 `@SerialName` 重命名。结果列只能查询单张表:目前还不能和 Join 一起使用, +也不支持算术运算、`CASE` 和子查询。 ## 最后 diff --git a/sqllin-dsl/doc/advanced-query.md b/sqllin-dsl/doc/advanced-query.md index 91f250a7..00e426e6 100644 --- a/sqllin-dsl/doc/advanced-query.md +++ b/sqllin-dsl/doc/advanced-query.md @@ -193,7 +193,72 @@ to a statement of the projection type. Each property of a projection type has to be a column of the table, of the same type, and nullable if the column is nullable, as a `NULL` read into a non-null property would quietly become `0` or an empty string. A projection type that breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is built, before it -runs. Expressions such as `COUNT(*)` can't be projected yet. +runs. To select expressions such as `count(*)`, use result columns. + +## Result Columns + +To select an expression, such as an aggregate function, give it a property of the result type with `AS`. Every other +property of the result type is read from its column, as in a projection, so only the expressions are listed: one alone, +or several in a `listOf`: + +```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + +@Serializable +data class PersonCount( + val people: Long, +) + +fun sample() { + lateinit var stats: SelectStatement + lateinit var adults: SelectStatement + database { + PersonTable { table -> + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name + // SELECT count(*) AS people FROM person WHERE age >= ? + adults = table SELECT (count(X) AS PersonCount::people) WHERE (age GTE 18) + } + } + val adultCount = adults.getResults().single().people +} +``` + +Like a projection type, a result type is a plain `@Serializable` type. A single result column has to be put in +parentheses, as `SELECT` and `AS` are both infix functions. Result columns work after `SELECT` and `SELECT_DISTINCT`, and +can be followed by `WHERE`, `GROUP_BY`, `ORDER_BY` and `LIMIT`. An expression can take the property of a column, too: +`table SELECT (upper(name) AS PersonName::name)` reads every name in upper case. + +The property has to have the type of the expression's values, which is checked at compile time. So `count(X)` goes into +a `Long` property, not an `Int` or a `String` one: + +| Function | Type of its values | +|---|---| +| `count`, `length`, `instr`, `random` | `Long` | +| `avg`, `round` | `Double` | +| `sum` | `Long` for a column of integers or Booleans, `Double` for a `Float` or `Double` column | +| `max`, `min`, `abs` | the type of their column | +| `upper`, `lower`, `trim`, `ltrim`, `rtrim`, `substr`, `replace`, `printf`, `group_concat` | `String` | + +The property also has to be nullable when its expression can be `NULL`, for the same reason as in a projection: + +- A function of a nullable column can be `NULL`, except `count`. +- Without `GROUP_BY`, an aggregate query returns one row even when no rows match, in which every aggregate function except + `count` is `NULL`, and so is every column. So in an aggregate query without `GROUP_BY`, every property but those of + `count` has to be nullable, as `maxAge: Int?` would be. With `GROUP_BY`, every group has rows, so a property only has to + be nullable when its column is. + +A result type that breaks one of these rules makes the `SELECT` throw an `IllegalArgumentException` when the statement is +built, before it runs, as does a property given two expressions. Only whether `GROUP_BY` follows can't be known yet then, +so a property that needs it is reported when the database scope ends, before any statement of the scope runs. + +A property given an expression is found by its name, so it can't be renamed with `@SerialName`. Result columns select from +a single table: they can't be used with a join yet, nor can arithmetic, `CASE` or subqueries. ## Finally diff --git a/sqllin-dsl/doc/sql-functions-cn.md b/sqllin-dsl/doc/sql-functions-cn.md index 4ef19ed7..95eed4b0 100644 --- a/sqllin-dsl/doc/sql-functions-cn.md +++ b/sqllin-dsl/doc/sql-functions-cn.md @@ -9,7 +9,7 @@ fun sample() { database { PersonTable { table -> table SELECT WHERE(abs(age) LTE 5) - table SELECT GROUP_BY(name) HAVING (count(X) > 2) + table SELECT GROUP_BY(name) HAVING (count(X) GT 2) } } } @@ -20,31 +20,34 @@ fun sample() { > **聚合函数**: `count`, `max`, `min`, `avg`, `sum`, `group_concat` > -> **数值函数**: `abs`, `round`, `random`, `sign` +> **数值函数**: `abs`, `round`, `random` > > **字符串函数**: `upper`, `lower`, `length`, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr`, `printf` `count` 函数有一个不同点,它可以接收一个 `X` 作为参数用于表示 SQL 中的 `count(*)`, 如前面的示例所示。 -SQLlin 当前只支持在条件语句中使用函数。我们将会考虑在未来的版本中支持在 _SELECT_ 关键字后使用函数。现在, -如果你有类似的需求,你可以使用 *[Kotlin 集合 API](https://kotlinlang.org/docs/collection-aggregate.html)* 来处理查询结果: +要在 _SELECT_ 关键字之后使用函数,可以用 `AS` 把它们交给结果类型的属性: ```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + fun sample() { - lateinit var selectStatement: SelectStatement + lateinit var stats: SelectStatement database { PersonTable { table -> - selectStatement = table SELECT X + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name } } - // Get the max value - selectStatement.getResult().maxOrNull() - // Get the min value - selectStatement.getResult().minOrNull() - // Get the count of query results - selectStatement.getResult().count() - // ...... } ``` +每个函数的结果都具有 SQLite 为它返回的值的类型,比如 `count` 为 `Long`,`avg` 为 `Double`,`AS` 只能把它交给这个类型的属性。 +结果列的详细用法请见[《高级查询》](advanced-query-cn.md#结果列)。 + 最后,让我们来学习[《高级查询》](advanced-query-cn.md)吧。 \ No newline at end of file diff --git a/sqllin-dsl/doc/sql-functions.md b/sqllin-dsl/doc/sql-functions.md index 17fc5be5..17203d9a 100644 --- a/sqllin-dsl/doc/sql-functions.md +++ b/sqllin-dsl/doc/sql-functions.md @@ -12,7 +12,7 @@ fun sample() { database { PersonTable { table -> table SELECT WHERE(abs(age) LTE 5) - table SELECT GROUP_BY(name) HAVING (count(X) > 2) + table SELECT GROUP_BY(name) HAVING (count(X) GT 2) } } } @@ -24,33 +24,36 @@ a `ClauseElement` as the result. The functions supported by SQLlin are as follow > **Aggregate functions**: `count`, `max`, `min`, `avg`, `sum`, `group_concat` > -> **Numeric functions**: `abs`, `round`, `random`, `sign` +> **Numeric functions**: `abs`, `round`, `random` > > **String functions**: `upper`, `lower`, `length`, `substr`, `trim`, `ltrim`, `rtrim`, `replace`, `instr`, `printf` The `count` function has a different point, it could receive `X` as parameter be used for representing `count(*)` in SQL, as shown in the example above. -SQLlin only supports using functions in conditions now. We will consider supporting using functions after the _SELECT_ keyword in -future versions. Now, if you have similar demands, you can use -[Kotlin Collections API](https://kotlinlang.org/docs/collection-aggregate.html) to handle query results: +To use functions after the _SELECT_ keyword, select them into properties of a result type with `AS`: ```kotlin +@Serializable +data class NameStats( + val name: String, + val people: Long, + val maxAge: Int, +) + fun sample() { - lateinit var selectStatement: SelectStatement + lateinit var stats: SelectStatement database { PersonTable { table -> - selectStatement = table SELECT X + // SELECT name,count(*) AS people,max(age) AS maxAge FROM person GROUP BY name + stats = table SELECT listOf(count(X) AS NameStats::people, max(age) AS NameStats::maxAge) GROUP_BY name } } - // Get the max value - selectStatement.getResult().maxOrNull() - // Get the min value - selectStatement.getResult().minOrNull() - // Get the count of query results - selectStatement.getResult().count() - // ...... } ``` +Each function's result has the type of the values SQLite returns for it, such as `Long` for `count` and `Double` for +`avg`, and `AS` only selects it into a property of that type. [Advanced Query](advanced-query.md#result-columns) describes +result columns in detail. + Finally, let's learn [Advanced Query](advanced-query.md). \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt index 5da6a1d6..580d51d4 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/DatabaseScope.kt @@ -693,6 +693,83 @@ public class DatabaseScope internal constructor( public inline infix fun Table.SELECT_DISTINCT(clause: GroupByClause): GroupBySelectStatement = select(getKSerializer(), clause, true) + // ========== SELECT with Result Columns ========== + // + // These select expressions, such as aggregate functions, into properties of a result type R with AS, as in + // `BookTable SELECT listOf(count(X) AS AuthorStats::books)`. Every other property of R is read from its column. + + /** + * Selects [column] into its property of [R], and every other property of [R] from its column. + * + * Example: + * ```kotlin + * @Serializable + * data class BookCount(val books: Long) + * + * val total = BookTable SELECT (BookTable.count(X) AS BookCount::books) + * // SELECT count(*) AS books FROM book + * ``` + * + * Can be followed by WHERE, GROUP BY, ORDER BY, or LIMIT. + * + * @throws IllegalArgumentException if [R] doesn't fit the query: an expression can be NULL while its property isn't + * nullable, or a property without an expression doesn't fit its column, as for [X]. A property that can only be + * non-null in a group of GROUP BY is reported when the scope ends, before anything runs, if no GROUP BY follows. + */ + @StatementDslMaker + public inline infix fun Table.SELECT(column: ResultColumn): ResultColumnSelectStatement = + select(getKSerializer(), listOf(column), false) + + /** + * Selects [column] into its property of [R], and every other property of [R] from its column, returning distinct + * rows. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT] + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(column: ResultColumn): ResultColumnSelectStatement = + select(getKSerializer(), listOf(column), true) + + /** + * Selects each of [columns] into its property of [R], and every other property of [R] from its column. + * + * Example: + * ```kotlin + * @Serializable + * data class AuthorStats(val author: String, val books: Long, val totalPages: Long) + * + * val stats = BookTable { table -> + * table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author + * } + * // SELECT author,count(*) AS books,sum(pages) AS totalPages FROM book GROUP BY author + * ``` + * + * Can be followed by WHERE, GROUP BY, ORDER BY, or LIMIT. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT], or [columns] give a property two + * expressions, or none at all + */ + @StatementDslMaker + public inline infix fun Table.SELECT(columns: Iterable>): ResultColumnSelectStatement = + select(getKSerializer(), columns, false) + + /** + * Selects each of [columns] into its property of [R], and every other property of [R] from its column, returning + * distinct rows. + * + * @throws IllegalArgumentException if [R] doesn't fit the query, as for [SELECT] + */ + @StatementDslMaker + public inline infix fun Table.SELECT_DISTINCT(columns: Iterable>): ResultColumnSelectStatement = + select(getKSerializer(), columns, true) + + public fun Table.select(serializer: KSerializer, columns: Iterable>, isDistinct: Boolean): ResultColumnSelectStatement { + val container = getSelectStatementGroup() + val statement = Select.select(this, columns, isDistinct, serializer, databaseConnection, container) + addSelectStatement(statement) + return statement + } + // ========== UNION Operations ========== private val unionSelectStatementGroupStack by lazy { ArrayDeque>() } @@ -855,7 +932,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.CREATE_INDEX(indexName: String, vararg columns: ClauseElement) { + public fun Table.CREATE_INDEX(indexName: String, vararg columns: ClauseElement<*>) { val statement = Create.createIndex(this, databaseConnection, indexName, *columns) addStatement(statement) } @@ -880,7 +957,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.CREATE_UNIQUE_INDEX(indexName: String, vararg columns: ClauseElement) { + public fun Table.CREATE_UNIQUE_INDEX(indexName: String, vararg columns: ClauseElement<*>) { val statement = Create.createUniqueIndex(this, databaseConnection, indexName, *columns) addStatement(statement) } @@ -949,7 +1026,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement) { + public infix fun Table.ALTER_ADD_COLUMN(column: ClauseElement<*>) { val statement = Alter.addColumn(this, column, databaseConnection) addStatement(statement) } @@ -1013,7 +1090,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { + public fun > Table.RENAME_COLUMN(oldColumn: R, newColumn: R) { val statement = Alter.renameColumn(this, oldColumn.valueName, newColumn, databaseConnection) addStatement(statement) } @@ -1036,7 +1113,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement) { + public fun Table.RENAME_COLUMN(oldColumnName: String, newColumn: ClauseElement<*>) { val statement = Alter.renameColumn(this, oldColumnName, newColumn, databaseConnection) addStatement(statement) } @@ -1059,7 +1136,7 @@ public class DatabaseScope internal constructor( */ @ExperimentalDSLDatabaseAPI @StatementDslMaker - public infix fun Table.DROP_COLUMN(column: ClauseElement) { + public infix fun Table.DROP_COLUMN(column: ClauseElement<*>) { val statement = Alter.dropColumn(this, column, databaseConnection) addStatement(statement) } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt index f852622c..16bf7385 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/BaseJoinClause.kt @@ -72,10 +72,10 @@ public infix fun JoinStatementWithoutCondition.ON(condition: SelectCondit @Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker -public inline infix fun JoinStatementWithoutCondition.USING(clauseElement: ClauseElement): JoinSelectStatement = +public inline infix fun JoinStatementWithoutCondition.USING(clauseElement: ClauseElement<*>): JoinSelectStatement = USING(listOf(clauseElement)) @Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") @StatementDslMaker -public infix fun JoinStatementWithoutCondition.USING(clauseElements: Iterable): JoinSelectStatement = +public infix fun JoinStatementWithoutCondition.USING(clauseElements: Iterable>): JoinSelectStatement = convertToJoinSelectStatement(clauseElements) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt index 75b8678b..b14d0611 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBlob.kt @@ -42,10 +42,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseBlob( +public class ClauseBlob internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseBlob = + ClauseBlob(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Creates an equality comparison condition (=). @@ -153,8 +168,10 @@ public class ClauseBlob( private fun appendNullableBlob(notNullSymbol: String, nullSymbol: String, blob: ByteArray?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) if (blob == null) { append(nullSymbol) @@ -168,8 +185,10 @@ public class ClauseBlob( private fun appendBlob(symbol: String, blob: ByteArray): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(symbol) } @@ -204,8 +223,10 @@ public class ClauseBlob( val parameters = blobs.toMutableList() require(parameters.isNotEmpty()) { "Param 'blobs' must not be empty!!!" } val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(" IN (") @@ -228,8 +249,10 @@ public class ClauseBlob( */ internal infix fun between(range: Pair): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(" BETWEEN ? AND ?") } diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt index 2810a03e..497f12ca 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseBoolean.kt @@ -28,10 +28,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseBoolean( +public class ClauseBoolean internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseBoolean = + ClauseBoolean(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Creates a condition comparing this Boolean column/function to a value. @@ -47,8 +62,10 @@ public class ClauseBoolean( */ internal infix fun _is(bool: Boolean?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append( when { @@ -82,8 +99,10 @@ public class ClauseBoolean( */ internal infix fun _isNot(bool: Boolean?): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append( when { diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt index 1588468a..2e864416 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseElement.kt @@ -37,15 +37,38 @@ import com.ctrip.sqllin.dsl.sql.Table * - GROUP BY columns * - SET assignments * - JOIN USING clauses + * - Result columns, as in `count(X) AS AuthorStats::books` * + * An element knows what it reads into, so a [ResultColumn] can only put it into a property that can hold it: the type + * of its values, and whether it can be NULL. + * + * @param V The type of the element's values, not counting NULL: `Int` for an `Int` or `Int?` column, `Long` for + * `count(*)`, `Double` for `avg(...)` * @property valueName The column name or function expression * @property table The table this element belongs to * @property isFunction Whether this represents a function call (e.g., COUNT, SUM) + * @property isNullable Whether the element can be NULL: for a column, whether the column is nullable, and for an + * aggregate function, whether it can be NULL for a group of rows, as `sum` of a nullable column is when all its + * values in the group are NULL + * @property isAggregate Whether the element is or contains an aggregate function, which makes a query that selects it + * an aggregate query + * @property isNullOnNoRows Whether the element is NULL when an aggregate query without GROUP BY matches no rows. Such a + * query still returns one row, in which a column, and every aggregate function except `count`, is NULL. * * @author Yuang Qiao */ -public sealed class ClauseElement( +public sealed class ClauseElement( internal val valueName: String, internal val table: Table<*>, internal val isFunction: Boolean, -) \ No newline at end of file + internal val isNullable: Boolean, + internal val isAggregate: Boolean, + internal val isNullOnNoRows: Boolean, +) { + + /** + * Creates the element of an aggregate function of this element that has values of the same type, as `max` and + * `min` do. + */ + internal abstract fun toAggregate(valueName: String, table: Table<*>): ClauseElement +} diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt index aa9c3da0..82e3cf5d 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseEnum.kt @@ -23,7 +23,8 @@ import com.ctrip.sqllin.dsl.sql.Table * * Enables type-safe enum comparisons in WHERE, HAVING, and other conditional clauses. * Enums are stored as integers (ordinal values) in SQLite and automatically converted - * during serialization/deserialization. + * during serialization/deserialization. `max` and `min` of an enum column are elements + * of the same enum type. * * Available operators: * - `lt`: Less than (<) - compares ordinal values @@ -59,10 +60,25 @@ import com.ctrip.sqllin.dsl.sql.Table * * @author Yuang Qiao */ -public class ClauseEnum>( +public class ClauseEnum> internal constructor( valueName: String, table: Table<*>, -) : ClauseElement(valueName, table, false) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseEnum = + ClauseEnum(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Less than (<) comparison using the enum's ordinal value. @@ -195,8 +211,10 @@ public class ClauseEnum>( */ private fun appendEnum(symbol: String, entry: T): SelectCondition { val sql = buildString { - append(table.tableName) - append('.') + if (!isFunction) { + append(table.tableName) + append('.') + } append(valueName) append(symbol) } @@ -216,8 +234,10 @@ public class ClauseEnum>( */ private fun appendNullableEnum(notNullSymbol: String, nullSymbol: String, entry: T?): SelectCondition { val builder = StringBuilder() - builder.append(table.tableName) - builder.append('.') + if (!isFunction) { + builder.append(table.tableName) + builder.append('.') + } builder.append(valueName) val parameters = if (entry == null){ builder.append(nullSymbol) diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt index f6cfcb9e..404b6859 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseNumber.kt @@ -35,13 +35,29 @@ import com.ctrip.sqllin.dsl.sql.Table * - `inIterable`: IN (?, ?, ...) - all values parameterized * - `between`: BETWEEN ? AND ? - both boundaries parameterized * + * @param V The type of the element's values: `Int` for an `Int` column, `Long` for `count(*)`, `Double` for `avg(...)` + * * @author Yuang Qiao */ -public class ClauseNumber( +public class ClauseNumber internal constructor( valueName: String, table: Table<*>, - isFunction: Boolean = false, -) : ClauseElement(valueName, table, isFunction) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseNumber = + ClauseNumber(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** * Less than (<) comparison using parameterized binding. @@ -54,7 +70,7 @@ public class ClauseNumber( internal infix fun lt(number: Number): SelectCondition = appendNumber("): SelectCondition = appendClauseNumber("<", clauseNumber) /** * Less than or equal (<=) comparison using parameterized binding. @@ -67,7 +83,7 @@ public class ClauseNumber( internal infix fun lte(number: Number): SelectCondition = appendNumber("<=?", number) /** Less than or equal (<=) - compare against another column/function */ - internal infix fun lte(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("<=", clauseNumber) + internal infix fun lte(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("<=", clauseNumber) /** * Equals (=) comparison using parameterized binding, or IS NULL for null values. @@ -80,7 +96,7 @@ public class ClauseNumber( internal infix fun eq(number: Number?): SelectCondition = appendNullableNumber("=", " IS NULL", number) /** Equals (=) - compare against another column/function */ - internal infix fun eq(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("=", clauseNumber) + internal infix fun eq(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("=", clauseNumber) /** * Not equals (!=) comparison using parameterized binding, or IS NOT NULL for null values. @@ -93,7 +109,7 @@ public class ClauseNumber( internal infix fun neq(number: Number?): SelectCondition = appendNullableNumber("!=", " IS NOT NULL", number) /** Not equals (!=) - compare against another column/function */ - internal infix fun neq(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber("!=", clauseNumber) + internal infix fun neq(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber("!=", clauseNumber) /** * Greater than (>) comparison using parameterized binding. @@ -106,7 +122,7 @@ public class ClauseNumber( internal infix fun gt(number: Number): SelectCondition = appendNumber(">?", number) /** Greater than (>) - compare against another column/function */ - internal infix fun gt(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">", clauseNumber) + internal infix fun gt(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber(">", clauseNumber) /** * Greater than or equal (>=) comparison using parameterized binding. @@ -119,7 +135,7 @@ public class ClauseNumber( internal infix fun gte(number: Number): SelectCondition = appendNumber(">=?", number) /** Greater than or equal (>=) - compare against another column/function */ - internal infix fun gte(clauseNumber: ClauseNumber): SelectCondition = appendClauseNumber(">=", clauseNumber) + internal infix fun gte(clauseNumber: ClauseNumber<*>): SelectCondition = appendClauseNumber(">=", clauseNumber) /** * IN operator - checks if value is in the given set. @@ -202,7 +218,7 @@ public class ClauseNumber( return SelectCondition(builder.toString(), parameters) } - private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber): SelectCondition { + private fun appendClauseNumber(symbol: String, clauseNumber: ClauseNumber<*>): SelectCondition { val sql = buildString { append(table.tableName) append('.') @@ -216,7 +232,7 @@ public class ClauseNumber( } override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() - override fun equals(other: Any?): Boolean = (other as? ClauseNumber)?.let { + override fun equals(other: Any?): Boolean = (other as? ClauseNumber<*>)?.let { it.valueName == valueName && it.table.tableName == table.tableName } ?: false } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt index 495f3320..6e07f41a 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ClauseString.kt @@ -36,25 +36,41 @@ import com.ctrip.sqllin.dsl.sql.Table * - `like`: LIKE pattern matching (case-insensitive, supports % and _ wildcards) * - `glob`: GLOB pattern matching (case-sensitive, supports * and ? wildcards) * + * @param V The type of the element's values: `String`, or `Char` for a `Char` column + * * @author Yuang Qiao */ -public class ClauseString( +public class ClauseString internal constructor( valueName: String, table: Table<*>, - isFunction: Boolean = false, -) : ClauseElement(valueName, table, isFunction) { + isFunction: Boolean, + isNullable: Boolean, + isAggregate: Boolean, + isNullOnNoRows: Boolean, +) : ClauseElement(valueName, table, isFunction, isNullable, isAggregate, isNullOnNoRows) { + + /** + * Creates the element of a column, as the code generated for a table does. + * + * @param isNullable Whether the column is nullable + */ + public constructor(valueName: String, table: Table<*>, isNullable: Boolean) : + this(valueName, table, isFunction = false, isNullable = isNullable, isAggregate = false, isNullOnNoRows = true) + + override fun toAggregate(valueName: String, table: Table<*>): ClauseString = + ClauseString(valueName, table, isFunction = true, isNullable = isNullable, isAggregate = true, isNullOnNoRows = true) /** Equals (=), or IS NULL if value is null */ internal infix fun eq(str: String?): SelectCondition = appendNullableString("=", " IS NULL", str) /** Equals (=) - compare against another column/function */ - internal infix fun eq(clauseString: ClauseString): SelectCondition = appendClauseString("=", clauseString) + internal infix fun eq(clauseString: ClauseString<*>): SelectCondition = appendClauseString("=", clauseString) /** Not equals (!=), or IS NOT NULL if value is null */ internal infix fun neq(str: String?): SelectCondition = appendNullableString("!=", " IS NOT NULL", str) /** Not equals (!=) - compare against another column/function */ - internal infix fun neq(clauseString: ClauseString): SelectCondition = appendClauseString("!=", clauseString) + internal infix fun neq(clauseString: ClauseString<*>): SelectCondition = appendClauseString("!=", clauseString) /** * Creates a less than comparison condition (<). @@ -70,7 +86,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun lt(clauseString: ClauseString): SelectCondition = appendClauseString("<", clauseString) + internal infix fun lt(clauseString: ClauseString<*>): SelectCondition = appendClauseString("<", clauseString) /** * Creates a less than or equal to comparison condition (<=). @@ -86,7 +102,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun lte(clauseString: ClauseString): SelectCondition = appendClauseString("<=", clauseString) + internal infix fun lte(clauseString: ClauseString<*>): SelectCondition = appendClauseString("<=", clauseString) /** * Creates a greater than comparison condition (>). @@ -102,7 +118,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun gt(clauseString: ClauseString): SelectCondition = appendClauseString(">", clauseString) + internal infix fun gt(clauseString: ClauseString<*>): SelectCondition = appendClauseString(">", clauseString) /** * Creates a greater than or equal to comparison condition (>=). @@ -118,7 +134,7 @@ public class ClauseString( * @param clauseString The String column/function to compare against * @return Condition expression comparing two String columns */ - internal infix fun gte(clauseString: ClauseString): SelectCondition = appendClauseString(">=", clauseString) + internal infix fun gte(clauseString: ClauseString<*>): SelectCondition = appendClauseString(">=", clauseString) /** * LIKE operator - case-insensitive pattern matching. @@ -179,7 +195,7 @@ public class ClauseString( return SelectCondition(sql, mutableListOf(str)) } - private fun appendClauseString(symbol: String, clauseString: ClauseString): SelectCondition { + private fun appendClauseString(symbol: String, clauseString: ClauseString<*>): SelectCondition { val sql = buildString { append(table.tableName) append('.') @@ -244,7 +260,7 @@ public class ClauseString( } override fun hashCode(): Int = valueName.hashCode() + table.tableName.hashCode() - override fun equals(other: Any?): Boolean = (other as? ClauseString)?.let { + override fun equals(other: Any?): Boolean = (other as? ClauseString<*>)?.let { it.valueName == valueName && it.table.tableName == table.tableName } ?: false } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt index dee44476..5f54f4b0 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ConditionClause.kt @@ -48,123 +48,123 @@ public sealed class ConditionClause(private val selectCondition: SelectCondit // Less than, < @StatementDslMaker -public infix fun ClauseNumber.LT(number: Number): SelectCondition = lt(number) +public infix fun ClauseNumber<*>.LT(number: Number): SelectCondition = lt(number) // Less than, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.LT(clauseNumber: ClauseNumber): SelectCondition = lt(clauseNumber) +public infix fun ClauseNumber<*>.LT(clauseNumber: ClauseNumber<*>): SelectCondition = lt(clauseNumber) // Less than or equal to, <= @StatementDslMaker -public infix fun ClauseNumber.LTE(number: Number): SelectCondition = lte(number) +public infix fun ClauseNumber<*>.LTE(number: Number): SelectCondition = lte(number) // Less than or equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.LTE(clauseNumber: ClauseNumber): SelectCondition = lte(clauseNumber) +public infix fun ClauseNumber<*>.LTE(clauseNumber: ClauseNumber<*>): SelectCondition = lte(clauseNumber) // Equals, == @StatementDslMaker -public infix fun ClauseNumber.EQ(number: Number?): SelectCondition = eq(number) +public infix fun ClauseNumber<*>.EQ(number: Number?): SelectCondition = eq(number) // Equals, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.EQ(clauseNumber: ClauseNumber): SelectCondition = eq(clauseNumber) +public infix fun ClauseNumber<*>.EQ(clauseNumber: ClauseNumber<*>): SelectCondition = eq(clauseNumber) // Not equal to, != @StatementDslMaker -public infix fun ClauseNumber.NEQ(number: Number?): SelectCondition = neq(number) +public infix fun ClauseNumber<*>.NEQ(number: Number?): SelectCondition = neq(number) // Not equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.NEQ(clauseNumber: ClauseNumber): SelectCondition = neq(clauseNumber) +public infix fun ClauseNumber<*>.NEQ(clauseNumber: ClauseNumber<*>): SelectCondition = neq(clauseNumber) // Greater than, > @StatementDslMaker -public infix fun ClauseNumber.GT(number: Number): SelectCondition = gt(number) +public infix fun ClauseNumber<*>.GT(number: Number): SelectCondition = gt(number) // Greater than, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.GT(clauseNumber: ClauseNumber): SelectCondition = gt(clauseNumber) +public infix fun ClauseNumber<*>.GT(clauseNumber: ClauseNumber<*>): SelectCondition = gt(clauseNumber) // Greater than or equal to, >= @StatementDslMaker -public infix fun ClauseNumber.GTE(number: Number): SelectCondition = gte(number) +public infix fun ClauseNumber<*>.GTE(number: Number): SelectCondition = gte(number) // Greater than or equal to, append to ClauseNumber @StatementDslMaker -public infix fun ClauseNumber.GTE(clauseNumber: ClauseNumber): SelectCondition = gte(clauseNumber) +public infix fun ClauseNumber<*>.GTE(clauseNumber: ClauseNumber<*>): SelectCondition = gte(clauseNumber) // If the 'number' in the 'numbers' @StatementDslMaker -public infix fun ClauseNumber.IN(numbers: Iterable): SelectCondition = inIterable(numbers) +public infix fun ClauseNumber<*>.IN(numbers: Iterable): SelectCondition = inIterable(numbers) // If the 'number' between the 'range' @StatementDslMaker -public infix fun ClauseNumber.BETWEEN(range: LongRange): SelectCondition = between(range) +public infix fun ClauseNumber<*>.BETWEEN(range: LongRange): SelectCondition = between(range) // Equals, == @StatementDslMaker -public infix fun ClauseString.EQ(str: String?): SelectCondition = eq(str) +public infix fun ClauseString<*>.EQ(str: String?): SelectCondition = eq(str) // Equals, append another ClauseString @StatementDslMaker -public infix fun ClauseString.EQ(clauseString: ClauseString): SelectCondition = eq(clauseString) +public infix fun ClauseString<*>.EQ(clauseString: ClauseString<*>): SelectCondition = eq(clauseString) // Not equals to, != @StatementDslMaker -public infix fun ClauseString.NEQ(str: String?): SelectCondition = neq(str) +public infix fun ClauseString<*>.NEQ(str: String?): SelectCondition = neq(str) // Not equals to, append another ClauseString @StatementDslMaker -public infix fun ClauseString.NEQ(clauseString: ClauseString): SelectCondition = neq(clauseString) +public infix fun ClauseString<*>.NEQ(clauseString: ClauseString<*>): SelectCondition = neq(clauseString) // SQL LIKE operator @StatementDslMaker -public infix fun ClauseString.LIKE(regex: String): SelectCondition = like(regex) +public infix fun ClauseString<*>.LIKE(regex: String): SelectCondition = like(regex) // SQL GLOB operator @StatementDslMaker -public infix fun ClauseString.GLOB(regex: String): SelectCondition = glob(regex) +public infix fun ClauseString<*>.GLOB(regex: String): SelectCondition = glob(regex) // Less than, < @StatementDslMaker -public infix fun ClauseString.LT(str: String): SelectCondition = lt(str) +public infix fun ClauseString<*>.LT(str: String): SelectCondition = lt(str) // Less than, append to ClauseString @StatementDslMaker -public infix fun ClauseString.LT(clauseString: ClauseString): SelectCondition = lt(clauseString) +public infix fun ClauseString<*>.LT(clauseString: ClauseString<*>): SelectCondition = lt(clauseString) // Less than or equal to, <= @StatementDslMaker -public infix fun ClauseString.LTE(str: String): SelectCondition = lte(str) +public infix fun ClauseString<*>.LTE(str: String): SelectCondition = lte(str) // Less than or equal to, append to ClauseString @StatementDslMaker -public infix fun ClauseString.LTE(clauseString: ClauseString): SelectCondition = lte(clauseString) +public infix fun ClauseString<*>.LTE(clauseString: ClauseString<*>): SelectCondition = lte(clauseString) // Greater than, > @StatementDslMaker -public infix fun ClauseString.GT(str: String): SelectCondition = gt(str) +public infix fun ClauseString<*>.GT(str: String): SelectCondition = gt(str) // Greater than, append to ClauseString @StatementDslMaker -public infix fun ClauseString.GT(clauseString: ClauseString): SelectCondition = gt(clauseString) +public infix fun ClauseString<*>.GT(clauseString: ClauseString<*>): SelectCondition = gt(clauseString) // Greater than or equal to, >= @StatementDslMaker -public infix fun ClauseString.GTE(str: String): SelectCondition = gte(str) +public infix fun ClauseString<*>.GTE(str: String): SelectCondition = gte(str) // Greater than or equal to, append to ClauseString @StatementDslMaker -public infix fun ClauseString.GTE(clauseString: ClauseString): SelectCondition = gte(clauseString) +public infix fun ClauseString<*>.GTE(clauseString: ClauseString<*>): SelectCondition = gte(clauseString) // If the 'string' in the 'strings' @StatementDslMaker -public infix fun ClauseString.IN(strings: Iterable): SelectCondition = inIterable(strings) +public infix fun ClauseString<*>.IN(strings: Iterable): SelectCondition = inIterable(strings) // If the 'string' between the 'range' @StatementDslMaker -public infix fun ClauseString.BETWEEN(range: Pair): SelectCondition = between(range) +public infix fun ClauseString<*>.BETWEEN(range: Pair): SelectCondition = between(range) // Less than, < @StatementDslMaker diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt index ddd579dc..8c3a75c8 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/Function.kt @@ -21,16 +21,35 @@ package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.FunctionDslMaker import com.ctrip.sqllin.dsl.sql.Table import com.ctrip.sqllin.dsl.sql.X +import kotlin.jvm.JvmName /** * SQLite aggregate and scalar functions for use in SELECT clauses. * * These functions can be used in WHERE, HAVING, ORDER BY, and SELECT expressions. - * All functions return [ClauseElement] wrappers that can be compared with operators. + * All functions return [ClauseElement] wrappers that can be compared with operators, + * and selected into a property of a result type with [AS]. + * + * Each function's result has the type of the values SQLite returns for it: `count` and `length` give a `Long`, + * `avg` and `round` a `Double`, `sum` a `Long` or a `Double` as its input holds integers or reals, and `max`, `min` + * and `abs` the type of their input. Whether the result can be NULL follows SQLite too: an aggregate function other + * than `count` is NULL for a group whose values are all NULL, and, without GROUP BY, when no rows match. * * @author Yuang Qiao */ +/** An aggregate function of [element] with values of type [V]: NULL when all its values are, or no rows match. */ +private fun Table<*>.numberAggregate(valueName: String, element: ClauseElement<*>): ClauseNumber = + ClauseNumber(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) + +/** A scalar function of [element] with values of type [V]: NULL when [element] is. */ +private fun Table<*>.numberFunction(valueName: String, element: ClauseElement<*>): ClauseNumber = + ClauseNumber(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = element.isAggregate, isNullOnNoRows = element.isNullOnNoRows) + +/** A scalar function of [element] with `String` values: NULL when [element] is. */ +private fun Table<*>.stringFunction(valueName: String, element: ClauseElement<*>): ClauseString = + ClauseString(valueName, this, isFunction = true, isNullable = element.isNullable, isAggregate = element.isAggregate, isNullOnNoRows = element.isNullOnNoRows) + /** * COUNT aggregate function - counts non-NULL values. * @@ -40,8 +59,8 @@ import com.ctrip.sqllin.dsl.sql.X * ``` */ @FunctionDslMaker -public fun Table.count(element: ClauseElement): ClauseNumber = - ClauseNumber("count(${element.valueName})", this, true) +public fun Table.count(element: ClauseElement<*>): ClauseNumber = + ClauseNumber("count(${element.valueName})", this, isFunction = true, isNullable = false, isAggregate = true, isNullOnNoRows = false) /** * COUNT(*) aggregate function - counts all rows (including NULLs). @@ -52,36 +71,98 @@ public fun Table.count(element: ClauseElement): ClauseNumber = * ``` */ @FunctionDslMaker -public fun Table.count(x: X): ClauseNumber = - ClauseNumber("count(*)", this, true) +public fun Table.count(x: X): ClauseNumber = + ClauseNumber("count(*)", this, isFunction = true, isNullable = false, isAggregate = true, isNullOnNoRows = false) /** - * AVG aggregate function - returns average value. + * AVG aggregate function - returns average value, as a `Double`. */ @FunctionDslMaker -public fun Table.avg(element: ClauseElement): ClauseNumber = - ClauseNumber("avg(${element.valueName})", this, true) +public fun Table.avg(element: ClauseElement<*>): ClauseNumber = + numberAggregate("avg(${element.valueName})", element) /** * SUM aggregate function - returns sum of values. + * + * The sum of integers is a `Long`, and the sum of reals a `Double`, so there is one overload per column type, and + * one for a Boolean column, whose sum counts the `true` values. There is none for `ULong`, as SQLite stores values + * above `Long.MAX_VALUE` as negative numbers, which would make the sum wrong. */ @FunctionDslMaker -public fun Table.sum(element: ClauseElement): ClauseNumber = - ClauseNumber("sum(${element.valueName})", this, true) +@JvmName("sumOfByte") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Short` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfShort") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of an `Int` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfInt") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Long` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfLong") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UByte` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUByte") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UShort` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUShort") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `UInt` column - returns a `Long`. */ +@FunctionDslMaker +@JvmName("sumOfUInt") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Float` column - returns a `Double`. */ +@FunctionDslMaker +@JvmName("sumOfFloat") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a `Double` column - returns a `Double`. */ +@FunctionDslMaker +@JvmName("sumOfDouble") +public fun Table.sum(element: ClauseNumber): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) + +/** SUM aggregate function of a Boolean column - returns the number of `true` values, as a `Long`. */ +@FunctionDslMaker +public fun Table.sum(element: ClauseBoolean): ClauseNumber = + numberAggregate("sum(${element.valueName})", element) /** - * MAX aggregate function - returns maximum value. + * MAX aggregate function - returns maximum value, of the same type as [element]: a `max` of an `Int` column is an + * `Int`, and of a String column a String. */ +@Suppress("UNCHECKED_CAST") @FunctionDslMaker -public fun Table.max(element: ClauseElement): ClauseNumber = - ClauseNumber("max(${element.valueName})", this, true) +public fun > Table.max(element: E): E = + element.toAggregate("max(${element.valueName})", this) as E /** - * MIN aggregate function - returns minimum value. + * MIN aggregate function - returns minimum value, of the same type as [element]: a `min` of an `Int` column is an + * `Int`, and of a String column a String. */ +@Suppress("UNCHECKED_CAST") @FunctionDslMaker -public fun Table.min(element: ClauseElement): ClauseNumber = - ClauseNumber("min(${element.valueName})", this, true) +public fun > Table.min(element: E): E = + element.toAggregate("min(${element.valueName})", this) as E /** * GROUP_CONCAT aggregate function - concatenates all non-NULL values in a group with a separator. @@ -100,15 +181,15 @@ public fun Table.min(element: ClauseElement): ClauseNumber = * @return ClauseString representing the concatenated result */ @FunctionDslMaker -public fun Table.group_concat(element: ClauseString, infix: String): ClauseString = - ClauseString("group_concat(${element.valueName},'$infix')", this, true) +public fun Table.group_concat(element: ClauseString<*>, infix: String): ClauseString = + ClauseString("group_concat(${element.valueName},'$infix')", this, isFunction = true, isNullable = element.isNullable, isAggregate = true, isNullOnNoRows = true) /** - * ABS scalar function - returns absolute value. + * ABS scalar function - returns absolute value, of the same type as [element]. */ @FunctionDslMaker -public fun Table.abs(element: ClauseNumber): ClauseNumber = - ClauseNumber("abs(${element.valueName})", this, true) +public fun Table.abs(element: ClauseNumber): ClauseNumber = + numberFunction("abs(${element.valueName})", element) /** * ROUND scalar function - rounds a number to a specified number of decimal places. @@ -124,11 +205,11 @@ public fun Table.abs(element: ClauseNumber): ClauseNumber = * * @param element The numeric value to round * @param digits The number of decimal places to round to - * @return ClauseNumber representing the rounded value + * @return ClauseNumber representing the rounded value, a `Double` even for an integer column */ @FunctionDslMaker -public fun Table.round(element: ClauseNumber, digits: Int): ClauseNumber = - ClauseNumber("round(${element.valueName},$digits)", this, true) +public fun Table.round(element: ClauseNumber<*>, digits: Int): ClauseNumber = + numberFunction("round(${element.valueName},$digits)", element) /** * RANDOM scalar function - returns a pseudo-random integer. @@ -144,8 +225,8 @@ public fun Table.round(element: ClauseNumber, digits: Int): ClauseNumber * @return ClauseNumber representing the random integer */ @FunctionDslMaker -public fun Table.random(): ClauseNumber = - ClauseNumber("random()", this, true) +public fun Table.random(): ClauseNumber = + ClauseNumber("random()", this, isFunction = true, isNullable = false, isAggregate = false, isNullOnNoRows = false) /** * SIGN scalar function - returns the sign of a number. @@ -164,29 +245,29 @@ public fun Table.random(): ClauseNumber = * @return ClauseNumber representing -1, 0, or 1 */ /* @FunctionDslMaker - public fun Table.sign(element: ClauseNumber): ClauseNumber = - ClauseNumber("sign(${element.valueName})", this, true) */ + public fun Table.sign(element: ClauseNumber<*>): ClauseNumber = + numberFunction("sign(${element.valueName})", element) */ /** * UPPER scalar function - converts string to uppercase. */ @FunctionDslMaker -public fun Table.upper(element: ClauseString): ClauseString = - ClauseString("upper(${element.valueName})", this, true) +public fun Table.upper(element: ClauseString<*>): ClauseString = + stringFunction("upper(${element.valueName})", element) /** * LOWER scalar function - converts string to lowercase. */ @FunctionDslMaker -public fun Table.lower(element: ClauseString): ClauseString = - ClauseString("lower(${element.valueName})", this, true) +public fun Table.lower(element: ClauseString<*>): ClauseString = + stringFunction("lower(${element.valueName})", element) /** * LENGTH scalar function - returns string/blob length in bytes. */ @FunctionDslMaker -public fun Table.length(element: ClauseString): ClauseNumber = - ClauseNumber("length(${element.valueName})", this, true) +public fun Table.length(element: ClauseString<*>): ClauseNumber = + numberFunction("length(${element.valueName})", element) /** * LENGTH scalar function - returns the length of a BLOB in bytes. @@ -203,8 +284,8 @@ public fun Table.length(element: ClauseString): ClauseNumber = * @return ClauseNumber representing the length in bytes */ @FunctionDslMaker -public fun Table.length(element: ClauseBlob): ClauseNumber = - ClauseNumber("length(${element.valueName})", this, true) +public fun Table.length(element: ClauseBlob): ClauseNumber = + numberFunction("length(${element.valueName})", element) /** * SUBSTR scalar function - extracts a substring from a string. @@ -224,8 +305,8 @@ public fun Table.length(element: ClauseBlob): ClauseNumber = * @return ClauseString representing the extracted substring */ @FunctionDslMaker -public fun Table.substr(element: ClauseString, start: Int, len: Int): ClauseString = - ClauseString("substr(${element.valueName},$start,$len)", this, true) +public fun Table.substr(element: ClauseString<*>, start: Int, len: Int): ClauseString = + stringFunction("substr(${element.valueName},$start,$len)", element) /** * TRIM scalar function - removes leading and trailing whitespace from a string. @@ -242,8 +323,8 @@ public fun Table.substr(element: ClauseString, start: Int, len: Int): Cla * @return ClauseString with whitespace removed from both ends */ @FunctionDslMaker -public fun Table.trim(element: ClauseString): ClauseString = - ClauseString("trim(${element.valueName})", this, true) +public fun Table.trim(element: ClauseString<*>): ClauseString = + stringFunction("trim(${element.valueName})", element) /** * LTRIM scalar function - removes leading (left) whitespace from a string. @@ -260,8 +341,8 @@ public fun Table.trim(element: ClauseString): ClauseString = * @return ClauseString with leading whitespace removed */ @FunctionDslMaker -public fun Table.ltrim(element: ClauseString): ClauseString = - ClauseString("ltrim(${element.valueName})", this, true) +public fun Table.ltrim(element: ClauseString<*>): ClauseString = + stringFunction("ltrim(${element.valueName})", element) /** * RTRIM scalar function - removes trailing (right) whitespace from a string. @@ -278,8 +359,8 @@ public fun Table.ltrim(element: ClauseString): ClauseString = * @return ClauseString with trailing whitespace removed */ @FunctionDslMaker -public fun Table.rtrim(element: ClauseString): ClauseString = - ClauseString("rtrim(${element.valueName})", this, true) +public fun Table.rtrim(element: ClauseString<*>): ClauseString = + stringFunction("rtrim(${element.valueName})", element) /** * REPLACE scalar function - replaces all occurrences of a substring with another string. @@ -298,8 +379,8 @@ public fun Table.rtrim(element: ClauseString): ClauseString = * @return ClauseString with replacements applied */ @FunctionDslMaker -public fun Table.replace(element: ClauseString, old: String, new: String): ClauseString = - ClauseString("replace(${element.valueName},'$old','$new')", this, true) +public fun Table.replace(element: ClauseString<*>, old: String, new: String): ClauseString = + stringFunction("replace(${element.valueName},'$old','$new')", element) /** * INSTR scalar function - finds the first occurrence of a substring. @@ -318,8 +399,8 @@ public fun Table.replace(element: ClauseString, old: String, new: String) * @return ClauseNumber representing the position (1-indexed) or 0 if not found */ @FunctionDslMaker -public fun Table.instr(element: ClauseString, sub: String): ClauseNumber = - ClauseNumber("instr(${element.valueName},'$sub')", this, true) +public fun Table.instr(element: ClauseString<*>, sub: String): ClauseNumber = + numberFunction("instr(${element.valueName},'$sub')", element) /** * PRINTF scalar function - formats a string according to a format specification. @@ -338,5 +419,5 @@ public fun Table.instr(element: ClauseString, sub: String): ClauseNumber * @return ClauseString with the formatted result */ @FunctionDslMaker -public fun Table.printf(format: String, element: ClauseString): ClauseString = - ClauseString("printf('$format',${element.valueName})", this, true) \ No newline at end of file +public fun Table.printf(format: String, element: ClauseString<*>): ClauseString = + stringFunction("printf('$format',${element.valueName})", element) \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt index c1824149..d9626307 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/GroupByClause.kt @@ -21,6 +21,7 @@ package com.ctrip.sqllin.dsl.sql.clause import com.ctrip.sqllin.dsl.annotation.StatementDslMaker import com.ctrip.sqllin.dsl.sql.statement.GroupBySelectStatement import com.ctrip.sqllin.dsl.sql.statement.JoinSelectStatement +import com.ctrip.sqllin.dsl.sql.statement.ResultColumnSelectStatement import com.ctrip.sqllin.dsl.sql.statement.WhereSelectStatement /** @@ -37,7 +38,7 @@ import com.ctrip.sqllin.dsl.sql.statement.WhereSelectStatement * * @author Yuang Qiao */ -public class GroupByClause internal constructor(private val columnNames: Iterable) : SelectClause { +public class GroupByClause internal constructor(private val columnNames: Iterable>) : SelectClause { override val clauseStr: String get() = buildString { @@ -56,29 +57,40 @@ public class GroupByClause internal constructor(private val columnNames: Iter * Creates a GROUP BY clause for aggregating rows. */ @StatementDslMaker -public fun GROUP_BY(vararg elements: ClauseElement): GroupByClause = GroupByClause(elements.toList()) +public fun GROUP_BY(vararg elements: ClauseElement<*>): GroupByClause = GroupByClause(elements.toList()) @StatementDslMaker -public infix fun WhereSelectStatement.GROUP_BY(element: ClauseElement): GroupBySelectStatement = +public infix fun WhereSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = appendToGroupBy(GroupByClause(listOf(element))).also { container changeLastStatement it } @StatementDslMaker -public infix fun WhereSelectStatement.GROUP_BY(elements: Iterable): GroupBySelectStatement { +public infix fun WhereSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { val statement = appendToGroupBy(GroupByClause(elements)) container changeLastStatement statement return statement } @StatementDslMaker -public infix fun JoinSelectStatement.GROUP_BY(element: ClauseElement): GroupBySelectStatement = +public infix fun JoinSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = appendToGroupBy(GroupByClause(listOf(element))).also { container changeLastStatement it } @StatementDslMaker -public infix fun JoinSelectStatement.GROUP_BY(elements: Iterable): GroupBySelectStatement { +public infix fun JoinSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { + val statement = appendToGroupBy(GroupByClause(elements)) + container changeLastStatement statement + return statement +} + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.GROUP_BY(element: ClauseElement<*>): GroupBySelectStatement = + GROUP_BY(listOf(element)) + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.GROUP_BY(elements: Iterable>): GroupBySelectStatement { val statement = appendToGroupBy(GroupByClause(elements)) container changeLastStatement statement return statement diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt index f74dee3e..7f961434 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/LimitClause.kt @@ -72,6 +72,12 @@ public infix fun JoinSelectStatement.LIMIT(count: Int): LimitSelectStatem container changeLastStatement it } +@StatementDslMaker +public infix fun ResultColumnSelectStatement.LIMIT(count: Int): LimitSelectStatement = + appendToLimit(LimitClause(count)).also { + container changeLastStatement it + } + /** * OFFSET clause for skipping rows in a SELECT query (pagination). * diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt index 8e332c19..90d1e69c 100644 --- a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/OrderByClause.kt @@ -37,7 +37,7 @@ import com.ctrip.sqllin.dsl.sql.statement.* */ public sealed interface OrderByClause : SelectClause -internal class CompleteOrderByClause(private val column2WayMap: Map) : OrderByClause { +internal class CompleteOrderByClause(private val column2WayMap: Map, OrderByWay>) : OrderByClause { override val clauseStr: String get() { @@ -69,50 +69,60 @@ public enum class OrderByWay(internal val str: String) { } @StatementDslMaker -public fun ORDER_BY(vararg column2Ways: Pair): OrderByClause = +public fun ORDER_BY(vararg column2Ways: Pair, OrderByWay>): OrderByClause = CompleteOrderByClause(mapOf(*column2Ways)) @StatementDslMaker -public inline infix fun WhereSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun WhereSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun WhereSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun WhereSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun HavingSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun HavingSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun HavingSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun HavingSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun GroupBySelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun GroupBySelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun GroupBySelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun GroupBySelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun JoinSelectStatement.ORDER_BY(column2Way: Pair): OrderBySelectStatement = +public inline infix fun JoinSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = ORDER_BY(mapOf(column2Way)) @StatementDslMaker -public infix fun JoinSelectStatement.ORDER_BY(column2WayMap: Map): OrderBySelectStatement = +public infix fun JoinSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { container changeLastStatement it } -internal class SimpleOrderByClause(private val columns: Iterable) : OrderByClause { +@StatementDslMaker +public infix fun ResultColumnSelectStatement.ORDER_BY(column2Way: Pair, OrderByWay>): OrderBySelectStatement = + ORDER_BY(mapOf(column2Way)) + +@StatementDslMaker +public infix fun ResultColumnSelectStatement.ORDER_BY(column2WayMap: Map, OrderByWay>): OrderBySelectStatement = + appendToOrderBy(CompleteOrderByClause(column2WayMap)).also { + container changeLastStatement it + } + +internal class SimpleOrderByClause(private val columns: Iterable>) : OrderByClause { override val clauseStr: String get() { @@ -130,45 +140,55 @@ internal class SimpleOrderByClause(private val columns: Iterable ORDER_BY(vararg elements: ClauseElement): OrderByClause = +public fun ORDER_BY(vararg elements: ClauseElement<*>): OrderByClause = SimpleOrderByClause(elements.toList()) @StatementDslMaker -public inline infix fun WhereSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun WhereSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = + ORDER_BY(listOf(column)) + +@StatementDslMaker +public infix fun WhereSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = + appendToOrderBy(SimpleOrderByClause(columns)).also { + container changeLastStatement it + } + +@StatementDslMaker +public inline infix fun HavingSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun WhereSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun HavingSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun HavingSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun GroupBySelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun HavingSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun GroupBySelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun GroupBySelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public inline infix fun JoinSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun GroupBySelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun JoinSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } @StatementDslMaker -public inline infix fun JoinSelectStatement.ORDER_BY(column: ClauseElement): OrderBySelectStatement = +public infix fun ResultColumnSelectStatement.ORDER_BY(column: ClauseElement<*>): OrderBySelectStatement = ORDER_BY(listOf(column)) @StatementDslMaker -public infix fun JoinSelectStatement.ORDER_BY(columns: Iterable): OrderBySelectStatement = +public infix fun ResultColumnSelectStatement.ORDER_BY(columns: Iterable>): OrderBySelectStatement = appendToOrderBy(SimpleOrderByClause(columns)).also { container changeLastStatement it } \ No newline at end of file diff --git a/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt new file mode 100644 index 00000000..ceb5a3f3 --- /dev/null +++ b/sqllin-dsl/src/commonMain/kotlin/com/ctrip/sqllin/dsl/sql/clause/ResultColumn.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2026 Ctrip.com. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("DSL_MARKER_APPLIED_TO_WRONG_TARGET") + +package com.ctrip.sqllin.dsl.sql.clause + +import com.ctrip.sqllin.dsl.annotation.StatementDslMaker +import kotlin.reflect.KProperty1 + +/** + * An expression selected into a property of the result type [R], as `count(X) AS AuthorStats::books` selects + * `count(*) AS books` into `AuthorStats.books`. + * + * A SELECT reads its rows into [R] by the names of [R]'s properties: a property is read from the column of the same + * name, unless a result column gives it an expression. So the result columns of a SELECT name only the properties that + * hold expressions, such as aggregate functions, and every other property is read from its column: + * + * ```kotlin + * @Serializable + * data class AuthorStats(val author: String, val books: Long, val totalPages: Long?) + * + * BookTable { table -> + * table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author + * } + * // SELECT author,count(*) AS books,sum(pages) AS totalPages FROM book GROUP BY author + * ``` + * + * @param R The result type the expression is selected into + * + * @author Yuang Qiao + */ +public class ResultColumn internal constructor( + internal val element: ClauseElement<*>, + internal val propertyName: String, +) + +/** + * Selects this element into [property] of the result type [R], as the SQL `expression AS name` does. + * + * The property's type has to be the type of the element's values, which is checked at compile time: `count(X)` goes + * into a `Long` property, and not into an `Int` or a `String` one. Whether the property has to be nullable depends on + * the rest of the query, as an aggregate function such as `sum` is NULL when no rows match, but not in a group of + * GROUP BY, so it is checked when the statement is built, before anything runs. + * + * The property is matched by its name, so it can't be renamed with `@SerialName`. + * + * @param property The property of the result type that receives the element's value + * @return The result column, to give to `SELECT`, alone or in a list + */ +@StatementDslMaker +public infix fun ClauseElement

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