Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions dialect_nosqlite_test.go

This file was deleted.

222 changes: 206 additions & 16 deletions dialect_sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -149,6 +150,7 @@ func (m *sqlite) SelectOne(c *Connection, model *Model, query Query) error {
if err := genericSelectOne(c, model, query); err != nil {
return fmt.Errorf("sqlite select one: %w", err)
}
normalizeTimesToUTC(model.Value)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that modernc and mattn parse timezones differently, and to ensure we return UTC codes consistently, we normalize this here.

return nil
})
}
Expand All @@ -158,6 +160,7 @@ func (m *sqlite) SelectMany(c *Connection, models *Model, query Query) error {
if err := genericSelectMany(c, models, query); err != nil {
return fmt.Errorf("sqlite select many: %w", err)
}
normalizeTimesToUTC(models.Value)
return nil
})
}
Expand Down Expand Up @@ -304,6 +307,11 @@ func urlParserSQLite3(cd *ConnectionDetails) error {
return nil
}

// Preserve the raw query string so finalizerSQLite can parse multi-value
// params (e.g. multiple _pragma entries) via url.Values, which supports
// duplicate keys. The generic withURL path sets RawOptions the same way.
cd.RawOptions = dbparts[1]

q, err := url.ParseQuery(dbparts[1])
if err != nil {
return fmt.Errorf("unable to parse sqlite query: %w", err)
Expand All @@ -316,28 +324,210 @@ func urlParserSQLite3(cd *ConnectionDetails) error {
return nil
}

// legacySQLiteParams maps mattn-style DSN params to SQLite pragma names for
// modernc.org/sqlite, which requires _pragma=name(value) syntax. Aliases
// (e.g. _foreign_keys/_fk) list the canonical long form first so the first
// match wins when both aliases are present in the same DSN.
var legacySQLiteParams = []struct{ key, pragma string }{
{"_foreign_keys", "foreign_keys"},
{"_fk", "foreign_keys"},
{"_journal_mode", "journal_mode"},
{"_journal", "journal_mode"},
{"_busy_timeout", "busy_timeout"},
{"_timeout", "busy_timeout"},
{"_synchronous", "synchronous"},
{"_sync", "synchronous"},
{"_auto_vacuum", "auto_vacuum"},
{"_vacuum", "auto_vacuum"},
{"_case_sensitive_like", "case_sensitive_like"},
{"_cslike", "case_sensitive_like"},
{"_defer_foreign_keys", "defer_foreign_keys"},
{"_defer_fk", "defer_foreign_keys"},
{"_locking_mode", "locking_mode"},
{"_locking", "locking_mode"},
{"_recursive_triggers", "recursive_triggers"},
{"_rt", "recursive_triggers"},
{"_cache_size", "cache_size"},
{"_ignore_check_constraints", "ignore_check_constraints"},
{"_query_only", "query_only"},
{"_secure_delete", "secure_delete"},
{"_writable_schema", "writable_schema"},
}

// sqliteInternalKeys are pop-internal connection options that must not be
// forwarded to the SQLite DSN.
var sqliteInternalKeys = map[string]bool{
"migration_table_name": true,
"retry_sleep": true,
"retry_limit": true,
"lock": true,
}

// moderncSQLiteParams is the complete set of DSN query parameters recognised
// by modernc.org/sqlite. Any key not in this set will be warned and stripped.
// Source: modernc.org/sqlite@v1.47.0/sqlite.go applyQueryParams() and
// modernc.org/sqlite@v1.47.0/conn.go newConn().
var moderncSQLiteParams = map[string]bool{
"vfs": true, // VFS name
"_pragma": true, // PRAGMA name(value); repeatable
"_time_format": true, // time write format; only "sqlite" is valid
"_txlock": true, // transaction locking: deferred/immediate/exclusive
"_time_integer_format": true, // integer time repr: unix/unix_milli/unix_micro/unix_nano
"_inttotime": true, // convert integer columns to time.Time
"_texttotime": true, // affect ColumnTypeScanType for TEXT date columns
}

func finalizerSQLite(cd *ConnectionDetails) {
defs := map[string]string{
"_busy_timeout": "5000",
// modernc.org/sqlite (registered as "sqlite3") requires pragmas via
// _pragma=name(value) DSN params. Legacy mattn-style params are silently
// ignored by modernc and must be translated.

// Build url.Values from RawOptions (set when a DSN URL was parsed) or the
// Options map (set programmatically). url.Values supports duplicate keys,
// which is required for multiple _pragma entries.
var q url.Values
if cd.RawOptions != "" {
var err error
q, err = url.ParseQuery(cd.RawOptions)
if err != nil {
q = url.Values{}
}
} else {
q = url.Values{}
for k, v := range cd.Options {
if !sqliteInternalKeys[k] {
q.Set(k, v)
}
}
}
forced := map[string]string{
"_fk": "true",

// _loc is a mattn-only timezone param with no modernc equivalent.
// modernc returns time.UTC natively; use _time_format if a different format is needed.
if q.Get("_loc") != "" {
log(logging.Warn, "SQLite DSN param \"_loc\" has no modernc.org/sqlite equivalent and will be ignored")
q.Del("_loc")
}

// Translate all legacy mattn-style params to _pragma=name(value).
for _, p := range legacySQLiteParams {
if val := q.Get(p.key); val != "" {
q.Del(p.key)
if !sqlitePragmaSet(q, p.pragma) {
q.Add("_pragma", p.pragma+"("+val+")")
}
}
}

for k, def := range defs {
cd.setOptionWithDefault(k, cd.option(k), def)
// Strip any remaining keys that modernc.org/sqlite does not recognise.
for k := range q {
if !moderncSQLiteParams[k] {
log(logging.Warn, "SQLite DSN param %q is not supported by modernc.org/sqlite and will be ignored", k)
q.Del(k)
delete(cd.Options, k)
}
}

for k, v := range forced {
// respect user specified options but print warning!
cd.setOptionWithDefault(k, cd.option(k), v)
if cd.option(k) != v { // when user-defined option exists
log(logging.Warn, "IMPORTANT! '%s: %s' option is required to work properly but your current setting is '%v: %v'.", k, v, k, cd.option(k))
log(logging.Warn, "It is highly recommended to remove '%v: %v' option from your config!", k, cd.option(k))
} // or override with `cd.Options[k] = v`?
if cd.URL != "" && !strings.Contains(cd.URL, k+"="+v) {
log(logging.Warn, "IMPORTANT! '%s=%s' option is required to work properly. Please add it to the database URL in the config!", k, v)
} // or fix user specified url?
// Apply default busy_timeout if not configured.
if !sqlitePragmaSet(q, "busy_timeout") {
q.Add("_pragma", "busy_timeout(5000)")
}
// Enforce foreign_keys.
if !sqlitePragmaSet(q, "foreign_keys") {
q.Add("_pragma", "foreign_keys(1)")
if cd.URL != "" {
log(logging.Warn, "IMPORTANT! '_pragma=foreign_keys(1)' is required for correct operation. Add it to your SQLite DSN.")
}
}
cd.RawOptions = q.Encode()

// Reflect all applied pragmas back into cd.Options for backward-compatible
// reads via cd.option(). sqliteOptionKey maps pragma names to their preferred
// option key; everything else uses "_"+pragmaName.
for _, pragma := range q["_pragma"] {
rawName, rawValue, ok := strings.Cut(pragma, "(")
if !ok {
continue
}
name := strings.ToLower(strings.TrimSpace(rawName))
value := strings.TrimSuffix(strings.TrimSpace(rawValue), ")")
key := "_" + name
if name == "foreign_keys" {
key = "_fk"
}
cd.setOption(key, value)
}
}

// sqlitePragmaSet reports whether q already contains a _pragma entry for
// pragmaName (case-insensitive). Requires the pragma name to be immediately
// followed by '(' to avoid false matches on names sharing a common prefix
// (e.g. "foreign_keys" vs "foreign_keys_per_table").
func sqlitePragmaSet(q url.Values, pragmaName string) bool {
prefix := strings.ToLower(pragmaName) + "("
for _, p := range q["_pragma"] {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(p)), prefix) {
return true
}
}
return false
}

var (
typeTime = reflect.TypeFor[time.Time]()
typeNullTime = reflect.TypeFor[sql.NullTime]()
)

// normalizeTimesToUTC walks v (a pointer to a struct or pointer to a slice of
// structs) and calls .UTC() on every time.Time and valid sql.NullTime field,
// including those inside embedded structs and behind pointer fields.
// This is required because modernc.org/sqlite may return time.Time values
// whose Location pointer is not time.UTC even when the stored instant is UTC
// (e.g. unnamed FixedZone("", 0) from rows written by mattn/go-sqlite3).
func normalizeTimesToUTC(v any) {
normalizeValue(reflect.ValueOf(v))
}

func normalizeValue(rv reflect.Value) {
// Dereference any pointer indirection (handles nil safely).
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.Slice, reflect.Array:
for i := range rv.Len() {
normalizeValue(rv.Index(i))
}
case reflect.Struct:
for i := range rv.NumField() {
f := rv.Field(i)
if !f.CanSet() {
continue
}
// Dereference one pointer level so *time.Time and *Struct are
// handled the same as their value equivalents.
target := f
if target.Kind() == reflect.Pointer {
if target.IsNil() {
continue
}
target = target.Elem()
}
switch target.Type() {
case typeTime:
target.Set(reflect.ValueOf(target.Interface().(time.Time).UTC()))
case typeNullTime:
nt := target.Interface().(sql.NullTime)
if nt.Valid {
nt.Time = nt.Time.UTC()
target.Set(reflect.ValueOf(nt))
}
default:
normalizeValue(target)
}
}
}
}

Expand Down
11 changes: 7 additions & 4 deletions dialect_sqlite_tag.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//go:build sqlite
// +build sqlite

package pop

import (
_ "github.com/mattn/go-sqlite3" // Load SQLite3 CGo driver
"database/sql"

moderncsqlite "modernc.org/sqlite" // Load SQLite3 pure-Go driver
)

func init() {
sql.Register("sqlite3", &moderncsqlite.Driver{})
}
Loading
Loading