Skip to content

New features for 2.4.0 in sqllin-dsl - #127

Merged
qiaoyuang merged 10 commits into
release/2.4.0from
feature/new-features-2.4.0
Oct 3, 2026
Merged

qiaoyuang merged 10 commits into
release/2.4.0from
feature/new-features-2.4.0

Conversation

@qiaoyuang

@qiaoyuang qiaoyuang commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

New features for 2.4.0 in sqllin-dsl, one commit per issue, plus two fixes found along the way, a dependency update and roadmap changes. Each commit message explains its change in detail, and CHANGELOG.md has an entry for each.

New features

  • INSERT_OR_IGNORE, for SQL INSERT OR IGNORE. (d1c2d42)
  • Projection: a SELECT reads rows into a narrower @Serializable type given to the clause function, as in table SELECT X<BookTitle>() or WHERE<BookTitle>(...), and selects only the columns it names. A type that doesn't fit the table is rejected when the statement is built. (bda5d27)
  • Result columns: expressions such as aggregate functions are selected 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. A property's type is checked at compile time and its nullability when the statement is built; for an aggregate query without GROUP BY, which returns a row of NULLs when no rows match, when the scope ends, before any statement runs. (0ba59f8)
  • INSERT INTO ... SELECT for INSERT, INSERT_OR_IGNORE and INSERT_OR_REPLACE, and Table#withName, which together rebuild a table in a migration, for a change ALTER TABLE can't make, such as adding a constraint, or dropping a column below Android API 34. (365123b)

Breaking changes

These may stop existing code from compiling. Migration is described in CHANGELOG.md.

  • ClauseElement, ClauseNumber and ClauseString take a type parameter, the type of their values. Code that only uses the DSL is unaffected; code that names these types needs a type argument. The public constructors of the Clause* classes, which the generated code calls, now take a column's nullability instead of whether the element is a function. (0ba59f8)
  • The SQL functions return the type of the values SQLite returns. max and min of a String column are now a ClauseString, and sum is overloaded by column type, so a sum of a String, BLOB, enum or ULong column no longer compiles. (0ba59f8)
  • On Kotlin/Native, a library compiled against 2.3.0 has to be recompiled, as the public DatabaseScope#select functions and the Clause* classes changed their signatures. (bda5d27, 0ba59f8)

Fixes

These predate 2.4.0; they were found by reading the code during this work, then confirmed with sqlite-jdbc.

  • The string arguments of replace, instr, printf and group_concat were not escaped. A ' in one broke the statement, and a crafted one rewrote it: instr(name, "zzz') + 1 + ('") GT 0 matched every row. Any such argument taken from user input was an injection point. A ' is now doubled, the only escape SQLite has in a string literal. (907351d)
  • A comparison between two elements qualified a function with its table name, producing invalid SQL such as book.length(name)<book.pages. A function is now written as it is. (2220147)

Other changes

  • Dependencies: AGP 9.4.1, KSP 2.3.12, androidx.annotation 1.11.0. (3592f2d)
  • Documentation: the advanced query guide describes projection and result columns; the SQL functions guide no longer says functions only work in conditions, and no longer lists the disabled sign; the guide to modifying the database describes INSERT INTO ... SELECT, rebuilding a table, INSERT_OR_IGNORE and INSERT_OR_REPLACE.
  • Roadmap: observable queries as high priority, type converters (merged with kotlinx.datetime) as medium, reading a query's results within the same transaction as low (0ba59f8); upsert as low (41ae7b7); value conversion functions as medium (3eb7342); UNION of other result types as high (c6b6cd7), see below.

Testing

  • jvmTest (53 tests) and testAndroidHostTest on Robolectric API 26 and 37 (106 tests) pass locally. The tests added for the two fixes fail with the old code. The table rebuild test passes on API 26, whose SQLite lacks DROP COLUMN.
  • Native tests have not run. The development machine is an Intel Mac, where they only compile; they compile for macosArm64, linuxX64, mingwX64 and watchosArm32. CI on this PR is their first run.
  • The compile-time rejections, such as count(X) into an Int property or mixing result types in one listOf, were verified by compiling temporary files.

Known issue, not addressed here

UNION returns a statement typed by the table's row type, but decodes with its first member's result type, so a union of joins, projections or result columns fails with a ClassCastException when its results are used. This predates 2.4.0; projections and result columns make it easier to reach. Fixing it changes the public, inline UNION functions, so it is on the roadmap as high priority instead. (c6b6cd7)

🤖 Generated with Claude Code

qiaoyuang and others added 10 commits October 2, 2026 18:25
INSERT_OR_REPLACE resolves a PRIMARY KEY or UNIQUE conflict by deleting the
existing row and inserting the new one, which replaces the existing row's other
columns. When the existing row should win instead, as when a paged list fetches
an item it already holds and must keep that item's position, there was no way to
say so.

INSERT_OR_IGNORE writes INSERT OR IGNORE INTO: each entity that conflicts with
an existing row is skipped and that row is left exactly as it is, while the
other entities are inserted. Like INSERT_OR_REPLACE it always writes the primary
key column, as a conflict on a key left out of the statement could never be
seen. A null key that the database assigns still can't conflict.

As SQLite documents, and as checked here, OR IGNORE also skips a row that would
violate NOT NULL, which can't happen for a non-null property, while a FOREIGN KEY
violation still fails the statement. The KDoc says so.

The test covers a conflict on the primary key, on another UNIQUE column and on a
composite key, and it fails if the primary key isn't written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SELECT always read rows into the table's own row type, so reading a few
columns meant fetching and decoding all of them. The column list was already
built from the deserializer's descriptor, and JOIN already took a result type of
its own, but a single-table SELECT tied its result type to the table's.

The result type is now given to the clause function, as with JOIN:

    PersonTable SELECT X<NameAndAge>()
    PersonTable SELECT WHERE<NameAndAge>(cond) ORDER_BY PersonTable.age LIMIT 10

for X, WHERE, ORDER BY, LIMIT and GROUP BY, after both SELECT and
SELECT_DISTINCT, and the chained clauses keep it. Only the columns named by the
type's properties are selected. Without a type argument a SELECT reads the
table's row type exactly as before, and every existing call resolves as it did.

A projection type has to fit the table: each property must be a column, of that
column's type, and nullable if the column is, as a NULL read into a non-null
property would quietly become 0 or "". A mismatch throws an
IllegalArgumentException while the statement is built.

Two other shapes were ruled out by compiling them. An overload of SELECT(X) that
was generic only in its return type made every existing `SELECT X` ambiguous, so
the no-clause form is a function, X<R>(), declared next to the object X. And the
projection can't be inferred from the type the statement is assigned to, as the
existing overload is the more specific one, so the type argument is required.

The public DatabaseScope.select functions now take a result type separate from
the table's. That is source-compatible and keeps their JVM signatures, but on
Kotlin/Native a library compiled against an earlier version may need to be
recompiled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A SELECT could only read columns: the column list came from the result type's
property names, and the SQL functions returned elements usable only in
conditions, with no Kotlin type and no way to name a result. So `count(*)` or a
per-group `sum` could not be selected at all.

An expression is now selected into a property of the result type with AS, and
the type's other properties are read from their columns, as in a projection:

    table SELECT listOf(count(X) AS AuthorStats::books, sum(pages) AS AuthorStats::totalPages) GROUP_BY author
    table SELECT (count(X) AS BookCount::books) WHERE (price LT 20.0)

It follows the existing convention of a single argument or a Kotlin collection,
as INSERT and GROUP_BY do, rather than adding a function that would look like a
SQL keyword without being one. It works after SELECT and SELECT_DISTINCT, and
is followed by WHERE, GROUP BY, ORDER BY and LIMIT, through a new
ResultColumnSelectStatement.

To check the type of a property at compile time, ClauseElement, ClauseNumber
and ClauseString take a type parameter, the type of their values. The generated
accessors give each column its property's type, and each function the type of
the values SQLite returns for it: count, length, instr and random a Long, avg
and round a Double, the string functions and group_concat a String, max, min
and abs the type of their argument. sum is overloaded by column type, Long for
integers and Booleans, Double for reals; it no longer takes a String, BLOB,
enum or ULong column. AS takes a KProperty1<R, P?> of the element's type P, so
count(X) goes into a Long property, not an Int or a String one. Mixing result
types in one listOf doesn't compile either.

Nullability can't be checked through a property reference, as KProperty1 is
covariant, so it is checked when the statement is built: an element knows
whether it can be NULL in a row, or in a group for an aggregate function. One
case depends on what follows: without GROUP BY an aggregate query returns one
row even when no rows match, in which every column and every aggregate except
count is NULL. As GROUP BY can still be appended then, the statements carry
that error until GROUP BY clears it, and the scope reports it when it ends,
before any of its statements runs, transactions included. A property given
two expressions, renamed with @SerialName, or given another table's column is
rejected too.

max and min now return an element of their argument's kind, so they can be a
Boolean, BLOB or enum element as well; these now respect isFunction like the
numeric and string ones, so that a condition on such a function isn't prefixed
with the table name.

Documentation: a result columns section in the advanced query guide, and the
SQL functions guide no longer says functions are for conditions only. It also
listed a sign function, which is disabled, and had an example using `>`
instead of GT, which didn't compile.

ROADMAP: N5 is supported. This also commits the earlier roadmap decisions:
observable queries (N1) as high priority, type converters merged with the
kotlinx.datetime item (N8) as medium priority, and using a query's results
within the same transaction (N6) as low priority.

Tests: jvmTest (49) and testAndroidHostTest on API 26 and 37 (98) pass. The
native test sources compile for macosArm64, linuxX64, mingwX64 and
watchosArm32 but were not run, as this machine is an Intel Mac. The
compile-time rejections were checked with a temporary file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The full upsert, INSERT ... ON CONFLICT (target) DO UPDATE or DO NOTHING,
updates the conflicting row in place, where INSERT OR REPLACE deletes it and
inserts a new one, firing delete triggers and cascades. It is deferred: it
needs SQLite 3.24, which the Android framework only has from API 30 on, while
SQLlin supports API 24; the DSL can't attach a clause to INSERT yet; and the
common cases already have a way, INSERT_OR_IGNORE for de-duplication, and an
UPDATE followed by INSERT_OR_IGNORE in a transaction for an update in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jvmTest and testAndroidHostTest of sqllin-dsl-test and sqllin-driver pass,
and the macosArm64 tests and the sample compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLite's ALTER TABLE can only rename a table, and add, rename or drop a
column; dropping one needs SQLite 3.35, which Android only has from API 34
on. Any other change, such as adding a constraint or changing the primary
key, rebuilds the table: create the new structure under a temporary name,
copy the rows with INSERT INTO ... SELECT, drop the old table and rename the
new one. The DSL could do all of it but the copy, and could only create a
table under the name its @DBRow class fixes at compile time.

INSERT, INSERT_OR_IGNORE and INSERT_OR_REPLACE now also take a SELECT of the
table's row type:

    ArchiveTable INSERT (PersonTable SELECT WHERE<Archive>(PersonTable.age GT 60))

The column list is the row type's properties, in the order the SELECT selects
them, so every column gets a value and the primary key is copied as it is
selected. Requiring the table's own row type makes that complete at compile
time. The SELECT becomes part of the INSERT: it is removed from the statements
of its scope, so it no longer runs on its own, and its deferred GROUP BY check
from N5 runs when it is taken.

Table.withName returns a table with the same structure under another name,
its CREATE TABLE statement renamed. It is a plain function, not a SQL keyword,
so it is lowercase and carries no DSL marker. Its KDoc and the guide explain
the rebuild, and why the new table is renamed rather than the old one: with
SQLite's default settings, renaming a table also renames the references to it
in other tables' foreign keys, which would then point at the dropped table.
That was checked with sqlite3: with legacy_alter_table off, renaming the old
table first rewrote the child's REFERENCES, while the documented order left
it in place.

The guide to modifying the database gains a section on rebuilding a table,
and its Insert section covers INSERT ... SELECT. It also documents
INSERT_OR_IGNORE and INSERT_OR_REPLACE, which it didn't mention.

Tests: testInsertSelect covers copying with a WHERE parameter, a SELECT taken
by an INSERT having no results of its own, filling a table from a grouped
aggregate and rejecting the ungrouped one, and the three INSERTs on a primary
key conflict. testTableRebuild runs a real migration from version 1 to 2: it
renames a column, adds a UNIQUE constraint and drops a column, then checks the
rows and keys, the constraint, and that another table's foreign key still
points at the rebuilt table. jvmTest (51) and testAndroidHostTest on API 26 and
37 (102) pass, so the rebuild works on API 26, where DROP COLUMN doesn't. The
native test sources compile for macosArm64, linuxX64, mingwX64 and
watchosArm32 but were not run, as this machine is an Intel Mac.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
INSERT INTO ... SELECT copies rows into a rebuilt table, but converting them on
the way needs what the DSL doesn't have yet: CAST to change a value's type,
coalesce and ifnull to replace a NULL, such as when making a column NOT NULL,
and literal values. They join the medium priority item for more functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Table.UNION returns a statement typed by the table's row type, but decodes
the rows with its first SELECT's deserializer. A union of projections, result
columns or joins therefore compiles, and its results fail with a
ClassCastException when used; checked with a union of two projections. This
predates 2.4.0, as joins already had their own result type, but projections
and result columns make it easier to reach.

Supporting such unions changes the public UNION functions, which are inline,
so it is deferred to the roadmap rather than fixed in 2.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
replace, instr, printf and group_concat put their string arguments into the
SQL between single quotes as they were. A ' in one broke the statement with a
syntax error, and a crafted one could rewrite it: checked with sqlite-jdbc,
instr(name, "zzz') + 1 + ('") GT 0 became

    WHERE instr(name,'zzz') + 1 + ('')>?

which matched every book, though no name contains that string. Any of these
arguments that comes from user input was an injection point.

The arguments are now written as SQL string literals with each ' doubled,
the only escape SQLite has in one, so whatever the string holds stays inside
the literal. Binding them as parameters was considered, but a function
element is SQL text without parameters, and making elements carry them
through WHERE, HAVING, ORDER BY, GROUP BY and result columns, in order, is a
much larger change that escaping makes unnecessary.

testFunctionStringArguments covers a ' in each of the four functions, and the
crafted argument above now matches no rows; the test fails with the old code.
jvmTest (52) and testAndroidHostTest on API 26 and 37 (104) pass, and the
macosArm64 tests compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A comparison between two elements, such as length(name) GT pages, wrote
both as table.valueName. For a column that is right, but a function's value
name is the call itself, so it produced

    WHERE book.length(name)<book.pages

which SQLite rejects with a syntax error; checked with sqlite-jdbc. A
comparison with a plain value already left a function unqualified, but the
comparisons between two elements, in ClauseNumber, ClauseString, ClauseBlob
and ClauseEnum, didn't check.

ClauseElement.appendSQL writes an element the way the value comparisons
always did, a column qualified by its table and a function as it is, and the
four element comparisons use it on both sides. Only comparisons involving a
function change, and those never worked.

testFunctionComparisons covers a function compared with a column, a column
with a function, two aggregates in HAVING, string functions, and max and min
of an enum column; the test fails with the old code. jvmTest (53) and
testAndroidHostTest on API 26 and 37 (106) pass, and the macosArm64 tests
compile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@qiaoyuang
qiaoyuang merged commit 3e2c4e0 into release/2.4.0 Oct 3, 2026
4 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