From 82c7ac751b86991b7c000d45bd9c62f072530120 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 22 Oct 2026 09:15:37 -0600 Subject: [PATCH] Don't re-run writes while inferring strict column types With `strict=true`, a column whose first value is NULL was typed by stepping ahead to a non-NULL row, then rewinding with sqlite3_reset + sqlite3_step. Rewinding re-runs the statement, so `INSERT ... RETURNING` inserted its rows twice and `UPDATE ... RETURNING` applied its update twice. Statements that write (sqlite3_stmt_readonly == 0) are no longer scanned; such a column gets its declared type, or `Any` when it has none. Strict `Any` columns (e.g. declared BLOB) also read every value as blob bytes, so an integer 1 came back as UInt8[0x31]. Values in an `Any` column are now read by their own storage class, as in non-strict queries. Co-Authored-By: Claude Opus 5.5 --- src/SQLite.jl | 6 ++++++ src/tables.jl | 4 +++- test/runtests.jl | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/SQLite.jl b/src/SQLite.jl index 8448b99..a51c150 100644 --- a/src/SQLite.jl +++ b/src/SQLite.jl @@ -609,6 +609,12 @@ end # get julia type for given column of the given statement function juliatype(handle, col, scanrows::Bool = false) stored_typeid = C.sqlite3_column_type(handle, col - 1) + # Rewinding after a scan re-runs the statement, which would repeat the writes of + # e.g. `INSERT ... RETURNING`; such a column gets its declared type or `Any`. + if scanrows && stored_typeid == C.SQLITE_NULL && C.sqlite3_stmt_readonly(handle) == 0 + decl_typestr = C.sqlite3_column_decltype(handle, col - 1) + return decl_typestr == C_NULL ? Any : juliatype(unsafe_string(decl_typestr), Any) + end did_row_scan = false while scanrows && stored_typeid == C.SQLITE_NULL # Scan forward through the rows until we find a non-NULL value for this column diff --git a/src/tables.jl b/src/tables.jl index 60f9c2e..3270afe 100644 --- a/src/tables.jl +++ b/src/tables.jl @@ -89,7 +89,9 @@ function getvalue( if t == C.SQLITE_NULL return missing end - sqlitevalue(nonmissingtype(T), handle, col) + S = nonmissingtype(T) + # An `Any` column holds values of several storage classes; read each by its own. + sqlitevalue(S === Any ? juliatype(t) : S, handle, col) end function getvalue( diff --git a/test/runtests.jl b/test/runtests.jl index a97f8a0..b06fcb8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1081,6 +1081,33 @@ end end end + @testset "Type inference does not repeat writes" begin + db = SQLite.DB() + strict(sql) = columntable( + DBInterface.execute(DBInterface.prepare(db, sql), (); strict = true), + ) + DBInterface.execute(db, "CREATE TABLE t (id INTEGER PRIMARY KEY, n INTEGER, note TEXT, extra)") + r = strict("INSERT INTO t (n) VALUES (1), (2) RETURNING id, note, extra") + @test r.id == [1, 2] + @test eltype(r.note) == Union{Missing,String} + @test all(ismissing, r.note) && all(ismissing, r.extra) + @test columntable(DBInterface.execute(db, "SELECT id FROM t")).id == [1, 2] + r = strict("UPDATE t SET n = n + 1, extra = 2.5 WHERE id = 2 RETURNING n, note, extra") + @test r.n == [3] && isequal(r.note, [missing]) && r.extra == [2.5] + r = strict("UPDATE t SET n = n + 1, extra = NULL RETURNING extra") + @test length(r.extra) == 2 && all(ismissing, r.extra) + @test columntable(DBInterface.execute(db, "SELECT n FROM t ORDER BY id")).n == [2, 4] + end + + @testset "Strict columns of type Any keep each value's storage class" begin + db = SQLite.DB() + DBInterface.execute(db, "CREATE TABLE b (x BLOB)") + DBInterface.execute(db, "INSERT INTO b VALUES (1), ('txt'), (x'0102'), (NULL)") + stmt = DBInterface.prepare(db, "SELECT x FROM b ORDER BY rowid") + tbl = columntable(DBInterface.execute(stmt, (); strict = true)) + @test isequal(tbl.x, [1, "txt", UInt8[0x01, 0x02], missing]) + end + @testset "PR #343: strict (and only strict) tables should error if types don't match" begin db = SQLite.DB()