Skip to content

Release/2.4.0 - #128

Merged
qiaoyuang merged 35 commits into
mainfrom
release/2.4.0
Oct 3, 2026
Merged

qiaoyuang merged 35 commits into
mainfrom
release/2.4.0

Conversation

@qiaoyuang

Copy link
Copy Markdown
Collaborator

All

  • Update Kotlin's version to 2.4.20
  • Update AGP's version to 9.4.1
  • Migrate the Android instrumented tests to Robolectric, they now run on the JVM as host unit tests against API 26 and API 37, and no longer need an emulator
  • Move the sqllin-driver tests back into the sqllin-driver module's commonTest, and remove the sqllin-driver-test module

sqllin-dsl

  • New DSL API: DatabaseScope#INSERT_OR_IGNORE for SQL syntax INSERT OR IGNORE
  • New DSL API: projection, which reads SELECT results into a narrower @Serializable type naming the columns to select, given as the type argument of the clause function: X<R>(), WHERE<R>(...), ORDER_BY<R>(...), LIMIT<R>(...) and GROUP_BY<R>(...), 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<Archive>(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<Int> for an Int column and ClauseNumber<Long> 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<Long>, avg and round a ClauseNumber<Double>, the string functions and group_concat a ClauseString<String>, 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<Long>, and of a Float or Double column a ClauseNumber<Double>. A sum of a String, BLOB, enum or ULong column no longer compiles; the last because SQLite stores a ULong above Long.MAX_VALUE as a negative number, which made the sum wrong
  • Fix documentation: the SQL functions guide listed a sign function, which isn't available, and its HAVING (count(X) > 2) example didn't compile; it is HAVING (count(X) GT 2). It no longer says that functions can only be used in conditions
  • Breaking change: The parameter of annotation @PrimaryKey renamed from isAutoincrement to autoIncrement, aligning it with the name already used in the documentation and with the naming of the other annotations. Call sites using the named argument @PrimaryKey(isAutoincrement = true) must be updated to @PrimaryKey(autoIncrement = true); positional usage such as @PrimaryKey(true) is unaffected
  • Breaking change: The nullability of a @PrimaryKey property now decides who supplies its value. A Long? key is assigned by the database, as before. A non-null Long key is now allowed: it remains an INTEGER PRIMARY KEY, a rowid alias, but is supplied by the caller and written by every INSERT. A key of any other type must be non-null and is declared NOT NULL. Previously every @PrimaryKey was forced to be nullable, against the annotation's own documentation, and because SQLite does not let PRIMARY KEY imply NOT NULL on such a column, a String key could hold NULL in any number of rows. autoIncrement = true now requires a Long? key, and a ULong? key, which used to be stored as NULL because it was left out of INSERT without being a rowid alias, is now rejected. To migrate, drop the ? from any non-Long @PrimaryKey; a single-column @CompositePrimaryKey that only existed to hold a caller-supplied Long key can become @PrimaryKey val id: Long
  • Breaking change: @CompositePrimaryKey now requires at least two properties. A single-column primary key is declared with @PrimaryKey, which for a Long key maps to INTEGER, a rowid alias, where a single-column @CompositePrimaryKey mapped it to BIGINT. To migrate, replace a lone @CompositePrimaryKey with @PrimaryKey
  • Breaking change: The DSL APIs ALERT_ADD_COLUMN and ALERT_RENAME_TABLE_TO renamed to ALTER_ADD_COLUMN and ALTER_RENAME_TABLE_TO, correcting a misspelling of the SQL keyword ALTER. The internal Alert operation object is renamed to Alter accordingly
  • Fix: the ALTER operations emitted the invalid keyword ALERT TABLE instead of ALTER TABLE, so ALTER_ADD_COLUMN, ALTER_RENAME_TABLE_TO, RENAME_COLUMN and DROP_COLUMN all failed at runtime and had never worked. The tests that covered them swallowed the failure, which is why it went unnoticed
  • Fix documentation: the KDoc of DatabaseScope now states that statement execution is deferred until the scope exits, and its example no longer reads a SelectStatement's results while the scope is still open, which throws
  • Fix documentation: the CREATE_INDEX and CREATE_UNIQUE_INDEX examples referenced a KClass.table extension that does not exist in the library, and referred to columns by property reference instead of through the generated table object
  • Fix: the SQL string functions added in 2.2.0, substr, trim, ltrim, rtrim, replace, instr and printf, now carry the same DSL marker as the other SQL functions, so IntelliJ IDEA highlights their calls the same way
  • Fix documentation: the installation guide now declares the task dependencies the generated code needs, as SQLlin's own builds always did. Without them Gradle fails the build, in particular when another KSP processor runs in the same module. It also states that each generated object is named after its class with a Table suffix, not after the table
  • Fix: the string arguments of replace, instr, printf and group_concat were put into the SQL between single quotes without escaping, so a ' in one broke the statement, and a crafted one could change what the statement does, such as making a condition true for every row. A ' is now escaped as '', the only escape SQLite has in a string literal, so any string stays a literal
  • Fix: a comparison between two elements, such as length(name) GT pages or HAVING (max(pages) GT min(pages)), qualified both with their table's name even when one was a function, producing invalid SQL such as book.length(name). A function is now written as it is

sqllin-driver

  • Update sqlite-jdbc's version to 3.53.4.0

sqllin-processor

  • Update KSP's version to 2.3.12
  • Fix: the visibility of the class annotated with @DBRow is now propagated to the generated table object. An internal @DBRow class used to produce a public object, which failed to compile with EXPOSED_SUPER_CLASS, EXPOSED_FUNCTION_RETURN_TYPE and EXPOSED_RECEIVER_TYPE. A @DBRow class that is neither public nor internal is now reported as an error
  • Fix: every SetClause property generated for a column declared after a nullable Long @PrimaryKey was typed nullable regardless of the column's own declaration, so UPDATE ... SET { column = null } compiled against NOT NULL columns and failed only at runtime. Each property now takes the nullability its own column declares
  • Fix: the columns of a @CompositePrimaryKey are now declared NOT NULL. SQLite, unlike standard SQL, does not let a table-level PRIMARY KEY imply it on a rowid table, so such a key used to accept NULL, and any number of rows sharing the same key once a NULL was part of it. SQLlin itself could not write those NULLs, but anything else writing to the database could, and SQLlin then read them back as 0 or an empty string. This only changes the schema of tables created from now on; an existing table keeps its schema, as SQLite cannot add NOT NULL to an existing column
  • Breaking change: an ON_DELETE_SET_DEFAULT or ON_UPDATE_SET_DEFAULT foreign key now requires the column to declare @Default, as the documentation always said. On @References the check was inverted: it accepted a non-null column without a default, whose parent row then could not be deleted (NOT NULL constraint failed), and rejected a nullable one. On @ForeignKey groups there was no check at all, so a nullable column without a default compiled and was set to NULL; that is now rejected too. To migrate, add @Default to the column, or use ON_DELETE_SET_NULL / ON_UPDATE_SET_NULL where setting it to NULL is the intent. Both checks hold whichever order @Default and the foreign key annotation are written in
  • Fix: a computed property of a @DBRow class, one without a backing field such as val title: String get() = ..., no longer becomes a column. kotlinx.serialization doesn't serialize such a property, but it was given a NOT NULL column that INSERT never wrote, so every insert failed with NOT NULL constraint failed, and an accessor that looked its column up past the end of the serializer's descriptor
  • Fix: a @DBRow property of a type no column can hold, such as a List, is now a compile-time error naming the property. It used to be skipped silently: left out of CREATE TABLE while its serializer still wrote and read it, so INSERT and SELECT failed at runtime with "no column named", and as the last property it left a trailing comma that made CREATE TABLE itself fail. Annotate such a property with kotlinx.serialization.Transient to keep it out of the table
  • Fix: the generated table objects no longer produce a DSL_MARKER_APPLIED_TO_WRONG_TARGET warning for every column, which Kotlin 2.3.20 and later report in the module that compiles them. Their @ColumnNameDslMaker is there for IntelliJ IDEA's DSL highlighting rather than for the compiler's DSL scope control, so the warning is now suppressed on each generated object
  • Fix: the generated SetClause setter of a non-null enum column no longer uses a safe call, value?.ordinal, which the module compiling the generated code reported as unnecessary. Only a nullable enum column keeps it

🤖 Generated with Claude Code

qiaoyuang and others added 30 commits September 17, 2026 10:05
* 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>
qiaoyuang and others added 5 commits October 3, 2026 13:30
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qiaoyuang
qiaoyuang merged commit b191559 into main Oct 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant