Skip to content

Commit ee0db94

Browse files
hperlclaude
andauthored
fix(sqlite): preserve SQLite URI params through finalizer (#18)
* fix(sqlite): preserve SQLite URI params through finalizer The `finalizerSQLite` stripped any DSN query param not in `moderncSQLiteParams`, with a comment claiming that set was the "complete" list of params recognised by modernc.org/sqlite. That's true for Go-driver params (_pragma etc.), but the list also needed SQLite's own URI params — `mode`, `cache`, `psow`, `nolock`, `immutable` — which SQLite parses itself when SQLITE_OPEN_URI is set (modernc enables it unconditionally in conn.go). Without this, a DSN like `file:name?mode=memory&cache=shared` had `mode` and `cache` dropped before reaching modernc, causing sqlite3_open_v2 to fall back to creating an on-disk file named `name` instead of an in-memory database. Downstream impact: Hydra and other services that use `DSN=memory` to construct `file:<random>?mode=memory&cache=shared` ended up with stray SQLite files accumulating in the working directory after each process start. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0277b33 commit ee0db94

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

dialect_sqlite.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,18 @@ var sqliteInternalKeys = map[string]bool{
368368
}
369369

370370
// moderncSQLiteParams is the complete set of DSN query parameters recognised
371-
// by modernc.org/sqlite. Any key not in this set will be warned and stripped.
372-
// Source: modernc.org/sqlite@v1.47.0/sqlite.go applyQueryParams() and
373-
// modernc.org/sqlite@v1.47.0/conn.go newConn().
371+
// by modernc.org/sqlite or by SQLite itself via SQLITE_OPEN_URI. Any key not
372+
// in this set will be warned and stripped.
373+
//
374+
// Go driver params (modernc.org/sqlite@v1.48.0/sqlite.go applyQueryParams()
375+
// and conn.go newConn()):
376+
// - vfs, _pragma, _time_format, _txlock, _time_integer_format, _inttotime,
377+
// _texttotime
378+
//
379+
// SQLite URI params (https://sqlite.org/uri.html) — processed by SQLite
380+
// itself when the DSN starts with "file:" and SQLITE_OPEN_URI is set, which
381+
// modernc.org/sqlite enables unconditionally:
382+
// - mode, cache, psow, nolock, immutable
374383
var moderncSQLiteParams = map[string]bool{
375384
"vfs": true, // VFS name
376385
"_pragma": true, // PRAGMA name(value); repeatable
@@ -379,6 +388,11 @@ var moderncSQLiteParams = map[string]bool{
379388
"_time_integer_format": true, // integer time repr: unix/unix_milli/unix_micro/unix_nano
380389
"_inttotime": true, // convert integer columns to time.Time
381390
"_texttotime": true, // affect ColumnTypeScanType for TEXT date columns
391+
"mode": true, // SQLite URI: ro | rw | rwc | memory
392+
"cache": true, // SQLite URI: shared | private
393+
"psow": true, // SQLite URI: powersafe overwrite
394+
"nolock": true, // SQLite URI: disable locking
395+
"immutable": true, // SQLite URI: read-only, no change check
382396
}
383397

384398
func finalizerSQLite(cd *ConnectionDetails) {

dialect_sqlite_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"database/sql"
55
"fmt"
66
"net/url"
7+
"os"
78
"path/filepath"
89
"testing"
910
"time"
@@ -546,6 +547,57 @@ func Test_ConnectionDetails_Finalize_SQLite_Programmatic(t *testing.T) {
546547
require.Equal(t, "1", cd.Options["_fk"])
547548
}
548549

550+
// Test_ConnectionDetails_Finalize_SQLite_URIParams verifies that SQLite URI
551+
// query parameters (mode, cache, psow, nolock, immutable) are preserved in
552+
// RawOptions. These are processed by SQLite itself when SQLITE_OPEN_URI is
553+
// set — modernc.org/sqlite passes the URI through unchanged — and stripping
554+
// them breaks `mode=memory` in-memory databases by falling back to disk files
555+
// named after the URI path.
556+
func Test_ConnectionDetails_Finalize_SQLite_URIParams(t *testing.T) {
557+
for _, tc := range []struct {
558+
key, value string
559+
}{
560+
{"mode", "memory"},
561+
{"cache", "shared"},
562+
{"psow", "0"},
563+
{"nolock", "1"},
564+
{"immutable", "1"},
565+
} {
566+
t.Run(tc.key, func(t *testing.T) {
567+
cd := &ConnectionDetails{
568+
URL: fmt.Sprintf("sqlite3://file:x?%s=%s", tc.key, tc.value),
569+
}
570+
require.NoError(t, cd.Finalize())
571+
q, err := url.ParseQuery(cd.RawOptions)
572+
require.NoError(t, err)
573+
require.Equal(t, tc.value, q.Get(tc.key),
574+
"SQLite URI param %q=%q must survive finalizer", tc.key, tc.value)
575+
})
576+
}
577+
}
578+
579+
// Test_ConnectionDetails_Finalize_SQLite_MemoryDSNStaysInMemory opens a DSN
580+
// with mode=memory&cache=shared and asserts no file is created on disk.
581+
// Regression test for the bug where finalizerSQLite stripped SQLite URI
582+
// params, causing in-memory DSNs to silently fall back to disk files.
583+
func Test_ConnectionDetails_Finalize_SQLite_MemoryDSNStaysInMemory(t *testing.T) {
584+
dbPath := filepath.Join(t.TempDir(), "memtest")
585+
cd := &ConnectionDetails{
586+
URL: fmt.Sprintf("sqlite3://file:%s?mode=memory&cache=shared", dbPath),
587+
}
588+
require.NoError(t, cd.Finalize())
589+
590+
db, err := sql.Open("sqlite3", cd.Database+"?"+cd.RawOptions)
591+
require.NoError(t, err)
592+
t.Cleanup(func() { _ = db.Close() })
593+
_, err = db.Exec("CREATE TABLE t(x INT); INSERT INTO t VALUES (1)")
594+
require.NoError(t, err)
595+
596+
_, err = os.Stat(dbPath)
597+
require.True(t, os.IsNotExist(err),
598+
"mode=memory DSN must not create an on-disk file at %q", dbPath)
599+
}
600+
549601
// Test_ConnectionDetails_Finalize_SQLite_DirectPragma verifies that
550602
// _pragma=name(value) entries set directly in the DSN survive and are echoed.
551603
func Test_ConnectionDetails_Finalize_SQLite_DirectPragma(t *testing.T) {

0 commit comments

Comments
 (0)