Commit 2e045237 authored by cznic's avatar cznic
Browse files

Merge branch 'add-timezone' into 'master'

add _timezone DSN query parameter

See merge request !94
parents c2c62726 18958226
Loading
Loading
Loading
Loading
+272 −0
Original line number Diff line number Diff line
@@ -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 {
+21 −4
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ type conn struct {

	writeTimeFormat   string
	beginMode         string
	loc               *time.Location
	intToTime         bool
	textToTime        bool
	integerTimeFormat string
@@ -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
		}
	}

@@ -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 {
@@ -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{
@@ -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.
+8 −0
Original line number Diff line number Diff line
@@ -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
+4 −4
Original line number Diff line number Diff line
@@ -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
						}

@@ -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
+8 −0
Original line number Diff line number Diff line
@@ -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" {