From affe9e2dc7ae59a9c01f2fe857bdaee160859ca3 Mon Sep 17 00:00:00 2001 From: Vasilii Novikov Date: Tue, 12 Jan 2021 21:19:38 +0300 Subject: [PATCH 1/4] Support BC form of timestamps and dates. Signed-off-by: Vasilii Novikov --- convert.go | 51 +++++++++++++++++++++++++++++++++++++++++++++ date.go | 12 ++++++----- date_test.go | 3 +++ tid_test.go | 1 - timestamp.go | 6 +++--- timestamp_test.go | 3 +++ timestamptz.go | 13 ++++++------ timestamptz_test.go | 3 +++ 8 files changed, 77 insertions(+), 15 deletions(-) diff --git a/convert.go b/convert.go index 193f771..06cc461 100644 --- a/convert.go +++ b/convert.go @@ -4,6 +4,7 @@ import ( "database/sql" "math" "reflect" + "strings" "time" errors "golang.org/x/xerrors" @@ -454,3 +455,53 @@ func init() { reflect.String: reflect.TypeOf(""), } } + +func trimTimestampBC(s string) (string, bool) { + bc := false + if strings.HasSuffix(s, "BC") { + s = strings.TrimSpace(s[:len(s)-2]) + bc = true + } + return s, bc +} + +func decodeTextTimestamp(layout string, s string, inLoc bool) (t time.Time, err error) { + s, bc := trimTimestampBC(s) + if inLoc { + t, err = time.Parse(layout, s) + } else { + t, err = time.ParseInLocation(layout, s, time.UTC) + } + if err != nil { + return + } + // Convert time before common era (BC). + if bc { + year := (t.Year() - 1) * -1 + t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC) + } + return t, nil +} + +func encodeTextTimestamp(layout string, t time.Time) string { + t = t.Truncate(time.Microsecond) + // Convert time before common era (BC). + if t.Before(time.Time{}) { + year := t.Year()*-1 + 1 + t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC) + return t.Format(layout) + " BC" + } + return t.Format(layout) +} + +// DecodeTextTimestamp decodes string timestamp to time.Time. +// Supports converting timestamp from BC (before common era) form. +func DecodeTextTimestamp(s string) (time.Time, error) { + return decodeTextTimestamp(pgTimestampFormat, s, false) +} + +// EncodeTextTimestamp encodes time.Time to timestamp string. +// If given time before common era then function converts it to correct Postgres form with BC suffix. +func EncodeTextTimestamp(t time.Time) string { + return encodeTextTimestamp(pgTimestampFormat, t) +} diff --git a/date.go b/date.go index 59e225d..98f9bc1 100644 --- a/date.go +++ b/date.go @@ -10,6 +10,8 @@ import ( errors "golang.org/x/xerrors" ) +const pgDateFormat = "2006-01-02" + type Date struct { Time time.Time Status Status @@ -111,7 +113,7 @@ func (dst *Date) DecodeText(ci *ConnInfo, src []byte) error { case "-infinity": *dst = Date{Status: Present, InfinityModifier: -Infinity} default: - t, err := time.ParseInLocation("2006-01-02", sbuf, time.UTC) + t, err := decodeTextTimestamp(pgDateFormat, sbuf, true) if err != nil { return err } @@ -159,7 +161,7 @@ func (src Date) EncodeText(ci *ConnInfo, buf []byte) ([]byte, error) { switch src.InfinityModifier { case None: - s = src.Time.Format("2006-01-02") + s = encodeTextTimestamp(pgDateFormat, src.Time) case Infinity: s = "infinity" case NegativeInfinity: @@ -223,7 +225,7 @@ func (src Date) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return src.Time, nil + return EncodeValueText(src) case Null: return nil, nil default: @@ -247,7 +249,7 @@ func (src Date) MarshalJSON() ([]byte, error) { switch src.InfinityModifier { case None: - s = src.Time.Format("2006-01-02") + s = encodeTextTimestamp(pgDateFormat, src.Time) case Infinity: s = "infinity" case NegativeInfinity: @@ -275,7 +277,7 @@ func (dst *Date) UnmarshalJSON(b []byte) error { case "-infinity": *dst = Date{Status: Present, InfinityModifier: -Infinity} default: - t, err := time.ParseInLocation("2006-01-02", *s, time.UTC) + t, err := decodeTextTimestamp(pgDateFormat, *s, true) if err != nil { return err } diff --git a/date_test.go b/date_test.go index 5c38e7a..40076dd 100644 --- a/date_test.go +++ b/date_test.go @@ -17,6 +17,9 @@ func TestDateTranscode(t *testing.T) { &pgtype.Date{Time: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Date{Time: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Date{Time: time.Date(2200, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, + &pgtype.Date{Time: time.Time{}, Status: pgtype.Present}, + &pgtype.Date{Time: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, + &pgtype.Date{Time: time.Date(-100, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Date{Status: pgtype.Null}, &pgtype.Date{Status: pgtype.Present, InfinityModifier: pgtype.Infinity}, &pgtype.Date{Status: pgtype.Present, InfinityModifier: -pgtype.Infinity}, diff --git a/tid_test.go b/tid_test.go index 818be8a..6807e02 100644 --- a/tid_test.go +++ b/tid_test.go @@ -60,4 +60,3 @@ func TestTIDAssignTo(t *testing.T) { } } } - diff --git a/timestamp.go b/timestamp.go index 0e12769..5f0dfc7 100644 --- a/timestamp.go +++ b/timestamp.go @@ -110,7 +110,7 @@ func (dst *Timestamp) DecodeText(ci *ConnInfo, src []byte) error { case "-infinity": *dst = Timestamp{Status: Present, InfinityModifier: -Infinity} default: - tim, err := time.Parse(pgTimestampFormat, sbuf) + tim, err := DecodeTextTimestamp(sbuf) if err != nil { return err } @@ -166,7 +166,7 @@ func (src Timestamp) EncodeText(ci *ConnInfo, buf []byte) ([]byte, error) { switch src.InfinityModifier { case None: - s = src.Time.Truncate(time.Microsecond).Format(pgTimestampFormat) + s = EncodeTextTimestamp(src.Time) case Infinity: s = "infinity" case NegativeInfinity: @@ -232,7 +232,7 @@ func (src Timestamp) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return src.Time, nil + return EncodeValueText(src) case Null: return nil, nil default: diff --git a/timestamp_test.go b/timestamp_test.go index 74cb122..90c96fe 100644 --- a/timestamp_test.go +++ b/timestamp_test.go @@ -22,6 +22,9 @@ func TestTimestampTranscode(t *testing.T) { &pgtype.Timestamp{Time: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Timestamp{Time: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Timestamp{Time: time.Date(2200, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, + &pgtype.Timestamp{Time: time.Time{}, Status: pgtype.Present}, + &pgtype.Timestamp{Time: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, + &pgtype.Timestamp{Time: time.Date(-100, 1, 1, 0, 0, 0, 0, time.UTC), Status: pgtype.Present}, &pgtype.Timestamp{Status: pgtype.Null}, &pgtype.Timestamp{Status: pgtype.Present, InfinityModifier: pgtype.Infinity}, &pgtype.Timestamp{Status: pgtype.Present, InfinityModifier: -pgtype.Infinity}, diff --git a/timestamptz.go b/timestamptz.go index a79bd66..7f24ba8 100644 --- a/timestamptz.go +++ b/timestamptz.go @@ -111,6 +111,8 @@ func (dst *Timestamptz) DecodeText(ci *ConnInfo, src []byte) error { *dst = Timestamptz{Status: Present, InfinityModifier: -Infinity} default: var format string + orig := sbuf + sbuf, _ = trimTimestampBC(sbuf) if len(sbuf) >= 9 && (sbuf[len(sbuf)-9] == '-' || sbuf[len(sbuf)-9] == '+') { format = pgTimestamptzSecondFormat } else if len(sbuf) >= 6 && (sbuf[len(sbuf)-6] == '-' || sbuf[len(sbuf)-6] == '+') { @@ -118,8 +120,7 @@ func (dst *Timestamptz) DecodeText(ci *ConnInfo, src []byte) error { } else { format = pgTimestamptzHourFormat } - - tim, err := time.Parse(format, sbuf) + tim, err := decodeTextTimestamp(format, orig, false) if err != nil { return err } @@ -168,7 +169,7 @@ func (src Timestamptz) EncodeText(ci *ConnInfo, buf []byte) ([]byte, error) { switch src.InfinityModifier { case None: - s = src.Time.UTC().Truncate(time.Microsecond).Format(pgTimestamptzSecondFormat) + s = encodeTextTimestamp(pgTimestamptzSecondFormat, src.Time.UTC()) case Infinity: s = "infinity" case NegativeInfinity: @@ -229,7 +230,7 @@ func (src Timestamptz) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return src.Time, nil + return EncodeValueText(src) case Null: return nil, nil default: @@ -253,7 +254,7 @@ func (src Timestamptz) MarshalJSON() ([]byte, error) { switch src.InfinityModifier { case None: - s = src.Time.Format(time.RFC3339Nano) + s = encodeTextTimestamp(time.RFC3339Nano, src.Time) case Infinity: s = "infinity" case NegativeInfinity: @@ -282,7 +283,7 @@ func (dst *Timestamptz) UnmarshalJSON(b []byte) error { *dst = Timestamptz{Status: Present, InfinityModifier: -Infinity} default: // PostgreSQL uses ISO 8601 for to_json function and casting from a string to timestamptz - tim, err := time.Parse(time.RFC3339Nano, *s) + tim, err := decodeTextTimestamp(time.RFC3339Nano, *s, false) if err != nil { return err } diff --git a/timestamptz_test.go b/timestamptz_test.go index 769c923..ae9ff07 100644 --- a/timestamptz_test.go +++ b/timestamptz_test.go @@ -22,6 +22,9 @@ func TestTimestamptzTranscode(t *testing.T) { &pgtype.Timestamptz{Time: time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, &pgtype.Timestamptz{Time: time.Date(2000, 1, 2, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, &pgtype.Timestamptz{Time: time.Date(2200, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Time{}, Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(0, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(-100, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, &pgtype.Timestamptz{Status: pgtype.Null}, &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: pgtype.Infinity}, &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: -pgtype.Infinity}, From be05b1acab96720427145f6f71dbd70f765e90b6 Mon Sep 17 00:00:00 2001 From: Vasilii Novikov Date: Sun, 24 Jan 2021 13:06:51 +0300 Subject: [PATCH 2/4] Revert changes in Value methods Signed-off-by: Vasilii Novikov --- date.go | 2 +- timestamp.go | 2 +- timestamptz.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/date.go b/date.go index 98f9bc1..706a9ee 100644 --- a/date.go +++ b/date.go @@ -225,7 +225,7 @@ func (src Date) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return EncodeValueText(src) + return src.Time, nil case Null: return nil, nil default: diff --git a/timestamp.go b/timestamp.go index 5f0dfc7..b76926e 100644 --- a/timestamp.go +++ b/timestamp.go @@ -232,7 +232,7 @@ func (src Timestamp) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return EncodeValueText(src) + return src.Time, nil case Null: return nil, nil default: diff --git a/timestamptz.go b/timestamptz.go index 7f24ba8..cb83452 100644 --- a/timestamptz.go +++ b/timestamptz.go @@ -230,7 +230,7 @@ func (src Timestamptz) Value() (driver.Value, error) { if src.InfinityModifier != None { return src.InfinityModifier.String(), nil } - return EncodeValueText(src) + return src.Time, nil case Null: return nil, nil default: From 35282bb677ce10e3665707a937ec9f520019c734 Mon Sep 17 00:00:00 2001 From: Vasilii Novikov Date: Sat, 30 Jan 2021 17:49:53 +0300 Subject: [PATCH 3/4] Use original location of time in BC form Signed-off-by: Vasilii Novikov --- convert.go | 4 ++-- testutil/testutil.go | 32 ++++++++++++++++++++++++++ timestamptz_test.go | 53 +++++++++++++++++++++++++------------------- 3 files changed, 64 insertions(+), 25 deletions(-) diff --git a/convert.go b/convert.go index 06cc461..770ec15 100644 --- a/convert.go +++ b/convert.go @@ -478,7 +478,7 @@ func decodeTextTimestamp(layout string, s string, inLoc bool) (t time.Time, err // Convert time before common era (BC). if bc { year := (t.Year() - 1) * -1 - t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC) + t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) } return t, nil } @@ -488,7 +488,7 @@ func encodeTextTimestamp(layout string, t time.Time) string { // Convert time before common era (BC). if t.Before(time.Time{}) { year := t.Year()*-1 + 1 - t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC) + t = time.Date(year, t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()) return t.Format(layout) + " BC" } return t.Format(layout) diff --git a/testutil/testutil.go b/testutil/testutil.go index e7b64b5..171a005 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -434,3 +434,35 @@ func TestDatabaseSQLNullToGoZeroConversion(t testing.TB, driverName, pgTypeName t.Errorf("%s: did not convert null to zero", driverName) } } + +func SetDatabaseTimezone(t *testing.T, name string) (revert func()) { + cfg, err := pgx.ParseConfig(os.Getenv("PGX_TEST_DATABASE")) + if err != nil { + t.Fatal(err) + } + conn, err := pgx.ConnectConfig(context.Background(), cfg) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + var original string + err = conn.QueryRow(context.Background(), "show time zone").Scan(&original) + if err != nil { + t.Fatal(err) + } + _, err = conn.Exec(context.Background(), fmt.Sprintf("alter database %s set timezone to '%s'", cfg.Database, name)) + if err != nil { + t.Fatal(err) + } + var reloaded bool + err = conn.QueryRow(context.Background(), "select pg_reload_conf()").Scan(&reloaded) + if err != nil { + t.Fatal(err) + } + if !reloaded { + t.Fatalf("failed to reload Postgres configs") + } + return func() { + SetDatabaseTimezone(t, original) + } +} diff --git a/timestamptz_test.go b/timestamptz_test.go index ae9ff07..ed48295 100644 --- a/timestamptz_test.go +++ b/timestamptz_test.go @@ -1,6 +1,7 @@ package pgtype_test import ( + "fmt" "reflect" "testing" "time" @@ -11,29 +12,35 @@ import ( ) func TestTimestamptzTranscode(t *testing.T) { - testutil.TestSuccessfulTranscodeEqFunc(t, "timestamptz", []interface{}{ - &pgtype.Timestamptz{Time: time.Date(1800, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1900, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1905, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1940, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1960, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(1999, 12, 31, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(2000, 1, 2, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(2200, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Time{}, Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(0, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Time: time.Date(-100, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, - &pgtype.Timestamptz{Status: pgtype.Null}, - &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: pgtype.Infinity}, - &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: -pgtype.Infinity}, - }, func(a, b interface{}) bool { - at := a.(pgtype.Timestamptz) - bt := b.(pgtype.Timestamptz) - - return at.Time.Equal(bt.Time) && at.Status == bt.Status && at.InfinityModifier == bt.InfinityModifier - }) + + for _, timezone := range []string{"UTC", "Europe/Berlin", "America/New_York"} { + t.Run(fmt.Sprintf("timezone %s", timezone), func(t *testing.T) { + defer testutil.SetDatabaseTimezone(t, timezone)() + testutil.TestSuccessfulTranscodeEqFunc(t, "timestamptz", []interface{}{ + &pgtype.Timestamptz{Time: time.Date(1800, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1900, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1905, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1940, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1960, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1970, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(1999, 12, 31, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(2000, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(2000, 1, 2, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(2200, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Time{}, Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(0, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Time: time.Date(-100, 1, 1, 0, 0, 0, 0, time.Local), Status: pgtype.Present}, + &pgtype.Timestamptz{Status: pgtype.Null}, + &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: pgtype.Infinity}, + &pgtype.Timestamptz{Status: pgtype.Present, InfinityModifier: -pgtype.Infinity}, + }, func(a, b interface{}) bool { + at := a.(pgtype.Timestamptz) + bt := b.(pgtype.Timestamptz) + + return at.Time.Equal(bt.Time) && at.Status == bt.Status && at.InfinityModifier == bt.InfinityModifier + }) + }) + } } func TestTimestamptzNanosecondsTruncated(t *testing.T) { From 19364aed564b11913cea52e8f0dd315c1970baaa Mon Sep 17 00:00:00 2001 From: Vasilii Novikov Date: Sun, 31 Jan 2021 19:40:16 +0300 Subject: [PATCH 4/4] Use SQL function to determine current database Signed-off-by: Vasilii Novikov --- testutil/testutil.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/testutil/testutil.go b/testutil/testutil.go index 171a005..b663752 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -436,21 +436,22 @@ func TestDatabaseSQLNullToGoZeroConversion(t testing.TB, driverName, pgTypeName } func SetDatabaseTimezone(t *testing.T, name string) (revert func()) { - cfg, err := pgx.ParseConfig(os.Getenv("PGX_TEST_DATABASE")) + conn, err := pgx.Connect(context.Background(), os.Getenv("PGX_TEST_DATABASE")) if err != nil { t.Fatal(err) } - conn, err := pgx.ConnectConfig(context.Background(), cfg) + defer conn.Close(context.Background()) + var dbname string + err = conn.QueryRow(context.Background(), "select current_database()").Scan(&dbname) if err != nil { t.Fatal(err) } - defer conn.Close(context.Background()) var original string err = conn.QueryRow(context.Background(), "show time zone").Scan(&original) if err != nil { t.Fatal(err) } - _, err = conn.Exec(context.Background(), fmt.Sprintf("alter database %s set timezone to '%s'", cfg.Database, name)) + _, err = conn.Exec(context.Background(), fmt.Sprintf("alter database %s set timezone to '%s'", dbname, name)) if err != nil { t.Fatal(err) }