Skip to content

Release/2.5.0 - #130

Merged
qiaoyuang merged 45 commits into
mainfrom
release/2.5.0
Oct 11, 2026
Merged

qiaoyuang merged 45 commits into
mainfrom
release/2.5.0

Conversation

@qiaoyuang

Copy link
Copy Markdown
Collaborator

All

  • Update Kotlin's version to 2.4.21
  • Rebuild the SQLite static libraries in libs, which the native tests on Linux and Windows link, from SQLite 3.51.1 with FTS3, FTS4 (with the enhanced query syntax), FTS5, R*Tree, Geopoly, the math functions, dbstat and column metadata enabled, and without debug information, which held paths of the machine that built them

sqllin-dsl

  • New API: the opt-in annotation @PlatformDependentSQLiteAPI, which marks the APIs that only work with some SQLite versions or compile-time options, which some platforms SQLlin supports don't have, such as Android's system SQLite at lower API levels. Using one is a compile error unless it is accepted with @OptIn(PlatformDependentSQLiteAPI::class)
  • Breaking change: RENAME_COLUMN, which needs SQLite 3.25.0 (Android API 30), and DROP_COLUMN, which needs SQLite 3.35.0 (Android API 34), are marked with @PlatformDependentSQLiteAPI, so code using them has to opt in
  • New experimental DSL API: joins of relations. FROM(relation) starts a join, which INNER_JOIN, LEFT_OUTER_JOIN, RIGHT_OUTER_JOIN, FULL_OUTER_JOIN, CROSS_JOIN and the NATURAL joins extend, each with its own ON or USING, as in (FROM(PersonTable) LEFT_OUTER_JOIN BookTable ON (PersonTable.id EQ BookTable.authorId)) SELECT listOf(...). A join is selected from with a projection, a clause function such as WHERE<R>(...), or result columns, which can now select from a join, and the type its rows are read into is checked against the joined relations when the statement is built: every property has to name a column of one of them, of a single one unless USING or a NATURAL join merges them, and has to be nullable where an outer join can leave its column or expression NULL. Columns are written with their relations' names, and an observed query of a join watches all its relations. RIGHT_OUTER_JOIN, FULL_OUTER_JOIN and their NATURAL forms need SQLite 3.39.0 (Android API 34), so they are marked with @PlatformDependentSQLiteAPI
  • The joins written as clauses of SELECT, such as table SELECT INNER_JOIN<R>(other) ON (...), which don't check the type their rows are read into, will be removed in the next version after 2.5.0: use the joins of relations instead
  • CREATE_INDEX and CREATE_UNIQUE_INDEX document that a function of the table's columns makes an expression index, as lower(email) for case-insensitive lookups or uniqueness, and reject a column or function of another table
  • New experimental DSL API: IF NOT EXISTS and IF EXISTS, with CREATE_IF_NOT_EXISTS, CREATE_INDEX_IF_NOT_EXISTS, CREATE_UNIQUE_INDEX_IF_NOT_EXISTS, CREATE_VIEW_IF_NOT_EXISTS, DROP_IF_EXISTS and DROP_INDEX_IF_EXISTS, which do nothing when the table, FTS table, index or view exists, or doesn't, so that a migration step can run more than once
  • New experimental DSL API: DROP_INDEX(name), for SQL syntax DROP INDEX, which drops an index that CREATE_INDEX or CREATE_UNIQUE_INDEX created. The guide to modifying the database now describes indexes
  • New experimental DSL API: partial indexes. WHERE after CREATE_INDEX, CREATE_UNIQUE_INDEX or their IF_NOT_EXISTS forms indexes only the rows that satisfy a condition, as in UserTable.CREATE_UNIQUE_INDEX("idx_user_email", UserTable.email) WHERE (UserTable.isDeleted IS false). SQLite allows neither parameters nor subqueries there, so the values of the condition are written into the SQL, and a subquery is rejected with an IllegalArgumentException. To support it, these functions now return an IndexStatement, where they returned Unit; this is source-compatible, but a library compiled against an earlier version has to be recompiled
  • New experimental DSL API: DISTINCT(element), for aggregate functions of the distinct values, as in count(DISTINCT(author)) for count(DISTINCT book.author). count, avg, sum and group_concat take it, and group_concat separates the distinct values with commas, as SQLite allows no other separator with DISTINCT. What it returns, a Distinct, can't be used anywhere else, which is checked at compile time
  • New experimental DSL API: ESCAPE after LIKE, for SQL syntax LIKE pattern ESCAPE character, as in name LIKE "100\\%%" ESCAPE '\\', after which %, _ and the escape character match only themselves. To support it, LIKE now returns a LikeCondition, which is a SelectCondition; this is source-compatible, but a library compiled against an earlier version has to be recompiled
  • New experimental DSL API: VACUUM() and VACUUM_INTO(file), for SQL syntax VACUUM and VACUUM INTO, which rebuild the database file into the minimum disk space, or write a vacuumed copy of the database to another file, a consistent backup of a database in use. Both throw an IllegalStateException inside a transaction, where SQLite can't vacuum. VACUUM_INTO needs SQLite 3.27.0 (Android API 30), so it is marked with @PlatformDependentSQLiteAPI
  • New experimental DSL API: expressions. +, -, *, /, the unary - and, of integer types, % compute with numbers of the same type, and with values of their type, as in pages + 1, giving a number of that type, and + concatenates strings, SQL's ||. They work in result columns, conditions and ORDER BY. SQLite gives NULL for a division by zero, so / and % can be NULL unless the divisor is a value other than zero. literal(value) makes a value an expression, and CAST(x AS type) converts an expression to another type, named as SQLlin names the columns of that Kotlin type: TINYINT, SMALLINT, INT, BIGINT, FLOAT, DOUBLE, TEXT and BLOB. The values in an expression are written into its SQL, so that it can be part of a view, a trigger or an index, which SQLite doesn't let take parameters. A literal on its own in ORDER BY or GROUP BY is rejected, as SQLite would read it as the number of a result column
  • New experimental DSL API: more functions, whose arguments are expressions: coalesce, ifnull and nullif, the scalar max and min of several expressions, hex, quote, unicode, randomblob, zeroblob, trim, ltrim and rtrim with the characters to remove, substr from a position, printf of several values, substr, replace and instr of expressions, round to an integer, the aggregate total, and the date and time functions date, time, datetime, julianday and strftime. A function can only be NULL where its result can: coalesce where all its arguments can, and a date and time function always, but datetime("now"). sign (SQLite 3.35.0, Android API 34), unixepoch (SQLite 3.38.0, Android API 34) and the math functions ceil, floor, trunc, sqrt, exp, ln, log10, log2, pow, mod and pi, which Android's SQLite has at no API level, are marked with @PlatformDependentSQLiteAPI
  • New experimental DSL API: more expression syntax: CASE(WHEN(condition) THEN value, ..., ELSE = value), SQL's searched CASE, which is NULL where ELSE is omitted; scalar subqueries, select[R::property], a SELECT of a single column used as a value, which is checked when the statement is built, and whose tables an observed query watches; IS and ISNOT of two expressions, the null-safe comparisons, which do what IS DISTINCT FROM does on every SQLite version; the COLLATE operator with the collations BINARY, NOCASE and RTRIM; and the bitwise operators and, or, inv(), shl and shr of Int and Long
  • New experimental DSL API: SET and INSERT of expressions. SET(listOf(expression AS Row::property)) sets columns to expressions, as in table UPDATE SET(listOf((visits + 1) AS Person::visits)) WHERE (...), and INSERT, INSERT_OR_IGNORE and INSERT_OR_REPLACE take a row of expressions, table INSERT listOf(literal("purge") AS Audit::action, datetime("now") AS Audit::at), for SQL syntax INSERT INTO ... VALUES, leaving the other columns to their default values or NULL. AS checks the type of each expression at compile time, and the statement checks, when it is built, that an expression that can be NULL is only written to a nullable column, that it isn't an aggregate function, and that it only reads the columns of the updated table, or none for INSERT, where SQLite allows none
  • New experimental DSL API: triggers. CREATE_TRIGGER(name) AFTER DELETE ON PersonTable WHEN (condition) BEGIN { ... }, for SQL syntax CREATE TRIGGER, with BEFORE and AFTER, the events INSERT, DELETE, UPDATE and UPDATE_OF(columns), and CREATE_TRIGGER_IF_NOT_EXISTS. new(column) and old(column) read the row being written, and a row the trigger doesn't have, which SQLite would only report when the trigger fires, is rejected when the statement is built. The body inserts, updates and deletes rows, with expressions, and RAISE(ABORT, message), RAISE(FAIL, message), RAISE(ROLLBACK, message) and RAISE(IGNORE) stop the statement that fired the trigger or skip its row. The values of a trigger are written into its SQL, as SQLite doesn't let a trigger take parameters. A trigger can't be on an FTS table or a view; INSTEAD OF triggers on views are left to another item of the roadmap. DROP_TRIGGER(name) and DROP_TRIGGER_IF_EXISTS(name) drop one. An observed query of an FTS table now counts the writes to any table that has triggers as changes of the FTS table too, as a trigger can write it
  • New experimental DSL API: table INSERT DEFAULT_VALUES, for SQL syntax INSERT INTO ... DEFAULT VALUES, which inserts a row of the default values of all columns, with INSERT_OR_IGNORE and INSERT_OR_REPLACE too
  • New experimental DSL API: ANALYZE(), ANALYZE(table) and ANALYZE(name), for SQL syntax ANALYZE, which gathers the statistics the query planner chooses indexes with; REINDEX(), REINDEX(table), REINDEX(name) and REINDEX(collation), for REINDEX, which rebuilds indexes; and PRAGMA_OPTIMIZE(), for PRAGMA optimize, which needs SQLite 3.18.0 (Android API 26), so it is marked with @PlatformDependentSQLiteAPI
  • New experimental DSL API: the orders of the columns of an index, ASC and DESC, as in PersonTable.CREATE_INDEX("idx_person_age", PersonTable.age to DESC), with CREATE_UNIQUE_INDEX and the IF_NOT_EXISTS forms too
  • New experimental DSL API: NULLS FIRST and NULLS LAST in ORDER BY, as in ORDER_BY(age to ASC NULLS LAST), which need SQLite 3.30.0 (Android API 31), so they are marked with @PlatformDependentSQLiteAPI. To support them, ORDER BY takes an Ordering, which OrderByWay is, rather than an OrderByWay; this is source-compatible, but a library compiled against an earlier version has to be recompiled
  • New API: deferred = true on @References and @ForeignKeyGroup, which makes a foreign key DEFERRABLE INITIALLY DEFERRED: checked when the transaction commits, rather than after each statement
  • New experimental API: the onConflict of @PrimaryKey, @CompositePrimaryKey, @Unique and @CompositeUnique, with the enum OnConflict, for the ON CONFLICT clause of the constraint, as in UNIQUE ON CONFLICT REPLACE. A composite key or group takes the one given on any of its properties, and the processor rejects a key or group whose properties give different ones. ABORT, the default, writes no clause
  • CREATE_INDEX and CREATE_UNIQUE_INDEX reject an expression with a subquery or an aggregate function, or a function that doesn't always give the same result for a row, such as random(), datetime('now') or a date and time function with the localtime modifier, and the WHERE of a partial index rejects such a function, where SQLite would only fail once the table has rows
  • New experimental DSL API: subqueries. select AS SomeView makes a SELECT a derived table, (SELECT ...) AS name, named after the object generated for a @DBView class, which gives it its name and column properties, though no such view has to exist in the database. A derived table is read like a table, in the FROM clause and in joins, which can join a table with itself through a derived table of its rows. IN takes a SELECT of a single column of values of the same kind, which is checked when the statement is built, and EXISTS(select) checks whether a SELECT returns any row; it can refer to the columns of the query it is in. They also work in the WHERE of UPDATE and DELETE. Parameters are bound in the order the SQL has them, and an observed query watches the tables its subqueries read
  • New experimental DSL API: NOT(condition), which negates a condition in parentheses, as in NOT(EXISTS(select))
  • New experimental API: the repeatable annotation @Check, which adds a CHECK constraint with an SQL expression every row has to satisfy: on a property, to its column, and on a @DBRow class, to the table. constraintName names it, which SQLite reports when a row fails it; SQLite before 3.34, as on Android below API 34, reports an unnamed one by the table's name. The processor rejects an empty expression, a name that isn't an SQL identifier, and two constraints of a table with the same name, and views and FTS tables reject @Check
  • New experimental DSL API: FTS4 and FTS3 full-text search tables, which SQLite has on every platform SQLlin supports, Android's included. A @DBRow class annotated with @Fts4 or @Fts3 gets a generated FtsTable object, which CREATE creates with CREATE VIRTUAL TABLE. Its columns are the class's String properties, and a @PrimaryKey property of type Long or Long? named rowid or docid reads and writes the rowid. @Fts4 takes the tokenizer and its arguments, the prefix lengths to index and the columns not to index, and @Fts3 the tokenizer and its arguments. MATCH searches the table or one of its columns, and snippet, offsets and matchinfo describe the matches. An observed query of an FTS table counts its changes from SQLlin's INSERT, UPDATE and DELETE on it, as a virtual table can't have triggers. withName on an FTS table returns an FTS table, so it is rebuilt as a table is
  • New experimental DSL API: views. A class annotated with @DBView gets a generated view object, such as AdultView for Adult, which CREATE_VIEW(AdultView) AS (select) creates from a SELECT of its row type, checked at compile time, and DROP(AdultView) drops. SQLite doesn't let a view take parameters, so the SELECT's values are written into the view's SQL. A view is queried like a table, in joins and compound SELECTs too, and an observed query of it watches the tables it reads, which SQLite lists through EXPLAIN. To keep views from being written to, the new Relation is the base of Table and View: SELECT, joins, the SQL functions and the clause elements take a Relation, while INSERT, UPDATE, DELETE, CREATE and ALTER keep taking a Table. This is source-compatible, but on Kotlin/Native a library compiled against an earlier version has to be recompiled
  • New experimental API: Database#observe, which turns a SELECT into a Flow of its results, emitted when it is collected and again whenever a statement run through the database changes a table the SELECT reads, if the results differ. A join or a compound SELECT watches all of its tables. The changes are counted by SQLite itself, through TEMP triggers on the observed tables, so rows changed by foreign key actions count, while rolled back transactions and statements that match no rows don't. Changes made through another Database instance, connection or process aren't seen. Using it needs @OptIn(ExperimentalDSLDatabaseAPI::class)
  • kotlinx.coroutines is now an api dependency of sqllin-dsl, as Database#observe returns a Flow
  • A Database creates the tracker of observed queries when it observes its first query, so that an app that observes none doesn't reach its code, which code shrinkers then remove: on Kotlin/Native, a release executable of an app that only inserts, queries, updates and deletes is about 418 KB smaller, and with R8, the code of sqllin-dsl it keeps is a quarter smaller
  • New experimental DSL API: compound SELECTs, with UNION, UNION_ALL, INTERSECT and EXCEPT as infix operators between two SELECTs of the same result type, as in (table SELECT WHERE(age LT 18)) UNION (table SELECT WHERE(age GTE 65)). The result type is checked at compile time, so the SELECTs can combine the rows of different tables through projections, result columns or joins. Chained operators are evaluated from left to right, as in SQL, and parentheses group them. A SELECT that ends with ORDER BY or LIMIT keeps them to itself, and the compound can be followed by its own ORDER BY and LIMIT. Using it needs @OptIn(ExperimentalDSLDatabaseAPI::class)
  • DSLDBConfiguration, Database(DSLDBConfiguration) and the DSL APIs CREATE, CREATE_INDEX, CREATE_UNIQUE_INDEX, DROP, ALTER_ADD_COLUMN, ALTER_RENAME_TABLE_TO, RENAME_COLUMN, DROP_COLUMN and PRAGMA_FOREIGN_KEYS are no longer experimental, so using them no longer needs @OptIn(ExperimentalDSLDatabaseAPI::class)
  • Deprecated: the UNION {} and UNION_ALL {} blocks, and the DatabaseScope#beginUnion, createUnionSelectStatement and endUnion functions they use. A block returns the table's row type but reads the rows into its first SELECT's result type, so a union of projections, result columns or joins fails with a ClassCastException when its results are used, and a nested block is flattened into the SQL, which changes its meaning unless it is the block's first statement. These bugs won't be fixed: use the infix operators instead. The blocks will be removed in a future version
  • Fix documentation: the guide to rebuilding a table didn't drop the views that read it, so on the JVM, Linux and Windows, where SQLite's legacy_alter_table setting is off by default, renaming the new table failed while a view read the dropped one, with "error in view ...: no such table". The guide now drops the views before dropping the old table and creates them again after the rename, which works on every platform, and explains that only where the setting is off do the foreign keys of other tables follow a renamed table regardless of whether foreign keys are enforced
  • Fix: a column given to a function, to GROUP BY or to ORDER BY, or selected into a property with AS, was written without its table's name, so in a join of tables that both have a column of that name, SQLite failed with "ambiguous column name". Such columns are now written with their tables' names, as conditions already were, except in the ORDER BY of a compound SELECT, which can only name the columns of its results
  • Fix: AND and OR combined conditions without parentheses. They apply in the order they are written, as Kotlin's infix functions do, so (age EQ 1) OR (age EQ 2) AND (name EQ "x") means (age = 1 OR age = 2) AND name = 'x', but SQL gives AND precedence over OR, so SQLite read it as age = 1 OR (age = 2 AND name = 'x') and returned other rows. A condition that combines others with the other operator is now put in parentheses
  • Fix: combining a condition with AND or OR added the other condition's parameters to its own, so a condition kept in a variable and used again, alone or combined, bound parameters its SQL doesn't have, which failed, as with an ArrayIndexOutOfBoundsException on the JVM. Combining conditions now leaves their parameters as they were
  • Fix: unsigned values were stored inconsistently. INSERT stored a UByte, UShort or UInt as the bits of a signed number on every platform, UPDATE did so on the JVM, but stored it as its number on Kotlin/Native and Android, so a UByte of 200 read back as 200, but compared, ordered and summed as -56 or as 200, depending on how it was written. They are now always stored as their numbers, which SQLite's 64-bit integers hold, so that SQL compares, orders and sums them as Kotlin does, as sum and max of such a column assumed. A ULong keeps the bits of a Long, as no 64-bit signed integer holds the values above Long.MAX_VALUE. Reading is unchanged and reads the old rows right, but in SQL an old value above the signed range stays negative until it is written again, which one UPDATE per column does: UPDATE t SET c = c + 256 WHERE c < 0 for a UByte column, with 65536 for a UShort one and 4294967296 for a UInt one

sqllin-driver

  • Fix: the JVM driver bound a UInt, UShort or UByte parameter as the bits of a signed number, unlike the Kotlin/Native driver, and the Android driver bound one written through execSQL as its string. All of them now bind it as its number, and a ULong as the Long of the same bits
  • Fix: on the JVM, Kotlin/Native and Android, a transaction whose COMMIT failed, as one does while a deferred foreign key isn't satisfied, wasn't rolled back, but left open in SQLite: the statements after it ran in it, and the next transaction failed to begin, with "cannot start a transaction within a transaction". It is now rolled back. Android's framework lets go of such a transaction and doesn't pass a ROLLBACK through, so the driver raises one with a TEMP trigger

sqllin-processor

  • Fix: the generated createSQL of a table didn't escape ", \ and $, which a Kotlin string literal treats specially, so a @Default value holding one could make the generated code fail to compile, or hold another value
  • Breaking change: Fix: reading a column's property in SET {}, as in SET { visits = visits + 1 }, compiled, but its getter only returns a placeholder, such as 0 or "", so the column was set to a value computed from it, here 1. The getter is now deprecated with the level ERROR, so reading the property is a compile error, while assigning it is unchanged

🤖 Generated with Claude Code

qiaoyuang and others added 30 commits October 5, 2026 23:00
MyMovie bridged a database query to androidx.paging by hand, and got it
wrong twice before a test with a real Pager found it: an offset-keyed source
invalidated by a mediator needs a prevKey, or a refresh leaves everything
above the user's position unreachable; with placeholders off, the
anchorPosition is relative to the presented list, not an absolute offset; and
offsets have to stay on the page size grid, or neighbouring pages overlap.
A source provided by SQLlin, built on observable queries, as Room provides
LimitOffsetPagingSource, would spare every consumer that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Table.UNION {} returned a statement typed by the table's row type, but read
the rows with its first SELECT's deserializer, through unchecked casts. A
union of projections, result columns or joins therefore compiled, and failed
with a ClassCastException wherever its results were used; with members of
different types and the same number of columns, values could also land in
the wrong properties without any error, as SQL matches the columns by
position and SQLlin reads them by name. The SQL was the members' SQL joined
with UNION, so a member's ORDER BY or LIMIT was a syntax error unless it was
the last one, where it applied to the whole union; the union itself could
take neither; and a nested block was flattened, so UNION { a; UNION_ALL { b;
c } } ran as (a UNION b) UNION ALL c. Each of these was checked with sqlite3.

The compound operators are now infix functions between two SELECTs, as in
SQL:

    (table SELECT WHERE(age LT 18)) UNION (table SELECT WHERE(age GTE 65))

UNION, UNION_ALL, INTERSECT and EXCEPT take a SelectStatement<R> on both
sides, and SelectStatement is invariant, so mixing result types doesn't
compile; the compound reads its rows into that same R. The SELECTs can come
from any tables, through projections, result columns or joins. They become
part of the compound, removed from the statements of their scope, and their
deferred GROUP BY check runs when they are combined.

Chained operators are evaluated from left to right, as SQLite does. SQLite
has no parentheses in a compound, so a member that would mean something
else is written as a subquery, SELECT * FROM (...): one that ends with ORDER
BY, LIMIT or OFFSET, which then apply to it alone, and a compound on the
right, as in a UNION (b UNION_ALL c), which is then combined as a whole.
FinalSelectStatement.isSimple tells a plain SELECT, which needs none, from
one with OFFSET or a block's union. A CompoundSelectStatement can be followed
by another operator, and by ORDER BY and LIMIT, which apply to all of it.

As new DSL API, the operators, CompoundSelectStatement and the ORDER BY and
LIMIT that follow it require @OptIn(ExperimentalDSLDatabaseAPI::class).

The UNION {} and UNION_ALL {} blocks, and the beginUnion,
createUnionSelectStatement and endUnion functions they need for being
inline, are deprecated and not fixed. Their messages, KDoc and the guide say
why and point to the infix operators. The two blocks suppress the
deprecation of the functions they call, which are deprecated with them.

The guide's UNION section becomes one on compound queries, in English and
Chinese, ending with the deprecated block, its bugs and how to replace it.
The roadmap's UNION item moves to Supported.

Tests: testCompoundSelect covers the four operators, with WHERE parameters
whose order matters; rows of two tables projected into one type; that a
combined SELECT no longer runs alone; members with their own ORDER BY and
LIMIT, which fail without the subquery; the compound's ORDER BY, LIMIT and
OFFSET; left-to-right evaluation against grouping, which return 2 and 1 rows
on the same data; result columns; and rejecting a member that needs GROUP BY.
testUnionSelect keeps covering the deprecated block. Mixed result types and
missing parentheses fail to compile, and the opt-in warning appears without
@OptIn, checked with temporary files, as were the guide's examples. jvmTest
(54) and testAndroidHostTest on API 26 and 37 (108) pass. 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>
setupNativeConfig got the main compilation with `by compilations.getting`
and created the sqlite3 cinterop with `by main.cinterops.creating`, which
Gradle 9.6 deprecates; the build script reported five warnings for them. They
are now getByName("main") and create("sqlite3"), as the deprecation messages
suggest. The cinterop's local val was never read, so it goes.

The cinterop keeps its name, so its tasks are still cinteropSqlite3<Target>.
Rerun from scratch, cinterop, compilation and test linking pass for
macosArm64 and linuxX64, the driver's jvmTest passes, the sqllin-dsl-test
sources compile for macosArm64, and the script compiles without warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DSLDBConfiguration, Database(DSLDBConfiguration), CREATE, CREATE_INDEX,
CREATE_UNIQUE_INDEX, DROP, ALTER_ADD_COLUMN, both ALTER_RENAME_TABLE_TO,
both RENAME_COLUMN, DROP_COLUMN and PRAGMA_FOREIGN_KEYS no longer carry
@ExperimentalDSLDatabaseAPI, so using them no longer needs an opt-in. The
marker stays on the compound SELECT operators added in 2.5.0, which are new.

The opt-ins only those APIs needed go: the sample's, and the test class's
and test functions', except testCompoundSelect's. The installation guide no
longer calls DSLDBConfiguration experimental, and CHANGELOG.md says the
opt-in can be removed.

jvmTest (54) and testAndroidHostTest on API 26 and 37 (108) pass, the sample
and the macosArm64 test sources compile, and nothing reports an opt-in
warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MyMovie bolted change notification onto SQLlin by hand: its data source
announced every write to a tracker keyed by list, which a PagingSource
listened to. Every write had to remember to announce itself, and a write to
the movies table couldn't say which lists showed those movies, so they kept
their old titles.

Database.observe turns a SELECT into a Flow of its results:

    val adults: Flow<List<Person>> = database.observe { PersonTable SELECT WHERE(PersonTable.age GTE 18) }

It emits the results when collected, and again whenever a statement run
through the database changes a table the SELECT reads, if the results
differ. The block runs again to build the SELECT each time. Queries run on
Dispatchers.IO unless another context is given.

Each SELECT now records the tables it reads: its table, the tables of a
join, and those of every member of a compound, carried along the chained
clauses.

The changes are counted by SQLite itself, as Room does. InvalidationTracker
creates a TEMP table of counts and, the first time a table is observed, TEMP
triggers on its INSERT, UPDATE and DELETE that add one to its count. So rows
changed by a foreign key action count though no statement names their
table, an UPDATE or DELETE matching no rows doesn't, and a rolled back
transaction rolls its counts back. The counts only grow, rather than being a
flag read and then cleared, as SQLlin can share a connection between
threads and a change between the read and the clear would be lost. A table
stays tracked until the connection closes, so triggers are never removed
while another observer may install them. On Android the counts are read in a
transaction, as a query outside one may run on a reader connection, which
has no TEMP table. After CREATE, DROP, ALTER or PRAGMA, missing triggers are
created again and every tracked table counts as changed.

Database runs each scope through execute, which reads the counts once the
statements ran, even when one failed, unless the scope only read. observe
tracks the SELECT's tables and notes their counts before running it, under
the same mutex as suspendedScope, so no later change is missed; equal
results aren't emitted again, and several changes in a row may lead to a
single query.

Only changes made through the same Database are seen, not those of another
instance, connection or process. Several SELECTs combine with Flow's
combine, which the guide notes may briefly mix new and old results. The
API is experimental, and kotlinx.coroutines becomes an api dependency of
sqllin-dsl, as the API returns a Flow.

Tests: testObserve covers the first results; an INSERT; no emission for a
change to another table, an UPDATE matching no rows, one leaving the results
equal, or a rolled back transaction; an UPDATE that changes them; a join
watching its second table; and a dropped and recreated table staying
watched. testObserveForeignKeyAction covers rows deleted by ON DELETE
CASCADE. jvmTest (56) and testAndroidHostTest on API 26 and 37 (112) pass, so
the triggers work on both. The native test sources compile for macosArm64,
linuxX64, mingwX64 and watchosArm32 but were not run, as this machine is an
Intel Mac. The guide's examples were compiled with a temporary file.

The guide gains a section on observed queries, in English and Chinese. The
roadmap's last high priority item moves to Supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view is a stored SELECT that queries read like a table. SQLlin had no way
to declare, create, query or drop one.

A class annotated with @dbview gets a generated view object, AdultView for
Adult, with a property per column like a table object, but no SET
properties, no CREATE TABLE statement and no constraints. The processor
rejects a constraint annotation on it, a class that is both @DBRow and
@dbview, and a missing @serializable.

    CREATE_VIEW(AdultView) AS (PersonTable SELECT WHERE<Adult>(PersonTable.age GTE 18))
    // CREATE VIEW adults(name,age) AS SELECT name,age FROM person WHERE age>=18

The SELECT has to read the view's row type, checked at compile time, and is
taken from its scope as INSERT INTO ... SELECT does. SQLite doesn't let a
view take parameters ("parameters are not allowed in views", checked with
sqlite3), so inlineParameters writes the SELECT's values into its SQL as
literals: strings with doubled quotes, BLOBs as X'..', Booleans as 1 and 0,
NaN as NULL. A ? inside a string literal or a quoted identifier, as in
replace(name, "?", ""), isn't a placeholder and stays. DROP(view) drops it
with DROP VIEW.

To keep views from being written to, Relation is the new base of Table and
View. SELECT, joins, the SQL functions and the clause elements take a
Relation; INSERT, UPDATE, DELETE, CREATE and ALTER keep taking a Table, so
writing to a view doesn't compile. This is source-compatible, and the
existing tests compile unchanged, but on Kotlin/Native a library compiled
against an earlier version has to be recompiled.

An observed query of a view watches the tables it reads. SQLite refuses an
AFTER trigger on a view, so InvalidationTracker resolves it: the OpenRead
instructions of EXPLAIN SELECT * FROM view name the root pages it opens,
which sqlite_master maps back to tables, through views over views and
joins alike, as checked with sqlite3.

The view API is experimental. The generated view objects opt in, so that
querying a view needs no opt-in.

Tests: testView creates a view from a SELECT whose literals include 'It''s'
and replace(name, "?", ""), queries it, with a WHERE on its columns,
observes it through an insert into its table, and drops it. Writing to a
view, CREATE on it and a SELECT of another type don't compile, and the
processor's three rejections were checked with temporary files. jvmTest
(57) and testAndroidHostTest on API 26 and 37 (114) pass, so EXPLAIN works
on Android too. The native test sources compile for macosArm64, linuxX64,
mingwX64 and watchosArm32 but were not run, as this machine is an Intel Mac.

The guide to modifying the database describes views, in English and
Chinese, and CREATE VIEW moves to the roadmap's Supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KSP 2.3.12 deprecates KSNode.validate(predicate) in favor of
validate(predicate, enableNewFeatures), and the processor reported a
warning for each of its two calls. They now pass enableNewFeatures = false,
the replacement the deprecation names, which validates exactly as before.

The processor compiles without warnings, and the code it generates for the
test module compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UByte, UShort and UInt were stored three different ways. INSERT went
through their serializers, which encode the bits as a signed number, on
every platform. UPDATE passed the value itself to the driver, which bound
it as a signed number on the JVM, as its number on Kotlin/Native, and as
its string on Android, which INTEGER affinity turned into its number. Reading
truncates to the type's width, so every value read back right, but SQL saw
a UByte of 200 as -56 or as 200, depending on how and where it was written:
checked on the JVM, WHERE testUByte > 100 and WHERE testUInt > 2000000000
matched neither row after an INSERT or an UPDATE. Comparisons, ORDER BY,
and the sum and max of such a column, which 2.4.0 typed as if it held the
numbers, were wrong above the signed range, from 128 for a UByte.

SQLite's 64-bit integers hold every UByte, UShort and UInt, so they are now
stored as their numbers everywhere, which SQL compares, orders and sums as
Kotlin does. A ULong keeps the bits of a Long, as before, since no 64-bit
signed integer holds the values above Long.MAX_VALUE.

- storedValue converts them; InsertValuesEncoder applies it in place of the
  serializers' signed encoding, as it already did for ByteArray, and
  SetClause applies it to what UPDATE SET binds.
- The JVM driver binds them with setLong(toLong()), as the native driver
  does, and the Android driver converts them before execSQL, as it already
  did for queries, so code using sqllin-driver directly agrees across
  platforms too.

Reading is unchanged, and reads rows written before this right. In SQL an
old value above the signed range stays negative until it is written again;
CHANGELOG.md gives the UPDATE that rewrites a column.

testUnsignedValues inserts values above the signed range, reads them back,
compares, orders, sums and takes the max of them, then updates them and
compares again; it fails with the old code. The jvmTest (58) and
testAndroidHostTest on API 26 and 37 (116) of sqllin-dsl-test, and those of
sqllin-driver (6, 12), pass. 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>
Rebuild libs/linux/libsqlite3.a and libs/windows/libsqlite3.a, which the
native tests on Linux and Windows link, from the SQLite 3.51.1
amalgamation with zig, enabling FTS3, FTS4 with the enhanced query
syntax, FTS5, R*Tree, Geopoly, the math functions, dbstat and column
metadata. The previous libraries had only FTS5, R*Tree and the math
functions, so they couldn't run tests of FTS3 and FTS4.

zig cc emits debug information by default, which held paths of the
machine that built the previous libraries, made them several times
larger, and made the Windows one depend on the name of a temporary file.
These are built with -g0, and the Windows one with its object file name
set, so that they hold no paths and the same sources and options always
give the same bytes. They are about a fifth of their previous size.

Add two low-priority items to the roadmap: using a SQLite bundled with
SQLlin on Android rather than the system's, and supporting FTS5 and
R*Tree, which Android's system SQLite lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A @DBRow class annotated with the new @fts4 or @fts3 gets a generated
FtsTable object, which CREATE creates with CREATE VIRTUAL TABLE. Its
columns are the class's String properties, and a @PrimaryKey Long or
Long? property named rowid or docid reads and writes the rowid. @fts4
takes the tokenizer and its arguments, the prefix lengths to index and
the columns not to index, and @fts3 the tokenizer and its arguments. The
processor rejects constraint annotations, columns of other types, a
misnamed or mistyped rowid, and @fts4 or @fts3 without @DBRow.

MATCH searches an FTS table or one of its columns, and snippet, offsets
and matchinfo describe the matches. The new APIs are experimental.

A virtual table can't have triggers, so an observed query of an FTS
table counts its changes from SQLlin's INSERT, UPDATE and DELETE on it:
the write statements now know their table, and Database#execute passes
the tables a scope writes to the invalidation tracker, which keeps their
counts in its log table. Which tracked tables are virtual is read again
after a schema change, as a table may be created again as the other kind.

withName on an FTS table returns an FTS table with the same options, so
that an FTS table is rebuilt as a table is; it threw before, as it only
recognized CREATE TABLE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The new repeatable annotation @check adds a CHECK constraint with an SQL
expression every row has to satisfy: on a property, to its column, and
on a @DBRow class, to the table. constraintName names it, which SQLite
reports when a row fails it; SQLite before 3.34, as on Android below API
34, reports an unnamed one by the table's name. The annotation is
experimental.

SQLite gives a column's constraint the name of the named one before it,
so a column's unnamed CHECK constraints come before its named ones, and
all of them before the constraint of @references. The processor rejects
an empty expression, a name that isn't an SQL identifier and two CHECK
constraints of a table with the same name, and views and FTS tables
reject @check.

The generated createSQL is now escaped as a Kotlin string literal: a
CHECK expression or a @default value holding a double quote broke the
generated code, and one holding a dollar sign became a string template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Triggers, upsert's DO UPDATE SET and rebuilding a table with converted
data all need UPDATE's SET and INSERT to take expressions, such as
visits = visits + 1. Add it to the medium-priority items, before
CREATE TRIGGER, which needs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
select AS SomeView makes a SELECT a derived table, (SELECT ...) AS name,
named after the object generated for a @dbview class, which gives it its
name and column properties, though no such view has to exist in the
database. It is read like a table in the FROM clause and in joins, and a
derived table of a table's rows joins the table with itself.

IN takes a SELECT of a single column of values of the same kind, which
is checked when the statement is built, and EXISTS(select) checks
whether a SELECT returns any row, referring to the columns of the query
it is in if need be. NOT(condition) negates a condition in parentheses,
for NOT EXISTS and NOT IN. These APIs are experimental.

A relation now gives how a FROM clause writes it, the parameters that
writes, and the tables it reads: a statement binds the parameters of
its derived tables first, then those of its clauses, always into a new
list, so that a derived table read by several queries keeps its own,
and a condition carries the tables of its subqueries to the observed
queries. A WHERE after result columns no longer drops the parameters
before it, which had none until now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The infix AND and OR apply in the order they are written, as Kotlin's
infix functions do, so (age EQ 1) OR (age EQ 2) AND (name EQ "x") means
(age = 1 OR age = 2) AND name = 'x'. They combined the SQL without
parentheses, as age = 1 OR age = 2 AND name = 'x', which SQLite reads
as age = 1 OR (age = 2 AND name = 'x'), as SQL gives AND precedence
over OR, so the query returned other rows, though the KDoc promised
parentheses.

A condition now knows the operator it combines others with, and is put
in parentheses as an operand of the other one, while a chain of the
same operator is written as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Combining a condition with AND or OR added the other condition's
parameters to its own list, so a condition kept in a variable and used
again, alone or combined, bound parameters its SQL doesn't have, which
failed with an ArrayIndexOutOfBoundsException on the JVM. Combining
conditions now puts their parameters in a new list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SQLite SQLlin runs on depends on the platform: Android's system
SQLite is 3.9 at API 24, SQLlin's minimum, and lacks options such as the
math functions, Apple's depends on the OS version, and on Linux and
Windows it is the SQLite the app links. An API that needs a newer SQLite
or a compile-time option fails at runtime where it is missing, so the
new opt-in annotation @PlatformDependentSQLiteAPI marks such APIs, and
using one is a compile error unless the app opts in.

RENAME_COLUMN, which needs SQLite 3.25.0 (Android API 30), and
DROP_COLUMN, which needs SQLite 3.35.0 (Android API 34), now carry it,
which is a breaking change for code using them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ER BY

A column given to a function, to GROUP BY or to ORDER BY, or selected
into a property with AS, was written without its table's name, as in
count(name), so in a join of tables that both have a column of that
name, SQLite failed with "ambiguous column name". Conditions already
wrote columns with their tables' names; now these do too, as in
count(book.name), except in the ORDER BY of a compound SELECT, which
can only name the columns of its results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FROM(relation) starts a join, which INNER_JOIN, LEFT_OUTER_JOIN,
RIGHT_OUTER_JOIN, FULL_OUTER_JOIN, CROSS_JOIN and the NATURAL joins
extend, each with its own ON or USING, so that a join can have more
than two relations. A join is selected from with a projection, a clause
function such as WHERE<R>(...), or result columns, which could only
select from a single table until now. The new APIs are experimental,
and RIGHT and FULL OUTER JOIN, which need SQLite 3.39.0, are marked with
@PlatformDependentSQLiteAPI.

The type rows are read into is checked against the joined relations
when the statement is built: every property has to name a column of one
of them, of a single one unless USING or a NATURAL join merges them, and
has to be nullable where an outer join can leave its column or
expression NULL, which elements now know from the tables of the columns
they read. A relation can only be joined once. What a SELECT reads is
now an internal From, a relation or a join, which these checks and the
SQL of the columns work on; a single relation keeps its SQL and messages.

A join doesn't start with its first relation as the receiver, as in
PersonTable INNER_JOIN BookTable, as such an infix function would take
over the old CROSS_JOIN(CategoryTable) in the lambda of a table, whose
receiver is that table. The old joins, written as clauses of SELECT, are
documented to be removed in the next version after 2.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since columns are written with their tables' names, a function given to
CREATE_INDEX or CREATE_UNIQUE_INDEX, as lower(name), became
lower(person.name), and the index took what followed its last '.', so it
wrote name), a syntax error, where it had made an expression index.
SQLite doesn't allow '.' in the expressions of an index anyway.

An index now writes a column by its name, and a function without the
indexed table's name before its columns, outside its string literals.
A column or function of another table is rejected, and the KDoc
documents expression indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the SQLite syntax that SQLlin doesn't support yet and the roadmap
didn't list, from DROP INDEX to window functions: the items that are
both easy and useful as high priority, those that are one of them as
medium priority, and the others, especially those that need much change
or a design discussion, as low priority. Add the removal of the old
joins in the next version after 2.5.0 too.

Give every item an ID, Fx, which stays the same when it moves to another
priority or is done, and which a new item takes the next number of.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Database created its InvalidationTracker at once, and every scope it
ran called the tracker's refresh, so the tracker's code was reachable
from any app, which code shrinkers then kept, with the coroutines code
its StateFlows need, though only observed queries use it.

The first observed query now creates it, under the executive mutex, and
a scope refreshes it only once it exists. On Kotlin/Native, a release
executable that only inserts, queries, updates and deletes went from
1,752,352 to 1,333,936 bytes, and with R8, the code of sqllin-dsl it
keeps went from 44 KB to 33 KB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing dropped the indexes that CREATE_INDEX and CREATE_UNIQUE_INDEX
create. DROP_INDEX(name) does, with DROP INDEX, as an experimental API.

The guide to modifying the database now describes indexes, ordinary,
unique and expression ones, which only the KDoc did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CREATE_IF_NOT_EXISTS, CREATE_INDEX_IF_NOT_EXISTS,
CREATE_UNIQUE_INDEX_IF_NOT_EXISTS and CREATE_VIEW_IF_NOT_EXISTS do
nothing when what they create exists, and DROP_IF_EXISTS, of a table or
a view, and DROP_INDEX_IF_EXISTS when what they drop doesn't, so that a
migration step can run more than once. CREATE_IF_NOT_EXISTS creates FTS
tables too, as CREATE VIRTUAL TABLE takes IF NOT EXISTS. They are
experimental.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHERE after CREATE_INDEX, CREATE_UNIQUE_INDEX or their IF_NOT_EXISTS
forms makes an index of the rows that satisfy a condition, such as a
unique index of the emails of the users that aren't deleted. SQLite
allows neither parameters nor subqueries there, so the values of the
condition are written into the SQL, its columns without their table's
name, and a subquery is rejected. WHERE is experimental.

For WHERE to complete it, these functions return an IndexStatement,
where they returned Unit, which replaces the statement where it is among
those of the scope or the transaction. This is source-compatible, but a
library compiled against an earlier version has to be recompiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DISTINCT(element) makes count, avg, sum and group_concat take only the
distinct values of an element, as count(DISTINCT book.author) does. It
returns a Distinct, which only these functions take, so it doesn't
compile anywhere else, where SQL doesn't allow it. max and min don't
take it, as it doesn't change them, and group_concat separates the
distinct values with commas, as SQLite allows only one argument with
DISTINCT. It is experimental.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ESCAPE after LIKE gives the pattern an escape character, after which %,
_ and the escape character match only themselves, as in
name LIKE "100\\%%" ESCAPE '\\', which matches the names that start
with 100%. It is experimental.

LIKE now returns a LikeCondition, a SelectCondition that ESCAPE can
follow, so that it reads as SQL does, without parentheses, and doesn't
compile after any other condition. This is source-compatible, but a
library compiled against an earlier version has to be recompiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
VACUUM() rebuilds the database file into the minimum disk space, as
deleting rows doesn't shrink it, and VACUUM_INTO(file) writes a vacuumed
copy of the database to another file, a consistent backup of a database
in use. Both throw inside a transaction, where SQLite can't vacuum, and
are experimental. VACUUM INTO needs SQLite 3.27.0, Android API 30, so it
is marked with @PlatformDependentSQLiteAPI.

As a change of the schema does, VACUUM makes observed queries query
again, as it may change the rowids of the tables without an INTEGER
PRIMARY KEY.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading a column's property in SET {}, as in SET { visits = visits + 1 },
compiled, but its getter only returns a placeholder, as 0 or "", so the
column was set to a value computed from it, here 1, with no error.

The processor now generates the getter deprecated with the level ERROR,
so reading the property doesn't compile, while assigning it is
unchanged. The test of the generated properties now assigns them, and
the compile-time check of their nullability, which reads their types,
suppresses the error. This is a breaking change for code that reads
them, which only got placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guide to rebuilding a table renamed the new table after dropping
the old one, which fails while a view reads the dropped table on the
JVM, Linux and Windows, where SQLite's legacy_alter_table is off by
default, as renaming then checks every view: "error in view ...: no
such table". Views came with 2.5.0.

The guide now drops such views before dropping the old table and
creates them again after the rename, which works on every platform, and
says that only where the setting is off do the foreign keys of other
tables follow a renamed table whether or not foreign keys are enforced.
testTableRebuild rebuilds a table a view reads, which fails on the JVM
without dropping the view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first version of triggers leaves out INSTEAD OF triggers, which
only views can have, and which would be of no use while the DSL can't
write to a view, as a view isn't a Table. F63 keeps them for later,
together with INSERT, UPDATE and DELETE on views.

Supported now lists the items in the order they were done, the newest
first, so F1 to F6 are put in that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
qiaoyuang and others added 15 commits October 9, 2026 22:28
An element now knows the tables its subqueries read, which an observed
query watches, whether it always gives the same result for a row, and
whether it is a literal on its own, and an expression derives these
from its operands. An expression carries no parameters: its values are
written into its SQL, so that it can be part of a view, a trigger or an
index.

literal(value) makes a value an expression; +, -, *, / and the unary -
compute with numbers of the same type, % with integers, and + of
strings concatenates them; CAST(x AS INT) converts an expression to the
types SQLlin names columns with. The new functions take expressions as
their arguments: coalesce, ifnull and nullif, the scalar max and min,
hex, quote, unicode, randomblob, zeroblob, more forms of trim, substr,
replace, instr, printf and round, the aggregate total, and the date and
time functions. sign, unixepoch and the math functions, which need a
newer SQLite or its math functions, are marked with
@PlatformDependentSQLiteAPI. A function can only be NULL where its
result can, as a division by zero is.

ORDER BY and GROUP BY reject a literal on its own, which SQLite would
read as the number of a result column, and an index rejects an
expression with a subquery, an aggregate function, or a function that
doesn't always give the same result, which SQLite only rejects once the
table has rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CASE(WHEN(condition) THEN value, ..., ELSE = value) is SQL's searched
CASE, which is NULL where ELSE is omitted. select[R::property] uses a
SELECT of a single column as a value, which is checked when the
statement is built, and whose tables an observed query watches. IS and
ISNOT of two expressions compare them as EQ and NEQ do, NULL equaling
NULL, which IS DISTINCT FROM does on newer SQLite only. COLLATE compares
and sorts a string by BINARY, NOCASE or RTRIM, and and, or, inv(), shl
and shr are the bitwise operators of Int and Long.

JSON's -> and ->>, which need SQLite 3.38.0, become their own item of
the roadmap, F64, and REGEXP is left out, as the JVM's SQLite has no
such function, and Android's only matches whole strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SET(listOf(expression AS Row::property)) sets columns to expressions,
as visits + 1, and INSERT, INSERT_OR_IGNORE and INSERT_OR_REPLACE take
a row of expressions, for INSERT INTO ... VALUES, leaving the other
columns to their default values. AS checks the type of each expression
at compile time, and the statement checks, when it is built, the
checks of result columns that apply: an expression that can be NULL is
only written to a nullable column, and it isn't an aggregate function
and only reads the updated table, or no table in INSERT, where SQLite
allows none. The error of reading a column in SET {} now points to
these.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CREATE_TRIGGER(name) AFTER UPDATE_OF(column) ON table WHEN (condition)
BEGIN { ... } creates a trigger, with BEFORE and AFTER, the events
INSERT, DELETE, UPDATE and UPDATE_OF, and an IF NOT EXISTS form, and
DROP_TRIGGER drops one. new(column) and old(column) read the row being
written, and a row the trigger doesn't have, which SQLite would only
report when the trigger fires, is rejected when the statement is built.
The body is written as any statements are: the scope collects them in
its own container instead of running them, and only INSERT, UPDATE,
DELETE and RAISE are allowed. The values of a trigger are written into
its SQL, as SQLite doesn't let a trigger take parameters.

A trigger can write an FTS table, which the tracker of observed queries
can't count changes of with triggers, so a write to a table that has
triggers now counts as a change of every tracked FTS table. The guide
to rebuilding a table drops the triggers of other tables that read it,
as it does views, and creates the table's own again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
table INSERT DEFAULT_VALUES inserts a row of the default values of all
columns, as INSERT INTO ... DEFAULT VALUES does, with INSERT_OR_IGNORE
and INSERT_OR_REPLACE too. A column without a default value takes NULL,
which a NOT NULL one rejects. DEFAULT_VALUES is a property of the scope,
as the events of triggers are, so it needs no import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ANALYZE gathers the statistics the query planner chooses indexes with,
of the whole database, a table or an index, and REINDEX rebuilds the
indexes of the database, a table, an index or a collation. PRAGMA
optimize, which needs SQLite 3.18.0, Android API 26, is marked with
@PlatformDependentSQLiteAPI. Like VACUUM, they count as changes of the
schema for observed queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CREATE_INDEX, CREATE_UNIQUE_INDEX and their IF_NOT_EXISTS forms take
the columns with the orders they are sorted in, as ORDER_BY does:
CREATE_INDEX("idx_person_age", PersonTable.age to DESC). The first
column is a parameter of its own, so that a call without columns, which
fails, still has a single candidate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ORDER_BY(age to ASC NULLS LAST) puts the NULL values last, as SQL
reads, and DESC NULLS FIRST first, which need SQLite 3.30.0, Android API
31, so NULLS is marked with @PlatformDependentSQLiteAPI.

ORDER BY now takes an Ordering, which OrderByWay and the new
NullsOrdering are, rather than an OrderByWay. Pair and Map are
covariant, so this is source-compatible, but a library compiled against
an earlier version has to be recompiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A COMMIT that fails, as one does while a deferred foreign key isn't
satisfied, leaves SQLite's transaction open. No driver rolled it back,
so the statements after it ran in it, and the next transaction failed
to begin, with "cannot start a transaction within a transaction". The
JVM driver even set the connection back to auto-commit over the open
transaction.

The JVM and Kotlin/Native drivers now roll it back, and throw the error
of the COMMIT. Android's framework lets go of such a transaction, and
turns a ROLLBACK given to it into its own end of a transaction, which
it has none of: it tells a ROLLBACK by its first characters, after a
comment too from some API level on. So the Android driver raises the
ROLLBACK with a TEMP trigger, whose table and trigger, created in the
transaction, are rolled back with it, as tested on API 26 and 37.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deferred = true on @references and @ForeignKeyGroup makes a foreign key
DEFERRABLE INITIALLY DEFERRED: checked when the transaction commits,
rather than after each statement, so that a transaction can insert a
child before its parent, or rows that refer to each other. A
transaction that commits while such a key isn't satisfied fails and is
rolled back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The onConflict of @PrimaryKey, @CompositePrimaryKey, @unique and
@CompositeUnique, of the new experimental enum OnConflict, writes the
ON CONFLICT clause of the constraint, as UNIQUE ON CONFLICT REPLACE.
ABORT, the default, writes none, so the tables declared before are
created as they were. A composite key or group takes the one given on
any of its properties, and the processor rejects one whose properties
give different ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F17 gave PRIMARY KEY and UNIQUE their conflict clauses, but not NOT
NULL, which a property's type declares, so that no annotation can hold
its clause yet. It would let NOT NULL ON CONFLICT REPLACE write the
default value instead of NULL, which SQLlin's statements rarely write to
a non-null column. F65 keeps it, with the two ways it could be declared,
until other syntax makes it of more use, or shows it isn't needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qiaoyuang
qiaoyuang merged commit 2fb6814 into main Oct 11, 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