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 +}