Loading all_test.go +272 −0 Original line number Diff line number Diff line Loading @@ -1535,6 +1535,278 @@ func TestTimeFormatBad(t *testing.T) { } } func TestTimezone(t *testing.T) { ref := time.Date(2021, 1, 2, 16, 39, 17, 123456789, time.UTC) t.Run("write", func(t *testing.T) { cases := []struct { tz string f string w string }{ {tz: "UTC", f: "sqlite", w: "2021-01-02 16:39:17.123456789+00:00"}, {tz: "America/New_York", f: "sqlite", w: "2021-01-02 11:39:17.123456789-05:00"}, {tz: "UTC", f: "", w: "2021-01-02 16:39:17.123456789 +0000 UTC"}, } for _, c := range cases { t.Run(c.tz+"/"+c.f, func(t *testing.T) { q := make(url.Values) q.Set("_timezone", c.tz) if c.f != "" { q.Set("_time_format", c.f) } dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("drop table if exists x; create table x (y text)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref); err != nil { t.Fatal(err) } var got string if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got != c.w { t.Fatalf("got %q, want %q", got, c.w) } }) } }) t.Run("read_with_tz", func(t *testing.T) { ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } // Strings that already carry timezone info should preserve the // represented instant while being coerced into the configured timezone. q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } cases := []struct { name string input string want time.Time }{ { name: "Z suffix", input: "2021-01-02T16:39:17Z", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, { name: "explicit offset", input: "2021-01-02 11:39:17-05:00", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, { name: "time string format", input: "2021-01-02 16:39:17.123456789 +0000 UTC", want: time.Date(2021, 1, 2, 11, 39, 17, 123456789, ny), }, { name: "offset differs from target tz", input: "2021-01-02 19:39:17+03:00", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if _, err := db.Exec("delete from x"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, c.input); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got.Location().String() != c.want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), c.want.Location()) } if !got.Equal(c.want) { t.Fatalf("got %v, want %v", got, c.want) } }) } }) t.Run("read", func(t *testing.T) { // Insert a bare datetime string (no timezone) and verify that // _timezone causes the parsed time.Time to have the right location. ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } // Insert a raw datetime string, as SQLite's datetime() would produce. if _, err := db.Exec(`insert into x values ('2021-01-02 16:39:17')`); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } // The raw string "2021-01-02 16:39:17" has no timezone. // _timezone=America/New_York tells the driver to interpret it // as New York time, so the wall clock stays the same but the // location is set. want := time.Date(2021, 1, 2, 16, 39, 17, 0, ny) if got.Location().String() != want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), want.Location()) } if !got.Equal(want) { t.Fatalf("got %v, want %v", got, want) } }) t.Run("read_integer", func(t *testing.T) { ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_inttotime", "true") q.Set("_time_integer_format", "unix") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref.Unix()); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } want := time.Unix(ref.Unix(), 0).In(ny) if got.Location().String() != want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), want.Location()) } if !got.Equal(want) { t.Fatalf("got %v, want %v", got, want) } }) t.Run("roundtrip", func(t *testing.T) { // Write a time.Time and read it back through the same // timezone-configured connection. The instant should be // preserved and the location should be the target timezone. ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } cases := []struct { name string f string // _time_format, empty for default (time.String) }{ {name: "default_format"}, {name: "sqlite_format", f: "sqlite"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") if c.f != "" { q.Set("_time_format", c.f) } dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got.Location().String() != ny.String() { t.Fatalf("got location %s, want %s", got.Location(), ny) } if !got.Equal(ref) { t.Fatalf("got %v, want %v (as instant)", got, ref) } }) } }) } func TestTimezoneBad(t *testing.T) { db, err := sql.Open(driverName, "file::memory:?_timezone=Bogus/Zone") if err != nil { t.Fatal(err) } defer db.Close() _, err = db.Exec("select 1") if err == nil { t.Fatal("wanted error") } if !strings.Contains(err.Error(), `unknown _timezone "Bogus/Zone"`) { t.Fatalf("unexpected error: %v", err) } } func TestIntToTimeDefaultOff(t *testing.T) { db, err := sql.Open(driverName, "file::memory:") if err != nil { Loading conn.go +21 −4 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ type conn struct { writeTimeFormat string beginMode string loc *time.Location intToTime bool textToTime bool integerTimeFormat string Loading Loading @@ -87,12 +88,18 @@ func (c *conn) parseTime(s string) (interface{}, bool) { return v, true } ts := strings.TrimSuffix(s, "Z") ts, hadZ := strings.CutSuffix(s, "Z") for _, f := range parseTimeFormats { t, err := time.Parse(f, ts) var t time.Time var err error if c.loc != nil && !hadZ { t, err = time.ParseInLocation(f, ts, c.loc) } else { t, err = time.Parse(f, ts) } if err == nil { return t, true return c.applyTimezone(t), true } } Loading @@ -102,6 +109,8 @@ func (c *conn) parseTime(s string) (interface{}, bool) { // Attempt to parse s as a time string produced by t.String(). If x > 0 it's // the index of substring "m=" within s. Return (s, false) if s is // not recognized as a valid time encoding. // This intentionally uses time.Parse, not time.ParseInLocation, // because the format already contains timezone information (-0700 MST). func (c *conn) parseTimeString(s0 string, x int) (interface{}, bool) { s := s0 if x > 0 { Loading @@ -109,12 +118,19 @@ func (c *conn) parseTimeString(s0 string, x int) (interface{}, bool) { } s = strings.TrimSpace(s) if t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", s); err == nil { return t, true return c.applyTimezone(t), true } return s0, false } func (c *conn) applyTimezone(t time.Time) time.Time { if c.loc == nil { return t } return t.In(c.loc) } // writeTimeFormats are the names and formats supported // by the `_time_format` DSN query param. var writeTimeFormats = map[string]string{ Loading @@ -122,6 +138,7 @@ var writeTimeFormats = map[string]string{ } func (c *conn) formatTime(t time.Time) string { t = c.applyTimezone(t) // Before configurable write time formats were supported, // time.Time.String was used. Maintain that default to // keep existing driver users formatting times the same. Loading driver.go +8 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,14 @@ func newDriver() *Driver { return d } // _texttotime: Enable ColumnTypeScanType to report time.Time instead of string // for TEXT columns declared as DATE, DATETIME, TIME, or TIMESTAMP. // // _timezone: A timezone to use for all time reads and writes, such as "UTC". // The value is parsed by time.LoadLocation. // Writes will convert to the timezone before formatting as a string; // it does not impact _inttotime integer values, as they always use UTC. // Reads will interpret timezone-less strings as being in this timezone. // Values that are in a known timezone, such as a string with a timezone specifier // or an integer with _inttotime (specified to be in UTC), will be converted to this timezone. // // _txlock: The locking behavior to use when beginning a transaction. May be // "deferred" (the default), "immediate", or "exclusive" (case insensitive). See: // https://www.sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions Loading rows.go +4 −4 Original line number Diff line number Diff line Loading @@ -128,10 +128,10 @@ func (r *rows) Next(dest []driver.Value) (err error) { // without breaking the legacy heuristic for existing users. switch r.c.integerTimeFormat { case "unix_micro": dest[i] = time.UnixMicro(v).UTC() dest[i] = r.c.applyTimezone(time.UnixMicro(v).UTC()) continue case "unix_nano": dest[i] = time.Unix(0, v).UTC() dest[i] = r.c.applyTimezone(time.Unix(0, v).UTC()) continue } Loading @@ -143,10 +143,10 @@ func (r *rows) Next(dest []driver.Value) (err error) { // timestamp? if v > 1e12 || v < -1e12 { // Milliseconds dest[i] = time.UnixMilli(v).UTC() dest[i] = r.c.applyTimezone(time.UnixMilli(v).UTC()) } else { // Seconds dest[i] = time.Unix(v, 0).UTC() dest[i] = r.c.applyTimezone(time.Unix(v, 0).UTC()) } default: dest[i] = v Loading sqlite.go +8 −0 Original line number Diff line number Diff line Loading @@ -184,6 +184,14 @@ func applyQueryParams(c *conn, query string) error { c.integerTimeFormat = v } if v := q.Get("_timezone"); v != "" { loc, err := time.LoadLocation(v) if err != nil { return fmt.Errorf("unknown _timezone %q: %w", v, err) } c.loc = loc } if v := q.Get("_txlock"); v != "" { lower := strings.ToLower(v) if lower != "deferred" && lower != "immediate" && lower != "exclusive" { Loading Loading
all_test.go +272 −0 Original line number Diff line number Diff line Loading @@ -1535,6 +1535,278 @@ func TestTimeFormatBad(t *testing.T) { } } func TestTimezone(t *testing.T) { ref := time.Date(2021, 1, 2, 16, 39, 17, 123456789, time.UTC) t.Run("write", func(t *testing.T) { cases := []struct { tz string f string w string }{ {tz: "UTC", f: "sqlite", w: "2021-01-02 16:39:17.123456789+00:00"}, {tz: "America/New_York", f: "sqlite", w: "2021-01-02 11:39:17.123456789-05:00"}, {tz: "UTC", f: "", w: "2021-01-02 16:39:17.123456789 +0000 UTC"}, } for _, c := range cases { t.Run(c.tz+"/"+c.f, func(t *testing.T) { q := make(url.Values) q.Set("_timezone", c.tz) if c.f != "" { q.Set("_time_format", c.f) } dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("drop table if exists x; create table x (y text)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref); err != nil { t.Fatal(err) } var got string if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got != c.w { t.Fatalf("got %q, want %q", got, c.w) } }) } }) t.Run("read_with_tz", func(t *testing.T) { ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } // Strings that already carry timezone info should preserve the // represented instant while being coerced into the configured timezone. q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } cases := []struct { name string input string want time.Time }{ { name: "Z suffix", input: "2021-01-02T16:39:17Z", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, { name: "explicit offset", input: "2021-01-02 11:39:17-05:00", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, { name: "time string format", input: "2021-01-02 16:39:17.123456789 +0000 UTC", want: time.Date(2021, 1, 2, 11, 39, 17, 123456789, ny), }, { name: "offset differs from target tz", input: "2021-01-02 19:39:17+03:00", want: time.Date(2021, 1, 2, 11, 39, 17, 0, ny), }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if _, err := db.Exec("delete from x"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, c.input); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got.Location().String() != c.want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), c.want.Location()) } if !got.Equal(c.want) { t.Fatalf("got %v, want %v", got, c.want) } }) } }) t.Run("read", func(t *testing.T) { // Insert a bare datetime string (no timezone) and verify that // _timezone causes the parsed time.Time to have the right location. ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } // Insert a raw datetime string, as SQLite's datetime() would produce. if _, err := db.Exec(`insert into x values ('2021-01-02 16:39:17')`); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } // The raw string "2021-01-02 16:39:17" has no timezone. // _timezone=America/New_York tells the driver to interpret it // as New York time, so the wall clock stays the same but the // location is set. want := time.Date(2021, 1, 2, 16, 39, 17, 0, ny) if got.Location().String() != want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), want.Location()) } if !got.Equal(want) { t.Fatalf("got %v, want %v", got, want) } }) t.Run("read_integer", func(t *testing.T) { ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_inttotime", "true") q.Set("_time_integer_format", "unix") dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref.Unix()); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } want := time.Unix(ref.Unix(), 0).In(ny) if got.Location().String() != want.Location().String() { t.Fatalf("got location %s, want %s", got.Location(), want.Location()) } if !got.Equal(want) { t.Fatalf("got %v, want %v", got, want) } }) t.Run("roundtrip", func(t *testing.T) { // Write a time.Time and read it back through the same // timezone-configured connection. The instant should be // preserved and the location should be the target timezone. ny, err := time.LoadLocation("America/New_York") if err != nil { t.Fatal(err) } cases := []struct { name string f string // _time_format, empty for default (time.String) }{ {name: "default_format"}, {name: "sqlite_format", f: "sqlite"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { q := make(url.Values) q.Set("_timezone", "America/New_York") q.Set("_texttotime", "true") if c.f != "" { q.Set("_time_format", c.f) } dsn := "file::memory:?" + q.Encode() db, err := sql.Open(driverName, dsn) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec("create table x (y datetime)"); err != nil { t.Fatal(err) } if _, err := db.Exec(`insert into x values (?)`, ref); err != nil { t.Fatal(err) } var got time.Time if err := db.QueryRow(`select y from x`).Scan(&got); err != nil { t.Fatal(err) } if got.Location().String() != ny.String() { t.Fatalf("got location %s, want %s", got.Location(), ny) } if !got.Equal(ref) { t.Fatalf("got %v, want %v (as instant)", got, ref) } }) } }) } func TestTimezoneBad(t *testing.T) { db, err := sql.Open(driverName, "file::memory:?_timezone=Bogus/Zone") if err != nil { t.Fatal(err) } defer db.Close() _, err = db.Exec("select 1") if err == nil { t.Fatal("wanted error") } if !strings.Contains(err.Error(), `unknown _timezone "Bogus/Zone"`) { t.Fatalf("unexpected error: %v", err) } } func TestIntToTimeDefaultOff(t *testing.T) { db, err := sql.Open(driverName, "file::memory:") if err != nil { Loading
conn.go +21 −4 Original line number Diff line number Diff line Loading @@ -29,6 +29,7 @@ type conn struct { writeTimeFormat string beginMode string loc *time.Location intToTime bool textToTime bool integerTimeFormat string Loading Loading @@ -87,12 +88,18 @@ func (c *conn) parseTime(s string) (interface{}, bool) { return v, true } ts := strings.TrimSuffix(s, "Z") ts, hadZ := strings.CutSuffix(s, "Z") for _, f := range parseTimeFormats { t, err := time.Parse(f, ts) var t time.Time var err error if c.loc != nil && !hadZ { t, err = time.ParseInLocation(f, ts, c.loc) } else { t, err = time.Parse(f, ts) } if err == nil { return t, true return c.applyTimezone(t), true } } Loading @@ -102,6 +109,8 @@ func (c *conn) parseTime(s string) (interface{}, bool) { // Attempt to parse s as a time string produced by t.String(). If x > 0 it's // the index of substring "m=" within s. Return (s, false) if s is // not recognized as a valid time encoding. // This intentionally uses time.Parse, not time.ParseInLocation, // because the format already contains timezone information (-0700 MST). func (c *conn) parseTimeString(s0 string, x int) (interface{}, bool) { s := s0 if x > 0 { Loading @@ -109,12 +118,19 @@ func (c *conn) parseTimeString(s0 string, x int) (interface{}, bool) { } s = strings.TrimSpace(s) if t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", s); err == nil { return t, true return c.applyTimezone(t), true } return s0, false } func (c *conn) applyTimezone(t time.Time) time.Time { if c.loc == nil { return t } return t.In(c.loc) } // writeTimeFormats are the names and formats supported // by the `_time_format` DSN query param. var writeTimeFormats = map[string]string{ Loading @@ -122,6 +138,7 @@ var writeTimeFormats = map[string]string{ } func (c *conn) formatTime(t time.Time) string { t = c.applyTimezone(t) // Before configurable write time formats were supported, // time.Time.String was used. Maintain that default to // keep existing driver users formatting times the same. Loading
driver.go +8 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,14 @@ func newDriver() *Driver { return d } // _texttotime: Enable ColumnTypeScanType to report time.Time instead of string // for TEXT columns declared as DATE, DATETIME, TIME, or TIMESTAMP. // // _timezone: A timezone to use for all time reads and writes, such as "UTC". // The value is parsed by time.LoadLocation. // Writes will convert to the timezone before formatting as a string; // it does not impact _inttotime integer values, as they always use UTC. // Reads will interpret timezone-less strings as being in this timezone. // Values that are in a known timezone, such as a string with a timezone specifier // or an integer with _inttotime (specified to be in UTC), will be converted to this timezone. // // _txlock: The locking behavior to use when beginning a transaction. May be // "deferred" (the default), "immediate", or "exclusive" (case insensitive). See: // https://www.sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions Loading
rows.go +4 −4 Original line number Diff line number Diff line Loading @@ -128,10 +128,10 @@ func (r *rows) Next(dest []driver.Value) (err error) { // without breaking the legacy heuristic for existing users. switch r.c.integerTimeFormat { case "unix_micro": dest[i] = time.UnixMicro(v).UTC() dest[i] = r.c.applyTimezone(time.UnixMicro(v).UTC()) continue case "unix_nano": dest[i] = time.Unix(0, v).UTC() dest[i] = r.c.applyTimezone(time.Unix(0, v).UTC()) continue } Loading @@ -143,10 +143,10 @@ func (r *rows) Next(dest []driver.Value) (err error) { // timestamp? if v > 1e12 || v < -1e12 { // Milliseconds dest[i] = time.UnixMilli(v).UTC() dest[i] = r.c.applyTimezone(time.UnixMilli(v).UTC()) } else { // Seconds dest[i] = time.Unix(v, 0).UTC() dest[i] = r.c.applyTimezone(time.Unix(v, 0).UTC()) } default: dest[i] = v Loading
sqlite.go +8 −0 Original line number Diff line number Diff line Loading @@ -184,6 +184,14 @@ func applyQueryParams(c *conn, query string) error { c.integerTimeFormat = v } if v := q.Get("_timezone"); v != "" { loc, err := time.LoadLocation(v) if err != nil { return fmt.Errorf("unknown _timezone %q: %w", v, err) } c.loc = loc } if v := q.Get("_txlock"); v != "" { lower := strings.ToLower(v) if lower != "deferred" && lower != "immediate" && lower != "exclusive" { Loading