Release/2.4.0 - #128
Merged
Merged
Release/2.4.0#128
Conversation
* Migrate Android tests from instrumented tests to Robolectric. Upgrade libraries' and Kotlin's version Move test code from sqllin-driver-test back to sqllin-driver * Replace deprecated `by project` property delegates The `val name: Type by project` delegate syntax is deprecated and scheduled for removal in Gradle 10. Use `project.property(name)` as the deprecation warning recommends, in the group/version declarations and in the `mavenPublishing` POM blocks of sqllin-driver, sqllin-dsl and sqllin-processor. Verified by generating the POM for each module: groupId, version, url, license, developer and scm are all still populated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The annotation's parameter was named `isAutoincrement` while parts of the documentation referred to it as `autoIncrement`, so code copied from the docs failed to compile with "Cannot find a parameter with this name". `autoIncrement` is the better of the two names: Kotlin's `is` prefix convention applies to properties rather than annotation parameters, none of the other annotations (`@CompositeUnique`, `@ForeignKey`, `@References`, `@Default`) carry such a prefix, and `isAutoincrement` was itself inconsistent in its casing. This is a source-incompatible rename, so call sites passing the argument by name have to be updated. The processor reads the argument positionally, so the generated DDL is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t (B3) The processor always emitted a `public` table object, so an `internal` @DBRow class failed to compile with EXPOSED_SUPER_CLASS, EXPOSED_FUNCTION_RETURN_TYPE and EXPOSED_RECEIVER_TYPE. Keeping a data layer internal therefore forced the entities to be public, which on iOS also pushes them into the generated ObjC header. Since every generated member lives inside that object, narrowing the object alone narrows all of them; the `override`s cannot be narrowed individually anyway, as Kotlin forbids reducing an override's visibility. A @DBRow class that is neither public nor internal is now reported through KSPLogger instead of producing code that cannot compile, because the generated object lives in a different file and cannot reference a private entity. Covered by a new `internal` test entity: without the fix, the test module no longer compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The class KDoc said statements are executed in batch when the scope exits, but
its example then read a query's results inside the scope:
val adults = PersonTable SELECT WHERE(age GTE 18) LIMIT 10
`adults` is a statement, not a list, and calling `getResults()` on it there
throws IllegalStateException. The example now keeps the statement in a variable
declared outside the scope and reads it afterwards, and the KDoc states the rule
explicitly, including its consequence that a read-modify-write cannot be
expressed in a single scope.
The example also used bare column names outside the table object's scope, where
they do not resolve, so it would not have compiled as written. It is now
wrapped in `PersonTable { table -> ... }`; the whole example was transcribed
into the test module and compiled to confirm it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` KDoc examples were written as
User::class.table.CREATE_INDEX("idx_user_email", User::email)
but no `KClass.table` extension exists anywhere in the library, and the columns
are not Kotlin property references either — they are accessors on the generated
table object. Both examples now use the form the tests already exercise:
UserTable.CREATE_INDEX("idx_user_email", UserTable.email)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` misspelled the SQL keyword `ALTER`. They are renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, and the internal `Alert` operation object to `Alter`, along with every reference in the documentation, the KDoc and the tests. This is a source-incompatible rename of public API. The 2.2.0 entry in the change log still says `ALERT`, which is what that version actually shipped, so it is left as it is. Note that the operations still emit the invalid keyword "ALERT TABLE" and therefore still fail at runtime; that is a separate defect, fixed in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sts (B10) `Alter.sqlStr` produced the invalid keyword `ALERT TABLE`, so every ALTER operation — `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO` (both overloads), `RENAME_COLUMN` (both overloads) and `DROP_COLUMN` — failed at runtime and had never worked. The existing tests hid this. Each of their seven cases wrapped the operation in try/catch, swallowed the exception, and then asserted only that the rows were still present, so none of them asserted anything about the operation itself. Every case was also built on a statement that was invalid to begin with: adding a column that already existed, renaming a table to its own name, or renaming a column onto an existing column's name. They could not have passed even with the correct keyword. They are replaced by a single migration test that drives 'alter_target' from the shape of `AlterBefore` to the shape of `AlterAfter`, reading the table back through the entity that matches the shape it should have at each point, so a step that does not run fails the test instead of passing quietly. `AlterWithLegacy` serves as a probe for whether the dropped column is really gone. Two platform details shape the test. DROP COLUMN requires SQLite 3.35, which the Android framework bundles only from API 34 on, so it runs last and its effect is asserted only where the statement actually executes. The helper reads the query results rather than merely executing the statement, because the Android driver's `rawQuery` is lazy: a missing table or column surfaces only once the cursor is read. Verified on jvmTest, testAndroidHostTest (Robolectric API 26 and 37) and macosArm64Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The processor decided whether a generated `SetClause` property is nullable by
reading `ColumnConstraintParser.isRowId`, a parser-level flag that is set once
the `@PrimaryKey` column has been parsed and never reset. Every column declared
after a `Long?` primary key was therefore generated as nullable, whatever the
entity declared. For
data class PersonWithId(@PrimaryKey val id: Long?, val name: String, val age: Age)
`name` and `age` were generated as `String?` and `Int?`, so
`UPDATE SET { name = null }` compiled against a NOT NULL column and failed only
at runtime. The behaviour also depended on the order the properties were
declared in.
The flag was redundant even for the key itself, whose `Long?` type already makes
it nullable, so the branch is removed and each property takes the nullability of
its own column.
A compile-time check in the test module assigns the generated properties to
non-null variables; it fails to compile without this fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every `@PrimaryKey` property was required to be nullable, unconditionally. That
contradicted the annotation's own KDoc, which says a key of any type other than
Long must be non-null, and the error message for it, "The primary key must be
not-null.", said the opposite of what the check enforced. The test suite had
followed the check rather than the documentation (`@PrimaryKey val sku: String?`).
The cost was more than an inconvenience. A forced-nullable String key generated
`sku TEXT PRIMARY KEY`, and on a rowid table SQLite does not let PRIMARY KEY
imply NOT NULL for anything but an INTEGER PRIMARY KEY, so such a key accepted
NULL in any number of rows. Several tests inserted products with a NULL SKU.
In standard SQL a primary key is NOT NULL whoever supplies it; what differs is
only whether an INSERT may leave it out for the database to assign, and only a
rowid alias can be assigned. The Kotlin `?` therefore expresses "not assigned
yet", not "may be NULL", and that is what it now means:
- `Long?`: an INTEGER PRIMARY KEY the database assigns; a plain INSERT omits it.
- `Long`: still an INTEGER PRIMARY KEY, and still a rowid alias, but supplied by
the caller and written by every INSERT. This is new, and replaces the
single-column @CompositePrimaryKey that a caller-supplied numeric key used to
need, which produced `BIGINT ... PRIMARY KEY(id)`, not a rowid alias.
- any other type: supplied by the caller, must be non-null, and is declared
`PRIMARY KEY NOT NULL`.
`Long` and `Long?` keys produce the same DDL, so switching between them needs no
migration.
`autoIncrement = true` now requires a `Long?` key. A nullable key of any other
type is rejected, which also covers `ULong?`: it maps to BIGINT, was treated as a
rowid because the check accepted BIGINT, and so was left out of INSERT although
nothing assigned it, storing NULL.
`PrimaryKeyInfo.isRowId` is renamed to `isGeneratedByDatabase`, since a non-null
Long key is a rowid alias that the database does not generate. Its KDoc had
always described this meaning.
The rejection paths were verified by compiling entities that declare a `String?`
key, a `ULong?` key and an `autoIncrement` non-null `Long` key.
This is a source-incompatible change: drop the `?` from any non-Long @PrimaryKey.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Standard SQL accepts a one-column table constraint, `PRIMARY KEY(col)`, and it means the same as declaring `PRIMARY KEY` on the column itself, so this is not a question of SQL validity. It is one of API shape: `@CompositePrimaryKey` is documented as a key that "consists of multiple columns", and a single-column key already has `@PrimaryKey`. Allowing both was not harmless. Whether SQLite makes a single-column key a rowid alias depends only on its declared type being exactly INTEGER, and the two paths disagreed on that for a Long: `@PrimaryKey` maps it to INTEGER, a rowid alias, while `@CompositePrimaryKey` maps it to BIGINT, which is not. The same intent, a numeric key the caller supplies, therefore produced two different storage layouts depending on which annotation was picked. That was the only way to express such a key before `@PrimaryKey val id: Long` became possible. The processor now rejects a `@CompositePrimaryKey` that ends up with exactly one column, pointing to `@PrimaryKey`. The count is only known once every property has been parsed, so the check runs when the primary key metadata is generated. This is a source-incompatible change: replace a lone `@CompositePrimaryKey` with `@PrimaryKey`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `@CompositePrimaryKey` produced, for example,
enrollment(studentId BIGINT,courseId BIGINT,...,PRIMARY KEY(studentId,courseId))
On a rowid table SQLite, unlike standard SQL, does not let a table-level PRIMARY
KEY imply NOT NULL, so these columns accepted NULL, and with it the key stopped
identifying rows: two rows with the key (NULL, 101) are both accepted, because a
unique index treats every NULL as distinct.
SQLlin itself cannot write those NULLs. The key columns are non-null Kotlin
types, the generated SetClause properties are non-null since the previous fix,
and the processor already refuses an ON DELETE SET NULL foreign key on a
non-null column. Anything else writing to the database can, though, and SQLlin
then reads such a row back without complaint, as 0 or an empty string, so two
rows keyed (NULL, 101) surface as two entities keyed (0, 101).
NOT NULL used to be appended in two places: inside the @PrimaryKey branch, and
in a final `else if` that composite key columns never reached. It is now one
rule after the branch: every non-null column is declared NOT NULL except a rowid
alias, which is the only column SQLite itself keeps from being NULL. Across all
33 tables generated by the test module, the only DDL that changes is that of the
two composite-key tables.
This only affects tables created from now on. An existing table keeps its
schema, since SQLite cannot add NOT NULL to an existing column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ON DELETE / ON UPDATE SET DEFAULT writes the column's default value, which is NULL when the column declares none. The documentation, in both user guides and in the KDoc of @default, has always said that a default is required for these triggers, but the processor enforced something else on each of its two paths. On @references the check was inverted. It read check(isNotNull || hasDefaultValue) { "The column must be nullable or have a default value ..." } so it accepted a non-null column without a default, whose parent row then could not be deleted (NOT NULL constraint failed), and rejected a nullable one, while its own message described the opposite rule. On a @ForeignKeyGroup there was no check at all. Both paths now require @default. A nullable column without one is rejected too: setting it to its default would only set it to NULL, which is what ON_DELETE_SET_NULL already says, so it is most likely a forgotten @default. The group path cannot check where it reads @foreignkey, as its SET NULL check does, because @default may come later among the property's annotations, as it does in the test entity DefaultFKChild. The check is made once every annotation of the property has been read, so it holds whichever order they are written in. Verified by compiling entities that cover both paths, nullable and non-null columns, and @default before and after the foreign key annotation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The processor collected a class's properties with getAllProperties(), which
includes computed properties that have no backing field, such as
val title: String get() = "$name by $author"
kotlinx.serialization doesn't serialize those, yet each one was given a column,
NOT NULL when its type was non-null, and an accessor that looked the column up
by its index in the serializer's descriptor. INSERT writes only the serialized
properties, so it never filled that column and every insert failed with "NOT
NULL constraint failed", and the accessor's index ran past the end of the
descriptor.
The property list now keeps only properties backed by a field, besides leaving
out @transient ones, so it matches exactly what the serializer writes, in the
same order. A body property with an initializer has a backing field, is
serialized, and still gets its column.
Book now declares a computed `title`. Without this fix, four tests fail with
"NOT NULL constraint failed: book.title".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The processor skipped a property whose type it couldn't map to a column, such as a List, without saying anything. The property was left out of CREATE TABLE, but its serializer still wrote and read it, so INSERT failed at runtime with "has no column named" and SELECT with "no such column". When it was the last property, the comma already written after the previous column stayed in place, so CREATE TABLE itself failed with a syntax error. Such a property is now a compile-time error that names it, gives its type with its nullability, lists the supported types, and suggests @transient to keep it out of the table. Every unsupported property of a class is reported at once, with its location, and no table file is generated for the class. The check relies on the previous fix: a computed property isn't serialized, so it isn't checked, whatever its type. TestPrimitiveTypeForKSP now has a @transient List and a computed List, so the test module stops compiling if either exclusion breaks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…DSL markers (B11)
SQLlin's four @DslMarker annotations are applied to functions, properties and
enum entries. That is where IntelliJ IDEA looks for them when it gives DSL
calls one of its highlighting styles, which is what they are for. It is not
where the compiler's DSL scope control applies, which needs them on types, and
since Kotlin 2.3.20 the compiler warns about this use (KT-81567): 157 warnings
in sqllin-dsl, and two per column in every generated table, which land in the
build of each module that uses SQLlin.
The markers stay, since they do their job. The warning is suppressed:
- on each generated table object, as generated code is compiled in the
user's module;
- in sqllin-dsl, at the narrowest scope that doesn't repeat itself: on a file
or class where more than half of the declarations carry a marker, and on
each such declaration otherwise.
The KDoc of the markers said they prevent implicit receiver nesting, which they
never did. It now describes what they are for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The setter generated for an enum column's SetClause property always appended `value?.ordinal`. The type of `value` is the property's type, which, since the fix to that property's nullability (B12), is non-null whenever the column is. For such a column the safe call is unnecessary, and the module compiling the generated code reported it, once per non-null enum column. B12 made this more common: before it, every column declared after a `Long?` primary key had been generated as nullable, which happened to make the safe call necessary. The setter is now given the same nullability that decides the property's type, and only a nullable enum column keeps the safe call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…B17) Every SQL function in Function.kt carries @FunctionDslMaker, which is how IntelliJ IDEA gives their calls a DSL highlighting style, except the seven string functions added in 2.2.0: substr, trim, ltrim, rtrim, replace, instr and printf. Their calls were therefore not highlighted like the rest. They now carry the marker too. The annotation has no effect at runtime, and the file already suppresses the compiler's warning about markers applied to functions, so nothing else changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…og (B13) The entry for b15d963 called it a fix, but one of the cases it now rejects used to work: a nullable column in a @ForeignKeyGroup with an ON ... SET DEFAULT trigger and no @default compiled, and deleting the parent row set the column to NULL. Such code no longer compiles, so the entry is now marked as a breaking change and says how to migrate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The installation guide added the generated directory to commonMain's sources but never made the tasks that read it depend on kspCommonMainKotlinMetadata. Gradle fails the build when a task reads another task's output without depending on it, and the generated sources are read by every Kotlin compilation and, once a module also runs another KSP processor such as Room or Koin Annotations, by that processor's KSP tasks as well, which a rule matching only compilation tasks does not cover. SQLlin's own sample and test builds have always declared the rule that covers both, matching compilation tasks by type and KSP tasks by name. The guide now shows exactly that rule, in English and in Chinese, and says why it is needed. It also states that each @DBRow class gets an object named after the class with a Table suffix, whatever the table is called, which the guide only implied through its examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename @PrimaryKey's parameter from `isAutoincrement` to `autoIncrement` The annotation's parameter was named `isAutoincrement` while parts of the documentation referred to it as `autoIncrement`, so code copied from the docs failed to compile with "Cannot find a parameter with this name". `autoIncrement` is the better of the two names: Kotlin's `is` prefix convention applies to properties rather than annotation parameters, none of the other annotations (`@CompositeUnique`, `@ForeignKey`, `@References`, `@Default`) carry such a prefix, and `isAutoincrement` was itself inconsistent in its casing. This is a source-incompatible rename, so call sites passing the argument by name have to be updated. The processor reads the argument positionally, so the generated DDL is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Propagate the @DBRow entity's visibility to the generated table object (B3) The processor always emitted a `public` table object, so an `internal` @DBRow class failed to compile with EXPOSED_SUPER_CLASS, EXPOSED_FUNCTION_RETURN_TYPE and EXPOSED_RECEIVER_TYPE. Keeping a data layer internal therefore forced the entities to be public, which on iOS also pushes them into the generated ObjC header. Since every generated member lives inside that object, narrowing the object alone narrows all of them; the `override`s cannot be narrowed individually anyway, as Kotlin forbids reducing an override's visibility. A @DBRow class that is neither public nor internal is now reported through KSPLogger instead of producing code that cannot compile, because the generated object lives in a different file and cannot reference a private entity. Covered by a new `internal` test entity: without the fix, the test module no longer compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Document that DatabaseScope defers execution to scope exit (B5) The class KDoc said statements are executed in batch when the scope exits, but its example then read a query's results inside the scope: val adults = PersonTable SELECT WHERE(age GTE 18) LIMIT 10 `adults` is a statement, not a list, and calling `getResults()` on it there throws IllegalStateException. The example now keeps the statement in a variable declared outside the scope and reads it afterwards, and the KDoc states the rule explicitly, including its consequence that a read-modify-write cannot be expressed in a single scope. The example also used bare column names outside the table object's scope, where they do not resolve, so it would not have compiled as written. It is now wrapped in `PersonTable { table -> ... }`; the whole example was transcribed into the test module and compiled to confirm it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix the index examples referencing a non-existent `KClass.table` (B8) The `CREATE_INDEX` and `CREATE_UNIQUE_INDEX` KDoc examples were written as User::class.table.CREATE_INDEX("idx_user_email", User::email) but no `KClass.table` extension exists anywhere in the library, and the columns are not Kotlin property references either — they are accessors on the generated table object. Both examples now use the form the tests already exercise: UserTable.CREATE_INDEX("idx_user_email", UserTable.email) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Rename the ALERT_* DSL APIs to ALTER_* (B9) `ALERT_ADD_COLUMN` and `ALERT_RENAME_TABLE_TO` misspelled the SQL keyword `ALTER`. They are renamed to `ALTER_ADD_COLUMN` and `ALTER_RENAME_TABLE_TO`, and the internal `Alert` operation object to `Alter`, along with every reference in the documentation, the KDoc and the tests. This is a source-incompatible rename of public API. The 2.2.0 entry in the change log still says `ALERT`, which is what that version actually shipped, so it is left as it is. Note that the operations still emit the invalid keyword "ALERT TABLE" and therefore still fail at runtime; that is a separate defect, fixed in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Fix the ALTER operations emitting "ALERT TABLE", and rewrite their tests (B10) `Alter.sqlStr` produced the invalid keyword `ALERT TABLE`, so every ALTER operation — `ALTER_ADD_COLUMN`, `ALTER_RENAME_TABLE_TO` (both overloads), `RENAME_COLUMN` (both overloads) and `DROP_COLUMN` — failed at runtime and had never worked. The existing tests hid this. Each of their seven cases wrapped the operation in try/catch, swallowed the exception, and then asserted only that the rows were still present, so none of them asserted anything about the operation itself. Every case was also built on a statement that was invalid to begin with: adding a column that already existed, renaming a table to its own name, or renaming a column onto an existing column's name. They could not have passed even with the correct keyword. They are replaced by a single migration test that drives 'alter_target' from the shape of `AlterBefore` to the shape of `AlterAfter`, reading the table back through the entity that matches the shape it should have at each point, so a step that does not run fails the test instead of passing quietly. `AlterWithLegacy` serves as a probe for whether the dropped column is really gone. Two platform details shape the test. DROP COLUMN requires SQLite 3.35, which the Android framework bundles only from API 34 on, so it runs last and its effect is asserted only where the statement actually executes. The helper reads the query results rather than merely executing the statement, because the Android driver's `rawQuery` is lazy: a missing table or column surfaces only once the cursor is read. Verified on jvmTest, testAndroidHostTest (Robolectric API 26 and 37) and macosArm64Test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Generate each SetClause property with its own column's nullability (B12) The processor decided whether a generated `SetClause` property is nullable by reading `ColumnConstraintParser.isRowId`, a parser-level flag that is set once the `@PrimaryKey` column has been parsed and never reset. Every column declared after a `Long?` primary key was therefore generated as nullable, whatever the entity declared. For data class PersonWithId(@PrimaryKey val id: Long?, val name: String, val age: Age) `name` and `age` were generated as `String?` and `Int?`, so `UPDATE SET { name = null }` compiled against a NOT NULL column and failed only at runtime. The behaviour also depended on the order the properties were declared in. The flag was redundant even for the key itself, whose `Long?` type already makes it nullable, so the branch is removed and each property takes the nullability of its own column. A compile-time check in the test module assigns the generated properties to non-null variables; it fails to compile without this fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a @PrimaryKey's nullability decide who supplies its value (B2) Every `@PrimaryKey` property was required to be nullable, unconditionally. That contradicted the annotation's own KDoc, which says a key of any type other than Long must be non-null, and the error message for it, "The primary key must be not-null.", said the opposite of what the check enforced. The test suite had followed the check rather than the documentation (`@PrimaryKey val sku: String?`). The cost was more than an inconvenience. A forced-nullable String key generated `sku TEXT PRIMARY KEY`, and on a rowid table SQLite does not let PRIMARY KEY imply NOT NULL for anything but an INTEGER PRIMARY KEY, so such a key accepted NULL in any number of rows. Several tests inserted products with a NULL SKU. In standard SQL a primary key is NOT NULL whoever supplies it; what differs is only whether an INSERT may leave it out for the database to assign, and only a rowid alias can be assigned. The Kotlin `?` therefore expresses "not assigned yet", not "may be NULL", and that is what it now means: - `Long?`: an INTEGER PRIMARY KEY the database assigns; a plain INSERT omits it. - `Long`: still an INTEGER PRIMARY KEY, and still a rowid alias, but supplied by the caller and written by every INSERT. This is new, and replaces the single-column @CompositePrimaryKey that a caller-supplied numeric key used to need, which produced `BIGINT ... PRIMARY KEY(id)`, not a rowid alias. - any other type: supplied by the caller, must be non-null, and is declared `PRIMARY KEY NOT NULL`. `Long` and `Long?` keys produce the same DDL, so switching between them needs no migration. `autoIncrement = true` now requires a `Long?` key. A nullable key of any other type is rejected, which also covers `ULong?`: it maps to BIGINT, was treated as a rowid because the check accepted BIGINT, and so was left out of INSERT although nothing assigned it, storing NULL. `PrimaryKeyInfo.isRowId` is renamed to `isGeneratedByDatabase`, since a non-null Long key is a rowid alias that the database does not generate. Its KDoc had always described this meaning. The rejection paths were verified by compiling entities that declare a `String?` key, a `ULong?` key and an `autoIncrement` non-null `Long` key. This is a source-incompatible change: drop the `?` from any non-Long @PrimaryKey. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject a @CompositePrimaryKey on a single property (B14) Standard SQL accepts a one-column table constraint, `PRIMARY KEY(col)`, and it means the same as declaring `PRIMARY KEY` on the column itself, so this is not a question of SQL validity. It is one of API shape: `@CompositePrimaryKey` is documented as a key that "consists of multiple columns", and a single-column key already has `@PrimaryKey`. Allowing both was not harmless. Whether SQLite makes a single-column key a rowid alias depends only on its declared type being exactly INTEGER, and the two paths disagreed on that for a Long: `@PrimaryKey` maps it to INTEGER, a rowid alias, while `@CompositePrimaryKey` maps it to BIGINT, which is not. The same intent, a numeric key the caller supplies, therefore produced two different storage layouts depending on which annotation was picked. That was the only way to express such a key before `@PrimaryKey val id: Long` became possible. The processor now rejects a `@CompositePrimaryKey` that ends up with exactly one column, pointing to `@PrimaryKey`. The count is only known once every property has been parsed, so the check runs when the primary key metadata is generated. This is a source-incompatible change: replace a lone `@CompositePrimaryKey` with `@PrimaryKey`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Declare the columns of a composite primary key NOT NULL (B4) A `@CompositePrimaryKey` produced, for example, enrollment(studentId BIGINT,courseId BIGINT,...,PRIMARY KEY(studentId,courseId)) On a rowid table SQLite, unlike standard SQL, does not let a table-level PRIMARY KEY imply NOT NULL, so these columns accepted NULL, and with it the key stopped identifying rows: two rows with the key (NULL, 101) are both accepted, because a unique index treats every NULL as distinct. SQLlin itself cannot write those NULLs. The key columns are non-null Kotlin types, the generated SetClause properties are non-null since the previous fix, and the processor already refuses an ON DELETE SET NULL foreign key on a non-null column. Anything else writing to the database can, though, and SQLlin then reads such a row back without complaint, as 0 or an empty string, so two rows keyed (NULL, 101) surface as two entities keyed (0, 101). NOT NULL used to be appended in two places: inside the @PrimaryKey branch, and in a final `else if` that composite key columns never reached. It is now one rule after the branch: every non-null column is declared NOT NULL except a rowid alias, which is the only column SQLite itself keeps from being NULL. Across all 33 tables generated by the test module, the only DDL that changes is that of the two composite-key tables. This only affects tables created from now on. An existing table keeps its schema, since SQLite cannot add NOT NULL to an existing column. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Require @default for an ON ... SET DEFAULT foreign key (B13) ON DELETE / ON UPDATE SET DEFAULT writes the column's default value, which is NULL when the column declares none. The documentation, in both user guides and in the KDoc of @default, has always said that a default is required for these triggers, but the processor enforced something else on each of its two paths. On @references the check was inverted. It read check(isNotNull || hasDefaultValue) { "The column must be nullable or have a default value ..." } so it accepted a non-null column without a default, whose parent row then could not be deleted (NOT NULL constraint failed), and rejected a nullable one, while its own message described the opposite rule. On a @ForeignKeyGroup there was no check at all. Both paths now require @default. A nullable column without one is rejected too: setting it to its default would only set it to NULL, which is what ON_DELETE_SET_NULL already says, so it is most likely a forgotten @default. The group path cannot check where it reads @foreignkey, as its SET NULL check does, because @default may come later among the property's annotations, as it does in the test entity DefaultFKChild. The check is made once every annotation of the property has been read, so it holds whichever order they are written in. Verified by compiling entities that cover both paths, nullable and non-null columns, and @default before and after the foreign key annotation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave computed properties of a @DBRow class out of the table (B16) The processor collected a class's properties with getAllProperties(), which includes computed properties that have no backing field, such as val title: String get() = "$name by $author" kotlinx.serialization doesn't serialize those, yet each one was given a column, NOT NULL when its type was non-null, and an accessor that looked the column up by its index in the serializer's descriptor. INSERT writes only the serialized properties, so it never filled that column and every insert failed with "NOT NULL constraint failed", and the accessor's index ran past the end of the descriptor. The property list now keeps only properties backed by a field, besides leaving out @transient ones, so it matches exactly what the serializer writes, in the same order. A body property with an initializer has a backing field, is serialized, and still gets its column. Book now declares a computed `title`. Without this fix, four tests fail with "NOT NULL constraint failed: book.title". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reject a @DBRow property whose type no column can hold (B15) The processor skipped a property whose type it couldn't map to a column, such as a List, without saying anything. The property was left out of CREATE TABLE, but its serializer still wrote and read it, so INSERT failed at runtime with "has no column named" and SELECT with "no such column". When it was the last property, the comma already written after the previous column stayed in place, so CREATE TABLE itself failed with a syntax error. Such a property is now a compile-time error that names it, gives its type with its nullability, lists the supported types, and suggests @transient to keep it out of the table. Every unsupported property of a class is reported at once, with its location, and no table file is generated for the class. The check relies on the previous fix: a computed property isn't serialized, so it isn't checked, whatever its type. TestPrimitiveTypeForKSP now has a @transient List and a computed List, so the test module stops compiling if either exclusion breaks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Suppress DSL_MARKER_APPLIED_TO_WRONG_TARGET where SQLlin applies its DSL markers (B11) SQLlin's four @DslMarker annotations are applied to functions, properties and enum entries. That is where IntelliJ IDEA looks for them when it gives DSL calls one of its highlighting styles, which is what they are for. It is not where the compiler's DSL scope control applies, which needs them on types, and since Kotlin 2.3.20 the compiler warns about this use (KT-81567): 157 warnings in sqllin-dsl, and two per column in every generated table, which land in the build of each module that uses SQLlin. The markers stay, since they do their job. The warning is suppressed: - on each generated table object, as generated code is compiled in the user's module; - in sqllin-dsl, at the narrowest scope that doesn't repeat itself: on a file or class where more than half of the declarations carry a marker, and on each such declaration otherwise. The KDoc of the markers said they prevent implicit receiver nesting, which they never did. It now describes what they are for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Generate a safe call only for a nullable enum column's setter (B18) The setter generated for an enum column's SetClause property always appended `value?.ordinal`. The type of `value` is the property's type, which, since the fix to that property's nullability (B12), is non-null whenever the column is. For such a column the safe call is unnecessary, and the module compiling the generated code reported it, once per non-null enum column. B12 made this more common: before it, every column declared after a `Long?` primary key had been generated as nullable, which happened to make the safe call necessary. The setter is now given the same nullability that decides the property's type, and only a nullable enum column keeps the safe call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Mark the SQL string functions added in 2.2.0 with @FunctionDslMaker (B17) Every SQL function in Function.kt carries @FunctionDslMaker, which is how IntelliJ IDEA gives their calls a DSL highlighting style, except the seven string functions added in 2.2.0: substr, trim, ltrim, rtrim, replace, instr and printf. Their calls were therefore not highlighted like the rest. They now carry the marker too. The annotation has no effect at runtime, and the file already suppresses the compiler's warning about markers applied to functions, so nothing else changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Mark the SET DEFAULT requirement as a breaking change in the change log (B13) The entry for b15d963 called it a fix, but one of the cases it now rejects used to work: a nullable column in a @ForeignKeyGroup with an ON ... SET DEFAULT trigger and no @default compiled, and deleting the parent row set the column to NULL. Such code no longer compiles, so the entry is now marked as a breaking change and says how to migrate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document the KSP task dependencies and the generated object's name (B19) The installation guide added the generated directory to commonMain's sources but never made the tasks that read it depend on kspCommonMainKotlinMetadata. Gradle fails the build when a task reads another task's output without depending on it, and the generated sources are read by every Kotlin compilation and, once a module also runs another KSP processor such as Room or Koin Annotations, by that processor's KSP tasks as well, which a rule matching only compilation tasks does not cover. SQLlin's own sample and test builds have always declared the rule that covers both, matching compilation tasks by type and KSP tasks by name. The guide now shows exactly that rule, in English and in Chinese, and says why it is needed. It also states that each @DBRow class gets an object named after the class with a Table suffix, whatever the table is called, which the guide only implied through its examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This reverts commit 96d05b5, the squash merge of #124. #124 fixes a set of independent issues, one commit each, and each commit message documents its own issue. Squashing folded them into a single commit and lost that per-issue history. This undoes the squash so that the same branch, feature/bug-fixes-2.4.0, can be merged again with a merge commit that keeps its individual commits. The re-merge brings back exactly the reverted content: the squashed commits were never part of this branch's history, so their merge base is still da96b10, where release/2.4.0 stood before the squash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revert "Bug fixes for 2.4.0 in sqllin-dsl and sqllin-processor (#124)"
Bug fixes for 2.4.0 in sqllin-dsl and sqllin-processor (re-land of #124)
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 <noreply@anthropic.com>
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<NameAndAge>()
PersonTable SELECT WHERE<NameAndAge>(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<R>(), 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 <noreply@anthropic.com>
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<R, P?> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<Archive>(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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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)<book.pages
which SQLite rejects with a syntax error; checked with sqlite-jdbc. A
comparison with a plain value already left a function unqualified, but the
comparisons between two elements, in ClauseNumber, ClauseString, ClauseBlob
and ClauseEnum, didn't check.
ClauseElement.appendSQL writes an element the way the value comparisons
always did, a column qualified by its table and a function as it is, and the
four element comparisons use it on both sides. Only comparisons involving a
function change, and those never worked.
testFunctionComparisons covers a function compared with a column, a column
with a function, two aggregates in HAVING, string functions, and max and min
of an enum column; the test fails with the old code. jvmTest (53) and
testAndroidHostTest on API 26 and 37 (106) pass, and the macosArm64 tests
compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New features for 2.4.0 in sqllin-dsl
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
All
Kotlin's version to2.4.20AGP's version to9.4.1Robolectric, they now run on the JVM as host unit tests againstAPI 26andAPI 37, and no longer need an emulatorsqllin-drivertests back into thesqllin-drivermodule'scommonTest, and remove thesqllin-driver-testmodulesqllin-dsl
DatabaseScope#INSERT_OR_IGNOREfor SQL syntaxINSERT OR IGNORESELECTresults into a narrower@Serializabletype naming the columns to select, given as the type argument of the clause function:X<R>(),WHERE<R>(...),ORDER_BY<R>(...),LIMIT<R>(...)andGROUP_BY<R>(...), afterSELECTorSELECT_DISTINCT. A type that doesn't fit the table is rejected with anIllegalArgumentExceptionwhen the statement is built. To support it, the publicDatabaseScope#selectfunctions 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 recompiledAS, as intable SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author, ortable 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 afterSELECTandSELECT_DISTINCT, followed byWHERE,GROUP_BY,ORDER_BYandLIMIT. A property must have the type of its expression's values, which is checked at compile time, and be nullable when its expression can beNULL, which is checked when the statement is built. An aggregate query withoutGROUP BYreturns a row even when no rows match, in which every column and every aggregate function exceptcountisNULL; asGROUP_BYcan still follow when the statement is built, this is checked when the scope ends, before any of its statements runsINSERT,INSERT_OR_IGNOREandINSERT_OR_REPLACEtaking aSelectStatementof the table's row type, for SQL syntaxINSERT INTO ... SELECT, as inArchiveTable INSERT (PersonTable SELECT WHERE<Archive>(PersonTable.age GT 60)). Every column is inserted, the primary key copied as it is selected. TheSELECTbecomes part of theINSERT, so it no longer runs on its ownTable#withName, which returns a table with the same structure under another name. WithINSERT INTO ... SELECT, it rebuilds a table in a migration, for a changeALTER TABLEcan'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 documentsINSERT_OR_IGNOREandINSERT_OR_REPLACEClauseElement,ClauseNumberandClauseStringnow take a type parameter, the type of their values, such asClauseNumber<Int>for anIntcolumn andClauseNumber<Long>forcount(X), which is what letsAScheck 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 asClauseElement<*>where any element is accepted. The public constructors ofClauseNumber,ClauseString,ClauseBoolean,ClauseBlobandClauseEnum, 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 recompiledcount,length,instrandrandomaClauseNumber<Long>,avgandroundaClauseNumber<Double>, the string functions andgroup_concataClauseString<String>, andmax,minandabsan element of the same kind and type as their argument. Somaxandminof a String column are now aClauseString, compared with strings inHAVING, where they used to be aClauseNumber.sumis overloaded by the type of its column: of a column of integers or Booleans it is aClauseNumber<Long>, and of aFloatorDoublecolumn aClauseNumber<Double>. Asumof a String, BLOB, enum orULongcolumn no longer compiles; the last because SQLite stores aULongaboveLong.MAX_VALUEas a negative number, which made the sum wrongsignfunction, which isn't available, and itsHAVING (count(X) > 2)example didn't compile; it isHAVING (count(X) GT 2). It no longer says that functions can only be used in conditions@PrimaryKeyrenamed fromisAutoincrementtoautoIncrement, 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@PrimaryKeyproperty now decides who supplies its value. ALong?key is assigned by the database, as before. A non-nullLongkey is now allowed: it remains anINTEGER PRIMARY KEY, a rowid alias, but is supplied by the caller and written by everyINSERT. A key of any other type must be non-null and is declaredNOT NULL. Previously every@PrimaryKeywas forced to be nullable, against the annotation's own documentation, and because SQLite does not letPRIMARY KEYimplyNOT NULLon such a column, aStringkey could holdNULLin any number of rows.autoIncrement = truenow requires aLong?key, and aULong?key, which used to be stored asNULLbecause it was left out ofINSERTwithout being a rowid alias, is now rejected. To migrate, drop the?from any non-Long@PrimaryKey; a single-column@CompositePrimaryKeythat only existed to hold a caller-suppliedLongkey can become@PrimaryKey val id: Long@CompositePrimaryKeynow requires at least two properties. A single-column primary key is declared with@PrimaryKey, which for aLongkey maps toINTEGER, a rowid alias, where a single-column@CompositePrimaryKeymapped it toBIGINT. To migrate, replace a lone@CompositePrimaryKeywith@PrimaryKeyALERT_ADD_COLUMNandALERT_RENAME_TABLE_TOrenamed toALTER_ADD_COLUMNandALTER_RENAME_TABLE_TO, correcting a misspelling of the SQL keywordALTER. The internalAlertoperation object is renamed toAlteraccordinglyALERT TABLEinstead ofALTER TABLE, soALTER_ADD_COLUMN,ALTER_RENAME_TABLE_TO,RENAME_COLUMNandDROP_COLUMNall failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticedDatabaseScopenow states that statement execution is deferred until the scope exits, and its example no longer reads aSelectStatement's results while the scope is still open, which throwsCREATE_INDEXandCREATE_UNIQUE_INDEXexamples referenced aKClass.tableextension that does not exist in the library, and referred to columns by property reference instead of through the generated table objectsubstr,trim,ltrim,rtrim,replace,instrandprintf, now carry the same DSL marker as the other SQL functions, so IntelliJ IDEA highlights their calls the same wayTablesuffix, not after the tablereplace,instr,printfandgroup_concatwere 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 literallength(name) GT pagesorHAVING (max(pages) GT min(pages)), qualified both with their table's name even when one was a function, producing invalid SQL such asbook.length(name). A function is now written as it issqllin-driver
sqlite-jdbc's version to3.53.4.0sqllin-processor
KSP's version to2.3.12@DBRowis now propagated to the generated table object. Aninternal@DBRowclass used to produce apublicobject, which failed to compile withEXPOSED_SUPER_CLASS,EXPOSED_FUNCTION_RETURN_TYPEandEXPOSED_RECEIVER_TYPE. A@DBRowclass that is neitherpublicnorinternalis now reported as an errorSetClauseproperty generated for a column declared after a nullableLong@PrimaryKeywas typed nullable regardless of the column's own declaration, soUPDATE ... SET { column = null }compiled againstNOT NULLcolumns and failed only at runtime. Each property now takes the nullability its own column declares@CompositePrimaryKeyare now declaredNOT NULL. SQLite, unlike standard SQL, does not let a table-levelPRIMARY KEYimply it on a rowid table, so such a key used to acceptNULL, and any number of rows sharing the same key once aNULLwas part of it. SQLlin itself could not write thoseNULLs, but anything else writing to the database could, and SQLlin then read them back as0or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot addNOT NULLto an existing columnON_DELETE_SET_DEFAULTorON_UPDATE_SET_DEFAULTforeign key now requires the column to declare@Default, as the documentation always said. On@Referencesthe check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (NOT NULL constraint failed), and rejected a nullable one. On@ForeignKeygroups there was no check at all, so a nullable column without a default compiled and was set toNULL; that is now rejected too. To migrate, add@Defaultto the column, or useON_DELETE_SET_NULL/ON_UPDATE_SET_NULLwhere setting it toNULLis the intent. Both checks hold whichever order@Defaultand the foreign key annotation are written in@DBRowclass, one without a backing field such asval title: String get() = ..., no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given aNOT NULLcolumn thatINSERTnever wrote, so every insert failed withNOT NULL constraint failed, and an accessor that looked its column up past the end of the serializer's descriptor@DBRowproperty of a type no column can hold, such as aList, is now a compile-time error naming the property. It used to be skipped silently: left out ofCREATE TABLEwhile its serializer still wrote and read it, soINSERTandSELECTfailed at runtime with "no column named", and as the last property it left a trailing comma that madeCREATE TABLEitself fail. Annotate such a property withkotlinx.serialization.Transientto keep it out of the tableDSL_MARKER_APPLIED_TO_WRONG_TARGETwarning for every column, which Kotlin 2.3.20 and later report in the module that compiles them. Their@ColumnNameDslMakeris there for IntelliJ IDEA's DSL highlighting rather than for the compiler's DSL scope control, so the warning is now suppressed on each generated objectSetClausesetter of a non-null enum column no longer uses a safe call,value?.ordinal, which the module compiling the generated code reported as unnecessary. Only a nullable enum column keeps it🤖 Generated with Claude Code