From c7dd38185bd92df2ac7d35c54bf03585ab212f70 Mon Sep 17 00:00:00 2001 From: Florent Biville <445792+fbiville@users.noreply.github.com> Date: Wed, 3 Aug 2022 10:50:26 +0200 Subject: [PATCH] Support UTC DateTime Structures Contrary to 5.0, invalid timezones received from the server will result in the transaction being retried and cause the connection to eventually put back to pool, after all attempts are exhausted. While this is not ideal, this has been done so to minimize breaking changes in 4.x. This also fixes race condition in integration tests where RESET was racing against NEXT and would sometimes see a FAILURE, marking the connection as dead. --- neo4j/internal/bolt/bolt3.go | 1 + neo4j/internal/bolt/bolt3_test.go | 3 + neo4j/internal/bolt/bolt4.go | 15 + neo4j/internal/bolt/bolt4_test.go | 31 ++ neo4j/internal/bolt/bolt4server_test.go | 18 + neo4j/internal/bolt/hydrator.go | 99 +++- neo4j/internal/bolt/hydrator_test.go | 285 ++++++++++- neo4j/internal/bolt/outgoing.go | 57 ++- neo4j/internal/bolt/outgoing_test.go | 96 +++- neo4j/test-integration/dbconn_test.go | 168 +------ neo4j/test-integration/temporaltypes2_test.go | 352 -------------- neo4j/test-integration/temporaltypes_test.go | 447 ------------------ neo4j/test-integration/utils_test.go | 3 +- testkit-backend/2cypher.go | 111 +++++ testkit-backend/2native.go | 112 ++++- testkit-backend/backend.go | 199 +++++++- 16 files changed, 955 insertions(+), 1042 deletions(-) delete mode 100644 neo4j/test-integration/temporaltypes2_test.go delete mode 100644 neo4j/test-integration/temporaltypes_test.go diff --git a/neo4j/internal/bolt/bolt3.go b/neo4j/internal/bolt/bolt3.go index 21efb4aa..7204eddb 100644 --- a/neo4j/internal/bolt/bolt3.go +++ b/neo4j/internal/bolt/bolt3.go @@ -113,6 +113,7 @@ func NewBolt3(serverName string, conn net.Conn, logger log.Logger, boltLog log.B b.state = bolt3_dead }, boltLogger: boltLog, + useUtc: false, } return b } diff --git a/neo4j/internal/bolt/bolt3_test.go b/neo4j/internal/bolt/bolt3_test.go index 0d275531..3102a2a4 100644 --- a/neo4j/internal/bolt/bolt3_test.go +++ b/neo4j/internal/bolt/bolt3_test.go @@ -101,6 +101,9 @@ func TestBolt3(ot *testing.T) { bolt := c.(*bolt3) assertBoltState(t, bolt3_ready, bolt) + if bolt.out.useUtc { + t.Fatalf("Bolt 3 connections must never send and receive UTC datetimes") + } return bolt, cleanup } diff --git a/neo4j/internal/bolt/bolt4.go b/neo4j/internal/bolt/bolt4.go index 478429bd..c1643b94 100644 --- a/neo4j/internal/bolt/bolt4.go +++ b/neo4j/internal/bolt/bolt4.go @@ -226,6 +226,10 @@ func (b *bolt4) connect(minor int, auth map[string]interface{}, userAgent string hello["routing"] = routingContext } } + checkUtcPatch := minor >= 3 + if checkUtcPatch { + hello["patch_bolt"] = []string{"utc"} + } // Merge authentication keys into hello, avoid overwriting existing keys for k, v := range auth { _, exists := hello[k] @@ -244,6 +248,17 @@ func (b *bolt4) connect(minor int, auth map[string]interface{}, userAgent string b.connId = succ.connectionId b.serverVersion = succ.server + if checkUtcPatch { + useUtc := false + for _, patch := range succ.patches { + if patch == "utc" { + useUtc = true + break + } + } + b.in.hyd.useUtc = useUtc + b.out.useUtc = useUtc + } // Construct log identity connectionLogId := fmt.Sprintf("%s@%s", b.connId, b.serverName) diff --git a/neo4j/internal/bolt/bolt4_test.go b/neo4j/internal/bolt/bolt4_test.go index d44d159c..2e6f90f0 100644 --- a/neo4j/internal/bolt/bolt4_test.go +++ b/neo4j/internal/bolt/bolt4_test.go @@ -138,6 +138,7 @@ func TestBolt4(ot *testing.T) { AssertStringEqual(t, bolt.ServerName(), "serverName") AssertTrue(t, bolt.IsAlive()) AssertTrue(t, reflect.DeepEqual(bolt.in.connReadTimeout, time.Duration(-1))) + AssertFalse(t, bolt.out.useUtc) }) ot.Run("Connect success with timeout hint", func(t *testing.T) { @@ -153,6 +154,36 @@ func TestBolt4(ot *testing.T) { AssertTrue(t, reflect.DeepEqual(bolt.in.connReadTimeout, 42*time.Second)) }) + for _, version := range [][]byte{{4, 3}, {4, 4}} { + major := version[0] + minor := version[1] + ot.Run(fmt.Sprintf("[%d.%d] Connect success with UTC patch", major, minor), func(t *testing.T) { + bolt, cleanup := connectToServer(t, func(srv *bolt4server) { + srv.waitForHandshake() + srv.acceptVersion(major, minor) + srv.waitForHelloWithPatches([]interface{}{"utc"}) + srv.acceptHelloWithPatches([]interface{}{"utc"}) + }) + defer cleanup() + defer bolt.Close() + + AssertTrue(t, bolt.out.useUtc) + }) + + ot.Run(fmt.Sprintf("[%d.%d] Connect success with unknown patch", major, minor), func(t *testing.T) { + bolt, cleanup := connectToServer(t, func(srv *bolt4server) { + srv.waitForHandshake() + srv.acceptVersion(major, minor) + srv.waitForHelloWithPatches([]interface{}{"utc"}) + srv.acceptHelloWithPatches([]interface{}{"some-unknown-patch"}) + }) + defer cleanup() + defer bolt.Close() + + AssertFalse(t, bolt.out.useUtc) + }) + } + invalidValues := []interface{}{4.2, "42", -42} for _, value := range invalidValues { ot.Run(fmt.Sprintf("Connect success with ignored invalid timeout hint %v", value), func(t *testing.T) { diff --git a/neo4j/internal/bolt/bolt4server_test.go b/neo4j/internal/bolt/bolt4server_test.go index 06a1aff1..9692c645 100644 --- a/neo4j/internal/bolt/bolt4server_test.go +++ b/neo4j/internal/bolt/bolt4server_test.go @@ -23,6 +23,7 @@ import ( "fmt" "io" "net" + "reflect" "testing" "github.com/neo4j/neo4j-go-driver/v4/neo4j/internal/packstream" @@ -76,6 +77,15 @@ func (s *bolt4server) sendIgnoredMsg() { s.send(msgIgnored) } +func (s *bolt4server) waitForHelloWithPatches(patches []interface{}) map[string]interface{} { + m := s.waitForHello() + actualPatches := m["patch_bolt"] + if !reflect.DeepEqual(actualPatches, patches) { + s.sendFailureMsg("?", fmt.Sprintf("Expected %v patches, got %v", patches, actualPatches)) + } + return m +} + // Returns the first hello field func (s *bolt4server) waitForHello() map[string]interface{} { msg := s.receiveMsg() @@ -240,6 +250,14 @@ func (s *bolt4server) acceptHelloWithHints(hints map[string]interface{}) { }) } +func (s *bolt4server) acceptHelloWithPatches(patches []interface{}) { + s.send(msgSuccess, map[string]interface{}{ + "connection_id": "cid", + "server": "fake/4.5", + "patch_bolt": patches, + }) +} + func (s *bolt4server) rejectHelloUnauthorized() { s.send(msgFailure, map[string]interface{}{ "code": "Neo.ClientError.Security.Unauthorized", diff --git a/neo4j/internal/bolt/hydrator.go b/neo4j/internal/bolt/hydrator.go index 45b79966..88f8c92c 100644 --- a/neo4j/internal/bolt/hydrator.go +++ b/neo4j/internal/bolt/hydrator.go @@ -51,6 +51,7 @@ type success struct { routingTable *db.RoutingTable num uint32 configurationHints map[string]interface{} + patches []string } func (s *success) String() string { @@ -92,6 +93,7 @@ type hydrator struct { cachedSuccess success boltLogger log.BoltLogger logId string + useUtc bool } func (h *hydrator) setErr(err error) { @@ -245,6 +247,9 @@ func (h *hydrator) success(n uint32) *success { case "hints": hints := h.amap() succ.configurationHints = hints + case "patch_bolt": + patches := h.strings() + succ.patches = patches default: // Unknown key, waste it h.trash() @@ -404,9 +409,25 @@ func (h *hydrator) value() interface{} { case 'Y': return h.point3d(n) case 'F': + if h.useUtc { + return h.unknownStructError(t) + } return h.dateTimeOffset(n) + case 'I': + if !h.useUtc { + return h.unknownStructError(t) + } + return h.utcDateTimeOffset(n) case 'f': + if h.useUtc { + return h.unknownStructError(t) + } return h.dateTimeNamedZone(n) + case 'i': + if !h.useUtc { + return h.unknownStructError(t) + } + return h.utcDateTimeNamedZone(n) case 'd': return h.localDateTime(n) case 'D': @@ -418,8 +439,7 @@ func (h *hydrator) value() interface{} { case 'E': return h.duration(n) default: - h.err = errors.New(fmt.Sprintf("Unknown tag: %02x", t)) - return nil + return h.unknownStructError(t) } case packstream.PackedByteArray: return h.unp.ByteArray() @@ -568,30 +588,82 @@ func (h *hydrator) point3d(n uint32) interface{} { func (h *hydrator) dateTimeOffset(n uint32) interface{} { h.unp.Next() - secs := h.unp.Int() + seconds := h.unp.Int() h.unp.Next() - nans := h.unp.Int() + nanos := h.unp.Int() h.unp.Next() - offs := h.unp.Int() - t := time.Unix(secs, nans).UTC() - l := time.FixedZone("Offset", int(offs)) - return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), l) + offset := h.unp.Int() + // time.Time in local timezone, e.g. 15th of June 2020, 15:30 in Paris (UTC+2h) + unixTime := time.Unix(seconds, nanos) + // time.Time computed in UTC timezone, e.g. 15th of June 2020, 13:30 in UTC + utcTime := unixTime.UTC() + // time.Time **copied** as-is in the target timezone, e.g. 15th of June 2020, 13:30 in target tz + timeZone := time.FixedZone("Offset", int(offset)) + return time.Date( + utcTime.Year(), + utcTime.Month(), + utcTime.Day(), + utcTime.Hour(), + utcTime.Minute(), + utcTime.Second(), + utcTime.Nanosecond(), + timeZone, + ) +} + +func (h *hydrator) utcDateTimeOffset(n uint32) interface{} { + h.unp.Next() + seconds := h.unp.Int() + h.unp.Next() + nanos := h.unp.Int() + h.unp.Next() + offset := h.unp.Int() + timeZone := time.FixedZone("Offset", int(offset)) + return time.Unix(seconds, nanos).In(timeZone) } func (h *hydrator) dateTimeNamedZone(n uint32) interface{} { + h.unp.Next() + seconds := h.unp.Int() + h.unp.Next() + nanos := h.unp.Int() + h.unp.Next() + zone := h.unp.String() + // time.Time in local timezone, e.g. 15th of June 2020, 15:30 in Paris (UTC+2h) + unixTime := time.Unix(seconds, nanos) + // time.Time computed in UTC timezone, e.g. 15th of June 2020, 13:30 in UTC + utcTime := unixTime.UTC() + // time.Time **copied** as-is in the target timezone, e.g. 15th of June 2020, 13:30 in target tz + l, err := time.LoadLocation(zone) + if err != nil { + h.setErr(err) + return nil + } + return time.Date( + utcTime.Year(), + utcTime.Month(), + utcTime.Day(), + utcTime.Hour(), + utcTime.Minute(), + utcTime.Second(), + utcTime.Nanosecond(), + l, + ) +} + +func (h *hydrator) utcDateTimeNamedZone(n uint32) interface{} { h.unp.Next() secs := h.unp.Int() h.unp.Next() nans := h.unp.Int() h.unp.Next() zone := h.unp.String() - t := time.Unix(secs, nans).UTC() - l, err := time.LoadLocation(zone) + timeZone, err := time.LoadLocation(zone) if err != nil { h.setErr(err) return nil } - return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), l) + return time.Unix(secs, nans).In(timeZone) } func (h *hydrator) localDateTime(n uint32) interface{} { @@ -740,3 +812,8 @@ func parseNotification(m map[string]interface{}) db.Notification { return n } + +func (h *hydrator) unknownStructError(t byte) interface{} { + h.setErr(fmt.Errorf("Unknown tag: %02x", t)) + return nil +} diff --git a/neo4j/internal/bolt/hydrator_test.go b/neo4j/internal/bolt/hydrator_test.go index c9a9e5e2..2241ef53 100644 --- a/neo4j/internal/bolt/hydrator_test.go +++ b/neo4j/internal/bolt/hydrator_test.go @@ -20,6 +20,7 @@ package bolt import ( + "errors" "fmt" "reflect" "testing" @@ -30,20 +31,23 @@ import ( "github.com/neo4j/neo4j-go-driver/v4/neo4j/internal/packstream" ) -func TestHydrator(ot *testing.T) { - zone := "America/New_York" - loc, err := time.LoadLocation(zone) +type hydratorTestCase struct { + name string + build func() // Builds/encodes stream same was as server would + x interface{} // Expected hydrated + err error + useUtc bool +} + +func TestHydrator(outer *testing.T) { + zoneName := "America/New_York" + timeZone, err := time.LoadLocation(zoneName) if err != nil { panic(err) } packer := packstream.Packer{} - cases := []struct { - name string - build func() // Builds/encodes stream same was as server would - x interface{} // Expected hydrated - err error - }{ + cases := []hydratorTestCase{ { name: "Ignored", build: func() { @@ -450,7 +454,7 @@ func TestHydrator(ot *testing.T) { t = time.Date(1999, 12, 31, 23, 59, 59, 1, time.UTC) packer.Int64(t.Unix()) packer.Int64(t.UnixNano() - (t.Unix() * int64(time.Second))) - packer.String(zone) + packer.String(zoneName) // Datetime, offset zone packer.StructHeader('F', 3) t = time.Date(1999, 12, 31, 23, 59, 59, 1, time.UTC) @@ -469,7 +473,7 @@ func TestHydrator(ot *testing.T) { dbtype.LocalTime(time.Date(0, 0, 0, 1, 2, 3, 4, time.Local)), dbtype.Date(time.Date(1999, 12, 31, 0, 0, 0, 0, time.UTC)), dbtype.LocalDateTime(time.Date(1999, 12, 31, 23, 59, 59, 1, time.Local)), - time.Date(1999, 12, 31, 23, 59, 59, 1, loc), + time.Date(1999, 12, 31, 23, 59, 59, 1, timeZone), time.Date(1999, 12, 31, 23, 59, 59, 1, time.FixedZone("Offset", 3)), dbtype.Duration{Months: 12, Days: 31, Seconds: 59, Nanos: 10001}, }}, @@ -597,15 +601,141 @@ func TestHydrator(ot *testing.T) { }}, }}, }, + { + name: "Record of UTC datetime with explicit offset with UTC support enabled", + useUtc: true, + build: func() { + tz := time.FixedZone("Offset", 3) + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, tz) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // UTC Datetime with explicit time zone offset + packer.StructHeader('I', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.Int(3) + }, + x: &db.Record{Values: []interface{}{ + time.Date(1999, 12, 31, 23, 59, 59, 1, time.FixedZone("Offset", 3)), + }}, + }, + { + name: "Record of UTC datetime with explicit offset with UTC support disabled", + useUtc: false, + build: func() { + tz := time.FixedZone("Offset", 3) + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, tz) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // UTC Datetime with explicit time zone offset + packer.StructHeader('I', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.Int(3) + }, + err: fmt.Errorf("Unknown tag: 49"), + }, + { + name: "Record of legacy datetime with explicit offset with UTC support enabled", + useUtc: true, + build: func() { + tz := time.FixedZone("Offset", 3) + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, tz) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // Datetime with explicit time zone offset + packer.StructHeader('F', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.Int(3) + }, + err: fmt.Errorf("Unknown tag: 46"), + }, + { + name: "Record of UTC datetime with timezone name with UTC support enabled", + useUtc: true, + build: func() { + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, timeZone) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // UTC Datetime with time zone name + packer.StructHeader('i', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.String(zoneName) + }, + x: &db.Record{Values: []interface{}{ + time.Date(1999, 12, 31, 23, 59, 59, 1, timeZone), + }}, + }, + { + name: "Record of UTC datetime with timezone name with UTC support disabled", + useUtc: false, + build: func() { + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, timeZone) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // UTC Datetime with time zone name + packer.StructHeader('i', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.String(zoneName) + }, + err: errors.New("Unknown tag: 69"), + }, + { + name: "Record of legacy datetime with timezone name with UTC support enabled", + useUtc: true, + build: func() { + datetime := time.Date(1999, 12, 31, 23, 59, 59, 1, timeZone) + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + // UTC Datetime with time zone name + packer.StructHeader('f', 3) + packer.Int64(datetime.Unix()) + packer.Int64(datetime.UnixNano() - (datetime.Unix() * int64(time.Second))) + packer.String(zoneName) + }, + err: errors.New("Unknown tag: 66"), + }, + { + name: "Record of UTC datetime with invalid timezone name with UTC support enabled", + useUtc: true, + build: func() { + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + packer.StructHeader('i', 3) + packer.Int64(42) + packer.Int64(42) + packer.String("LA/Confidential") + }, + err: errors.New("unknown time zone LA/Confidential"), + }, + { + name: "Record of legacy datetime with invalid timezone name with UTC support disabled", + build: func() { + packer.StructHeader(byte(msgRecord), 1) + packer.ArrayHeader(1) + packer.StructHeader('f', 3) + packer.Int64(42) + packer.Int64(42) + packer.String("LA/Confidential") + }, + err: errors.New("unknown time zone LA/Confidential"), + }, } // Shared among calls in real usage so we do the same while testing it. hydrator := hydrator{} for _, c := range cases { - ot.Run(c.name, func(t *testing.T) { + outer.Run(c.name, func(t *testing.T) { defer func() { hydrator.err = nil }() + hydrator.useUtc = c.useUtc + if (c.x != nil) == (c.err != nil) { + t.Fatalf("test case needs to define either expected result or error (xor)") + } packer.Begin([]byte{}) c.build() buf, err := packer.End() @@ -630,3 +760,134 @@ func TestHydrator(ot *testing.T) { }) } } + +func TestUtcDateTime(outer *testing.T) { + // Thu Jun 16 2022 13:00:00 UTC + secondsSinceEpoch := int64(1655384400) + + hydrator := &hydrator{useUtc: true} + + outer.Run("UTC Datetime with offset in seconds", func(t *testing.T) { + offsetInSeconds := 2*60*60 + 30*60 // UTC+2h30 + bytes := recordOfUtcDateTimeWithOffset(t, secondsSinceEpoch, offsetInSeconds) + + rawRecord, err := hydrator.hydrate(bytes) + + if err != nil { + t.Fatal(err) + } + record := rawRecord.(*db.Record) + rawDatetime := record.Values[0] + datetime := rawDatetime.(time.Time) + _, offset := datetime.Zone() + if offset != 2*60*60+30*60 { + t.Fatalf("Expected offset of +2 hours (7200 seconds), got %d", offset) + } + year := datetime.Year() + if year != 2022 { + t.Errorf("Expected year 2022, got %d", year) + } + month := datetime.Month() + if month != 6 { + t.Errorf("Expected month of June (6), got %d", month) + } + day := datetime.Day() + if day != 16 { + t.Errorf("Expected day 16, got %d", day) + } + hour := datetime.Hour() + if hour != 15 { + t.Errorf("Expected hour 15, got %d", hour) + } + minutes := datetime.Minute() + if minutes != 30 { + t.Errorf("Expected minute 30, got %d", minutes) + } + seconds := datetime.Second() + if seconds != 0 { + t.Errorf("Expected second 0, got %d", seconds) + } + nanos := datetime.Nanosecond() + if nanos != 0 { + t.Errorf("Expected nanosecond 0, got %d", nanos) + } + }) + + outer.Run("UTC Datetime with named timezone", func(t *testing.T) { + timeZone := "Australia/Eucla" // UTC+8h45 in that point in time + bytes := recordOfUtcDateTimeWithTimeZoneName(t, secondsSinceEpoch, timeZone) + + rawRecord, err := hydrator.hydrate(bytes) + + if err != nil { + t.Fatal(err) + } + record := rawRecord.(*db.Record) + rawDatetime := record.Values[0] + datetime := rawDatetime.(time.Time) + _, offset := datetime.Zone() // "Australia/Eucla" is normalized to sth else + if offset != 8*60*60+45*60 { + t.Fatalf("Expected +8h45 offset (31500 seconds), got %d", offset) + } + year := datetime.Year() + if year != 2022 { + t.Errorf("Expected year 2022, got %d", year) + } + month := datetime.Month() + if month != 6 { + t.Errorf("Expected month of June (6), got %d", month) + } + day := datetime.Day() + if day != 16 { + t.Errorf("Expected day 16, got %d", day) + } + hour := datetime.Hour() + if hour != 21 { + t.Errorf("Expected hour 21, got %d", hour) + } + minutes := datetime.Minute() + if minutes != 45 { + t.Errorf("Expected minute 45, got %d", minutes) + } + seconds := datetime.Second() + if seconds != 0 { + t.Errorf("Expected second 0, got %d", seconds) + } + nanos := datetime.Nanosecond() + if nanos != 0 { + t.Errorf("Expected nanosecond 0, got %d", nanos) + } + }) +} + +func recordOfUtcDateTimeWithOffset(t *testing.T, secondsSinceEpoch int64, utcOffsetInSeconds int) []byte { + packer := packstream.Packer{} + packer.Begin([]byte{}) + packer.StructHeader(msgRecord, 1) + packer.ArrayHeader(1) + packer.StructHeader('I', 3) + packer.Int64(secondsSinceEpoch) + packer.Int64(0) + packer.Int(utcOffsetInSeconds) + result, err := packer.End() + if err != nil { + t.Fatal("Build error") + } + return result +} + +func recordOfUtcDateTimeWithTimeZoneName(t *testing.T, secondsSinceEpoch int64, tzName string) []byte { + packer := packstream.Packer{} + packer.Begin([]byte{}) + packer.StructHeader(msgRecord, 1) + packer.ArrayHeader(1) + packer.StructHeader('i', 3) + packer.Int64(secondsSinceEpoch) + packer.Int64(0) + packer.String(tzName) + result, err := packer.End() + if err != nil { + t.Fatal("Build error") + } + return result +} diff --git a/neo4j/internal/bolt/outgoing.go b/neo4j/internal/bolt/outgoing.go index 92c32d4d..c006ab91 100644 --- a/neo4j/internal/bolt/outgoing.go +++ b/neo4j/internal/bolt/outgoing.go @@ -35,6 +35,7 @@ type outgoing struct { onErr func(err error) boltLogger log.BoltLogger logId string + useUtc bool } func (o *outgoing) begin() { @@ -253,19 +254,18 @@ func (o *outgoing) packStruct(x interface{}) { o.packer.Float64(v.Y) o.packer.Float64(v.Z) case time.Time: - zone, offset := v.Zone() - secs := v.Unix() + int64(offset) - nanos := v.Nanosecond() - if zone == "Offset" { - o.packer.StructHeader('F', 3) - o.packer.Int64(secs) - o.packer.Int(nanos) - o.packer.Int(offset) + if o.useUtc { + if zone, _ := v.Zone(); zone == "Offset" { + o.packUtcDateTimeWithTzOffset(v) + } else { + o.packUtcDateTimeWithTzName(v) + } + break + } + if zone, _ := v.Zone(); zone == "Offset" { + o.packLegacyDateTimeWithTzOffset(v) } else { - o.packer.StructHeader('f', 3) - o.packer.Int64(secs) - o.packer.Int(nanos) - o.packer.String(v.Location().String()) + o.packLegacyDateTimeWithTzName(v) } case dbtype.LocalDateTime: t := time.Time(v) @@ -388,3 +388,36 @@ func (o *outgoing) packX(x interface{}) { o.onErr(&db.UnsupportedTypeError{Type: reflect.TypeOf(x)}) } } + +// deprecated: remove once 4.x Neo4j all reach EOL +func (o *outgoing) packLegacyDateTimeWithTzOffset(dateTime time.Time) { + _, offset := dateTime.Zone() + o.packer.StructHeader('F', 3) + o.packer.Int64(dateTime.Unix() + int64(offset)) + o.packer.Int(dateTime.Nanosecond()) + o.packer.Int(offset) +} + +// deprecated: remove once 4.x Neo4j all reach EOL +func (o *outgoing) packLegacyDateTimeWithTzName(dateTime time.Time) { + _, offset := dateTime.Zone() + o.packer.StructHeader('f', 3) + o.packer.Int64(dateTime.Unix() + int64(offset)) + o.packer.Int(dateTime.Nanosecond()) + o.packer.String(dateTime.Location().String()) +} + +func (o *outgoing) packUtcDateTimeWithTzOffset(dateTime time.Time) { + _, offset := dateTime.Zone() + o.packer.StructHeader('I', 3) + o.packer.Int64(dateTime.Unix()) + o.packer.Int(dateTime.Nanosecond()) + o.packer.Int(offset) +} + +func (o *outgoing) packUtcDateTimeWithTzName(dateTime time.Time) { + o.packer.StructHeader('i', 3) + o.packer.Int64(dateTime.Unix()) + o.packer.Int(dateTime.Nanosecond()) + o.packer.String(dateTime.Location().String()) +} diff --git a/neo4j/internal/bolt/outgoing_test.go b/neo4j/internal/bolt/outgoing_test.go index 5a0fcc9c..929762ee 100644 --- a/neo4j/internal/bolt/outgoing_test.go +++ b/neo4j/internal/bolt/outgoing_test.go @@ -84,7 +84,7 @@ func unpack(u *packstream.Unpacker) interface{} { func TestOutgoing(ot *testing.T) { var err error // Utility to unpack through dechunking and a custom build func - dechunkAndUnpack := func(t *testing.T, build func(outgoing *outgoing)) interface{} { + dechunkAndUnpack := func(t *testing.T, build func(*testing.T, *outgoing)) interface{} { out := &outgoing{ chunker: newChunker(), packer: packstream.Packer{}, @@ -100,7 +100,7 @@ func TestOutgoing(ot *testing.T) { } }() err = nil - build(out) + build(t, out) if err != nil { t.Fatal(err) } @@ -126,12 +126,12 @@ func TestOutgoing(ot *testing.T) { // tests for top level appending and sending outgoing messages cases := []struct { name string - build func(outgoing *outgoing) + build func(t *testing.T, outgoing *outgoing) expect interface{} }{ { name: "hello", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendHello(nil) }, expect: &testStruct{ @@ -141,7 +141,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "begin", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendBegin(map[string]interface{}{"mode": "r"}) }, expect: &testStruct{ @@ -151,7 +151,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "commit", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendCommit() }, expect: &testStruct{ @@ -160,7 +160,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "rollback", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRollback() }, expect: &testStruct{ @@ -169,7 +169,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "goodbye", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendGoodbye() }, expect: &testStruct{ @@ -178,7 +178,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "reset", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendReset() }, expect: &testStruct{ @@ -187,7 +187,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "pull all", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendPullAll() }, expect: &testStruct{ @@ -196,7 +196,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "pull n", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendPullN(7) }, expect: &testStruct{ @@ -206,7 +206,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "pull n+qid", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendPullNQid(7, 10000) }, expect: &testStruct{ @@ -216,7 +216,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "discard n+qid", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendDiscardNQid(7, 10000) }, expect: &testStruct{ @@ -226,7 +226,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "run, no params, no meta", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRun("cypher", nil, nil) }, expect: &testStruct{ @@ -236,7 +236,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "run, no params, meta", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRun("cypher", nil, map[string]interface{}{"mode": "r"}) }, expect: &testStruct{ @@ -246,7 +246,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "run, params, meta", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRun("cypher", map[string]interface{}{"x": 1, "y": "2"}, map[string]interface{}{"mode": "r"}) }, expect: &testStruct{ @@ -256,7 +256,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "run, params, meta", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRun("cypher", map[string]interface{}{"x": 1, "y": "2"}, map[string]interface{}{"mode": "r"}) }, expect: &testStruct{ @@ -266,7 +266,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "route", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRoute(map[string]string{"key1": "val1", "key2": "val2"}, []string{"deutsch-mark", "mark-twain"}, "adb") }, expect: &testStruct{ @@ -276,7 +276,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "route, default database", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRoute(map[string]string{"key1": "val1", "key2": "val2"}, []string{"deutsch-mark", "mark-twain"}, db.DefaultDatabase) }, expect: &testStruct{ @@ -286,7 +286,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "route, default bookmarks", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRoute(map[string]string{"key1": "val1", "key2": "val2"}, nil, "adb") }, expect: &testStruct{ @@ -296,7 +296,7 @@ func TestOutgoing(ot *testing.T) { }, { name: "route, default bookmarks and database", - build: func(out *outgoing) { + build: func(t *testing.T, out *outgoing) { out.appendRoute(map[string]string{"key1": "val1", "key2": "val2"}, nil, db.DefaultDatabase) }, expect: &testStruct{ @@ -304,6 +304,58 @@ func TestOutgoing(ot *testing.T) { fields: []interface{}{map[string]interface{}{"key1": "val1", "key2": "val2"}, []interface{}{}, nil}, }, }, + { + name: "UTC datetime struct, with timezone offset", + build: func(t *testing.T, out *outgoing) { + defer func() { + out.useUtc = false + }() + out.useUtc = true + minusTwoHours := -2 * 60 * 60 + tz := time.FixedZone("Offset", minusTwoHours) + out.begin() + // June 15, 2020 12:30:00 in "Offset" + // aka June 15, 2020 14:30:00 UTC + // aka 1592231400 seconds since Unix epoch + out.packStruct(time.Date(2020, 6, 15, 12, 30, 0, 42, tz)) + out.end() + }, + expect: &testStruct{ + tag: 'I', + fields: []interface{}{ + int64(1592231400), + int64(42), + int64(-2 * 60 * 60), + }, + }, + }, + { + name: "UTC datetime struct, with timezone name", + build: func(t *testing.T, out *outgoing) { + defer func() { + out.useUtc = false + }() + out.useUtc = true + tz, err := time.LoadLocation("Pacific/Honolulu") + if err != nil { + t.Fatal(err) + } + out.begin() + // June 15, 2020 04:30:00 in "Pacific/Honolulu" + // aka June 15, 2020 14:30:00 UTC + // aka 1592231400 seconds since Unix epoch + out.packStruct(time.Date(2020, 6, 15, 4, 30, 0, 42, tz)) + out.end() + }, + expect: &testStruct{ + tag: 'i', + fields: []interface{}{ + int64(1592231400), + int64(42), + "Pacific/Honolulu", + }, + }, + }, } for _, c := range cases { ot.Run(c.name, func(t *testing.T) { @@ -429,7 +481,7 @@ func TestOutgoing(ot *testing.T) { for _, c := range paramCases { ot.Run(c.name, func(t *testing.T) { - x := dechunkAndUnpack(t, func(out *outgoing) { + x := dechunkAndUnpack(t, func(t *testing.T, out *outgoing) { out.begin() out.packMap(c.inp) out.end() diff --git a/neo4j/test-integration/dbconn_test.go b/neo4j/test-integration/dbconn_test.go index 0946c2ad..7c0cc480 100644 --- a/neo4j/test-integration/dbconn_test.go +++ b/neo4j/test-integration/dbconn_test.go @@ -25,13 +25,11 @@ import ( "math/big" "net" "net/url" - "reflect" "strings" "testing" "time" "github.com/neo4j/neo4j-go-driver/v4/neo4j/db" - "github.com/neo4j/neo4j-go-driver/v4/neo4j/dbtype" "github.com/neo4j/neo4j-go-driver/v4/neo4j/internal/bolt" . "github.com/neo4j/neo4j-go-driver/v4/neo4j/internal/testutil" "github.com/neo4j/neo4j-go-driver/v4/neo4j/log" @@ -236,7 +234,7 @@ func TestConnectionConformance(ot *testing.T) { { name: "Next passed the summary", fun: func(t *testing.T, c db.Connection) { - s, err := boltConn.Run(db.Command{Cypher: "RETURN datetime()"}, db.TxConfig{Mode: db.ReadMode}) + s, err := boltConn.Run(db.Command{Cypher: "RETURN 42"}, db.TxConfig{Mode: db.ReadMode}) AssertNoError(t, err) rec, sum, err := c.Next(s) AssertNextOnlyRecord(t, rec, sum, err) @@ -385,7 +383,7 @@ func TestConnectionConformance(ot *testing.T) { } boltConn.Reset() // Should be working now - s, err := boltConn.Run(db.Command{Cypher: "RETURN datetime()"}, db.TxConfig{Mode: db.ReadMode}) + s, err := boltConn.Run(db.Command{Cypher: "RETURN 42"}, db.TxConfig{Mode: db.ReadMode}) AssertNoError(t, err) if s == nil { t.Fatal("Didn't get a stream") @@ -430,7 +428,7 @@ func TestConnectionConformance(ot *testing.T) { if recS != bigBuilder.String() { t.Errorf("Strings differ") } - // Run the same thing once again to excerise buffer reuse at connection + // Run the same thing once again to exercise buffer reuse at connection // level (there has been a bug caught by this). stream, err = boltConn.Run(db.Command{Cypher: query, Params: map[string]interface{}{"x": bigBuilder.String()}}, db.TxConfig{Mode: db.ReadMode}) AssertNoError(t, err) @@ -440,164 +438,8 @@ func TestConnectionConformance(ot *testing.T) { if recS != bigBuilder.String() { t.Errorf("Strings differ") } - }) - - assertTime := func(t *testing.T, t1, t2 time.Time) { - t.Helper() - if t1.Hour() != t2.Hour() || t1.Minute() != t2.Minute() || - t1.Second() != t2.Second() || t1.Nanosecond() != t2.Nanosecond() { - t.Errorf("Time %+v vs %+v", t1, t1) - } - } - - assertTimeOffset := func(t *testing.T, t1, t2 time.Time) { - t.Helper() - - _, off1 := t1.Zone() - _, off2 := t2.Zone() - - if off1 != off2 { - t.Errorf("Offset %d vs %d", off1, off2) - } - } - - assertTimeZone := func(t *testing.T, t1, t2 time.Time) { - t.Helper() - - z1, _ := t1.Zone() - z2, _ := t2.Zone() - - if z1 != z2 { - t.Errorf("Zone %s vs %s", z1, z2) - } - } - - assertDate := func(t *testing.T, t1, t2 time.Time) { - t.Helper() - if t1.Year() != t2.Year() || t1.Month() != t2.Month() || t1.Day() != t2.Day() { - t.Errorf("Date %s vs %s", t1, t2) - } - } - - assertDuration := func(t *testing.T, dur1, dur2 dbtype.Duration) { - t.Helper() - if !reflect.DeepEqual(dur1, dur2) { - t.Errorf("Duration %+v vs %+v", dur1, dur2) - } - } - - // Temporal types - ot.Run("Temporal types", func(tt *testing.T) { - london, _ := time.LoadLocation("Europe/London") - - // In Cypher - cTime := "time({ hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'+03:00' })" - cDate := "date({ year: 1994, month: 11, day: 15 })" - cDateTimeO := "datetime({ year: 1859, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'+02:30' })" - cDateTimeZ := "datetime({ year: 1959, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'Europe/London' })" - cLocalTime := "localtime({ hour: 23, minute: 49, second: 59, nanosecond: 999999999 })" - cLocalDateTime := "localdatetime({ year: 1859, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999 })" - cDuration := "duration({ months: 16, days: 45, seconds: 120, nanoseconds: 187309812 })" - // Same as above in time.Time - tTime := time.Date(0, 0, 0, 23, 49, 59, 999999999, time.FixedZone("Offset", 3*60*60)) - tDate := time.Date(1994, 11, 15, 0, 0, 0, 0, time.Local) - tDateTimeO := time.Date(1859, 5, 31, 23, 49, 59, 999999999, time.FixedZone("Offset", 150*60)) - tDateTimeZ := time.Date(1959, 5, 31, 23, 49, 59, 999999999, london) - tLocalTime := time.Date(0, 0, 0, 23, 49, 59, 999999999, time.Local) - tLocalDateTime := time.Date(1859, 5, 31, 23, 49, 59, 999999999, time.Local) - tDuration := dbtype.Duration{Months: 16, Days: 45, Seconds: 120, Nanos: 187309812} - - tt.Run("Reading", func(t *testing.T) { - query := "RETURN " + - cTime + ", " + cDate + ", " + cDateTimeO + ", " + cDateTimeZ + ", " + cLocalTime + ", " + cLocalDateTime + ", " + cDuration - stream, err := boltConn.Run(db.Command{Cypher: query}, db.TxConfig{Mode: db.ReadMode}) - AssertNoError(t, err) - rec, sum, err := boltConn.Next(stream) - if rec == nil || err != nil || sum != nil { - t.Fatalf("Should be a record, %+v, %+v, %+v", rec, sum, err) - } - - // Verify each temporal type - // Time - gotTime := time.Time(rec.Values[0].(dbtype.Time)) - assertTime(t, gotTime, tTime) - assertTimeOffset(t, gotTime, tTime) - // Date - gotDate := time.Time(rec.Values[1].(dbtype.Date)) - assertDate(t, gotDate, tDate) - // DateTime, offset - gotDateTime := time.Time(rec.Values[2].(time.Time)) - assertDate(t, time.Time(gotDateTime), tDateTimeO) - assertTime(t, gotDateTime, tDateTimeO) - assertTimeOffset(t, gotDateTime, tDateTimeO) - // DateTime, zone - gotDateTime = time.Time(rec.Values[3].(time.Time)) - assertDate(t, time.Time(gotDateTime), tDateTimeZ) - assertTime(t, gotDateTime, tDateTimeZ) - assertTimeZone(t, gotDateTime, tDateTimeZ) - // Local time - gotTime = time.Time(rec.Values[4].(dbtype.LocalTime)) - assertTime(t, gotTime, tLocalTime) - // Local DateTime - gotDateTime = time.Time(rec.Values[5].(dbtype.LocalDateTime)) - assertDate(t, time.Time(gotDateTime), tLocalDateTime) - assertTime(t, gotDateTime, tLocalDateTime) - // Duration - gotDuration := rec.Values[6].(dbtype.Duration) - assertDuration(t, gotDuration, tDuration) - }) - - tt.Run("Writing", func(t *testing.T) { - // Make a node with all temporal types as parameters and make sure that we can interpret - // it the same way again. - r := randInt() - stream, _ := boltConn.Run(db.Command{ - Cypher: "CREATE (n:Rand {" + - "val: $r, time: $time, date: $date, dateTimeO: $dateTimeO, " + - "dateTimeZ: $dateTimeZ, localTime: $localTime, localDateTime: $localDateTime}) " + - "RETURN n", - Params: map[string]interface{}{ - "r": r, - "time": dbtype.Time(tTime), - "date": dbtype.Date(tDate), - "dateTimeO": tDateTimeO, - "dateTimeZ": tDateTimeZ, - "localTime": dbtype.LocalTime(tLocalTime), - "localDateTime": dbtype.LocalDateTime(tLocalDateTime), - }}, db.TxConfig{Mode: db.WriteMode}) - rec, sum, err := boltConn.Next(stream) - if rec == nil || err != nil || sum != nil { - t.Fatalf("Should be a record, %+v, %+v, %+v", rec, sum, err) - } - - // Verify all temporal instances as when reading (as long as that test passes, the - // errors here should be due to writing). - node := rec.Values[0].(dbtype.Node) - // Time - gotTime := time.Time(node.Props["time"].(dbtype.Time)) - assertTime(t, gotTime, tTime) - assertTimeOffset(t, gotTime, tTime) - // Date - gotDate := time.Time(node.Props["date"].(dbtype.Date)) - assertDate(t, gotDate, tDate) - // DateTime, offset - gotDateTime := time.Time(node.Props["dateTimeO"].(time.Time)) - assertDate(t, time.Time(gotDateTime), tDateTimeO) - assertTime(t, gotDateTime, tDateTimeO) - assertTimeOffset(t, gotDateTime, tDateTimeO) - // DateTime, zone - gotDateTime = time.Time(node.Props["dateTimeZ"].(time.Time)) - assertDate(t, time.Time(gotDateTime), tDateTimeZ) - assertTime(t, gotDateTime, tDateTimeZ) - assertTimeZone(t, gotDateTime, tDateTimeZ) - // Local time - gotTime = time.Time(node.Props["localTime"].(dbtype.LocalTime)) - assertTime(t, gotTime, tLocalTime) - // Local DateTime - gotDateTime = time.Time(node.Props["localDateTime"].(dbtype.LocalDateTime)) - assertDate(t, time.Time(gotDateTime), tLocalDateTime) - assertTime(t, gotDateTime, tLocalDateTime) - }) + _, err = boltConn.Consume(stream) + assertNoError(t, err) }) // Bookmark tests diff --git a/neo4j/test-integration/temporaltypes2_test.go b/neo4j/test-integration/temporaltypes2_test.go deleted file mode 100644 index b85451a2..00000000 --- a/neo4j/test-integration/temporaltypes2_test.go +++ /dev/null @@ -1,352 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package test_integration - -import ( - "math" - "math/rand" - "testing" - "time" - - "github.com/neo4j/neo4j-go-driver/v4/neo4j" - "github.com/neo4j/neo4j-go-driver/v4/neo4j/test-integration/dbserver" -) - -func TestTemporalTypes(tt *testing.T) { - server := dbserver.GetDbServer() - driver := server.Driver() - if server.Version.LessThan(V340) { - tt.Skip("Temporal types are only available after neo4j 3.4.0 release") - } - - sendAndReceive := func(t *testing.T, p interface{}) interface{} { - return single(t, driver, "CREATE (n:Node {x: $p}) RETURN n.x", map[string]interface{}{"p": p}) - } - - // Checks that the adjusted hh:mm:ss nano are the same regardless of time zone. - assertTimePart := func(t *testing.T, t1, t2 time.Time) { - if t1.Hour() != t2.Hour() || t1.Minute() != t2.Minute() || t1.Second() != t2.Second() || t1.Nanosecond() != t2.Nanosecond() { - t.Errorf("Time parts differs: %v vs %v", t1, t2) - } - } - - // Checks that the adjusted year, month and day are the same regardless of time zone. - assertDatePart := func(t *testing.T, t1, t2 time.Time) { - if t1.Year() != t2.Year() || t1.Month() != t2.Month() || t1.Day() != t2.Day() { - t.Errorf("Date parts differs: %v vs %v", t1, t2) - } - } - - // Checks that the time instant is the same, ignores time zone - assertEqual := func(t *testing.T, t1, t2 time.Time) { - if !t1.Equal(t2) { - t.Errorf("Times not equal: %v vs %v", t1, t2) - } - } - - assertDurationEqual := func(t *testing.T, d1, d2 neo4j.Duration) { - if !d1.Equal(d2) { - t.Errorf("Durations not equal: %v vs %v", d1, d2) - } - } - - // Checks that the Go time uses the Go Local location. - assertLocal := func(t *testing.T, t1 time.Time) { - if t1.Location() != time.Local { - t.Errorf("time.Local is not used: %v", t1.Location()) - } - } - - // Checks that zone/location offset matches the expectation - assertOffset := func(t *testing.T, t1 time.Time, expected int) { - _, offset := t1.Zone() - if offset != expected { - t.Errorf("Wrong offset, expected %d but was %d (%v)", expected, offset, t1) - } - } - - assertZone := func(t *testing.T, t1 time.Time, expected string) { - zone, _ := t1.Zone() - if zone != expected { - t.Errorf("Wrong zone name, expected %s but was %s (%v)", expected, zone, t1) - } - } - - tt.Run("Receive", func(rt *testing.T) { - rt.Run("neo4j.Duration", func(t *testing.T) { - d1 := single(t, driver, "RETURN duration({ months: 16, days: 45, seconds: 120, nanoseconds: 187309812 })", nil).(neo4j.Duration) - d2 := neo4j.Duration{Months: 16, Days: 45, Seconds: 120, Nanos: 187309812} - assertDurationEqual(t, d1, d2) - // Verify that utility function used to build duration works - assertDurationEqual(t, d1, neo4j.DurationOf(16, 45, 120, 187309812)) - }) - - rt.Run("neo4j.Date", func(t *testing.T) { - t1 := time.Time(single(t, driver, "RETURN date({ year: 1994, month: 11, day: 15 })", nil).(neo4j.Date)) - t2 := time.Date(1994, 11, 15, 0, 0, 0, 0, time.Local) - assertDatePart(t, t1, t2) - // Verify that utility function used to build LocalTime out of Go time works - t3 := time.Time(neo4j.DateOf(t2)) - assertEqual(t, t1, t3) - }) - - rt.Run("neo4j.LocalTime", func(t *testing.T) { - t1 := time.Time(single(t, driver, "RETURN localtime({ hour: 23, minute: 49, second: 59, nanosecond: 999999999 })", nil).(neo4j.LocalTime)) - t2 := time.Date(0, 0, 0, 23, 49, 59, 999999999, time.Local) - assertTimePart(t, t1, t2) - // The received time should be represented with the local time zone for convenience. - assertLocal(t, t1) - // Verify that utility function used to build LocalTime out of Go time works - t3 := time.Time(neo4j.LocalTimeOf(t2)) - assertEqual(t, t1, t3) - assertLocal(t, t3) - }) - - rt.Run("neo4j.Time+offset", func(t *testing.T) { - t1 := time.Time(single(t, driver, "RETURN time({ hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'+03:00' })", nil).(neo4j.Time)) - t2 := time.Date(0, 0, 0, 23, 49, 59, 999999999, time.FixedZone("Offset", 3*60*60)) - assertTimePart(t, t1, t2) - assertOffset(t, t1, 3*60*60) - // Verify that utility function used to build Time out of Go time works - t3 := time.Time(neo4j.OffsetTimeOf(t2)) - assertEqual(t, t1, t3) - }) - - rt.Run("neo4j.LocalDateTime", func(t *testing.T) { - t1 := time.Time(single(t, driver, - "RETURN localdatetime({ year: 1859, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999 })", nil).(neo4j.LocalDateTime)) - t2 := time.Date(1859, 5, 31, 23, 49, 59, 999999999, time.Local) - assertTimePart(t, t1, t2) - assertDatePart(t, t1, t2) - // The received time should be represented with the local time zone for convenience. - assertLocal(t, t1) - // Verify that utility function used to build LocalDateTime out of Go time works - t3 := time.Time(neo4j.LocalDateTimeOf(t2)) - assertEqual(t, t1, t3) - // Verify that utility function can build a local time of a Go time of another time zone than local - t4 := time.Time(neo4j.LocalDateTimeOf(time.Date(1859, 5, 31, 23, 49, 59, 999999999, time.UTC))) - assertTimePart(t, t1, t4) - assertDatePart(t, t1, t4) - }) - - rt.Run("time.Time+offset", func(t *testing.T) { - t1 := single(t, driver, - "RETURN datetime({ year: 1859, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'+02:30' })", nil).(time.Time) - offset := 150 * 60 - t2 := time.Date(1859, 5, 31, 23, 49, 59, 999999999, time.FixedZone("Offset", offset)) - assertTimePart(t, t1, t2) - assertDatePart(t, t1, t2) - assertOffset(t, t1, offset) - }) - - rt.Run("time.Time+zone", func(t *testing.T) { - t1 := single(t, driver, - "RETURN datetime({ year: 1959, month: 5, day: 31, hour: 23, minute: 49, second: 59, nanosecond: 999999999, timezone:'Europe/London'})", nil).(time.Time) - offset := 60 * 60 - location, err := time.LoadLocation("Europe/London") - if err != nil { - t.Fatal(err) - } - t2 := time.Date(1959, 5, 31, 23, 49, 59, 999999999, location) - assertTimePart(t, t1, t2) - assertDatePart(t, t1, t2) - assertOffset(t, t1, offset) - assertZone(t, t1, "BST") - }) - }) - - tt.Run("Random", func(rt *testing.T) { - const numRand = 100 - - randomDate := func() neo4j.Date { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.Date(time.Date( - sign*rand.Intn(9999), - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - 0, 0, 0, 0, time.Local)) - } - - randomDuration := func() neo4j.Duration { - sign := int64(1) - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.DurationOf( - sign*rand.Int63n(math.MaxInt32), - sign*rand.Int63n(math.MaxInt32), - sign*rand.Int63n(math.MaxInt32), - rand.Intn(1000000000)) - } - - randomLocalTime := func() neo4j.LocalTime { - return neo4j.LocalTimeOf( - time.Date( - 0, 0, 0, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.Local)) - } - - randomLocalDateTime := func() neo4j.LocalDateTime { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.LocalDateTimeOf( - time.Date( - sign*rand.Intn(9999), - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.Local)) - } - - randomOffsetTime := func() neo4j.OffsetTime { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.OffsetTimeOf( - time.Date( - 0, 0, 0, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.FixedZone("Offset", sign*rand.Intn(64800)))) - } - randomOffsetDateTime := func() time.Time { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return time.Date( - rand.Intn(300)+1900, - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.FixedZone("Offset", sign*rand.Intn(64800))) - } - - randomZonedDateTime := func() time.Time { - var zones = []string{ - "Africa/Harare", "America/Aruba", "Africa/Nairobi", "America/Dawson", "Asia/Beirut", "Asia/Tashkent", - "Canada/Eastern", "Europe/Malta", "Europe/Volgograd", "Indian/Kerguelen", "Etc/GMT+3", - } - - location, err := time.LoadLocation(zones[rand.Intn(len(zones))]) - if err != nil { - panic(err) - } - - return time.Date( - rand.Intn(300)+1900, - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(17)+6, // to be safe from DST changes - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - location) - } - - rt.Run("Date", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomDate() - d2 := sendAndReceive(t, d1).(neo4j.Date) - assertDatePart(t, time.Time(d1), time.Time(d2)) - } - }) - - rt.Run("Duration", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomDuration() - d2 := sendAndReceive(t, d1).(neo4j.Duration) - assertDurationEqual(t, d1, d2) - } - }) - - rt.Run("LocalTime", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomLocalTime() - d2 := sendAndReceive(t, d1).(neo4j.LocalTime) - assertTimePart(t, time.Time(d1), time.Time(d2)) - assertLocal(t, time.Time(d2)) - } - }) - - rt.Run("OffsetTime", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomOffsetTime() - d2 := sendAndReceive(t, d1).(neo4j.OffsetTime) - assertTimePart(t, time.Time(d1), time.Time(d2)) - assertZone(t, time.Time(d2), "Offset") - } - }) - - rt.Run("LocalDateTime", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomLocalDateTime() - d2 := sendAndReceive(t, d1).(neo4j.LocalDateTime) - assertDatePart(t, time.Time(d1), time.Time(d2)) - assertTimePart(t, time.Time(d1), time.Time(d2)) - assertLocal(t, time.Time(d2)) - } - }) - - rt.Run("Offset DateTime", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomOffsetDateTime() - d2 := sendAndReceive(t, d1).(time.Time) - assertDatePart(t, d1, d2) - assertTimePart(t, d1, d2) - assertZone(t, d2, "Offset") - } - }) - - rt.Run("Zoned DateTime", func(t *testing.T) { - for i := 0; i < numRand; i++ { - d1 := randomZonedDateTime() - d2 := sendAndReceive(t, d1).(time.Time) - assertDatePart(t, d1, d2) - assertTimePart(t, d1, d2) - zone, _ := d1.Zone() - assertZone(t, d2, zone) - } - }) - }) -} diff --git a/neo4j/test-integration/temporaltypes_test.go b/neo4j/test-integration/temporaltypes_test.go deleted file mode 100644 index d890fdbd..00000000 --- a/neo4j/test-integration/temporaltypes_test.go +++ /dev/null @@ -1,447 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package test_integration - -import ( - "math" - "math/rand" - "time" - - "github.com/neo4j/neo4j-go-driver/v4/neo4j" - "github.com/neo4j/neo4j-go-driver/v4/neo4j/test-integration/dbserver" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/ginkgo/extensions/table" - . "github.com/onsi/gomega" -) - -var _ = Describe("Temporal Types", func() { - const ( - numberOfRandomValues = 200 - ) - - server := dbserver.GetDbServer() - var driver neo4j.Driver - var session neo4j.Session - var result neo4j.Result - var err error - - rand.Seed(time.Now().UnixNano()) - - BeforeEach(func() { - driver := server.Driver() - - if server.Version.LessThan(V340) { - Skip("temporal types are only available after neo4j 3.4.0 release") - } - - session, err = driver.Session(neo4j.AccessModeWrite) - Expect(err).To(BeNil()) - Expect(session).NotTo(BeNil()) - }) - - AfterEach(func() { - if session != nil { - session.Close() - } - - if driver != nil { - driver.Close() - } - }) - - randomDuration := func() neo4j.Duration { - sign := int64(1) - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.DurationOf( - sign*rand.Int63n(math.MaxInt32), - sign*rand.Int63n(math.MaxInt32), - sign*rand.Int63n(math.MaxInt32), - rand.Intn(1000000000)) - } - - randomLocalDate := func() neo4j.Date { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.DateOf( - time.Date( - sign*rand.Intn(9999), - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - 0, 0, 0, 0, time.Local)) - } - - randomLocalDateTime := func() neo4j.LocalDateTime { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.LocalDateTimeOf( - time.Date( - sign*rand.Intn(9999), - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.Local)) - } - - randomLocalTime := func() neo4j.LocalTime { - return neo4j.LocalTimeOf( - time.Date( - 0, 0, 0, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.Local)) - } - - randomOffsetTime := func() neo4j.OffsetTime { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return neo4j.OffsetTimeOf( - time.Date( - 0, 0, 0, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.FixedZone("Offset", sign*rand.Intn(64800)))) - } - - randomOffsetDateTime := func() time.Time { - sign := 1 - if rand.Intn(2) == 0 { - sign = -sign - } - - return time.Date( - rand.Intn(300)+1900, - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(24), - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - time.FixedZone("Offset", sign*rand.Intn(64800))) - } - - randomZonedDateTime := func() time.Time { - var zones = []string{ - "Africa/Harare", "America/Aruba", "Africa/Nairobi", "America/Dawson", "Asia/Beirut", "Asia/Tashkent", - "Canada/Eastern", "Europe/Malta", "Europe/Volgograd", "Indian/Kerguelen", "Etc/GMT+3", - } - - location, err := time.LoadLocation(zones[rand.Intn(len(zones))]) - Expect(err).To(BeNil()) - - return time.Date( - rand.Intn(300)+1900, - time.Month(rand.Intn(12)+1), - rand.Intn(28)+1, - rand.Intn(17)+6, // to be safe from DST changes - rand.Intn(60), - rand.Intn(60), - rand.Intn(1000000000), - location) - } - - testSendAndReceive := func(query string, data interface{}, expected []interface{}) { - result, err = session.Run(query, map[string]interface{}{"x": data}) - Expect(err).To(BeNil()) - - if result.Next() { - var received = result.Record().Values - - Expect(received).To(Equal(expected)) - } - Expect(result.Err()).To(BeNil()) - Expect(result.Next()).To(BeFalse()) - } - - testSendAndReceiveValue := func(value interface{}) { - result, err = session.Run("CREATE (n:Node {value: $value}) RETURN n.value", map[string]interface{}{"value": value}) - Expect(err).To(BeNil()) - - if result.Next() { - var received = result.Record().Values[0] - - Expect(received).To(Equal(value)) - } - Expect(result.Err()).To(BeNil()) - Expect(result.Next()).To(BeFalse()) - } - - testSendAndReceiveValueComp := func(value interface{}, comp func(x, y interface{}) bool) { - result, err = session.Run("CREATE (n:Node {value: $value}) RETURN n.value", map[string]interface{}{"value": value}) - Expect(err).To(BeNil()) - - if result.Next() { - var received = result.Record().Values[0] - - Expect(comp(value, received)).To(Equal(true)) - } - Expect(result.Err()).To(BeNil()) - Expect(result.Next()).To(BeFalse()) - } - - Context("Send and Receive", func() { - It("duration", func() { - data := neo4j.DurationOf(14, 35, 75, 789012587) - - testSendAndReceive("WITH $x AS x RETURN x, x.months, x.days, x.seconds, x.millisecondsOfSecond, x.microsecondsOfSecond, x.nanosecondsOfSecond", - data, - []interface{}{ - data, - int64(14), - int64(35), - int64(75), - int64(789), - int64(789012), - int64(789012587), - }) - }) - - It("date", func() { - data := neo4j.DateOf(time.Date(1976, 6, 13, 0, 0, 0, 0, time.Local)) - - testSendAndReceive("WITH $x AS x RETURN x, x.year, x.month, x.day", - data, - []interface{}{ - data, - int64(1976), - int64(6), - int64(13), - }) - }) - - It("local time", func() { - data := neo4j.LocalTimeOf(time.Date(0, 0, 0, 12, 34, 56, 789012587, time.Local)) - - testSendAndReceive("WITH $x AS x RETURN x, x.hour, x.minute, x.second, x.millisecond, x.microsecond, x.nanosecond", - data, - []interface{}{ - data, - int64(12), - int64(34), - int64(56), - int64(789), - int64(789012), - int64(789012587), - }) - }) - - It("offset time", func() { - data := neo4j.OffsetTimeOf(time.Date(0, 0, 0, 12, 34, 56, 789012587, time.FixedZone("Offset", 90*60))) - - testSendAndReceive("WITH $x AS x RETURN x, x.hour, x.minute, x.second, x.millisecond, x.microsecond, x.nanosecond, x.offset", - data, - []interface{}{ - data, - int64(12), - int64(34), - int64(56), - int64(789), - int64(789012), - int64(789012587), - "+01:30", - }) - }) - - It("local date time", func() { - data := neo4j.LocalDateTimeOf(time.Date(1976, 6, 13, 12, 34, 56, 789012587, time.Local)) - - testSendAndReceive("WITH $x AS x RETURN x, x.year, x.month, x.day, x.hour, x.minute, x.second, x.millisecond, x.microsecond, x.nanosecond", - data, - []interface{}{ - data, - int64(1976), - int64(6), - int64(13), - int64(12), - int64(34), - int64(56), - int64(789), - int64(789012), - int64(789012587), - }) - }) - - It("offset date time", func() { - data := time.Date(1976, 6, 13, 12, 34, 56, 789012587, time.FixedZone("Offset", -90*60)) - - testSendAndReceive("WITH $x AS x RETURN x, x.year, x.month, x.day, x.hour, x.minute, x.second, x.millisecond, x.microsecond, x.nanosecond, x.offset", - data, - []interface{}{ - data, - int64(1976), - int64(6), - int64(13), - int64(12), - int64(34), - int64(56), - int64(789), - int64(789012), - int64(789012587), - "-01:30", - }) - }) - - It("zoned date time", func() { - location, err := time.LoadLocation("US/Pacific") - Expect(err).To(BeNil()) - data := time.Date(1959, 5, 31, 23, 49, 59, 999999999, location) - - testSendAndReceive("WITH $x AS x RETURN x, x.year, x.month, x.day, x.hour, x.minute, x.second, x.millisecond, x.microsecond, x.nanosecond, x.timezone", - data, - []interface{}{ - data, - int64(1959), - int64(5), - int64(31), - int64(23), - int64(49), - int64(59), - int64(999), - int64(999999), - int64(999999999), - "US/Pacific", - }) - }) - }) - - Context("Send and receive random arrays", func() { - It("duration", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomDuration() - } - - testSendAndReceiveValue(list) - }) - - It("date", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomLocalDate() - } - - testSendAndReceiveValue(list) - }) - - It("local time", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomLocalTime() - } - - testSendAndReceiveValue(list) - }) - - It("offset time", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomOffsetTime() - } - - testSendAndReceiveValue(list) - }) - - It("local date time", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomLocalDateTime() - } - - testSendAndReceiveValueComp(list, func(x, y interface{}) bool { - l1 := x.([]interface{}) - l2 := y.([]interface{}) - - equal := true - for i, x := range l1 { - y := l2[i] - d1 := x.(neo4j.LocalDateTime) - d2 := y.(neo4j.LocalDateTime) - if !d1.Time().Equal(d2.Time()) { - equal = false - } - } - return equal - }) - }) - - It("offset date time", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomOffsetDateTime() - } - - testSendAndReceiveValue(list) - }) - - It("zoned date time", func() { - listSize := rand.Intn(1000) - list := make([]interface{}, listSize) - for i := 0; i < listSize; i++ { - list[i] = randomZonedDateTime() - } - - testSendAndReceiveValue(list) - }) - }) - - DescribeTable("should be able to send and receive nil pointer property", - func(value interface{}) { - result, err = session.Run("CREATE (n {value: $value}) RETURN n.value", map[string]interface{}{"value": value}) - Expect(err).To(BeNil()) - - if result.Next() { - Expect(result.Record().Values[0]).To(BeNil()) - } - Expect(result.Next()).To(BeFalse()) - Expect(result.Err()).To(BeNil()) - }, - Entry("Duration", (*neo4j.Duration)(nil)), - Entry("Date", (*neo4j.Date)(nil)), - Entry("LocalTime", (*neo4j.LocalTime)(nil)), - Entry("OffsetTime", (*neo4j.OffsetTime)(nil)), - Entry("LocalDateTime", (*neo4j.LocalDateTime)(nil)), - Entry("DateTime{Offset|Zoned}", (*time.Time)(nil)), - ) -}) diff --git a/neo4j/test-integration/utils_test.go b/neo4j/test-integration/utils_test.go index 66a2e982..c4484c02 100644 --- a/neo4j/test-integration/utils_test.go +++ b/neo4j/test-integration/utils_test.go @@ -21,10 +21,9 @@ package test_integration import ( "fmt" - "reflect" - "github.com/neo4j/neo4j-go-driver/v4/neo4j" "github.com/onsi/ginkgo" + "reflect" . "github.com/onsi/gomega" ) diff --git a/testkit-backend/2cypher.go b/testkit-backend/2cypher.go index cefe10b7..3b79c266 100644 --- a/testkit-backend/2cypher.go +++ b/testkit-backend/2cypher.go @@ -21,6 +21,8 @@ package main import ( "fmt" + "github.com/neo4j/neo4j-go-driver/v4/neo4j/dbtype" + "time" "github.com/neo4j/neo4j-go-driver/v4/neo4j" ) @@ -39,6 +41,89 @@ func nativeToCypher(v interface{}) map[string]interface{} { return valueResponse("CypherBool", x) case float64: return valueResponse("CypherFloat", x) + case dbtype.Date: + date := x.Time() + values := map[string]interface{}{ + "year": date.Year(), + "month": date.Month(), + "day": date.Day(), + } + return map[string]interface{}{ + "name": "CypherDate", + "data": values, + } + case dbtype.LocalDateTime: + localDateTime := x.Time() + values := map[string]interface{}{ + "year": localDateTime.Year(), + "month": localDateTime.Month(), + "day": localDateTime.Day(), + "hour": localDateTime.Hour(), + "minute": localDateTime.Minute(), + "second": localDateTime.Second(), + "nanosecond": localDateTime.Nanosecond(), + } + return map[string]interface{}{ + "name": "CypherDateTime", + "data": values, + } + case dbtype.Duration: + values := map[string]interface{}{ + "months": x.Months, + "days": x.Days, + "seconds": x.Seconds, + "nanoseconds": x.Nanos, + } + return map[string]interface{}{ + "name": "CypherDuration", + "data": values, + } + case dbtype.Time: + time := x.Time() + _, offset := time.Zone() + values := map[string]interface{}{ + "hour": time.Hour(), + "minute": time.Minute(), + "second": time.Second(), + "nanosecond": time.Nanosecond(), + "utc_offset_s": offset, + } + return map[string]interface{}{ + "name": "CypherTime", + "data": values, + } + case dbtype.LocalTime: + localTime := x.Time() + values := map[string]interface{}{ + "hour": localTime.Hour(), + "minute": localTime.Minute(), + "second": localTime.Second(), + "nanosecond": localTime.Nanosecond(), + } + return map[string]interface{}{ + "name": "CypherTime", + "data": values, + } + case time.Time: + tzName, offset := x.Zone() + values := map[string]interface{}{ + "year": x.Year(), + "month": x.Month(), + "day": x.Day(), + "hour": x.Hour(), + "minute": x.Minute(), + "second": x.Second(), + "nanosecond": x.Nanosecond(), + "utc_offset_s": offset, + } + if tzName != "Offset" { + values["timezone_id"] = x.Location().String() + } + return map[string]interface{}{ + "name": "CypherDateTime", + "data": values, + } + case []interface{}: values := make([]interface{}, len(x)) for i, y := range x { @@ -64,6 +149,32 @@ func nativeToCypher(v interface{}) map[string]interface{} { "labels": nativeToCypher(x.Labels), "props": nativeToCypher(x.Props), }} + case neo4j.Relationship: + return map[string]interface{}{ + "name": "Relationship", + "data": map[string]interface{}{ + "id": nativeToCypher(x.Id), + "startNodeId": nativeToCypher(x.StartId), + "endNodeId": nativeToCypher(x.EndId), + "type": nativeToCypher(x.Type), + "props": nativeToCypher(x.Props), + }} + case neo4j.Path: + nodes := make([]interface{}, len(x.Nodes)) + for i := range x.Nodes { + nodes[i] = x.Nodes[i] + } + rels := make([]interface{}, len(x.Relationships)) + for i := range x.Relationships { + rels[i] = x.Relationships[i] + } + return map[string]interface{}{ + "name": "Path", + "data": map[string]interface{}{ + "nodes": nativeToCypher(nodes), + "relationships": nativeToCypher(rels), + }, + } } panic(fmt.Sprintf("Don't know how to patch %T", v)) } diff --git a/testkit-backend/2native.go b/testkit-backend/2native.go index 9d4afc7c..2a35428d 100644 --- a/testkit-backend/2native.go +++ b/testkit-backend/2native.go @@ -20,39 +20,131 @@ package main import ( + "encoding/json" "fmt" + "github.com/neo4j/neo4j-go-driver/v4/neo4j/dbtype" + "time" ) // Converts received proxied "cypher" types to Go native types. -func cypherToNative(c interface{}) interface{} { +func cypherToNative(c interface{}) (interface{}, error) { m := c.(map[string]interface{}) d := m["data"].(map[string]interface{}) n := m["name"] switch n { + case "CypherDateTime": + year := d["year"].(json.Number) + month := d["month"].(json.Number) + day := d["day"].(json.Number) + hour := d["hour"].(json.Number) + minute := d["minute"].(json.Number) + second := d["second"].(json.Number) + nanosecond := d["nanosecond"].(json.Number) + timezone, expectedOffset, err := loadTimezone(d) + if err != nil { + return nil, err + } + dateTime := time.Date(asInt(year), time.Month(asInt(month)), asInt(day), asInt(hour), asInt(minute), asInt(second), asInt(nanosecond), timezone) + if timezone == time.Local { + return dbtype.LocalDateTime(dateTime), nil + } + if _, actualOffset := dateTime.Zone(); actualOffset != expectedOffset { + return nil, fmt.Errorf("expected UTC offset of %d for %s, but actual offset is %d", expectedOffset, d, actualOffset) + } + return dateTime, nil + case "CypherDate": + year := d["year"].(json.Number) + month := d["month"].(json.Number) + day := d["day"].(json.Number) + return dbtype.Date(time.Date(asInt(year), time.Month(asInt(month)), asInt(day), 0, 0, 0, 0, time.Local)), nil + case "CypherDuration": + months := d["months"].(json.Number) + days := d["days"].(json.Number) + seconds := d["seconds"].(json.Number) + nanoseconds := d["nanoseconds"].(json.Number) + return dbtype.Duration{ + Months: asInt64(months), + Days: asInt64(days), + Seconds: asInt64(seconds), + Nanos: asInt(nanoseconds), + }, nil + case "CypherTime": + hour := d["hour"].(json.Number) + minute := d["minute"].(json.Number) + second := d["second"].(json.Number) + nanosecond := d["nanosecond"].(json.Number) + timeZone := time.Local + if offset, foundOffset := readOffset(d); foundOffset { + timeZone = time.FixedZone("Offset", offset) + return dbtype.Time(time.Date(0, 0, 0, asInt(hour), asInt(minute), asInt(second), asInt(nanosecond), timeZone)), nil + } + return dbtype.LocalTime(time.Date(0, 0, 0, asInt(hour), asInt(minute), asInt(second), asInt(nanosecond), timeZone)), nil case "CypherString": - return d["value"].(string) + return d["value"].(string), nil case "CypherInt": - return int64(d["value"].(float64)) + return d["value"].(json.Number).Int64() case "CypherBool": - return d["value"].(bool) + return d["value"].(bool), nil case "CypherFloat": - return d["value"].(float64) + return d["value"].(json.Number).Float64() case "CypherNull": - return nil + return nil, nil case "CypherList": lc := d["value"].([]interface{}) ln := make([]interface{}, len(lc)) + var err error for i, x := range lc { - ln[i] = cypherToNative(x) + if ln[i], err = cypherToNative(x); err != nil { + return nil, err + } } - return ln + return ln, nil case "CypherMap": mc := d["value"].(map[string]interface{}) mn := make(map[string]interface{}) + var err error for k, x := range mc { - mn[k] = cypherToNative(x) + if mn[k], err = cypherToNative(x); err != nil { + return nil, err + } } - return mn + return mn, nil } panic(fmt.Sprintf("Don't know how to convert %s to native", n)) } + +func loadTimezone(data map[string]interface{}) (*time.Location, int, error) { + offset, foundOffset := readOffset(data) + rawTimezoneId := data["timezone_id"] + if rawTimezoneId != nil { + timezoneId := rawTimezoneId.(string) + location, err := time.LoadLocation(timezoneId) + if err != nil { + return nil, 0, err + } + return location, offset, nil + } + if !foundOffset { + return time.Local, 0, nil + } + return time.FixedZone("Offset", offset), offset, nil +} + +func readOffset(data map[string]interface{}) (int, bool) { + if rawOffset := data["utc_offset_s"]; rawOffset != nil { + return asInt(rawOffset.(json.Number)), true + } + return 0, false +} + +func asInt(number json.Number) int { + return int(asInt64(number)) +} + +func asInt64(number json.Number) int64 { + result, err := number.Int64() + if err != nil { + panic(fmt.Sprintf("could not convert JSON value to int64: %v", err)) + } + return result +} diff --git a/testkit-backend/backend.go b/testkit-backend/backend.go index d542786c..1f3c1d0d 100644 --- a/testkit-backend/backend.go +++ b/testkit-backend/backend.go @@ -211,7 +211,9 @@ func (b *backend) writeResponse(name string, data interface{}) { func (b *backend) toRequest(s string) map[string]interface{} { req := map[string]interface{}{} - err := json.Unmarshal([]byte(s), &req) + decoder := json.NewDecoder(strings.NewReader(s)) + decoder.UseNumber() + err := decoder.Decode(&req) if err != nil { panic(fmt.Sprintf("Unable to parse: '%s' as a request: %s", s, err)) } @@ -222,11 +224,15 @@ func (b *backend) toTransactionConfigApply(data map[string]interface{}) func(*ne txConfig := neo4j.TransactionConfig{} // Optional transaction meta data if data["txMeta"] != nil { - txConfig.Metadata = data["txMeta"].(map[string]interface{}) + txMetadata := data["txMeta"].(map[string]interface{}) + if err := patchNumbersInMap(txMetadata); err != nil { + panic(err) + } + txConfig.Metadata = txMetadata } // Optional timeout in milliseconds if data["timeout"] != nil { - txConfig.Timeout = time.Millisecond * time.Duration((data["timeout"].(float64))) + txConfig.Timeout = time.Millisecond * time.Duration(asInt64(data["timeout"].(json.Number))) } return func(conf *neo4j.TransactionConfig) { if txConfig.Metadata != nil { @@ -238,13 +244,16 @@ func (b *backend) toTransactionConfigApply(data map[string]interface{}) func(*ne } } -func (b *backend) toCypherAndParams(data map[string]interface{}) (string, map[string]interface{}) { +func (b *backend) toCypherAndParams(data map[string]interface{}) (string, map[string]interface{}, error) { cypher := data["cypher"].(string) params, _ := data["params"].(map[string]interface{}) + var err error for i, p := range params { - params[i] = cypherToNative(p) + if params[i], err = cypherToNative(p); err != nil { + return "", nil, err + } } - return cypher, params + return cypher, params, nil } func (b *backend) handleTransactionFunc(isRead bool, data map[string]interface{}) { @@ -358,8 +367,17 @@ func (b *backend) handleRequest(req map[string]interface{}) { authTokenMap["credentials"].(string), authTokenMap["realm"].(string)) default: - b.writeError(errors.New("Unsupported scheme")) - return + parameters := authTokenMap["parameters"].(map[string]interface{}) + if err := patchNumbersInMap(parameters); err != nil { + b.writeError(err) + return + } + authToken = neo4j.CustomAuth( + authTokenMap["scheme"].(string), + authTokenMap["principal"].(string), + authTokenMap["credentials"].(string), + authTokenMap["realm"].(string), + parameters) } // Parse URI (or rather type cast) uri := data["uri"].(string) @@ -374,6 +392,15 @@ func (b *backend) handleRequest(req map[string]interface{}) { if data["resolverRegistered"].(bool) { c.AddressResolver = b.customAddressResolverFunction() } + if data["connectionAcquisitionTimeoutMs"] != nil { + c.ConnectionAcquisitionTimeout = time.Millisecond * time.Duration(asInt64(data["connectionAcquisitionTimeoutMs"].(json.Number))) + } + if data["maxConnectionPoolSize"] != nil { + c.MaxConnectionPoolSize = asInt(data["maxConnectionPoolSize"].(json.Number)) + } + if data["maxTxRetryTimeMs"] != nil { + c.MaxTransactionRetryTime = time.Millisecond * time.Duration(asInt64(data["maxTxRetryTimeMs"].(json.Number))) + } }) if err != nil { b.writeError(err) @@ -419,7 +446,7 @@ func (b *backend) handleRequest(req map[string]interface{}) { sessionConfig.DatabaseName = data["database"].(string) } if data["fetchSize"] != nil { - sessionConfig.FetchSize = int(data["fetchSize"].(float64)) + sessionConfig.FetchSize = asInt(data["fetchSize"].(json.Number)) } session := driver.NewSession(sessionConfig) idKey := b.nextId() @@ -439,7 +466,11 @@ func (b *backend) handleRequest(req map[string]interface{}) { case "SessionRun": sessionState := b.sessionStates[data["sessionId"].(string)] - cypher, params := b.toCypherAndParams(data) + cypher, params, err := b.toCypherAndParams(data) + if err != nil { + b.writeError(err) + return + } result, err := sessionState.session.Run(cypher, params, b.toTransactionConfigApply(data)) if err != nil { b.writeError(err) @@ -471,7 +502,11 @@ func (b *backend) handleRequest(req map[string]interface{}) { case "TransactionRun": tx := b.transactions[data["txId"].(string)] - cypher, params := b.toCypherAndParams(data) + cypher, params, err := b.toCypherAndParams(data) + if err != nil { + b.writeError(err) + return + } result, err := tx.Run(cypher, params) if err != nil { b.writeError(err) @@ -574,6 +609,20 @@ func (b *backend) handleRequest(req map[string]interface{}) { b.writeResponse("SkipTest", map[string]interface{}{"reason": reason}) return } + if strings.Contains(testName, "test_should_echo_all_timezone_ids") || + strings.Contains(testName, "test_date_time_cypher_created_tz_id") { + b.writeResponse("RunSubTests", nil) + return + } + b.writeResponse("RunTest", nil) + + case "StartSubTest": + testName := data["testName"].(string) + arguments := data["subtestArguments"].(map[string]interface{}) + if reason, ok := mustSkipSubTest(testName, arguments); ok { + b.writeResponse("SkipTest", map[string]interface{}{"reason": reason}) + return + } b.writeResponse("RunTest", nil) default: @@ -581,6 +630,29 @@ func (b *backend) handleRequest(req map[string]interface{}) { } } +func (b *backend) writeRecord(result neo4j.Result, record *neo4j.Record, expectRecord bool) { + if expectRecord && record == nil { + b.writeResponse("BackendError", map[string]interface{}{ + "msg": "Found no record where one was expected.", + }) + } else if !expectRecord && record != nil { + b.writeResponse("BackendError", map[string]interface{}{ + "msg": "Found a record where none was expected.", + }) + } + + if record != nil { + b.writeResponse("Record", serializeRecord(record)) + } else { + err := result.Err() + if err != nil && err.Error() != "result cursor is not available anymore" { + b.writeError(err) + return + } + b.writeResponse("NullRecord", nil) + } +} + func mustSkip(testName string) (string, bool) { skippedTests := testSkips() for testPattern, exclusionReason := range skippedTests { @@ -591,6 +663,13 @@ func mustSkip(testName string) (string, bool) { return "", false } +func mustSkipSubTest(testName string, arguments map[string]interface{}) (string, bool) { + if strings.Contains(testName, "test_should_echo_all_timezone_ids") { + return mustSkipTimeZoneSubTest(arguments) + } + return "", false +} + func matches(pattern, testName string) bool { if pattern == testName { return true @@ -608,6 +687,59 @@ func asRegex(rawPattern string) *regexp.Regexp { return regexp.MustCompile(pattern) } +func serializePlan(plan neo4j.Plan) map[string]interface{} { + if plan == nil { + return nil + } + return map[string]interface{}{ + "args": plan.Arguments(), + "operatorType": plan.Operator(), + "children": serializePlans(plan.Children()), + "identifiers": plan.Identifiers(), + } +} + +func serializePlans(children []neo4j.Plan) []map[string]interface{} { + result := make([]map[string]interface{}, len(children)) + for i, child := range children { + result[i] = serializePlan(child) + } + return result +} + +func serializeProfile(profile neo4j.ProfiledPlan) map[string]interface{} { + if profile == nil { + return nil + } + result := map[string]interface{}{ + "args": profile.Arguments(), + "children": serializeProfiles(profile.Children()), + "dbHits": profile.DbHits(), + "identifiers": profile.Identifiers(), + "operatorType": profile.Operator(), + "rows": profile.Records(), + } + return result +} + +func serializeProfiles(children []neo4j.ProfiledPlan) []map[string]interface{} { + result := make([]map[string]interface{}, len(children)) + for i, child := range children { + result[i] = serializeProfile(child) + } + return result +} + +func serializeRecord(record *neo4j.Record) map[string]interface{} { + values := record.Values + cypherValues := make([]interface{}, len(values)) + for i, v := range values { + cypherValues[i] = nativeToCypher(v) + } + data := map[string]interface{}{"values": cypherValues} + return data +} + // you can use '*' as wildcards anywhere in the qualified test name (useful to exclude a whole class e.g.) func testSkips() map[string]string { return map[string]string{ @@ -621,3 +753,48 @@ func testSkips() map[string]string { "stub.configuration_hints.test_connection_recv_timeout_seconds.TestRoutingConnectionRecvTimeout.*": "No GetRoutingTable support - too tricky to implement in Go", } } + +func mustSkipTimeZoneSubTest(arguments map[string]interface{}) (string, bool) { + rawDateTime := arguments["dt"].(map[string]interface{}) + dateTimeData := rawDateTime["data"].(map[string]interface{}) + timeZoneName := dateTimeData["timezone_id"].(string) + location, err := time.LoadLocation(timeZoneName) + if err != nil { + return fmt.Sprintf("time zone not supported: %s", err), true + } + dateTime := time.Date( + asInt(dateTimeData["year"].(json.Number)), + time.Month(asInt(dateTimeData["month"].(json.Number))), + asInt(dateTimeData["day"].(json.Number)), + asInt(dateTimeData["hour"].(json.Number)), + asInt(dateTimeData["minute"].(json.Number)), + asInt(dateTimeData["second"].(json.Number)), + asInt(dateTimeData["nanosecond"].(json.Number)), + location, + ) + expectedOffset := asInt(dateTimeData["utc_offset_s"].(json.Number)) + if _, actualOffset := dateTime.Zone(); actualOffset != expectedOffset { + return fmt.Sprintf("Expected offset %d for timezone %s and time %s, got offset %d instead", + expectedOffset, timeZoneName, dateTime.String(), actualOffset), + true + } + return "", false +} + +// some TestKit tests send large integer values which require to configure +// the JSON deserializer to use json.Number instead of float64 (lossy conversions +// would happen otherwise) +// however, some specific dictionaries (like transaction metadata and custom +// auth parameters) are better off relying on numbers being treated as float64 +func patchNumbersInMap(dictionary map[string]interface{}) error { + for key, value := range dictionary { + if number, ok := value.(json.Number); ok { + floatingPointValue, err := number.Float64() + if err != nil { + return fmt.Errorf("could not deserialize number %v in map %v: %w", number, dictionary, err) + } + dictionary[key] = floatingPointValue + } + } + return nil +}