From 53283cee261dcdffce0e9613f93c298f85ec144e Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 22 Oct 2026 09:38:57 -0600 Subject: [PATCH 1/2] Read non-strict values without dispatching on the column type Non-strict queries decide each value's Julia type from its storage class, but the read went through two dynamic dispatches: `getcolumn` on the column's declared `Type` and `sqlitevalue` on the storage class's type. `getvalue` now branches on the storage class and calls the reader for it directly, and `getcolumn(::Row{false}, i)` no longer dispatches on the declared type, which never changes a non-strict value. 200k rows x 5 columns into `Tables.columntable` (Julia 1.12): 470 ms -> 86 ms, 3.40M -> 1.70M allocations; a row loop reading one integer column: 66 ms -> 29 ms. Co-Authored-By: Claude Opus 5.5 --- src/tables.jl | 13 ++++++++----- test/runtests.jl | 12 ++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/tables.jl b/src/tables.jl index 60f9c2e..b4ab289 100644 --- a/src/tables.jl +++ b/src/tables.jl @@ -101,11 +101,12 @@ function getvalue( rownumber == q.current_rownumber[] || wrongrow(rownumber) handle = _get_stmt_handle(q.stmt) t = C.sqlite3_column_type(handle, col - 1) - if t == C.SQLITE_NULL - return missing - end - TT = juliatype(t) # native SQLite Int, Float, and Text types - return sqlitevalue(ifelse(TT === Any && !isbitstype(T), T, TT), handle, col) + # branch on the storage class so each read is a static call + t == C.SQLITE_NULL && return missing + t == C.SQLITE_INTEGER && return C.sqlite3_column_int64(handle, col - 1) + t == C.SQLITE_FLOAT && return C.sqlite3_column_double(handle, col - 1) + t == C.SQLITE_TEXT && return sqlitevalue(String, handle, col) + return sqlitevalue(isbitstype(T) ? Any : T, handle, col) end function Tables.getcolumn(r::Row, ::Type{T}, i::Int, nm::Symbol) where {T} @@ -115,6 +116,8 @@ end function Tables.getcolumn(r::Row, i::Int) Tables.getcolumn(r, getquery(r).types[i], i, getquery(r).names[i]) end +# non-strict values don't depend on the column type; skip dispatching on it +Tables.getcolumn(r::Row{false}, i::Int) = getvalue(getquery(r), i, getfield(r, :rownumber), Any) function Tables.getcolumn(r::Row, nm::Symbol) Tables.getcolumn(r, getquery(r).lookup[nm]) end diff --git a/test/runtests.jl b/test/runtests.jl index a97f8a0..ddef3b0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1067,6 +1067,18 @@ end end end + @testset "Non-strict values follow each value's storage class" begin + db = SQLite.DB() + DBInterface.execute(db, "CREATE TABLE mixed (x INTEGER)") + for v in (1, 1.5, "abc", :sym, UInt8[0x01, 0x02], missing) + DBInterface.execute(db, "INSERT INTO mixed VALUES (?)", (v,)) + end + expected = [1, 1.5, "abc", :sym, UInt8[0x01, 0x02], missing] + @test isequal([row.x for row in DBInterface.execute(db, "SELECT x FROM mixed")], expected) + @test isequal(columntable(DBInterface.execute(db, "SELECT x FROM mixed")).x, expected) + close(db) + end + @testset "strict mode" begin @testset "Issue #353: ALTER TABLE on a STRICT table" begin for strict in (false, true) From e6009eea26be505cf5d761ab0c8ccda73c36fa71 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 22 Oct 2026 09:55:19 -0600 Subject: [PATCH 2/2] Cover strict row access by name Co-Authored-By: Claude Opus 5.5 --- test/runtests.jl | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/runtests.jl b/test/runtests.jl index ddef3b0..b8e4c1f 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1076,6 +1076,9 @@ end expected = [1, 1.5, "abc", :sym, UInt8[0x01, 0x02], missing] @test isequal([row.x for row in DBInterface.execute(db, "SELECT x FROM mixed")], expected) @test isequal(columntable(DBInterface.execute(db, "SELECT x FROM mixed")).x, expected) + # strict rows read through each column's inferred type + row = first(DBInterface.execute(DBInterface.prepare(db, "SELECT 1 AS i, 1.5 AS f"), (); strict = true)) + @test row.i === 1 && row.f === 1.5 close(db) end